Platform Events Reference

This section is the definitive source for all webhook event types emitted by Sent. For an end-to-end view of delivery, retries, and the full event catalogue at a glance, see the Webhooks Lifecycle page.

Event Structure

Every event that your webhook endpoint receives has a consistent envelope:

{
  "field": "message",
  "event": "message.delivered",
  "timestamp": "2025-10-31T10:10:42Z",
  "payload": {
    "updated_at": "2025-10-31T10:10:41Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "outbound_number": "+1987654321",
    "agent_id": "agent_abc123",
    "message_status": "DELIVERED",
    "channel": "sms",
    "body": "Hi Jane, your order has been confirmed."
  }
}
{
  "field": "message",
  "event": "message.received",
  "timestamp": "2025-10-31T10:10:42Z",
  "payload": {
    "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "updated_at": "2025-10-31T10:10:40Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "inbound_number": "+1234567890",
    "outbound_number": "+1987654321",
    "text": "Hello, I have a question about my order",
    "channel": "sms",
    "received_at": "2025-10-31T10:10:40Z"
  }
}
{
  "field": "templates",
  "event": "templates.approved",
  "timestamp": "2025-10-31T12:18:14Z",
  "payload": {
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "whatsapp_template_id": "1234567890123456",
    "status": "APPROVED",
    "language": "en_US",
    "category": "UTILITY",
    "channel": "whatsapp"
  }
}
{
  "field": "channel",
  "event": "channel.activated",
  "timestamp": "2025-10-31T14:22:09Z",
  "request_id": "req_7X9zKp2jDw",
  "payload": {
    "channel": "sms",
    "status": "ACTIVE",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "country": "XK",
    "number_type": "ALPHANUMERIC",
    "sender_value": "SENTDM",
    "updated_at": "2025-10-31T14:22:09Z"
  }
}
{
  "field": "contact",
  "event": "contact.opt_out",
  "timestamp": "2026-09-24T14:00:00Z",
  "request_id": "req_9Y0aLq3kEx",
  "payload": {
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "contact_id": "a1b2c3d4-9dad-11d1-80b4-00c04fd430c8",
    "from": "+14155551234",
    "to": "+18005550100",
    "opt_out": true,
    "source": "INBOUND_KEYWORD",
    "channel": "sms",
    "text": "STOP",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c9"
  }
}
FieldTypeDescription
fieldstringThe parent event type: message, templates, channel, contact, or link
eventstring | omittedGranular sub-type (for example, message.delivered). Present on every message event, and on every template event whose status maps to a published sub-type. Omitted when the status has no published sub-type (see Template sub-types)
timestampstringISO 8601 timestamp of event creation
request_idstring | omittedCorrelates the deliveries produced by one action, when the action had a request id. Channel events carry it; the other families omit it
payloadobjectNested object containing event-specific data

Event Types & Details

Your handler receives the full event object: field, event, timestamp, and the nested payload.

message

Triggered whenever a message's delivery status changes, or when an inbound message is received. Each status transition fires a separate event with a matching event.

Outbound message status events:

All outbound status events share one payload shape. Only event, payload.message_status, and the timestamps change between sub-types; the Status Definitions table below lists every pairing.

{
  "field": "message",
  "event": "message.delivered",
  "timestamp": "2025-10-31T12:15:42Z",
  "payload": {
    "updated_at": "2025-10-31T12:15:42Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "outbound_number": "+1987654321",
    "message_status": "DELIVERED",
    "channel": "sms",
    "body": "Hi Jane, your order has been confirmed."
  }
}

Messages that are held or suppressed by platform policy emit three additional sub-types with the same payload shape:

  • message.scheduled: the send fell inside the recipient's quiet hours and is deferred, not failed. Sent evaluates quiet-hours windows per channel against the rules for the recipient's country and time zone, both derived from the recipient's phone number. The event carries message_status: "SCHEDULED". Sent releases the message automatically when the recipient's quiet hours end and it continues through the normal send pipeline, so later status events (message.sent, message.delivered, and so on) follow. SCHEDULED is not a terminal status.
  • message.filtered: a policy gate suppressed the send before any provider call. Either the recipient opted out or is on your phone-channel suppression list, or your routing rules denied the send. The message is not dispatched. payload.reason_code says which: BUSINESS_004 for an opted-out or suppressed recipient, DELIVERY_004 for a denied destination.
  • message.blocked: a precondition gated the send: an account-level one such as insufficient balance or an onboarding message quota, a template that is not approved for sending, or a free-form send to a contact with no open conversation. The message is not dispatched. payload.reason_code names the precondition, for example BUSINESS_003 for the balance or BUSINESS_020 for the onboarding limit.

Why a message didn't go out. message.failed, message.filtered and message.blocked carry payload.reason_code and payload.reason. reason_code is a stable code from the Error Catalog to branch on; reason is a sentence to show a person, and its wording may improve. A cause the API also rejects synchronously keeps the same code on the message: an opted-out recipient is BUSINESS_004 whether the send request was refused or a later filter stopped it. Causes only a message outcome can have are DELIVERY_001 to DELIVERY_016. The codes are Sent's classification of the outcome, never a carrier's or provider's own code. Both fields are omitted on every other sub-type.

{
  "field": "message",
  "event": "message.blocked",
  "timestamp": "2025-10-31T12:15:42Z",
  "payload": {
    "updated_at": "2025-10-31T12:15:42Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "outbound_number": "+1987654321",
    "message_status": "BLOCKED",
    "channel": "sent",
    "body": null,
    "reason_code": "BUSINESS_003",
    "reason": "The account balance is too low to send this message. Add funds to resume sending"
  }
}

