Skip to main content
Every webhook event has a consistent envelope:
All timestamps (occurred_at, sent_at, delivered_at, failed_at, received_at, clicked_at) are ISO-8601 UTC with millisecond precision and a Z suffix. Phone numbers (to, from) are E.164 strings. The envelope owns the event identity (id, occurred_at); the event-specific payload carries only event fields and resource references (message_id, tracking_link_id).

Identifiers

data.id uniquely identifies the event, and together with event_type it is the delivery deduplication key — if you receive the same (event_type, id) pair twice, treat the second as a redelivery. It also arrives in the X-Event-ID header.
  • Message events (message.sent, message.delivered, message.failed): id equals message_id — the message is the event identity, since a message emits at most one event of each type. The same id therefore recurs across one message’s lifecycle (message.sent then message.delivered), distinguished by event_type.
  • message.replied: id equals message_id and identifies the inbound message (the reply itself), not the outbound message it responds to. Correlate via conversation_id.
  • link.clicked: id is a unique per-click identifier — every unique click delivers its own event. tracking_link_id identifies the link and recurs across clicks of that link.

Test events

Message events include is_test (boolean). It is true for test messages — sent from the app’s test flow or via POST /v1/projects/{id}/test — and for the synthetic event produced by the dashboard’s Send test webhook button (which additionally corresponds to no real message: its ids are random). Production traffic always carries is_test: false. link.clicked has no is_test field.

message.sent

Triggered when a message is successfully accepted by the carrier.

message.delivered

Triggered when the carrier confirms the message was delivered to the recipient’s device.

message.failed

Triggered when the carrier rejects the message or delivery fails. error_code is a short delivery-failure code (e.g. "300" opted-out, "012" invalid destination) paired with a human-readable error_message. The full list of codes, messages, and how to react to each lives in Delivery error codes. Fields describing a message that was accepted before failing (sent_at, message_text) may be absent when the message was rejected at send time.

message.replied

Triggered when an inbound SMS/MMS is received from a contact.
To answer a message.replied event with your own text, call POST /v1/conversations/{conversation_id}/messages; the reply goes out from the same number inside the same thread and its outcome arrives on message.sent / message.delivered / message.failed. See Two-Way Conversations. Triggered when a recipient clicks a tracking link in your message.

What “unique” means

is_unique_click is true the first time a given device fingerprint clicks a particular tracking link, and false for that fingerprint’s later clicks on the same link. The fingerprint is derived from the clicker’s IP address and User-Agent — it identifies a device on a network, not a person. Two consequences worth planning around if you aggregate these events yourself:
  • One person who taps a link on cell data and again on Wi-Fi produces two unique clicks, because the IP changed.
  • Two people behind one shared connection (a household or office NAT) using the same device model and browser can collapse into one unique click.
Uniqueness is scoped per link, and each recipient receives their own tracking link, so one person’s repeat clicks never suppress another recipient’s. Clicks we identify as automated — carrier link scanners, security crawlers, and link-preview fetchers — arrive with is_bot: true and are excluded from the click totals shown in campaign reports.

Email events (early access)

The email product is in early access. These five event types are already in the registry and can be subscribed on any endpoint, but no email traffic is sent until the product reaches general availability. The /v1/email/* API answers 403 EMAIL_EARLY_ACCESS in the meantime.
Email events use the same envelope as message events: data.event_type, data.id, data.occurred_at, and data.payload, with meta.attempt. There are only five, and each one is deliberate: There is no email.opened: open tracking is a pixel, which makes it both approximate and, at campaign volume, the highest-rate event we could send. There is no inbound email event either, because the product has no inbox. Test sends and seed addresses never emit email webhooks.

Deduplication

Every email payload carries a stable id that is also the dedup key, so a redelivery of the same event reuses the same value: The HTML body of a campaign is never included in a webhook payload.

email.delivered

email.bounced

Only permanent bounces fire. bounce_type and bounce_subtype come from the mailbox provider; reason is the SMTP diagnostic code when one is supplied.

email.complained

reason is the provider’s feedback type (for example abuse), and is null when the provider does not supply one.
Treat this event as final. We suppress the address automatically; re-adding it to a list will not send to it again.

email.unsubscribed

scope says what the recipient opted out of, and reason is one_click for an RFC 8058 one-click unsubscribe or the reason chosen on the hosted preferences page.
A mailbox provider retrying the one-click POST does not produce a second event: the dedup key is the message, and a recipient unsubscribes from one send once.

email.clicked

Fires once per recipient per link. Repeat clicks on the same link by the same recipient do not fire again, and clicks we identify as automated (link scanners, security crawlers, preview fetchers) never fire at all.
Click events are flushed in batches, so occurred_at is the moment of the click rather than the moment of delivery. device_type, browser, and os are best-effort and may be null.