Sending messages
Send messages with Turbo Notify and follow the WhatsApp policies (opens in a new tab) .
The body of POST /v1/numbers/{ref}/messages (or the
POST /v1/messages shortcut for the main number) carries the message
content in the content object. The type comes
from the anchor field present in content (text, image_url,
latitude, start_at, …). Do not send a content_type field in the
request: it is never accepted (including it returns 422).
Text, location, contact, calendar, message with a link button
(interactive_type) and media (image, video, audio, document, sticker, see
Sending media) are supported.
To quote (reply to) an existing message (any content variant), use the dedicated POST /v1/messages/{message_id}/reply endpoint, where {message_id} is the public id of the quoted message (msg_{hex32}). The body of that request contains only content. The resulting webhook is message.replied (instead of message.sent); see Webhooks.
Quote to bring an earlier message into view, when the conversation has moved on and your answer depends on it (the contact’s, yours or, in a group, any participant’s). To answer the message that just arrived, send a normal message, as people do in a chat: the chat already shows what came before, and a quote on every answer is only noise.
You can reply to any message from the last 12 months, including one whose content already expired (Message retention): the conversation stays threaded through the quoted message, only the quote bubble goes without the original text.
Before sending (or replying), Turbo Notify opens the chat the way a person would: the number shows as online, the unread messages in the destination chat are marked as read, and only then is the message sent. To mark a chat as read without sending anything, see Mark as read.
Basic send
A basic send takes only the recipient’s number and the message text. The sender is your account’s main number.
curl -X POST \ https://api.turbonotify.com/v1/messages \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": {"number": "+5511988887777"}, "content": { "text": "Hello, this message was sent with Turbo Notify!" } }'const response = await fetch('https://api.turbonotify.com/v1/messages', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ to: { number: '+5511988887777' }, content: { text: 'Hello, this message was sent with Turbo Notify!' } })});
const result = await response.json();console.log(result);import requests
url = "https://api.turbonotify.com/v1/messages"headers = { "Authorization": "Bearer YOUR_KEY", "Content-Type": "application/json"}data = { "to": {"number": "+5511988887777"}, "content": { "text": "Hello, this message was sent with Turbo Notify!" }}
response = requests.post(url, json=data, headers=headers)result = response.json()print(result)package main
import ( "fmt" "io" "net/http" "strings")
func main() { payload := strings.NewReader(`{"to": {"number": "+5511988887777"}, "content": {"text": "Hello, this message was sent with Turbo Notify!"}}`) req, _ := http.NewRequest("POST", "https://api.turbonotify.com/v1/messages", payload) req.Header.Set("Authorization", "Bearer YOUR_KEY") req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body))}<?php$url = 'https://api.turbonotify.com/v1/messages';$data = [ 'to' => ['number' => '+5511988887777'], 'content' => [ 'text' => 'Hello, this message was sent with Turbo Notify!' ]];
$options = [ 'http' => [ 'header' => [ 'Authorization: Bearer YOUR_KEY', 'Content-Type: application/json' ], 'method' => 'POST', 'content' => json_encode($data) ]];
$context = stream_context_create($options);$result = file_get_contents($url, false, $context);$response = json_decode($result, true);var_dump($response);?>using System.Text;using System.Text.Json;
var client = new HttpClient();client.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_KEY");
var data = new { to = new { number = "+5511988887777" }, content = new { text = "Hello, this message was sent with Turbo Notify!" }};
var json = JsonSerializer.Serialize(data);var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync( "https://api.turbonotify.com/v1/messages", content);var result = await response.Content.ReadAsStringAsync();Console.WriteLine(result);import java.net.http.*;import java.net.URI;
public class TurboNotify { public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient();
var json = """ { "to": {"number": "+5511988887777"}, "content": { "text": "Hello, this message was sent with Turbo Notify!" } } """;
var request = HttpRequest.newBuilder() .uri(URI.create("https://api.turbonotify.com/v1/messages")) .header("Authorization", "Bearer YOUR_KEY") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); }}After sending, the text can be corrected with Edit a message.
Mentions (@mentions)
To mention someone, write the @<number> token in the text (or in a
media caption, see Sending media): @ followed
by the contact’s phone number as E.164 digits, without +. It is a real
WhatsApp mention, with a tappable name and a notification on the mentioned
person’s phone.
{ "to": { "number": "+5511988887777" }, "content": { "text": "Hi @5511977776666, your order has arrived!" }}In groups, mention a participant by number:
{ "to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a" }, "content": { "text": "Good morning @5511977776666, can you check today's order?" }}The request has no mentions field: Turbo Notify detects mentions in the text
itself. Every valid token becomes a real mention on the recipient’s device.
Token rules
| Rule | Behavior |
|---|---|
| Syntax | @ + 7 to 15 digits, delimited by a word boundary: name@domain.com, @@123, sequences longer than 15 digits and @+55… do not become mentions |
| Normalization | 12-digit Brazilian numbers are normalized to the 13-digit form: the stored and returned content carries the normalized token (e.g. @551177776666 → @5511977776666) |
| Invalid token | Invalid or non-normalizable digits stay as plain text: a send never fails because of a mention |
| Where it works | In 1:1 chats and groups, in text and in the caption of image/video/document (audio/sticker have no caption) |
| Reusing a received token | The @<number> token you receive in message.received can be pasted back into a send: the mention works without any conversion |
| Quota | Mentioning someone never consumes message or contact quota |
After the send, the message carries the mentions structure
(a list of {contact_id?, name?, number}; contact_id and name are omitted
when the mention does not match a known contact) in
GET /v1/messages/{message_id} and in the data.mentions
of the message.sent/message.replied/message.edited events.
A literal @<digits> in the text is a mention only if it appears in that structure.
The same detection rules apply to POST .../messages/{message_id}/reply
(the reply’s text/caption) and to POST .../messages/{message_id}/edit (the
new text or caption of the edit replaces the message’s entire set of mentions,
see Edit and delete messages).
Sending from an extra number
To send from an extra number registered and active in your account, put the number’s alias directly in the URL path.
curl -X POST \ https://api.turbonotify.com/v1/numbers/vendas-sp/messages \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": {"number": "+5511988887777"}, "content": { "text": "Message sent from an extra number." } }'const response = await fetch('https://api.turbonotify.com/v1/numbers/vendas-sp/messages', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ to: { number: '+5511988887777' }, content: { text: 'Message sent from an extra number.' } })});
const result = await response.json();console.log(result);import requests
url = "https://api.turbonotify.com/v1/numbers/vendas-sp/messages"headers = { "Authorization": "Bearer YOUR_KEY", "Content-Type": "application/json"}data = { "to": {"number": "+5511988887777"}, "content": { "text": "Message sent from an extra number." }}
response = requests.post(url, json=data, headers=headers)result = response.json()print(result)package main
import ( "fmt" "io" "net/http" "strings")
func main() { payload := strings.NewReader(`{"to": {"number": "+5511988887777"}, "content": {"text": "Message sent from an extra number."}}`) req, _ := http.NewRequest("POST", "https://api.turbonotify.com/v1/numbers/vendas-sp/messages", payload) req.Header.Set("Authorization", "Bearer YOUR_KEY") req.Header.Set("Content-Type", "application/json") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) fmt.Println(string(body))}<?php$url = 'https://api.turbonotify.com/v1/numbers/vendas-sp/messages';$data = [ 'to' => ['number' => '+5511988887777'], 'content' => [ 'text' => 'Message sent from an extra number.' ]];
$options = [ 'http' => [ 'header' => [ 'Authorization: Bearer YOUR_KEY', 'Content-Type: application/json' ], 'method' => 'POST', 'content' => json_encode($data) ]];
$context = stream_context_create($options);$result = file_get_contents($url, false, $context);$response = json_decode($result, true);var_dump($response);?>using System.Text;using System.Text.Json;
var client = new HttpClient();client.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_KEY");
var data = new { to = new { number = "+5511988887777" }, content = new { text = "Message sent from an extra number." }};
var json = JsonSerializer.Serialize(data);var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync( "https://api.turbonotify.com/v1/numbers/vendas-sp/messages", content);var result = await response.Content.ReadAsStringAsync();Console.WriteLine(result);import java.net.http.*;import java.net.URI;
public class TurboNotify { public static void main(String[] args) throws Exception { var client = HttpClient.newHttpClient();
var json = """ { "to": {"number": "+5511988887777"}, "content": { "text": "Message sent from an extra number." } } """;
var request = HttpRequest.newBuilder() .uri(URI.create("https://api.turbonotify.com/v1/numbers/vendas-sp/messages")) .header("Authorization", "Bearer YOUR_KEY") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); }}If the alias in the URL is not registered as an extra number or is not active, the send is rejected.
Learn more in Adding extra numbers.
Sending without duplicates
Sending (POST .../messages) and replying (POST .../messages/{message_id}/reply) accept the optional Idempotency-Key header: a string of up to 255 characters that you choose. If the request fails with a timeout or a network error and you repeat it with the same key, on the same number, the message is not sent again: the response carries the message_id of the original message. Without the key, repeating can deliver the message twice.
Use a new key for each new message, derived from something stable on your side (for example the id of the order that triggered the notification). An empty key or one longer than 255 characters is refused with 400 invalid_idempotency_key.
curl -X POST https://api.turbonotify.com/v1/messages \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4711-confirmation" \ -d '{"to": {"number": "+5511988887777"}, "content": {"text": "Your order is confirmed."}}'Possible errors
| Status | Error code | Description |
|---|---|---|
400 | contact_not_found | The contact_id provided does not belong to the organization. |
400 | contacts_not_enabled | Sending by contact_id is not set up. |
400 | invalid_phone_number | The to.number provided is not a valid phone number in E.164 format (e.g. +5511988887777). |
400 | invalid_recipient | The recipient given in to could not be resolved to a sending address. Providing none, or more than one, of number, group_id and contact_id returns 422 validation_error. |
400 | not_group_member | Your number is no longer a participant of the given group_id, see Sending to a group. |
402 | feature_not_available | Sending to a group (to.group_id) requires the Business plan or higher, see Sending to a group. |
402 | subscription_inactive | Subscription with a pending payment, ended, or with an expired trial (details.status). Blocks every send until the subscription is active again. |
402 | contact_quota_exceeded | The current cycle’s allowance of distinct contacts is used up, see Quota and usage. |
401 | not_authenticated | The API key is invalid or missing. |
403 | insufficient_scope | The API key does not have the messages:send scope (the response includes required_scope). |
404 | number_not_found | The alias or id in the URL does not match any number registered in the account. |
400 | invalid_number_id | The value in the URL starts with num_ but is not a valid number id. |
404 | group_not_found | The group_id given in to was not found in the account. |
409 | number_not_connected | The sending number is not connected to WhatsApp. Reconnect the number and try again. |
422 | validation_error | The request body is malformed or failed validation. The response includes details.errors with the rejected fields. |
422 | group_not_owned_by_number | The group_id provided belongs to another number in the account, not to the alias in the URL path. |
422 | message_not_replyable | (only POST .../messages/{message_id}/reply) The quoted message has not been sent to WhatsApp yet or has already been deleted. |
429 | messages_quota_exceeded | The current cycle’s message allowance is used up, see Quota and usage. |
429 | send_rate_spacing | Sent before the minimum interval between messages for your plan, see Rate limits. |
429 | rate_limit_exceeded | The API’s requests-per-second limit was exceeded. The other send protection limits (messages_per_minute_limit_exceeded, recipients_per_day_limit_exceeded, warmup_limit_exceeded, duplicate_burst_blocked, reply_ratio_too_low) have their own codes, see Rate limits. |
500 | internal_error | Unexpected internal server error. |
Message status
The status shows which stage the message is in. Each value is explained in Status.
{
"message_id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"status": {
"state": "pending",
"at": "2025-07-16T10:00:00Z",
"error": null
}
} The final outcome arrives by webhook with the message.sent, message.delivered, message.read or message.send.failed events.
Security and privacy
All communication between your system and the Turbo Notify API uses HTTPS, so data is encrypted in transit.
How long message content is kept (the messages themselves stay available for 12 months) is covered in Message retention.
Rich content: location, contact, calendar and link button
POST /v1/messages accepts four rich content
variants besides text. Each variant has its own anchor field
inside the content object and reaches WhatsApp as the corresponding
native type (locationMessage, contactMessage, eventMessage).
Send a location
curl -X POST \ https://api.turbonotify.com/v1/messages \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": {"number": "+5511988887777"}, "content": { "latitude": -23.5505, "longitude": -46.6333, "name": "Avenida Paulista", "address": "São Paulo, SP", "url": "https://maps.google.com/?q=-23.5505,-46.6333" } }'await fetch('https://api.turbonotify.com/v1/messages', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ to: { number: '+5511988887777' }, content: { latitude: -23.5505, longitude: -46.6333, name: 'Avenida Paulista', address: 'São Paulo, SP', url: 'https://maps.google.com/?q=-23.5505,-46.6333' } })});import requests
requests.post( "https://api.turbonotify.com/v1/messages", headers={ "Authorization": "Bearer YOUR_KEY", "Content-Type": "application/json", }, json={ "to": {"number": "+5511988887777"}, "content": { "latitude": -23.5505, "longitude": -46.6333, "name": "Avenida Paulista", "address": "São Paulo, SP", "url": "https://maps.google.com/?q=-23.5505,-46.6333", }, },)| Field | Type | Required | Validation |
|---|---|---|---|
content.latitude | number | yes | -90 ≤ x ≤ 90 |
content.longitude | number | yes | -180 ≤ x ≤ 180 |
content.name | string | no | up to 200 characters |
content.address | string | no | up to 500 characters |
content.url | string | no | http:///https:// only |
A location that was sent cannot be edited: deleting it is the only change. See what can be changed after sending.
Send a contact
curl -X POST \ https://api.turbonotify.com/v1/messages \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": {"number": "+5511988887777"}, "content": { "display_name": "João Silva", "phones": [ { "label": "Mobile", "number": "+5511977776666" }, { "label": "Work", "number": "+551133334444" } ], "emails": [ { "label": "Work", "address": "joao@acme.com" } ], "organization": "Acme Corp", "title": "CTO", "address": "Av. Paulista 1578, São Paulo - SP" } }'await fetch('https://api.turbonotify.com/v1/messages', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ to: { number: '+5511988887777' }, content: { display_name: 'João Silva', phones: [ { label: 'Mobile', number: '+5511977776666' }, { label: 'Work', number: '+551133334444' } ], emails: [ { label: 'Work', address: 'joao@acme.com' } ], organization: 'Acme Corp', title: 'CTO', address: 'Av. Paulista 1578, São Paulo - SP' } })});import requests
requests.post( "https://api.turbonotify.com/v1/messages", headers={ "Authorization": "Bearer YOUR_KEY", "Content-Type": "application/json", }, json={ "to": {"number": "+5511988887777"}, "content": { "display_name": "João Silva", "phones": [ {"label": "Mobile", "number": "+5511977776666"}, {"label": "Work", "number": "+551133334444"}, ], "emails": [ {"label": "Work", "address": "joao@acme.com"}, ], "organization": "Acme Corp", "title": "CTO", "address": "Av. Paulista 1578, São Paulo - SP", }, },)| Field | Type | Required | Notes |
|---|---|---|---|
content.display_name | string | yes | 1 to 200 characters |
content.phones[] | array | yes | 1 to 10 items; number in E.164 |
content.phones[].label | string | no | defaults to "Mobile" (Mobile/Home/Work/…) |
content.emails[] | array | no | 0 to 10 items; address validated as an email |
content.emails[].label | string | no | defaults to "Email" |
content.organization | string | no | up to 200 characters |
content.title | string | no | up to 100 characters |
content.address | string | no | up to 500 characters |
Turbo Notify builds the contact card (vCard) from these fields. A card that was sent cannot be edited: deleting it is the only change. See what can be changed after sending.
Send a calendar event
curl -X POST \ https://api.turbonotify.com/v1/messages \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": {"number": "+5511988887777"}, "content": { "name": "Sprint Planning", "description": "Sprint 24 planning.", "start_at": "2026-05-01T14:00:00Z", "end_at": "2026-05-01T15:00:00Z", "join_link": "https://meet.example.com/abc", "has_reminder": true, "reminder_offset_sec": 600, "location": { "latitude": -23.5505, "longitude": -46.6333, "name": "HQ - Main Room" } } }'await fetch('https://api.turbonotify.com/v1/messages', { method: 'POST', headers: { 'Authorization': 'Bearer YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ to: { number: '+5511988887777' }, content: { name: 'Sprint Planning', description: 'Sprint 24 planning.', start_at: '2026-05-01T14:00:00Z', end_at: '2026-05-01T15:00:00Z', join_link: 'https://meet.example.com/abc', has_reminder: true, reminder_offset_sec: 600, location: { latitude: -23.5505, longitude: -46.6333, name: 'HQ - Main Room' } } })});import requests
requests.post( "https://api.turbonotify.com/v1/messages", headers={ "Authorization": "Bearer YOUR_KEY", "Content-Type": "application/json", }, json={ "to": {"number": "+5511988887777"}, "content": { "name": "Sprint Planning", "description": "Sprint 24 planning.", "start_at": "2026-05-01T14:00:00Z", "end_at": "2026-05-01T15:00:00Z", "join_link": "https://meet.example.com/abc", "has_reminder": True, "reminder_offset_sec": 600, "location": { "latitude": -23.5505, "longitude": -46.6333, "name": "HQ - Main Room", }, }, },)| Field | Type | Required | Notes |
|---|---|---|---|
content.name | string | yes | event title; up to 200 characters |
content.description | string | no | up to 2000 characters |
content.start_at | datetime | yes | ISO-8601 with time zone (anchor field of the calendar variant) |
content.end_at | datetime | no | ISO-8601 with time zone; must be > start_at |
content.join_link | string | no | http:///https:// only |
content.location | object | no | same format as the location object |
content.has_reminder | bool | no | defaults to false |
content.reminder_offset_sec | int | no | seconds before the event; only takes effect with has_reminder=true and is ignored, without an error, when has_reminder is false or absent |
content.extra_guests_allowed | bool | no | defaults to false |
Send a link button
A message with a button that opens a link. The anchor field is
interactive_type, always "cta_url".
{ "to": { "number": "+5511988887777" }, "content": { "interactive_type": "cta_url", "body": "Your order has shipped.", "footer": "Example Store", "url_button": { "display_text": "Track", "url": "https://example.com/tracking/123" } }}| Field | Type | Required | Notes |
|---|---|---|---|
content.interactive_type | string | yes | always "cta_url" |
content.body | string | yes | 1 to 1024 characters |
content.header | object | no | { "type": "text", "text": "..." }, text with 1 to 60 characters |
content.footer | string | no | up to 60 characters |
content.url_button.display_text | string | yes | 1 to 20 characters |
content.url_button.url | string | yes | http:// or https:// URL |
A message with a link button cannot be edited after it is sent: deleting it is the only change. See what can be changed after sending.
How it looks to the recipient
Each type appears in the recipient’s WhatsApp with the native interface: location with a map and a button to open it, contact with a card that can be saved, calendar with an “Add to calendar” button, link button with the button right below the text.
The entry a guest creates in their calendar with the “Add to calendar” button does not follow an edit or a cancel of the event: see What happens to the guests.
To see exactly how the matching message.sent event reaches your webhook,
open the Webhook Inspector.
The “Enriched” panel renders these types with dedicated cards.
Sending to a group
To send to a group, the to field
takes the object { "group_id": "grp_..." } instead of
{ "number": "+5511988887777" }, which is used for 1:1 chats. See
Groups overview for details of the model.
{ "to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a" }, "content": { "text": "Good morning, team" }}The to field is polymorphic. It accepts exactly one of three forms (no kind or type):
to form | Destination |
|---|---|
{ "number": "+5511988887777" } | 1:1 message (a person) |
{ "group_id": "grp_..." } | Group message |
{ "contact_id": "ct_..." } | 1:1 message by Turbo Notify contact ID |
- The three forms are mutually exclusive. Send exactly one.
group_idis the publicgrp_{hex32}identifier received inmessage.receivedwebhooks/polling (to.group_id) or in the group lifecycle events.- The
contentobject uses the same format as 1:1 sends (text, image, video, audio, document, sticker, location, contact, calendar, link button), with no differences. - To quote a message inside the group, use the dedicated
POST /v1/numbers/{ref}/messages/{message_id}/replyendpoint.
The response has the standard format (message_id, status). The matching
message.sent webhook uses to.group_id (the same value you sent in the
request); the envelope’s number_alias identifies the number that sent it,
which helps you match received and sent messages in the same group.
Sending by contact_id
Use to.contact_id to send to a contact Turbo Notify already knows, without providing the phone number.
Turbo Notify uses the number recorded for that contact.
{ "to": { "contact_id": "ct_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }, "content": { "text": "Hi, how are you?" }}