The rendered body. payload.body carries the message text as plain text. The field is always present, so read it and test for null rather than testing whether the key exists. When it holds a value, that value is the render for the same event's channel, which lets you read the two fields as one fact: the copy that was dispatched on message.sent, message.delivered, message.read and a carrier-reported message.failed, and the copy that would have gone out on message.filtered, message.blocked and a pipeline-side message.failed.

null means Sent is not asserting a body for the event. Three cases produce it:

  • message.queued, message.routed and message.scheduled, which all fire before routing picks a channel.
  • A message.filtered, message.blocked or message.failed whose channel is a pseudo-channel (sent or auto). The send was stopped before routing picked a channel, and a template can author different copy per channel, so there is no single body to name.
  • A delivery report for an attempt the message has since been rerouted away from.

Two limits to build the handler around. The body is plain text only: a WhatsApp or RCS header, footer and buttons are not published here, so a short body does not mean the message had no header, and a message with buttons looks the same as one without. And the body is truncated at 3072 characters. Call GET /v3/messages/{id} when you need the full composed message.

The inbound counterpart is text on message.received, which carries what the contact sent. The names differ, so a handler that threads a conversation together reads body on the outbound events and text on the inbound one.

Outbound Message Event Fields (all outbound sub-types):

FieldTypeDescription
fieldstringParent event type: message
eventstringGranular sub-type: one of message.queued, message.routed, message.sent, message.delivered, message.read, message.failed, message.scheduled, message.filtered, message.blocked
timestampstringISO 8601 timestamp of the event
payload.updated_atstringISO 8601 timestamp of the status change
payload.account_idstringThe account the event is about: the Sender Profile for a profile's event, otherwise your account
payload.message_idstringMessage UUID, used to look up the message in your DB
payload.template_idstring | nullUUID of the template used (null if no template)
payload.template_namestring | omittedName of the template used (omitted when not resolved)
payload.outbound_numberstringRecipient phone number (E.164 format)
payload.agent_idstring | omittedIdentifier of the agent that originated the message (omitted when not set)
payload.message_statusstringQUEUED, ROUTED, SENT, DELIVERED, READ, FAILED, SCHEDULED, FILTERED, BLOCKED
payload.channelstringsms, mms, whatsapp, or rcs once routing has resolved a channel. Before that, and on a policy-gated outcome for a smart-routed send, it is the pseudo-channel sent or auto, which means no channel was picked. Treat both the same way
payload.bodystring | nullThe rendered message text, plain text only, truncated at 3072 characters. null when Sent is not asserting a body for the event, in the cases described in the preceding section
payload.reason_codestring | omittedWhy the message reached FAILED, FILTERED or BLOCKED, as a stable code such as DELIVERY_013 or BUSINESS_004. See the Error Catalog. Omitted on every other sub-type
payload.reasonstring | omittedA sentence explaining reason_code. Show it rather than branching on it. Omitted whenever reason_code is

Inbound message event (message.received):

Fires when an end-user sends a message to one of your provisioned numbers, for example, a reply to an outbound campaign, a STOP/START/HELP keyword on SMS, or a free-text reply on WhatsApp. The payload shape is different from outbound status events:

{
  "field": "message",
  "event": "message.received",
  "timestamp": "2025-10-31T10:10:42Z",
  "payload": {
    "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "updated_at": "2025-10-31T10:10:40Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "inbound_number": "+1234567890",
    "outbound_number": "+1987654321",
    "text": "Hello, I have a question about my order",
    "channel": "sms",
    "received_at": "2025-10-31T10:10:40Z"
  }
}
{
  "field": "message",
  "event": "message.received",
  "timestamp": "2025-10-31T10:10:42Z",
  "payload": {
    "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "updated_at": "2025-10-31T10:10:40Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "inbound_number": "+1234567890",
    "outbound_number": "+1987654321",
    "text": "Thanks, got your message!",
    "channel": "whatsapp",
    "received_at": "2025-10-31T10:10:40Z"
  }
}
{
  "field": "message",
  "event": "message.received",
  "timestamp": "2025-10-31T10:10:42Z",
  "payload": {
    "message_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "updated_at": "2025-10-31T10:10:40Z",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "inbound_number": "+1234567890",
    "outbound_number": "+1987654321",
    "text": "Tapped: View order",
    "channel": "rcs",
    "received_at": "2025-10-31T10:10:40Z"
  }
}

Inbound Message Event Fields (message.received):

FieldTypeDescription
fieldstringParent event type: message
eventstringAlways message.received
timestampstringISO 8601 timestamp of the event
payload.message_idstringUUID of the inbound message record; unique per inbound message
payload.updated_atstringISO 8601 timestamp of the message update
payload.account_idstringThe account the event is about: the Sender Profile for a profile's event, otherwise your account
payload.inbound_numberstringSender's phone number (the contact) in E.164 format
payload.outbound_numberstringYour provisioned number that received the message
payload.textstring | nullMessage body text
payload.channelstringChannel the message arrived on: sms, mms, whatsapp, or rcs
payload.mediaarray | omittedThe attachments the contact sent, on an inbound MMS. Each entry carries url, and mime_type, size_bytes and hash_sha256 when the carrier declared them. The url is the carrier's own link: it is unauthenticated and expires, so download the file on receipt. Omitted when the message has no attachments. See Inbound MMS
payload.received_atstringISO 8601 timestamp when the provider received the message

Status Definitions:

