Skip to content

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.

HeaderDescription
X-Webhook-Signaturesha256=<hex>: HMAC-SHA256 of {X-Webhook-Timestamp}.{raw_body} with your secret.
X-Webhook-TimestampUnix epoch (seconds): use it to reject deliveries older than ~5 minutes (replay protection).
X-Webhook-AttemptNumber of the current attempt (starts at 1, increases with each redelivery).
X-Webhook-Max-AttemptsTotal number of attempts Turbo Notify will make before giving up.
User-AgentTurboNotify-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:

validation.py
import hmac
import 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)
validation.js
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:

PropertyTypeDescription
idstringUnique event identifier (evt_…). Use it as the idempotency key.
typestringEvent type (e.g. message.sent).
organization_idstringPublic identifier of your organization (org_{hex32}). Always present.
number_aliasstringIdentifier 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_idstringIdentifier 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_atstringMoment 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).
dataobjectEvent-specific data (see Message fields).

Message fields

All message.* events share the fields below in data:

FieldTypeDescription
idstring | nullPublic message identifier (msg_{hex32}). null only in message.send.failed (record not created).
fromobjectWho originated the event. Person (inbound): {contact_id?, name?, number}. Outbound: {contact_id?, name?, alias, number}.
toobjectRecipient. 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?}.
previewstringExcerpt of the content (up to 50 characters). See Preview by type.
content_typestringtext, 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.
directionstring"outbound" (action by your organization, through the API or the phone) or "inbound" (action by the contact, coming from WhatsApp).
channelstringAlways "whatsapp".
originstring"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.
atstringISO 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).
mentionsarrayOptional: 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).
canceledbooleanOptional: true only on message.edited of a canceled calendar event. Absent in every other case. See Calendar events.

Field order in from/to: always contact_id, name, alias, number (alias where it exists; optional fields omitted when null). The group to is the exception: it keeps {group_id, name?}. Your number involved in the event is identified by the number_alias of the envelope. created_at (envelope) is the moment the event was emitted; at (in data) is the moment of the action on WhatsApp: distinct values.

name in from/to is 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 a message.sent to someone who never appeared in either source, to.name is 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:

FieldTypeDescription
errorobjectThe failure error. Present in all *.failed events.
error.codestringStable machine-readable code: branch on this value (e.g. media_fetch_failed, unsupported_media_format).
error.messagestringHuman-readable description of the failure.
error.hintstringActionable 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_typePreview
textTruncated text
imageCaption or [Image]
sticker[Sticker]
videoCaption or [Video]
audioTranscription (when available) → caption → [Audio]
documentCaption → file name → [Document]
locationReadable text without brackets, e.g. Av. Paulista, Sao Paulo, SP
calendarNo 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)
interactiveBody 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)

