Events via webhook
Turbo Notify notifies your system about events (confirmed send, delivery, read, received message, reaction, failure) via webhook (HTTP POST). The data object of each event is identical to the one returned by the GET /events endpoint.
If an AI agent is the one replying, the usual setup is: the webhook delivers the event to your application, and the agent acts through the tools of the MCP server (for example send_text). Without a public endpoint, the agent can read the same events with the list_events tool.
Receiving events
To start receiving events, register your webhook URL in the dashboard (opens in a new tab) . The URL must be public, reachable over HTTPS, and respond with a 2xx status within 10 seconds.
Privacy
Keep sensitive information, such as API keys or internal identifiers, out of your webhook URL. Use generic paths and validate each request with the signature header.
Security
Every webhook request sent by Turbo Notify includes the headers below. Use the signature to confirm the request came from Turbo Notify and not from a third party.
| Header | Description |
|---|---|
X-Webhook-Signature | sha256=<hex>: HMAC-SHA256 of {X-Webhook-Timestamp}.{raw_body} with your secret. |
X-Webhook-Timestamp | Unix epoch (seconds): use it to reject deliveries older than ~5 minutes (replay protection). |
X-Webhook-Attempt | Number of the current attempt (starts at 1, increases with each redelivery). |
X-Webhook-Max-Attempts | Total number of attempts Turbo Notify will make before giving up. |
User-Agent | TurboNotify-Webhook/1.0: useful to allow the requests through your web application firewall (WAF). |
The secret is defined by you, in the dashboard (opens in a new tab) , in the same form where you register the URL. Turbo Notify does not generate a secret: pick a long random value, store it in your application, and enter the same value on both sides.
Signature validation example:
import hmacimport hashlib
def verify_signature(payload: bytes, timestamp: str, signature: str, secret: str) -> bool: signed_data = f"{timestamp}.".encode() + payload expected = "sha256=" + hmac.new( secret.encode(), signed_data, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature)
# Usage:# timestamp = request.headers["X-Webhook-Timestamp"]# signature = request.headers["X-Webhook-Signature"]# verify_signature(request.body, timestamp, signature, secret)const crypto = require('crypto');
function verifySignature(payload, timestamp, signature, secret) { const signedData = `${timestamp}.${payload}`; const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(signedData) .digest('hex'); const expectedBuf = Buffer.from(expected); const signatureBuf = Buffer.from(signature ?? ''); if (expectedBuf.length !== signatureBuf.length) return false; return crypto.timingSafeEqual(expectedBuf, signatureBuf);}
// Usage:// const timestamp = req.headers['x-webhook-timestamp'];// const signature = req.headers['x-webhook-signature'];// verifySignature(req.body, timestamp, signature, secret);Event structure
Every webhook request follows the same base structure:
| Property | Type | Description |
|---|---|---|
id | string | Unique event identifier (evt_…). Use it as the idempotency key. |
type | string | Event type (e.g. message.sent). |
organization_id | string | Public identifier of your organization (org_{hex32}). Always present. |
number_alias | string | Identifier of the Turbo Notify number the event belongs to: the alias itself, "main" for the main number or the alias of an extra number. Present in events tied to a specific number: message.*, number.*, group.*, all contact.* (including contact.quota.*) and message.quota.* (the message quota is per number). Omitted in storage.* and in media.unavailable, the events that do not belong to a single number. |
number_id | string | Identifier of the number (num_…), right after number_alias. Present exactly when number_alias is present, and it never changes: it is the key to store. An event’s number_alias is the alias the number had when the event happened, so after a rename older and newer events of the same number carry different aliases and the same number_id. |
created_at | string | Moment Turbo Notify emitted the event, distinct from data.at (moment of the action on WhatsApp). ISO 8601 in UTC with a Z suffix and second precision (e.g. 2026-06-01T13:00:05Z). |
data | object | Event-specific data (see Message fields). |
Message fields
All message.* events share the fields below in data:
| Field | Type | Description |
|---|---|---|
id | string | null | Public message identifier (msg_{hex32}). null only in message.send.failed (record not created). |
from | object | Who originated the event. Person (inbound): {contact_id?, name?, number}. Outbound: {contact_id?, name?, alias, number}. |
to | object | Recipient. 1:1 inbound: {contact_id?, name?, alias, number}, the alias is always present and identifies your number that received it. 1:1 outbound: {contact_id?, name?, number}. Group: {group_id, name?}. |
preview | string | Excerpt of the content (up to 50 characters). See Preview by type. |
content_type | string | text, image, audio, video, document, sticker, location, calendar, contact, poll, interactive. Messages of an unknown type arrive as unsupported. It is "" (empty string) in message.deleted, message.edit.failed and message.delete.failed: those operations do not repeat the type of the original content. |
direction | string | "outbound" (action by your organization, through the API or the phone) or "inbound" (action by the contact, coming from WhatsApp). |
channel | string | Always "whatsapp". |
origin | string | "tn" (action originated through the Turbo Notify API) or "native" (originated in the WhatsApp channel). Inbound events (message.received/delivered/read) are always "native". Always present. |
at | string | ISO 8601 in UTC with a Z suffix and second precision (e.g. 2026-06-01T13:00:05Z): moment of the actual action on WhatsApp (send, delivery, read, edit, etc.). Distinct from created_at (moment Turbo Notify emitted the event). |
mentions | array | Optional: present only when the message mentions someone, and only in the events that carry the message body: message.received, message.replied, message.sent, message.edited. Items {contact_id?, name?, number} (same format as the inbound from), in the order of the tokens in the text, without duplicates. An @<digits> in the preview/text is a mention only if the number is listed here. Absent in delivered/read/deleted/reacted and in the *.failed events. In message.edited, it is the set re-extracted from the new text (or the new caption). See Mentions (@mentions). |
canceled | boolean | Optional: true only on message.edited of a canceled calendar event. Absent in every other case. See Calendar events. |
Field order in
from/to: alwayscontact_id, name, alias, number(alias where it exists; optional fields omitted whennull). The grouptois the exception: it keeps{group_id, name?}. Your number involved in the event is identified by thenumber_aliasof the envelope.created_at(envelope) is the moment the event was emitted;at(indata) is the moment of the action on WhatsApp: distinct values.
nameinfrom/tois the push name (display name) the contact set on WhatsApp. Turbo Notify never infers it or looks it up in an address book. It becomes available when the contact has already sent at least one inbound message to your organization (that is how WhatsApp delivers the push name), or when the destination number is itself a Turbo Notify number of another organization whose name is already known. Outside these two cases, in amessage.sentto someone who never appeared in either source,to.nameis omitted: this is not an error; treat the absence as “name not known yet”, not as a delivery failure.
message.*.failed events add a structured error object: the same
format as the status.error returned by GET /v1/numbers/{ref}/messages/{id} and
GET /v1/numbers/{ref}/messages/{id}/status, so the event and the lookup say the same thing:
| Field | Type | Description |
|---|---|---|
error | object | The failure error. Present in all *.failed events. |
error.code | string | Stable machine-readable code: branch on this value (e.g. media_fetch_failed, unsupported_media_format). |
error.message | string | Human-readable description of the failure. |
error.hint | string | Actionable guidance to fix it, derived from the code. Omitted (key absent, never null) when there is no guidance for the code. |
Preview by type
content_type | Preview |
|---|---|
text | Truncated text |
image | Caption or [Image] |
sticker | [Sticker] |
video | Caption or [Video] |
audio | Transcription (when available) → caption → [Audio] |
document | Caption → file name → [Document] |
location | Readable text without brackets, e.g. Av. Paulista, Sao Paulo, SP |
calendar | No brackets: weekday and month abbreviated in English, time in UTC. E.g. Meeting — Tue, 2 Jun 2026 14:00 |
contact | [Contact: name] or [Contact] when the card has no name |
poll | "" (empty string) |
interactive | Body text (body) of the interactive message, or [Interactive] when the body is empty |
unsupported | "" (empty string): message type not recognized by Turbo Notify |
Example: message.sent (outbound)
{ "id": "evt_b7adf529683940edcdc533081ae182c5", "type": "message.sent", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:00:06Z", "data": { "id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70", "from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "to": { "name": "João Silva", "number": "5511977776666" }, "preview": "Your message...", "content_type": "text", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T13:00:05Z" }}created_at (13:00:06, emitted by Turbo Notify) is distinct from data.at
(13:00:05, send confirmation on WhatsApp). The event was emitted 1 s after
the action.
The number_alias of the envelope identifies your number involved in the event. In outbound 1:1 conversations that number matches from.
origin tells the source of the outbound send apart:
"tn": sent through the Turbo Notify API."native": detected directly from the WhatsApp app (when the organization types on the phone).
content_type and preview mirror message.received: text, image, audio, video, document, sticker, location, calendar, contact, poll or interactive. For the full body, call GET /v1/numbers/{ref}/messages/{id}.
Example: message.received (inbound)
{ "id": "evt_2b09b39a44ffc0d3039251f995e4c284", "type": "message.received", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T18:32:11Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "preview": "Hi, I'd like to know...", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:32:11Z" }}from.name carries the display name set on WhatsApp (push name); omitted when not set. The number_alias of the envelope resolves to "main" or the alias of the extra number that received the message.
Example: message.received (group)
When a message arrives from a WhatsApp group,
to carries group_id and the group name at the time of the event (name) instead of number.
Your number that takes part in the group is identified by the number_alias of the envelope.
from is still the participant who sent it: the sender is always a person, never the group.
For 1:1 messages, to.group_id is omitted.
{ "id": "evt_c00a44c3fc774698b0f130d40bb5f104", "type": "message.received", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-02T01:55:14Z", "data": { "id": "msg_e95500cc03c1492e81e46a77c1f0d420", "from": { "contact_id": "ct_45f2ece8a66a4eeaa661aeb9b8e58c77", "name": "Ana Souza", "number": "5511988887777" }, "to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a", "name": "TNT" }, "preview": "Good morning, everyone", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-02T01:55:14Z" }}to.group_idis the public group identifier (grp_{hex32}), stable per organization. Raw WhatsApp JIDs are never exposed.- The
number_aliasof the envelope identifies your number that takes part in the group. Use it to tell which number received it when the organization has several extra numbers. - To detect group messages: check
"group_id" in data.to. 1:1 conversations never carry this field.
Example: message.received with mentions
When the text (or the caption) mentions someone, the
body arrives with the tokens already normalized to @<number> (E.164 digits without
+) and data.mentions carries the matching contacts, already resolved:
{ "id": "evt_f11b55d4ed885709c1f241e51cc6f215", "type": "message.received", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-07-03T12:15:02Z", "data": { "id": "msg_f06611dd14d2503f92f57b88d2f1e531", "from": { "contact_id": "ct_45f2ece8a66a4eeaa661aeb9b8e58c77", "name": "Ana Souza", "number": "5511988887777" }, "to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a", "name": "TNT" }, "mentions": [ { "contact_id": "ct_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "name": "João Silva", "number": "5511977776666" } ], "preview": "Hi @5511977776666, can you review the contract?", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-07-03T12:15:01Z" }}mentionsappears only in the events that carry the message body (message.received,message.replied,message.sent,message.edited) and only when there are mentions.- An
@<digits>in the text/previewis a mention only if the number is listed inmentions. Otherwise it is plain text. - The same token you receive can be pasted back into a send with a mention.
Example: message.edited
{ "id": "evt_1f2e21dbffd042ed8913f047c40360e4", "type": "message.edited", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T14:00:00Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "preview": "Corrected message text", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T14:00:00Z" }}{ "id": "evt_918e22c6067759c500da08b4b58d1e8e", "type": "message.edited", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T14:05:01Z", "data": { "id": "msg_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", "from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "to": { "name": "João Silva", "number": "5511977776666" }, "preview": "Updated message text", "content_type": "text", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T14:05:00Z" }}A calendar event that was edited or canceled also arrives as message.edited, with content_type: "calendar" and the event name in preview. Examples are in Calendar events.
When the caption of an image, video or document is edited, message.edited carries the content_type of the file (image, video or document) and the new caption in preview (up to 50 characters, as with text). The same holds when the caption is edited in the WhatsApp app (origin: "native"). See Edit a caption.
Example: message.deleted
{ "id": "evt_5b285e5daa729a6dbbe721b7dbfb0572", "type": "message.deleted", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T15:00:00Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "preview": "", "content_type": "", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T15:00:00Z" }}{ "id": "evt_aee054157f581b985d932b19cd360a07", "type": "message.deleted", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T15:05:01Z", "data": { "id": "msg_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", "from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "to": { "name": "João Silva", "number": "5511977776666" }, "preview": "", "content_type": "", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T15:05:00Z" }}In message.deleted, both preview and content_type are always
"" (empty string). The event only tells you the message was deleted, not
what it contained. Do not branch on content_type here; use data.id
to find the message on your side.
Example: message.reacted
For message.reacted: content_type is always "text" and preview contains the emoji (or "" for a removal). Use GET /v1/numbers/{ref}/messages/{id} for the full reaction details.
{ "id": "evt_ba9178bb9becc1b3b37e4acc3aa31c93", "type": "message.reacted", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T16:00:00Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "preview": "👍", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T16:00:00Z" }}{ "id": "evt_4b34400f674df225f2f38dd79eba6227", "type": "message.reacted", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T16:05:01Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "to": { "name": "João Silva", "number": "5511977776666" }, "preview": "❤️", "content_type": "text", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T16:05:00Z" }}To remove a reaction you sent earlier, send the reaction property as null in POST /v1/numbers/{ref}/messages/{id}/react. See Send a reaction. The message.reacted event is emitted with preview: "".
Example: message.replied (inbound/outbound)
Replies (messages that quote another one) are delivered as a dedicated event. preview contains the preview of the new message (the reply, not the quoted message). quoted_message_id identifies the quoted message in the msg_<hex32> format; omitted when the target could not be resolved (a message from before you used Turbo Notify, or removed by retention: use GET /v1/numbers/{ref}/messages/{id} to read replied.message_id and replied.preview in those cases).
{ "id": "evt_cad607ebaa2f414ac955d3c726bb7e8b", "type": "message.replied", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:00:00Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "preview": "Yes, that's the order!", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "quoted_message_id": "msg_c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", "at": "2026-06-01T13:00:00Z" }}{ "id": "evt_debdd6a4dc6642778f08bc4de99798c2", "type": "message.replied", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:01:01Z", "data": { "id": "msg_b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", "from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "to": { "name": "João Silva", "number": "5511977776666" }, "preview": "No problem, I'll check.", "content_type": "text", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "quoted_message_id": "msg_c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", "at": "2026-06-01T13:01:00Z" }}To quote an earlier message through the API, use the dedicated endpoint POST /v1/numbers/{ref}/messages/{message_id}/reply (flat body). To answer the message that just arrived, send a normal message. See Sending messages.
Example: message.delivered / message.read (inbound receipt)
Receipts are always inbound. from is who triggered the receipt; to identifies your organization’s side (1:1: your number; group: the group). Your number is given by the number_alias of the envelope.
{ "id": "evt_f4702fbb477a28939969781b0a3f5964", "type": "message.delivered", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:00:30Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "preview": "Hi, I'd like to know...", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T13:00:30Z" }}{ "id": "evt_38059003a68e27e22391e62d7c8b94ac", "type": "message.read", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:01:15Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "preview": "Hi, I'd like to know...", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T13:01:15Z" }}preview follows the same plan content retention period as the full message body: it is not permanent. A delivery that has not completed yet keeps the content until it is delivered or the retries end (at most 72 hours). Once the delivery has completed and the plan’s period has passed, preview and transcription (when present) carry a redaction marker instead of the original content in a manual redelivery and in the events of GET /v1/events, which keeps returning the event. The message stays available by id in GET /v1/numbers/{ref}/messages/{id}, with content_expired: true and an empty preview: only content expires. See Message retention for the full model (message record: 12 months on every plan; content, including preview: 1 hour by default, up to 7 hours on Lone Wolf and up to 72 hours on Business and Custom).
Group receipts: one per participant
When you send a message to a group, every other participant
generates its own delivered (on receiving it) and read (on reading it). In each
receipt: from = that participant, to = the group. Your number that
observed the receipt is given by the number_alias of the envelope.
{ "id": "evt_7857525b786570587e8170fdc73a60aa", "type": "message.read", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:01:15Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a", "name": "TNT" }, "preview": "Hi, team!", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T13:01:15Z" }}from.numberchanges with each participant that confirms;to(the group) and thenumber_aliasof the envelope (your number) stay the same for every receipt of the same message.- To detect group receipts: check
"group_id" in data.to.
Examples by content_type (inbound)
The webhook envelope has the same structure for every inbound
type. Only data.content_type and data.preview change. The
snippets below show only the data object to reduce visual noise.
To get the full body (text, vCard, coordinates, event
description, media link, etc.), call GET /v1/numbers/{ref}/messages/{id}.
text: plain text
{ "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "Hi, I'd like to know...", "content_type": "text", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:32:11Z"}Replies (messages that quote another one) are delivered as the dedicated
message.replied event, never as message.received with
content_type=reply.
image
{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0010", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "Product photo", "content_type": "image", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:34:01Z"}Events do not include the media block. Call GET /v1/numbers/{ref}/messages/{id} to get the image block with the download URL.
video
{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0011", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "[Video]", "content_type": "video", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:35:12Z"}audio: voice message or music
{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0012", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "[Audio]", "content_type": "audio", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:36:08Z"}{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0012", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "Hi, can you confirm the order?", "content_type": "audio", "direction": "inbound", "channel": "whatsapp", "origin": "native", "transcription": { "kind": "available", "text": "Hi, can you confirm the order?", "language": "en" }, "at": "2026-06-01T18:36:08Z"}{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0012", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "[Audio]", "content_type": "audio", "direction": "inbound", "channel": "whatsapp", "origin": "native", "transcription": { "kind": "unavailable", "reason": "timeout" }, "at": "2026-06-01T18:36:08Z"}In GET /v1/numbers/{ref}/messages/{id}, the typical mime_type of the audio block is audio/ogg (voice message) or audio/mpeg (music).
When BYO Speech-to-Text is turned on for your organization (see Audio transcription), inbound audio messages gain the transcription field: a union discriminated by kind (omitted when the message is not audio):
{"kind": "available", "text": "...", "language"?: "pt"}: transcription completed;languageis the BCP-47 code of the detected/configured language when available.{"kind": "unavailable", "reason": "...", "hint"?: "..."}: transcription not available.reasonis one of these 6 values:not_configured,provider_error,file_too_large(audio over 24 MB),timeout,provider_not_supported(the configured transcription provider is not supported right now) orinternal_error.hintis optional guidance, written for whoever reads the event (with the link to the dashboard Transcription screen when there is something to adjust there).
A transcription failure never blocks the message flow.
document
{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0013", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "invoice-april.pdf", "content_type": "document", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:37:00Z"}sticker
{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0014", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "[Sticker]", "content_type": "sticker", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:37:33Z"}location
{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0001", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "Av. Paulista, Sao Paulo, SP", "content_type": "location", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:38:21Z"}preview is the readable text without brackets. Full body via GET /v1/numbers/{ref}/messages/{id} in the location field with latitude, longitude, name, address, etc.
calendar: calendar event
{ "id": "msg_a1b2c3d4e5f60718293a4b5c6d7e0003", "from": { "name": "João Silva", "number": "5511977776666" }, "to": { "alias": "main", "number": "5511973117056" }, "preview": "Sprint Planning — Mon, 1 Jun 2026 17:00", "content_type": "calendar", "direction": "inbound", "channel": "whatsapp", "origin": "native", "at": "2026-06-01T18:40:00Z"}Full body via GET /v1/numbers/{ref}/messages/{id} in the calendar field with name, description, start_at, end_at, etc.
Event types
Your webhook receives exactly the 43 event types documented here,
and no others. This
page covers the 25 events of the message.*, number.*,
storage.* and media.* families. The other 18 live in three
dedicated pages: 6 contact.* events (including contact.quota.*) in
Contact events, 10 group.* events in
Group lifecycle events, and 2
message.quota.* events in Message quota events.
They all arrive at the same configured endpoint, with no separate registration.
Messages: send cycle
| Type | When it is emitted |
|---|---|
message.sent | Message sent successfully to WhatsApp (no quote). |
message.send.failed | Failure sending a regular message (no quote). |
message.delivered | Receipt: the recipient received a message you sent. |
message.read | Receipt: the recipient read a message you sent. |
Messages: replies (quotes)
| Type | When it is emitted |
|---|---|
message.replied | Reply sent or received (any content_type, in both directions). |
message.reply.failed | Failure sending a reply (through POST /v1/numbers/{ref}/messages/{message_id}/reply). |
Messages: receiving and operations
| Type | When it is emitted |
|---|---|
message.received | Message received from a contact (text, media, no quote). |
message.edited | A message was edited (yours or the contact’s), a caption was edited, or a calendar event was edited or canceled. |
message.edit.failed | Failure editing a message, a caption or a calendar event. |
message.deleted | A message was deleted (yours or the contact’s). |
message.delete.failed | Failure deleting a message. |
message.reacted | A reaction was added or removed (preview = emoji or ""). |
message.react.failed | Failure reacting to a message. |
message.calendar_responded | A guest answered a calendar event (response = going, not_going or maybe). See Calendar events. |
Numbers: lifecycle
| Type | When it is emitted |
|---|---|
number.created | Extra number created. |
number.connected | Main or extra number connected to WhatsApp. |
number.disconnected | The number stayed disconnected beyond the automatic reconnection window. |
number.connect.failed | Pairing/connection failure. |
number.revoked | Device unlinked by WhatsApp. |
number.remove.scheduled | Deletion of an extra number scheduled (it stays active until the end of the period already paid). |
number.remove.canceled | The scheduled deletion was canceled. |
number.removed | Extra number was removed. |
All number.* events carry the common fields id (the number’s identifier, num_…, equal to the envelope’s number_id), alias and number (when available). id is the first field of data and never changes, even if the alias is renamed: a platform that resells numbers uses id to route the event to the right customer without a lookup. message.* events and the other events of a number identify the number by the envelope’s number_alias and number_id. The name field (the number’s WhatsApp display name, captured on connection) is optional in all events and omitted when null. The alias "main" identifies the main number; any other value identifies an extra number. number.connected, number.disconnected, number.connect.failed and number.revoked can also carry contact_id (ct_…, the contact that represents the number itself), omitted when it does not exist yet. number.disconnected and number.revoked always carry reason, a stable code that says what happened (see Disconnect reasons).
All number.* events also carry channel (always "whatsapp") and origin (always "tn": Turbo Notify is the one reporting the number’s lifecycle). number.* events do not carry direction, because the number’s lifecycle (creation, connection, disconnection, revocation, removal) is not an exchange of messages on WhatsApp.
Automatic reconnection. When a number’s connection drops and Turbo Notify can bring it back on its own (the connection to WhatsApp dropped, or Turbo Notify moved the connection during maintenance, a load redistribution or the recovery from a failure on its side), nothing is sent right away. If the number comes back within the automatic reconnection window (about one minute), no number.disconnected and no number.connected are sent: as far as you are concerned, the number never went down. If it does not come back in time, a single number.disconnected is sent at the end of the window, with at equal to the moment the number went offline; that is why this at can be about one to two minutes earlier than the envelope created_at. Turbo Notify keeps trying, and number.connected arrives when the number comes back. A disconnection that needs you is sent right away (see the reasons below).
Disconnect reasons
Use reason to decide what to show your customer. The values are stable and written from their point of view.
| Event | reason | What happened and what to do |
|---|---|---|
number.disconnected | connection_lost | The connection to WhatsApp dropped and did not come back within the automatic reconnection window. Turbo Notify keeps reconnecting: you do not need to do anything. |
number.disconnected | service_interruption | An interruption on Turbo Notify’s side took the number offline. It comes back on its own; if it stays disconnected, connect it again (POST /v1/numbers/{ref}/connect). |
number.disconnected | replaced_by_another_device | Another connection took over this number’s linked device. Sent right away. |
number.disconnected | disconnected_by_request | Someone in your account disconnected or removed the number. Sent right away. |
number.revoked | logged_out_on_phone | The device was logged out on the phone (WhatsApp, Linked devices). Pair the number again. |
number.revoked | replaced_by_another_device | Another connection took over this number’s linked device and, for security, it was unlinked. Pair the number again. |
The same code has the same meaning in every number.* event: the event type tells you the consequence (number.disconnected: the number is still paired and can come back; number.revoked and number.connect.failed: it has to be paired again). New reasons may be added. Treat a value you do not know as connection_lost in number.disconnected and as logged_out_on_phone in number.revoked.
{ "id": "evt_6f0e1d2c3b4a59687766554433221100", "type": "number.disconnected", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:06:21Z", "data": { "id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "name": "Turbo Notify", "alias": "main", "number": "5511973117056", "reason": "connection_lost", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T13:05:12Z" }}The at field (in data) carries the moment of the actual action on WhatsApp, and it is a value distinct from the envelope created_at (moment Turbo Notify emitted the event). The real-action events number.connected, number.disconnected, number.revoked and number.connect.failed carry at with that genuine WhatsApp moment (typically earlier than created_at); the events generated by Turbo Notify itself (number.created, number.remove.scheduled, number.remove.canceled and number.removed) do not carry at, only the envelope created_at (number.remove.scheduled carries scheduled_at, the moment the deletion will happen).
{ "id": "evt_2dd89f2fad140cf3ba32f2ed9f289a22", "type": "number.connected", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:00:01Z", "data": { "id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "name": "Turbo Notify", "alias": "main", "number": "5511973117056", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T13:00:00Z" }}number.connect.failed carries two fields about the failure: reason, a
stable code to decide what to show your customer, and error, an English
sentence that is always the same for the same reason. Decide by reason and
use error only in your logs. The reason values are the same as the status
reason field, listed in
Failure reasons. Treat an unknown
value as pairing_failed.
{ "id": "evt_4bcdecc36d67daab8f863f45a288c624", "type": "number.connect.failed", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "vendas_sp", "number_id": "num_4e5f60718293a4b5c6d7e8f90a1b2c3d", "created_at": "2026-06-01T13:03:01Z", "data": { "id": "num_4e5f60718293a4b5c6d7e8f90a1b2c3d", "alias": "vendas_sp", "number": "5511988887777", "error": "Pairing was not completed in time. Request a new code or QR code and try again.", "reason": "pairing_timeout", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T13:03:00Z" }}Error sentence per reason
reason | error |
|---|---|
pairing_timeout | Pairing was not completed in time. Request a new code or QR code and try again. |
paired_phone_mismatch | A different WhatsApp account completed the pairing. Pair again using the phone of the registered number. |
phone_already_bound | This phone number is already linked to another number in the account. Use a different phone number or remove the other number. |
main_number_not_connected | The main number must be connected before you pair extra numbers. Reconnect the main number and try again. |
code_generation_failed | WhatsApp did not provide a pairing code or QR code. Try again in a moment. |
connection_lost | The number lost its connection to WhatsApp. Pair it again. |
pairing_failed | Pairing could not be completed. Pair the number again. |
New reasons may be added, each with its own sentence.
Storage and media
Events related to the BYO Object Storage integration (AWS S3, Cloudflare R2, Supabase Storage) and to the availability of received media.
Storage health
| Type | When it is emitted |
|---|---|
storage.degraded | The periodic checks of your bucket failed several times in a row. Sent once per outage, not on every check that keeps failing. In the meantime, received media is not saved to the bucket. |
storage.recovered | Sent once, on the first successful check after a storage.degraded. Received media is saved to your bucket again. |
Media availability
| Type | When it is emitted |
|---|---|
media.unavailable | The media could not be saved to your bucket (reason: no_storage, storage_circuit_open, upload_failed). |
Example: storage.degraded
{ "id": "evt_85c989abd492e9905d8f038b981a4d6d", "type": "storage.degraded", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "created_at": "2026-06-01T13:00:00Z", "data": { "reason": "healthcheck_failed", "consecutive_failures": 5, "error_code": "access_denied", "affected_messages_count": 0, "channel": "whatsapp", "origin": "tn" }}Why the storage degraded
storage.degraded carries two fields that say what happened, reason and error_code. Both have a closed list of values. New values may be added: treat an error_code you do not know as unknown_error.
reason says which check failed:
reason | What happened |
|---|---|
healthcheck_failed | The periodic checks of your bucket failed several times in a row. error_code says how the provider answered. |
error_code says how the storage provider answered the check that failed:
error_code | What happened and what to do |
|---|---|
access_denied | The credentials are wrong or have no permission on the bucket. Check the access key and the read, write and delete permissions. |
bucket_unreachable | The bucket does not exist for this region or endpoint. Check the bucket name, the region and the endpoint. |
region_mismatch | The region saved for the integration is not the bucket’s region. Correct the region. |
endpoint_unreachable | The endpoint could not be reached. Check the address and that the provider is up. |
endpoint_url_blocked | The saved endpoint address is refused because it does not point to a public storage service. Enter the address the provider publishes. |
network_error | The connection to the provider failed or took too long. If it keeps happening, check the provider’s status. |
quota_exceeded | The provider says the account is over its storage quota or is throttling requests. Free space or raise the limit at the provider. |
object_not_found | The provider could not find the bucket or the test file the check had just written. Check the bucket name and region. |
provider_unavailable | The provider answered with an outage, for example a paused project or maintenance. Check the account status in the provider’s dashboard. |
unknown_error | Any other failure. Review the credentials and the account status at the provider. |
When the storage answers again, you receive storage.recovered.
Example: storage.recovered
{ "id": "evt_57b5936ddcfaa5c4a714c25321e62bb7", "type": "storage.recovered", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "created_at": "2026-06-01T13:08:00Z", "data": { "degraded_since": "2026-06-01T13:00:00Z", "duration_seconds": 480, "channel": "whatsapp", "origin": "tn" }}Example: media.unavailable
{ "id": "evt_42a7e38a04bef3cfe4f6a2cb6b387f06", "type": "media.unavailable", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "created_at": "2026-06-01T14:15:30Z", "data": { "id": "msg_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "content_type": "image", "direction": "inbound", "reason": "no_storage", "channel": "whatsapp", "origin": "tn" }}Failure events
Failure events share a common envelope and differ from one another in their set of extra fields. id can be null only in message.send.failed (when the record was not created).
message.send.failed / message.reply.failed
{ "id": "evt_e80780d9147341cab5a0791102f44c02", "type": "message.send.failed", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:00:01Z", "data": { "id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70", "from": { "alias": "main", "number": "5511973117056" }, "to": { "number": "5511988887777" }, "preview": "Message that failed", "content_type": "text", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "error": { "code": "number_not_connected", "message": "The number is not connected to WhatsApp.", "hint": "The number was not connected to WhatsApp. Reconnect it, then send the message again." }, "at": "2026-06-01T13:00:00Z" }}from and to are always present in every message.*.failed event,
even when no message record was created (id: null).
If the failure happens before the recipient is identified, from carries the
number and alias that tried to send. The error.hint field carries the guidance
for the code, as in the example above (number_not_connected: the number was
not connected, reconnect it and send again), and it is omitted (not null)
when there is no guidance for the code.
message.edit.failed / message.delete.failed / message.react.failed
Unlike message.send.failed, these operations fail on a message that already exists: id is always present.
{ "id": "evt_833a2144b32918475ce853581a753a5f", "type": "message.edit.failed", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:05:01Z", "data": { "id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70", "from": { "alias": "main", "number": "5511973117056" }, "to": { "number": "5511988887777" }, "preview": "", "content_type": "", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "error": { "code": "edit_failed", "message": "We could not edit this message on WhatsApp. Try again in a moment." }, "at": "2026-06-01T13:05:00Z" }}WhatsApp only accepts the edit of a text or a caption within 15 minutes of sending it: after that POST .../edit itself answers 400 edit_window_expired and sends nothing. When WhatsApp refuses an edit the API accepted (a calendar event, for example), Turbo Notify emits message.edit.failed with error.code: "edit_failed". POST .../edit also rejects messages that are not in an editable state, with 400 message_not_editable. See Edit and delete.
When a calendar event edit fails, error.code can be message_secret_unavailable (this number can no longer change the event, for example because it was linked again after the event was sent) or calendar_edit_unavailable (the platform cannot change events at the moment). In both cases the error.hint tells you to send a new event.
message.delete.failed follows exactly the same shape: only the value of
type changes; preview and content_type are also "".
message.react.failed differs in one point besides the type: its
content_type is always the fixed literal "text" (a reaction is always text),
while edit.failed and delete.failed carry "".
Group messages in outbound webhooks
When you send a message to a group through
POST /v1/numbers/{ref}/messages with to.group_id, the subsequent webhooks
(message.sent, message.delivered, message.read,
message.send.failed) use the same format: to carries
group_id and the group name (name), and the number_alias of the envelope identifies your number that sent it:
{ "id": "evt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "type": "message.sent", "organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "number_alias": "main", "number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "created_at": "2026-06-01T13:00:06Z", "data": { "id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70", "from": { "name": "Turbo Notify", "alias": "main", "number": "5511973117056" }, "to": { "group_id": "grp_e442553a56b14e2695f6acfd7d651b1a", "name": "TNT" }, "preview": "Good morning, team", "content_type": "text", "direction": "outbound", "channel": "whatsapp", "origin": "tn", "at": "2026-06-01T13:00:05Z" }}to.group_idis the public identifiergrp_{hex32}. The same value provided inPOST /v1/numbers/{ref}/messages.- The
number_aliasof the envelope identifies your number that sent the message to the group. - To detect group messages: check
"group_id" in data.to.
Mapping asynchronous operations
API operations that trigger asynchronous processing expose X-Request-ID in the response header. Use GET /v1/events?request_id=req_... to query the operation’s events and identify the outcome by the message id (data.id):
| Operation | Success event | Failure event |
|---|---|---|
| Send a message | message.sent | message.send.failed |
| Reply to a message | message.replied | message.reply.failed |
| Edit a message | message.edited | message.edit.failed |
| Delete a message | message.deleted | message.delete.failed |
Reactions and number operations are not covered by this filter. For a reaction,
follow message.reacted/message.react.failed by the data.id of the
target message. For numbers, follow number.connected,
number.connect.failed, number.remove.scheduled and number.remove.canceled
by number (for example GET /v1/events?number_id=num_4e5f60718293a4b5c6d7e8f90a1b2c3d or GET /v1/events?number_alias=vendas_sp).
Delivery attempts
If your endpoint does not respond with a 2xx status within 10 seconds, Turbo Notify schedules another attempt with a random delay: each wait is a random value between 0 and the maximum shown, never a fixed one:
| Attempt | Delay before this attempt |
|---|---|
| 1 | None: initial delivery |
| 2 | ~0 s (next redelivery cycle, ≤15 s) |
| 3 | up to 5 seconds |
| 4 | up to 5 minutes |
| 5 | up to 30 minutes |
| 6 | up to 2 hours |
After the 6th attempt fails, the event is marked as dead_letter and no further attempt is made.
Delivery guarantee and idempotency
Turbo Notify delivers each event at least once. In exceptional situations, the same event may be delivered more than once. Use the id field to implement idempotency and avoid duplicate processing.
The id field as the idempotency key
The id field (e.g. evt_3f1c9a7e2b8d4c6fa0e15b7d9c2a4e68) identifies the event uniquely and is stable across delivery attempts. If the same id reaches your endpoint twice, it is an automatic redelivery by Turbo Notify. The event was already processed on the first delivery.
Store the id values you receive and ignore events whose id is already in your database:
const seen = new Set();
function handleEvent(event) { if (seen.has(event.id)) { return; // redelivery: already processed } seen.add(event.id); // ... process}In production, keep the id in a table or in a cache that holds it for at least three days.
Timestamps on redeliveries
The created_at field (envelope) reflects the moment Turbo Notify emitted the event: when the event record is created and published, never the time the webhook was sent to your endpoint. When present, at (in data) reflects the moment of the actual action on WhatsApp. These are distinct values: an event can be emitted some time after the action it describes (events generated by Turbo Notify itself have no at). On redeliveries, both created_at and at stay unchanged: they are the same values as in the original attempt.
When you receive a redelivery:
created_atis the emission moment;at(when present) is the moment of the action on WhatsApp, both fixed across attempts.X-Webhook-Attemptincreases with each attempt: use it for diagnostics, not for business logic.X-Webhook-Timestampreflects the time of the current HTTP send and is used for signature validation and replay protection; it is not the event timestamp.
Alternative: polling
If you prefer to consume events on demand instead of exposing a public endpoint, use polling via GET /events. The data object is the same as in the webhook.