StatusSub-typeDirectionDescription
QUEUEDmessage.queuedOutboundMessage accepted and waiting to be dispatched
ROUTEDmessage.routedOutboundMessage assigned to a carrier or provider
SENTmessage.sentOutboundMessage sent to carrier or WhatsApp
DELIVEREDmessage.deliveredOutboundMessage delivered to recipient's device
READmessage.readOutboundMessage read by recipient (WhatsApp & RCS)
FAILEDmessage.failedOutboundMessage delivery failed permanently
SCHEDULEDmessage.scheduledOutboundMessage deferred until the recipient's quiet hours end; re-enters the send pipeline automatically when they do
FILTEREDmessage.filteredOutboundMessage suppressed by a policy gate (consent opt-out, suppression list, or routing deny); not dispatched
BLOCKEDmessage.blockedOutboundMessage gated by a precondition: account-level (for example, insufficient balance), template not approved, or no open conversation for a free-form send; not dispatched
RECEIVEDmessage.receivedInboundInbound message received from a contact

templates

Triggered when a template moves through review. Each transition fires a separate event with a matching event.

Approval is tracked per channel, and who reviews a template depends on the account:

  • With a connected WhatsApp Business Account, Meta reviews it. An APPROVED or PENDING verdict applies to every channel; a rejection from Meta applies to the WhatsApp leg only, and the other legs keep the state they had.
  • Without one, Sent's compliance team reviews it and decides each channel individually, usually within minutes rather than days.

Either way the legs can settle at different times, so each decision reports on its own. payload.channel names the leg, and is omitted when a decision applies to the whole template.

{
  "field": "templates",
  "event": "templates.approved",
  "timestamp": "2025-10-31T12:18:14Z",
  "payload": {
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "9ba7b840-9dad-11d1-80b4-00c04fd430c8",
    "template_name": "order_confirmation",
    "whatsapp_template_id": "1234567890123456",
    "status": "APPROVED",
    "language": "en_US",
    "category": "UTILITY",
    "channel": "whatsapp"
  }
}

Template sub-types

All template events share one payload shape. Only event, payload.status, and payload.reason change between sub-types.

Sent creates three auto-replies for you, answering opt-in, opt-out, and help keywords, and you can add your own for any keyword you choose. All of them are reviewed like any other template, so they raise these events too. payload.auto_reply_action is how you tell them apart from the templates you author: the three report OPT_IN, OPT_OUT, and HELP, and one of yours reports OTHER.

payload.statuseventWhat it means
PENDINGtemplates.pendingThe template was submitted and is in review. A submission covers every channel, so this event names no leg
APPROVEDtemplates.approvedThe template is approved and can be sent
REJECTEDtemplates.rejectedThe template was rejected. payload.reason carries why
PAUSEDtemplates.pausedLow quality paused the template. Sends against it are rejected until it recovers
DISABLEDtemplates.disabledThe template was disabled, for example for a policy violation
DELETED, PENDING_DELETIONtemplates.deletedThe template was deleted, including a deletion made outside Sent
CATEGORY_UPDATEDtemplates.category_updatedThe category changed, which changes how the template is priced. Two things cause it: Meta setting or moving the category on the WhatsApp leg, which names whatsapp, and you changing it on an edit, which names no leg because the category belongs to the template. payload.reason reads Category set to MARKETING when there was no category before, and Category changed from UTILITY to MARKETING otherwise
QUALITY_UPDATEDtemplates.quality_updatedThe quality rating changed. payload.reason names the transition, for example Quality changed from GREEN to YELLOW. Meta reports UNKNOWN when it has too little data to rate the template, and a rating that keeps falling ends in PAUSED

Meta forwards a wider set of lifecycle values than the preceding table covers, and adds to it without notice. A status outside the table arrives on the bare templates parent with no event key and the status reported verbatim in payload.status, so route on field first and treat event as optional.

Template Event Fields:

FieldTypeDescription
fieldstringEvent type: templates
eventstring | omittedThe specific transition, for example templates.approved
timestampstringISO 8601 timestamp of the event
payload.account_idstringThe account the event is about: the Sender Profile for a profile's event, otherwise your account
payload.template_idstringTemplate UUID in Sent
payload.template_namestringTemplate name
payload.whatsapp_template_idstringMeta's WhatsApp template ID, assigned when the WhatsApp leg is submitted. Empty until then, and empty for the lifetime of a template on an account with no WhatsApp channel
payload.statusstringThe status the template reached. See the preceding sub-type table for the values that carry an event; any other value Meta forwards is reported here verbatim.
payload.languagestringTemplate language code (e.g. en_US)
payload.categorystringTemplate category: MARKETING, UTILITY, AUTHENTICATION
payload.channelstring | omittedThe leg this decision is about: sms, mms, whatsapp, or rcs. Omitted when the decision applies to the whole template, which affects every leg
payload.auto_reply_actionstring | omittedWhich consent keyword the template answers, when it is one of the auto-replies Sent creates for you: OPT_IN, OPT_OUT, HELP, or OTHER for a keyword you defined. Omitted for an ordinary template, so its presence is the answer to whether the event is about an auto-reply. The same field appears on GET /v3/templates
payload.reasonstring | omittedReason for the decision (e.g. rejection reason for REJECTED, change description for CATEGORY_UPDATED and QUALITY_UPDATED). Supplied by whichever reviewer decided the leg, or by Sent when the change was yours. Omitted when not set.

channel

Triggered when one of your channels moves through provisioning and compliance. Getting a sender live is the longest-running asynchronous process on the platform. A 10DLC registration goes to The Campaign Registry, and a sender ID in some markets pre-registers with the destination's regulator, so these events tell you where a market stands without polling GET /v3/channels.