webhook-payload.json
{
"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)

webhook-payload.json
{
"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.

message.received (group)
{
"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_id is the public group identifier (grp_{hex32}), stable per organization. Raw WhatsApp JIDs are never exposed.
  • The number_alias of 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:

message.received (group, with mention)
{
"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"
}
}
  • mentions appears 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/preview is a mention only if the number is listed in mentions. Otherwise it is plain text.
  • The same token you receive can be pasted back into a send with a mention.

Example: message.edited

message.edited (inbound: the contact edited the message)
{
"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"
}
}
message.edited (outbound: you edited the message through the API)
{
"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

message.deleted (inbound: the contact deleted the message)
{
"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"
}
}
message.deleted (outbound: you deleted the message through the API)
{
"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.

message.reacted (inbound: the contact reacted to one of your messages)
{
"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"
}
}
message.reacted (outbound: you reacted to a message through the API)
{
"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).

message.replied (inbound)
{
"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"
}
}
message.replied (outbound: sent through the API)
{
"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.

message.delivered (1:1)
{
"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"
}
}
message.read (1:1)
{
"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.

message.read (group): receipt from one participant
{
"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.number changes with each participant that confirms; to (the group) and the number_alias of 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

data: message.received (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

data: message.received (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

data: message.received (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

data: message.received (audio, without BYO STT)
{
"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"
}
data: message.received (audio, with BYO STT active: success)
{
"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"
}
data: message.received (audio, with BYO STT active: failure)
{
"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; language is the BCP-47 code of the detected/configured language when available.
  • {"kind": "unavailable", "reason": "...", "hint"?: "..."}: transcription not available. reason is 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) or internal_error. hint is 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

data: message.received (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

data: message.received (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

data: message.received (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

data: message.received (calendar)
{
"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

TypeWhen it is emitted
message.sentMessage sent successfully to WhatsApp (no quote).
message.send.failedFailure sending a regular message (no quote).
message.deliveredReceipt: the recipient received a message you sent.
message.readReceipt: the recipient read a message you sent.

Messages: replies (quotes)

TypeWhen it is emitted
message.repliedReply sent or received (any content_type, in both directions).
message.reply.failedFailure sending a reply (through POST /v1/numbers/{ref}/messages/{message_id}/reply).

Messages: receiving and operations

TypeWhen it is emitted
message.receivedMessage received from a contact (text, media, no quote).
message.editedA message was edited (yours or the contact’s), a caption was edited, or a calendar event was edited or canceled.
message.edit.failedFailure editing a message, a caption or a calendar event.
message.deletedA message was deleted (yours or the contact’s).
message.delete.failedFailure deleting a message.
message.reactedA reaction was added or removed (preview = emoji or "").
message.react.failedFailure reacting to a message.
message.calendar_respondedA guest answered a calendar event (response = going, not_going or maybe). See Calendar events.

Numbers: lifecycle

TypeWhen it is emitted
number.createdExtra number created.
number.connectedMain or extra number connected to WhatsApp.
number.disconnectedThe number stayed disconnected beyond the automatic reconnection window.
number.connect.failedPairing/connection failure.
number.revokedDevice unlinked by WhatsApp.
number.remove.scheduledDeletion of an extra number scheduled (it stays active until the end of the period already paid).
number.remove.canceledThe scheduled deletion was canceled.
number.removedExtra 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.

EventreasonWhat happened and what to do
number.disconnectedconnection_lostThe 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.disconnectedservice_interruptionAn 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.disconnectedreplaced_by_another_deviceAnother connection took over this number’s linked device. Sent right away.
number.disconnecteddisconnected_by_requestSomeone in your account disconnected or removed the number. Sent right away.
number.revokedlogged_out_on_phoneThe device was logged out on the phone (WhatsApp, Linked devices). Pair the number again.
number.revokedreplaced_by_another_deviceAnother 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.

number.disconnected (example)
{
"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).

number.connected (example)
{
"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.

number.connect.failed (example)
{
"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

reasonerror
pairing_timeoutPairing was not completed in time. Request a new code or QR code and try again.
paired_phone_mismatchA different WhatsApp account completed the pairing. Pair again using the phone of the registered number.
phone_already_boundThis phone number is already linked to another number in the account. Use a different phone number or remove the other number.
main_number_not_connectedThe main number must be connected before you pair extra numbers. Reconnect the main number and try again.
code_generation_failedWhatsApp did not provide a pairing code or QR code. Try again in a moment.
connection_lostThe number lost its connection to WhatsApp. Pair it again.
pairing_failedPairing 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

TypeWhen it is emitted
storage.degradedThe 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.recoveredSent once, on the first successful check after a storage.degraded. Received media is saved to your bucket again.

Media availability

TypeWhen it is emitted
media.unavailableThe media could not be saved to your bucket (reason: no_storage, storage_circuit_open, upload_failed).

Example: storage.degraded

webhook-payload.json
{
"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:

reasonWhat happened
healthcheck_failedThe 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_codeWhat happened and what to do
access_deniedThe credentials are wrong or have no permission on the bucket. Check the access key and the read, write and delete permissions.
bucket_unreachableThe bucket does not exist for this region or endpoint. Check the bucket name, the region and the endpoint.
region_mismatchThe region saved for the integration is not the bucket’s region. Correct the region.
endpoint_unreachableThe endpoint could not be reached. Check the address and that the provider is up.
endpoint_url_blockedThe saved endpoint address is refused because it does not point to a public storage service. Enter the address the provider publishes.
network_errorThe connection to the provider failed or took too long. If it keeps happening, check the provider’s status.
quota_exceededThe provider says the account is over its storage quota or is throttling requests. Free space or raise the limit at the provider.
object_not_foundThe provider could not find the bucket or the test file the check had just written. Check the bucket name and region.
provider_unavailableThe provider answered with an outage, for example a paused project or maintenance. Check the account status in the provider’s dashboard.
unknown_errorAny other failure. Review the credentials and the account status at the provider.

When the storage answers again, you receive storage.recovered.

Example: storage.recovered

webhook-payload.json
{
"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

webhook-payload.json
{
"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

message.send.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.

message.edit.failed
{
"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:

message.sent (outbound to a group)
{
"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_id is the public identifier grp_{hex32}. The same value provided in POST /v1/numbers/{ref}/messages.
  • The number_alias of 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):

OperationSuccess eventFailure event
Send a messagemessage.sentmessage.send.failed
Reply to a messagemessage.repliedmessage.reply.failed
Edit a messagemessage.editedmessage.edit.failed
Delete a messagemessage.deletedmessage.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:

AttemptDelay before this attempt
1None: initial delivery
2~0 s (next redelivery cycle, ≤15 s)
3up to 5 seconds
4up to 5 minutes
5up to 30 minutes
6up 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:

dedup.js
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_at is the emission moment; at (when present) is the moment of the action on WhatsApp, both fixed across attempts.
  • X-Webhook-Attempt increases with each attempt: use it for diagnostics, not for business logic.
  • X-Webhook-Timestamp reflects 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.