The subject is always one market, not the account. payload.country names it, and payload.number_type and payload.sender_value say which sender it uses. A market that registers with nobody reports the same way as one that does.

Two fields answer two different questions. event says what happened; payload.status says whether the market can send. They move independently: a resubmission filed against a market whose sender is already live arrives as channel.submitted carrying ACTIVE. Route on event and read status for the current state.

{
  "field": "channel",
  "event": "channel.activated",
  "timestamp": "2025-10-31T14:22:09Z",
  "request_id": "req_7X9zKp2jDw",
  "payload": {
    "channel": "sms",
    "status": "ACTIVE",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "country": "XK",
    "number_type": "ALPHANUMERIC",
    "sender_value": "SENTDM",
    "updated_at": "2025-10-31T14:22:09Z"
  }
}
{
  "field": "channel",
  "event": "channel.activated",
  "timestamp": "2025-10-31T14:22:09Z",
  "request_id": "req_7X9zKp2jDw",
  "payload": {
    "channel": "sms",
    "status": "ACTIVE",
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "country": "US",
    "number_type": "TEN_DLC",
    "sender_value": "+12015550101",
    "compliance": {
      "brand": {
        "inherit": false,
        "legal_name": "Tenant 42 Coffee Company LLC",
        "business_name": "Tenant 42 Coffee Co",
        "tax_id": "12-3456789",
        "tax_id_type": "us_ein",
        "ein_issuing_country": "US",
        "entity_type": "PRIVATE_PROFIT",
        "street": "123 Main Street",
        "city": "New York",
        "state": "NY",
        "postal_code": "10001",
        "country": "US",
        "website": "https://tenant42.example.com",
        "contact_name": "Jane Doe",
        "contact_email": "jane@tenant42.example.com",
        "contact_phone": "+12125550123"
      },
      "campaign": {
        "inherit": false,
        "description": "Order and delivery updates for coffee subscriptions",
        "message_flow": "Customers opt in at checkout on tenant42.example.com by ticking a box that is unchecked by default. Consent is not a condition of purchase.",
        "use_cases": [
          {
            "use_case": "DELIVERY_NOTIFICATION",
            "sample_messages": ["Tenant 42 Coffee: your order #1234 ships today. Reply STOP to opt out."]
          }
        ],
        "opt_in_message": "Tenant 42 Coffee: you are subscribed to order updates. Reply STOP to opt out, HELP for help.",
        "opt_out_message": "Tenant 42 Coffee: you will receive no further messages.",
        "help_message": "Tenant 42 Coffee: for help, email support@tenant42.example.com. Reply STOP to opt out.",
        "opt_in_keywords": "START,YES",
        "opt_out_keywords": "STOP",
        "help_keywords": "HELP",
        "privacy_policy_link": "https://tenant42.example.com/privacy",
        "terms_and_conditions_link": "https://tenant42.example.com/terms",
        "volume": "1000"
      },
      "documents": []
    },
    "updated_at": "2025-10-31T14:22:09Z"
  }
}

Channel sub-types

eventWhat it means
channel.submittedYour request is filed and it's with Sent or a registry. Fires for a first filing, for a resubmission, and for a sender ID that Sent pre-registers with the destination's regulator on your behalf once that market's documents are in, because to you they are the same fact
channel.changes_requestedSomething needs correcting before the market can go forward, and nothing progresses until you act. A market that pre-registers its sender ID arrives here while its required documents are missing. Call GET /v3/compliance/requirements for the market to see which fields and documents are outstanding
channel.approvedThe verdict is in and the sender is being acquired. For a 10DLC market, this fires once the carriers elect your campaign. Not sendable: wait for channel.activated
channel.rejectedThe market's sender was turned down before it ever went live, either because The Campaign Registry rejected its campaign or because a sender still being set up was withdrawn. payload.reason says why when one was given. This is not channel.deactivated, which means a sender that worked stopped
channel.activatedThe sender is live and the market can carry traffic. Subscribe to this one alone if all you need is to know when you can send
channel.deactivatedThe sender stopped working, whether its number went back to the pool, its campaign lapsed, or Sent cancelled it for you. Stop queueing against the market until it returns. Carries INACTIVE, the same value GET /v3/channels reports for that market. Do not read it as PROVISIONING: that status means the market has never sent yet and Sent is setting it up, where INACTIVE means it worked and stopped

Channel statuses

payload.statusWhat it means
PROVISIONINGSent is working on it and there is nothing for you to do. A number is being procured, or a registration assembled
PENDING_REVIEWSubmitted, and a regulator or carrier is deciding. Their answer moves it to ACTIVE or back to ACTION_NEEDED
ACTION_NEEDEDWaiting on you. Nothing progresses until you act
ACTIVEThe market can carry traffic
INACTIVEThe market lost its sender and cannot send

ACTIVE means provisioning and compliance are complete. It does not promise that the next send succeeds: a suspended account and a destination blocked by a routing rule both report ACTIVE.

Channel Event Fields:

FieldTypeDescription
fieldstringEvent type: channel
eventstringThe specific transition, for example channel.activated
timestampstringISO 8601 timestamp of the event
request_idstring | omittedCorrelates the deliveries produced by one action, when the action had a request id
payload.channelstringThe channel the market belongs to: sms, whatsapp, or rcs
payload.statusstringWhere the market stands. See the preceding status table
payload.account_idstringThe account the market belongs to: the Sender Profile for a profile's market, otherwise your account
payload.countrystringThe market's destination country as an ISO 3166-1 alpha-2 code, for example XK
payload.number_typestring | omittedThe kind of sender the market uses, for example TEN_DLC, LOCAL, or ALPHANUMERIC. Omitted when the market has no sender type of its own
payload.sender_valuestring | omittedThe sender itself, either a number in E.164 or an alphanumeric sender ID. Omitted until one is assigned
payload.complianceobject | omittedWhat the market has supplied for compliance, in the same shape as GET /v3/channels/sms/{country}/{type}. Omitted for markets that register with nobody, and on channel.deactivated events
payload.compliance.brandobject | omittedThe identity the market registers under, carrying the same fields you supplied on POST /v3/channels/sms or PATCH /v3/channels/sms/{country}/{type}. TCR brand IDs are not included. Present on TEN_DLC markets once brand fields have been supplied. Absent for markets that do not register with a compliance regime
payload.compliance.brand.inheritbooleanfalse when the market has its own dedicated brand registration; true when it runs on the organization's brand
payload.compliance.campaignobject | omittedThe programme the market registers, carrying the same fields you supplied. Keywords (opt_in_keywords, opt_out_keywords, help_keywords) are comma-delimited strings. volume is a string or null when not set. TCR campaign IDs are not included. Present on TEN_DLC markets once campaign fields have been supplied. Absent for markets that do not register with a compliance regime
payload.compliance.campaign.inheritbooleanfalse when the market has its own dedicated campaign; true when it runs on the organization's campaign
payload.compliance.campaign.use_casesarrayOne entry per declared use case. Each entry carries use_case (the use case type string, for example DELIVERY_NOTIFICATION) and sample_messages (array of example message strings)
payload.compliance.documentsarray | omittedDocuments attached to the market. Each entry carries key (the requirement name), file_name, and document_id. An empty array means the market has been given none; absent means documents were not available for this event
payload.reason_codestring | omittedWhy the market is not ACTIVE, as a stable code from CHANNEL_001 to CHANNEL_011: the same code GET /v3/channels reports for the market. Branch on this rather than on reason. See Channel reason codes. Omitted while ACTIVE
payload.reasonstring | omittedWhy the market reached this state, as a sentence to show a person: the specific explanation when one was given, such as a reviewer's correction, otherwise what reason_code means for this market. Always present for an ACTION_NEEDED market. On an ACTIVE market it appears only when the event carried its own explanation, such as a correction requested against a sender that still works
payload.updated_atstringISO 8601 timestamp of the change

Your organization's webhook receives your organization's own channel events. To receive every Sender Profile's at the same endpoint, select channel under sender_profile, which clones the webhook onto each profile, and payload.account_id names the profile whose market moved. See Sender Profile Events.


contact

Triggered when a contact signals a consent change, asks for help, or sends one of your custom auto-reply keywords. These events state the signal outright so you do not have to parse keywords out of message.received. They also cover cases that produce no inbound message at all, such as a network handling an opt-out on your behalf.

Two of the four sub-types change consent; two do not. contact.help and contact.custom_keyword report the contact's existing state without changing it. Read opt_out for the state and the envelope's event for what happened, rather than inferring one from the other.

{
  "field": "contact",
  "event": "contact.opt_out",
  "timestamp": "2026-09-24T14:00:00Z",
  "request_id": "req_9Y0aLq3kEx",
  "payload": {
    "account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "contact_id": "a1b2c3d4-9dad-11d1-80b4-00c04fd430c8",
    "from": "+14155551234",
    "to": "+18005550100",
    "opt_out": true,
    "source": "INBOUND_KEYWORD",
    "channel": "sms",
    "text": "STOP",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c9"
  }
}

Contact sub-types

eventWhat it means
contact.opt_inContact sent an opt-in keyword (for example START). Sets opt_out: false
contact.opt_outContact sent an opt-out keyword (for example STOP). Sets opt_out: true
contact.helpContact sent a help keyword (for example HELP). Does not change consent state
contact.custom_keywordContact sent one of your custom auto-reply keywords. Does not change consent state. Use payload.template_id to identify which keyword fired

Contact Event Fields:

FieldTypeDescription
fieldstringEvent type: contact
eventstringThe specific sub-type, for example contact.opt_out
timestampstringISO 8601 timestamp of the event
request_idstring | omittedCorrelates the deliveries produced by one action
payload.account_idstringThe account the contact belongs to: the Sender Profile for a profile's contact, otherwise your account
payload.contact_idstringThe contact who raised the signal. Always resolvable against GET /v3/contacts/{id}, including for numbers not messaged before
payload.fromstringThe contact's phone number in E.164 format. The same party message.received publishes as inbound_number. Compare against contact.format_e164, not contact.phone_number, which holds the original format and may lack the leading +
payload.tostring | nullYour provisioned number that received the signal, in E.164 format. This is your number, not the contact's. Null when the signal did not arrive at a number: an RCS signal terminates at an agent rather than a number, and a provider-reported opt-out may name no receiving number at all. The key is always present; check for null rather than checking whether the key exists
payload.agent_idstring | omittedThe RCS agent the signal reached. Omitted entirely on SMS and WhatsApp payloads. On RCS, exactly one of to and agent_id is populated
payload.opt_outbooleanWhether the contact is opted out after this signal. On contact.help and contact.custom_keyword this reports the contact's existing state, which neither event changes
payload.sourcestringHow the signal reached Sent: INBOUND_KEYWORD (contact sent a matching text) or PROVIDER_SIGNAL (network reported it). A provider signal usually carries no message_id or text
payload.channelstringThe channel the signal arrived on, for example sms, mms, whatsapp, or rcs. A keyword sent as an MMS reports mms, so a consent handler must accept it
payload.textstring | nullThe text the contact sent, for example STOP. Null when the signal did not arrive as text. The key is always present
payload.message_idstring | nullThe inbound message that carried the signal, joinable against message.received on message_id. Null when the signal was network-reported or when the message belongs to a different account. The key is always present
payload.template_idstring | nullThe auto-reply template whose keyword matched. On contact.custom_keyword, use this to identify which keyword fired rather than branching on text. Null when no template was involved. The key is always present

payload.to is the opposite of to on POST /v3/messages. On a send, to is your list of recipients. On a contact event, to is your own provisioned number that received the signal. Reply to payload.from, not to payload.to.

Two signals from the same contact can arrive out of order, because each is queued independently. Compare the envelope timestamp before overwriting a stored consent state. Two signals stamped in the same second are unordered; read the contact resource to settle them.

message.received still names the same two parties inbound_number and outbound_number rather than from and to. Join a contact event to its inbound message on message_id rather than comparing number fields.


Triggered when a tracked link Sent published on your behalf is used, expires, or is revoked. Sent shortens the URLs in your message copy and hosts the media you attach, and serves both from the s.dm domain. payload.link_kind says which one an event is about: url for a destination you supplied, file for media Sent hosts.

This family is opt-in and delivers nothing until you ask for it. Subscribe to link explicitly. A message subscription never inherits link events, and neither does a wildcard.

A click is a request, not a read receipt. link.clicked means Sent served the redirect and link.downloaded means bytes went out. Neither proves a person saw anything: messaging providers and link scanners fetch URLs on their own. Filter on payload.traffic_class before you report a click-through rate, and treat likely_human as a hint rather than as delivery confirmation.

{
  "field": "link",
  "event": "link.clicked",
  "timestamp": "2025-10-31T15:04:02Z",
  "payload": {
    "customer_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
    "sender_profile_id": null,
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "record_id": "A78B2BU0",
    "link_kind": "url",
    "channel": "sms",
    "reference_key": "body:0",
    "occurred_at": "2025-10-31T15:04:00Z",
    "request_method": "GET",
    "status_code": 302,
    "traffic_class": "likely_human",
    "access_country": "US",
    "device": "mobile",
    "browser": "chrome"
  }
}
eventWhat it means
link.clickedA tracked short link was followed and Sent served the 302. Always link_kind: "url"
link.downloadedA hosted file behind a tracked link was served, as a 200 or as a 206 for a ranged request. Always link_kind: "file"
link.expiredA published link reached its expiration
link.revokedA published link was permanently revoked

The first two report a request that was served. The last two report a change to the link itself, so they describe no request and omit every request-shaped field, including request_method, status_code, traffic_class, access_country, device, and browser. channel and reference_key describe the link rather than the request, so a lifecycle event reports them exactly as an access event does. Both kinds of link can expire or be revoked, so read link_kind rather than assuming a lifecycle event is about a URL.

Sent forwards these four and no others. link.created, link.requested, link.tags_changed, and link.purged exist in Sent's own link analytics and are never delivered to a webhook, so do not build a handler that waits for the moment a link is minted.

Traffic classes

payload.traffic_classWhat it means
likely_humanThe request looks like a person's browser
providerA messaging platform prefetching the link before or while it delivers the message
botA crawler, scanner, or other automated client
unknownThe request supplied too little to classify

Every value is derived from the user agent, which makes this a filter for obvious noise rather than a fact to bill or report on.

Link Event Fields:

FieldTypeDescription
fieldstringEvent type: link
eventstringThe specific sub-type, for example link.clicked
timestampstringISO 8601 timestamp of when Sent emitted the event, which is not when the access happened. See payload.occurred_at
payload.customer_idstringThe organization the link belongs to. Always the parent account, never a Sender Profile. This family publishes the owner as this pair rather than as the single account_id the other families use
payload.sender_profile_idstring | nullThe Sender Profile that owns the link, or null when the organization owns it directly. The key is always present
payload.message_idstringThe message the link was published in. Always present on a delivered event, because a link Sent cannot attribute to a message produces no webhook at all
payload.record_idstringThe link's public identifier, which is the eight-character code in the short URL, for example A78B2BU0. Unique across both link kinds and never reused, so it is the key to group one link's events by
payload.link_kindstringWhat the link points at: url for a destination you supplied, file for media Sent hosts. Implied by the sub-type on the two access events, and published as its own field so you can branch on it without parsing the event name
payload.channelstring | omittedThe channel the message carrying the link went out on: sms, whatsapp, or rcs. Omitted when the link records no channel of its own, which is a property of the link and not of the event: a lifecycle event reports it exactly as an access event does
payload.reference_keystring | omittedThe label tying this link back to a position in the message, for example body:0 for the first link in the body. Omitted when the link was created without one
payload.occurred_atstringISO 8601 timestamp of when the access or lifecycle change actually happened
payload.request_methodstring | omittedThe HTTP method of the request that was served. Omitted on lifecycle events
payload.status_codeinteger | omittedThe HTTP status Sent answered the request with: 302 for a link, 200 or 206 for a file. Omitted on lifecycle events
payload.traffic_classstring | omittedA coarse guess at what made the request. See the preceding table. Omitted when the request supplied nothing to derive it from
payload.access_countrystring | omittedWhere the request appeared to come from, as an ISO 3166-1 alpha-2 code. Omitted when it could not be resolved
payload.devicestring | omittedThe requesting device class: mobile, tablet, desktop, or unknown
payload.browserstring | omittedThe requesting browser family, for example chrome or safari, or unknown
payload.referrer_hoststring | omittedThe host of the page that linked here, when the request supplied one. The host only, never a full referring URL
payload.bytes_servedinteger | omittedHow many bytes were served on a file access. A ranged request reports the bytes in that range rather than the size of the file, so several accesses of one file can each report a part
payload.access_outcomestring | omittedHow the request was served, when it was recorded. Free text describing the outcome, so show it to a human rather than branching on it

Link events carry no request_id.

Nothing in this payload identifies the visitor. No IP address and no visitor token crosses the boundary, and there is no field that could carry one. access_country, device, and browser are coarse buckets derived from the request, and each is absent whenever the request supplied too little to derive it.

Grouping and deduplication. One link accumulates many events, and any one of those events can be delivered more than once, so a handler needs two separate keys. payload.record_id identifies the link: group a link's events by it. The X-Webhook-Event-ID header identifies the delivery: deduplicate on it, exactly as on every other family. The payload carries no event identifier of its own.

Two timestamps that genuinely differ. The envelope's timestamp is when Sent emitted the event; payload.occurred_at is when the click, download, or lifecycle change happened. Elsewhere the two sit within the same second. Here they are separated by the time it takes Sent to receive the access, and a replay can widen that to days, so report on occurred_at and use timestamp only to reason about delivery.

The visitor's country is access_country, not country. A channel event's country is a destination market you registered for. This one is a property of a single visitor, and the two names are kept apart so a handler switching on payload shape cannot confuse them.

A Sender Profile's links go to its own webhooks. Your organization's webhook doesn't receive them, as with every family. To track every profile's links at one endpoint, select link under sender_profile, which clones your webhook onto each profile. See Sender Profile Events.

The event can arrive before the message is readable. A messaging provider may fetch a link within milliseconds of the send, and nothing delays the event to wait for the message record. If GET /v3/messages/{id} does not yet know payload.message_id, retry the read rather than treating the id as invalid.


Event Filtering

You can configure which events to receive in the webhook settings in your Sent Dashboard to reduce noise and improve performance.

Subscribe to specific event types when creating or updating a webhook by setting event_types and, optionally, event_filters:

{
  "event_types": ["message", "templates", "channel", "contact", "link"],
  "event_filters": {
    "message": ["delivered", "failed", "read", "received"],
    "templates": ["approved", "rejected"],
    "channel": ["activated", "deactivated"],
    "contact": ["opt_in", "opt_out"],
    "link": ["clicked", "downloaded"]
  }
}

The event_filters map accepts a parent event_type as the key and a list of sub-type suffixes as values. In the preceding example only message.delivered, message.failed, message.read, message.received, templates.approved, and templates.rejected events are delivered. message.queued, message.routed, message.sent, and the other template sub-types are suppressed.

Every filter key must also appear in event_types, and the values are bare suffixes: send "delivered", not "message.delivered". Either mistake is rejected with 400.

Available Sub-type Filters for message:

ValueFires forDirection
queuedmessage.queuedOutbound
routedmessage.routedOutbound
sentmessage.sentOutbound
deliveredmessage.deliveredOutbound
readmessage.readOutbound (WhatsApp & RCS)
failedmessage.failedOutbound
scheduledmessage.scheduledOutbound
filteredmessage.filteredOutbound
blockedmessage.blockedOutbound
receivedmessage.receivedInbound

Available Sub-type Filters for templates:

ValueFires for
pendingtemplates.pending
approvedtemplates.approved
rejectedtemplates.rejected
pausedtemplates.paused
disabledtemplates.disabled
deletedtemplates.deleted
category_updatedtemplates.category_updated
quality_updatedtemplates.quality_updated

Available Sub-type Filters for channel:

ValueFires for
submittedchannel.submitted
changes_requestedchannel.changes_requested
approvedchannel.approved
rejectedchannel.rejected
activatedchannel.activated
deactivatedchannel.deactivated

Available Sub-type Filters for contact:

ValueFires for
opt_incontact.opt_in
opt_outcontact.opt_out
helpcontact.help
custom_keywordcontact.custom_keyword

Available Sub-type Filters for link:

ValueFires for
clickedlink.clicked
downloadedlink.downloaded
expiredlink.expired
revokedlink.revoked

A filtered templates subscription receives only the sub-types you list. Statuses that carry no sub-type are delivered to templates subscriptions that set no filter, so subscribe to the parent if you want every lifecycle value.

Available Filters:

  • Message: Subscribe to all or specific message.* sub-types (outbound status + inbound message.received)
  • Templates: Subscribe to all or specific templates.* sub-types
  • Channel: Subscribe to all or specific channel.* sub-types. channel.activated alone answers "can this market send yet"
  • Contact: Subscribe to all or specific contact.* sub-types. contact.opt_out alone is sufficient for consent tracking
  • Link: Subscribe to all or specific link.* sub-types. Nothing is delivered until you subscribe to link explicitly

Legacy messages event type

messages (plural) is the retired spelling of the message parent, from before event types moved to dot-notation sub-types. Webhooks created before the rename may still store messages in their event_types array; dispatch treats it as an alias of message, so those webhooks receive all message.* events. You can't subscribe to messages when creating or updating a webhook: it is not a registered event type, and the API accepts only the canonical message.

Sender Profile Events

Clone your organization's webhook onto its Sender Profiles to receive their events at your endpoint. That covers every profile you have and every profile you create later. Select the profile events under sender_profile when you create or update the webhook:

{
  "display_name": "Status notifications",
  "endpoint_url": "https://example.com/webhooks/sent",
  "event_types": ["message"],
  "event_filters": { "message": ["failed"] },
  "sender_profile": {
    "event_types": ["message", "channel"],
    "event_filters": { "message": ["delivered"] }
  }
}

This webhook receives your organization's failed messages. Each Sender Profile gets a clone of it that receives that profile's delivered messages and every channel event from that profile. sender_profile takes the same event types and filter rules as the top-level fields.

  • A clone is a webhook the profile owns. It appears in the profile's webhook list, and the profile can edit it, turn it off, or delete it like any of its webhooks.
  • Clones use your webhook's URL and signing secret, so your endpoint verifies every event with one secret. A profile can read that secret, as it can for any of its webhooks.
  • Your changes replace the profile's. Editing your webhook, turning it on or off, or rotating its secret updates every clone, including one a profile edited or turned off. Each clone keeps its own delivery health: a clone switched off after failed deliveries stays off until your next change to the webhook or until the profile turns it back on.
  • A clone a profile deleted stays deleted while sender_profile stays set. Clearing sender_profile deletes every clone, and setting it again clones the webhook onto every profile.
  • Deleting your webhook deletes its clones.
  • Your webhook's event history includes its clones'. GET /v3/webhooks/{id}/events on your organization's webhook lists the events its clones delivered beside its own. A clone's own history lists only its profile's events.

A webhook receives only its owner's events. Your organization's webhook receives your organization's events, and each clone receives its profile's, so a profile's event reaches your endpoint once, through its clone. Your organization's own events never reach a profile's webhook, and one profile's events never reach another's.

Only your organization's webhook can set sender_profile. A request made as a Sender Profile, with its own key or with x-profile-id, is rejected with 400. event_types can be empty when sender_profile.event_types isn't, so the webhook only feeds its clones. At least one event is required across the two.

On PUT /v3/webhooks/{id}, omit sender_profile to keep the clones' events as they are, or send "sender_profile": {"event_types": []} to delete the clones. The rest of the update still reaches every clone. Webhook responses don't include sender_profile, so keep a record of what you set.

Which account an event is about

payload.account_id names the account the event belongs to, which is also the owner of the webhook that delivers it. That's the Sender Profile for an event delivered through its clone, and your organization for your own events. Map payload.account_id to your tenant: any value other than your organization's ID is one of your profiles.

A Sender Profile's message.delivered, as the profile's clone of your webhook delivers it:

{
  "field": "message",
  "event": "message.delivered",
  "timestamp": "2025-10-31T10:10:42Z",
  "payload": {
    "account_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "message_id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
    "message_status": "DELIVERED",
    "channel": "sms"
  }
}

account_id is the profile's ID. The other payload fields are unchanged and omitted here.

Delivery Headers & Signing

Every outgoing webhook request includes the following headers so that you can authenticate and de-duplicate events:

HeaderDescription
X-Webhook-IDUUID of the webhook configuration that produced the request
X-Webhook-Event-IDUUID of the event, identical on every attempt at that event, so it is the value to deduplicate on
X-Webhook-TimestampUnix timestamp in seconds at which this attempt was signed. A retry is signed when it is sent, so this moves between attempts
X-Webhook-AttemptIndex of this delivery attempt, starting at 1
X-Webhook-Signaturev1,{base64_hmac}: HMAC-SHA256 over {webhook_id}.{timestamp}.{raw_body} using your signing secret
X-Webhook-Event-TypeFully qualified event type (message.delivered, templates, etc.)

Signing secrets are issued in the Sent Dashboard prefixed with whsec_ (Svix-compatible). Always verify the signature server-side before trusting the payload, and compare the timestamp against your clock to reject replayed requests.

Delivery Statuses & Retries

Each webhook delivery is tracked in the Sent Dashboard with its own row and lifecycle:

StatusMeaning
PENDINGEvent created, queued for delivery
RETRYINGA previous attempt failed; the next attempt is scheduled with backoff
DELIVEREDEndpoint returned 2xx; consecutive-failure counter resets to 0
FAILEDExceeded the webhook's configured retry count (retry_count, 1–5, defaults to 3)

Retry schedule: a delivery attempt counts as failed when your endpoint returns a non-2xx status, the request times out, or the connection fails. Failed attempts are retried with exponential backoff: the first retry fires about one minute after the failure and the delay doubles with each subsequent attempt, up to a maximum of 60 minutes between attempts. Retries stop as soon as your endpoint returns 2xx or the retry count is exhausted.

Per-event metadata stored alongside each attempt includes the delivery attempt count, HTTP status code, the first portion of the response body, the error message, and start/completion timestamps, all surfaced in the Sent Dashboard's webhook event detail view.

Auto-disable after consecutive failures

If a webhook accumulates 10 consecutive failed events, it is automatically disabled. The counter spans events, and only an event's first attempt increments it, so it measures how many distinct events your endpoint failed rather than how many times Sent re-asked about one of them. Any successful delivery, including a retry that succeeds, resets it to 0. A failed test delivery doesn't count toward it. This guards against expired or compromised endpoints. Re-enable the webhook from the Sent Dashboard once your endpoint is healthy again.

This section is the canonical statement of the retry schedule, retry budget, and auto-disable threshold.

Delivery Constraints

Webhook configuration is subject to the following constraints:

  • Per-webhook timeout: 5–120 seconds, defaults to 30 seconds (configured when you create or update the webhook)
  • Retry count: 1–5 attempts per event, defaults to 3
  • URL scheme: must be http:// or https://
  • URL validation: private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.) are rejected at creation, update, and delivery time

On this page