# Webhooks

## Domain Types

### message.received

- `MessageReceivedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

    Delivery id (`whd_…`), equal to the `webhook-id` header.

  - `created_at: string`

  - `data: object { brand_id, chat_id, message, 3 more }`

    - `brand_id: string`

      The logical brand this chat is scoped under, the value to answer as. Distinct from the Apple business UUID, which is served beside it as `business_id`; `brand_unknown:<business_id>` for an unregistered business.

    - `chat_id: string`

    - `message: object { reply, text, attachments, 4 more }`

      - `reply: object { kind, answers, items, 3 more }`

        The normalized interactive response when the inbound was `type:interactive`; null otherwise (including a customer answering a DEGRADED prompt in plain text).

        - `kind: string`

          Normalized reply kind. One of `app_extension`, `apple_pay`, `choices`, `form`, `invitation_response`, `list_picker`, `time_slots`. Each kind carries its own shape. The six that answer a part you sent are shaped by that part's published schema, served at `GET /v1/parts/{type}`; `invitation_response` answers an invitation rather than a part, and reports the customer's verdict in `item_id`. This set describes replies classified on or after 2026-08-27. `app_extension` was called `imessage_app` before that, and a recorded event is never rewritten, so events recorded earlier keep the old spelling wherever you read them back: the event log, a chat's own events, the transcript, and a redelivered webhook. Accept `imessage_app` as a historical synonym when you read history.

        - `answers: optional array of object { field_id, item_ids, kind, 4 more }`

          One record per answered page, on `form` replies; a page whose submission carried no items emits no record. `question` is the page's title, the prompt the customer answered; `kind` says what shape of answer it is (`input`, `single_select`, `multi_select`, `picker`, `date`). A page answered with one item reports `field_id` and `value`; one answered with several reports `item_ids` and `values`, aligned entry for entry. Records carry the wire's raw fields, while the reply's own `values` map (unchanged) applies a display fallback per item: its value, else its identifier, else its title. So rebuild a page's `values` entry from a record by that fallback, never by joining the record's `values` alone. The one leg a record cannot rebuild is the title: records carry no titles, so when an item brought neither a value nor an identifier, read that page's answer from `values`, where the title already landed. Every field is present only when the submission carried its source.

          - `field_id: optional string`

          - `item_ids: optional array of string`

          - `kind: optional string`

            One of `input`, `single_select`, `multi_select`, `picker`, `date`. Present only when the submission said which.

          - `page_id: optional string`

          - `question: optional string`

          - `value: optional string`

          - `values: optional array of string`

        - `items: optional array of object { id, subtitle, title }`

          The chosen options with their display text, on `list_picker` replies: one entry per selected item, in the order the reply listed them. Display only: `item_ids` is unchanged and stays the set to match on. Present only when at least one chosen item carried display text; each entry's `title` and `subtitle` are themselves present only when set.

          - `id: optional string`

          - `subtitle: optional string`

          - `title: optional string`

        - `request_id: optional string`

          The identifier of the interactive message this reply answers: the same value the sending message's transcript row reports as its own `request_id`, so a tap is matched to the exact send that asked rather than inferred from item ids (the same card sent twice shares item ids, while each send delivers under a fresh identifier). Present when the send recorded one.

        - `timezone_offset_minutes: optional number`

          On `form` replies only: the customer's device timezone offset at submission, verbatim from the wire. Minutes, positive west of UTC (the JavaScript getTimezoneOffset convention, so a US Central device reads +300). Present exactly when the device reported one; never fabricated. Other reply kinds never carry it: the similarly named field on a time-slot reply is an echo of the event your own send defined, not customer data.

        - `title: optional string`

          The chosen option's display text, as the customer saw it, on `choices` replies. Display only: match on `item_id`, never on this text. Present only when the device sent it, so replies recorded before it was captured have no such key.

      - `text: string`

        Plain-text body; null when the inbound carried none.

      - `attachments: optional array of object { decrypted, id, decrypted_size, 6 more }`

        Present when the inbound carried files: one entry per attachment, with the outcome of our decrypt-and-retain pass. Absent when the message carried none, so an ordinary text inbound's shape is unchanged.

        - `decrypted: boolean`

          Whether we could decrypt the file. `false` means no bytes exist to serve and `error` says what happened.

        - `id: optional string`

          The retained file's `att_id`; `GET /v1/attachments/{att}/content` serves the bytes and this part sends them onward. Absent when nothing was retained, and `retention` then says why.

        - `decrypted_size: optional number`

          Plaintext size in bytes; present when decrypted.

        - `error: optional string`

          The decrypt failure, when `decrypted` is `false`.

        - `mime_type: optional string`

        - `name: optional string`

          Filename as the customer sent it.

        - `retention: optional "too_large" or "brand_unresolved" or "store_unavailable" or "disabled"`

          Why no `id` was minted for a file that DID decrypt; present exactly when that happened. `too_large` is yours to act on (the file exceeds what we retain); the other values are platform conditions.

          - `"too_large"`

          - `"brand_unresolved"`

          - `"store_unavailable"`

          - `"disabled"`

        - `sha256: optional string`

          Hex SHA-256 of the plaintext; present when decrypted.

        - `size: optional number`

          Transfer size in bytes, as delivered.

      - `group: optional string`

        The entry point's groupID, same delivery and same present-only-when-set rule as `intent`.

      - `intent: optional string`

        The entry point's intentID, exactly as Apple delivered it. The button, URL or QR code the customer started from sets this, not the message body. It is present only when the entry point set one, so an ordinary chat's shape is unchanged. Route your own skills on it — our brand routing matches the same representation.

      - `raw: optional unknown`

        The full inbound Apple envelope, verbatim, when present. A legacy escape hatch, not a surface to automate on: everything a workflow needs is in the normalized fields (`text`, `reply`, `attachments`, `business_id`), and this key is ABSENT on ordinary deliveries unless the platform is configured to send it. Two promises hold whatever that configuration says: a delivery carrying `reply_unresolved` always includes `raw` (the payload we could not normalize), and the verbatim envelope is always readable from `GET /v1/chats/{chat}/events`, which is the forensic record.

      - `reply_unresolved: optional object { reason, step }`

        Present only when Apple substituted an interactiveDataRef for a >10 KB reply and resolution failed permanently — `reply` is then null.

        - `reason: string`

        - `step: string`

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

    - `seq: number`

      The inbound's seq — the events cursor, and part of the dedupe key.

    - `business_id: optional string`

      The Apple business UUID this message arrived on. Present when the inbound envelope named one, which is every ordinary delivery; when Apple sends none the key is absent, never an empty string. Use this rather than reading the envelope inside `raw`.

  - `type: "message.received"`

    - `"message.received"`

### message.sent

- `MessageSentWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, message_id, seq, 2 more }`

    - `chat_id: string`

    - `message_id: string`

    - `seq: number`

    - `group_id: optional string`

      The FIRST message's id of a fan-out (a promoted URL) — absent on an ordinary single-message send.

    - `invitation: optional object { id, reference_id, template_id }`

      Present only for an invitation delivery (POST /v1/invitations); `reference_id` is the partner's correlation handle.

      - `id: string`

      - `reference_id: string`

      - `template_id: string`

  - `type: "message.sent"`

    - `"message.sent"`

### message.failed

- `MessageFailedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, message_id, reason, 4 more }`

    - `chat_id: string`

    - `message_id: string`

    - `reason: "permanent" or "undelivered" or "auth_error" or 5 more`

      `platform_error` and `invitation_undeliverable` occur only for invitation-class sends, so those two are worth resubmitting only there.

      - `"permanent"`

      - `"undelivered"`

      - `"auth_error"`

      - `"suppressed"`

      - `"consent_revoked"`

      - `"automation_paused"`

      - `"platform_error"`

      - `"invitation_undeliverable"`

    - `seq: number`

    - `group_id: optional string`

      Same meaning as on `message.sent` — ties a fan-out member's failure back to the one request.

    - `last_error: optional string`

      Gateway error of the last attempt; rides only on a gateway-verdict failure.

    - `last_status: optional number`

      Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure.

  - `type: "message.failed"`

    - `"message.failed"`

### chat.closed

- `ChatClosedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, seq }`

    - `chat_id: string`

    - `seq: number`

  - `type: "chat.closed"`

    - `"chat.closed"`

### chat.reopened

- `ChatReopenedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, seq }`

    - `chat_id: string`

    - `seq: number`

  - `type: "chat.reopened"`

    - `"chat.reopened"`

### chat.owner_changed

- `ChatOwnerChangedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, owner, seq, 2 more }`

    - `chat_id: string`

    - `owner: string`

      The NEW owner — `partner`, `flow`, `human_pending` or `human`. `flow` is a platform flow holding the turn (the survey `/resolve` starts, the question `/clarify` asks): it ends on the customer's answer or its own timeout, and ownership then returns through this same event.

    - `seq: number`

    - `reason: optional string`

      Free-text reason when the causing transition carried one (e.g. a partner handoff).

    - `summary: optional string`

      Partner-supplied summary when the causing transition carried one.

  - `type: "chat.owner_changed"`

    - `"chat.owner_changed"`

### chat.handoff_requested

- `ChatHandoffRequestedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, owner, seq, 3 more }`

    - `chat_id: string`

    - `owner: string`

      Always `human_pending` for this kind.

    - `seq: number`

    - `reason: optional string`

      Why — your own free-text reason, or `trigger_word` for an escalation we matched.

    - `summary: optional string`

      Partner-supplied summary when the handoff carried one.

    - `term: optional string`

      The exact standardized word/phrase matched; only on `reason:"trigger_word"`.

  - `type: "chat.handoff_requested"`

    - `"chat.handoff_requested"`

### chat.send_forced

- `ChatSendForcedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, message_id, overridden_owner, 2 more }`

    - `chat_id: string`

    - `message_id: string`

      Joins the event to the forced send.

    - `overridden_owner: string`

      The owner as it was at commit — who was spoken over.

    - `owner: string`

    - `seq: number`

  - `type: "chat.send_forced"`

    - `"chat.send_forced"`

### chat.authenticated

- `ChatAuthenticatedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, seq, sub, 2 more }`

    - `chat_id: string`

    - `seq: number`

    - `sub: string`

      The provider's opaque subject identifier. This is the whole of what we pass through from the claims.

    - `message_id: optional string`

      The id of the authenticate message this sign-in completed, equal to the send response's `message_id` and the transcript business row's `message_id`. A correlation id, not a claim. Absent on a sign-in whose bubble was sent before this shipped.

    - `request_id: optional string`

      The requestIdentifier the authenticate message this sign-in completed was delivered under, equal to the transcript business row's `request_id`. A correlation id, not a claim. Every authenticate send has minted one since the flow shipped, so it is present on a sign-in whose bubble predates this field.

  - `type: "chat.authenticated"`

    - `"chat.authenticated"`

### chat.authentication_closed

- `ChatAuthenticationClosedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, request_id, seq, 2 more }`

    - `chat_id: string`

    - `request_id: string`

      The requestIdentifier the authenticate message was delivered under, equal to the transcript business row's `request_id` and to `chat.authenticated`'s. A correlation id, not a claim; always present.

    - `seq: number`

    - `status: "cancel" or "failure" or "unknown" or "expired"`

      Why the bubble is dead. `cancel`: the customer dismissed it. `failure`: the sign-in did not complete. `unknown`: the device could not say, or reported a status outside this set. `expired`: the 30-minute sign-in window, counted from when the message was accepted, closed with no `chat.authenticated` and no earlier close.

      - `"cancel"`

      - `"failure"`

      - `"unknown"`

      - `"expired"`

    - `message_id: optional string`

      The id of the authenticate message that closed, equal to the send response's `message_id` and the transcript business row's. A correlation id, not a claim. Absent on a bubble that was sent before this shipped.

  - `type: "chat.authentication_closed"`

    - `"chat.authentication_closed"`

### chat.trigger_word

- `ChatTriggerWordWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { action, chat_id, handled_by, 8 more }`

    - `action: "escalate" or "opt_out" or "opt_in" or 3 more`

      - `"escalate"`

      - `"opt_out"`

      - `"opt_in"`

      - `"menu"`

      - `"preferences"`

      - `"stop_automation"`

    - `chat_id: string`

    - `handled_by: "partner" or "platform"`

      Who the event asks something of. `platform`: informational, nothing owed. `partner`: yours, and the delegation fields say how much. With `queued_for_human` present, routing was already seized and only the acknowledgement is delegated to you, deadline `ack_by`. With `queued_for_human` ABSENT, your brand runs self-managed escalation and the whole escalation is yours: nothing was queued, and the chat moves only on your `/handoff` or `/clarify`.

      - `"partner"`

      - `"platform"`

    - `owner: string`

    - `seq: number`

      The seq of the `message.received` this rides with.

    - `term: string`

      The exact word/phrase matched — the deterministic floor to tune your NLU against.

    - `ack_by: optional string`

      The deadline for your acknowledgement, about 10 seconds out. Send anything before it and yours is what the customer reads; past it we send our own. It is not a routing fallback.

    - `agents_available: optional boolean`

      Honest reachability signal — never fabricated.

    - `escalation_channel: optional "offered"`

      The escalation entered the escalation-channel flow (out-of-hours/exempt brand) — no ack is delegated and no `ack_by` is promised.

      - `"offered"`

    - `queued_for_human: optional boolean`

      Routing was secured at t=0 regardless of partner behaviour; false on the escalation-channel path. ABSENT (not false) on a self-managed brand's escalation, and that absence is the field to branch on.

    - `wait_estimate_seconds: optional number`

      Rides only when a real estimate is configured — never invented.

  - `type: "chat.trigger_word"`

    - `"chat.trigger_word"`

### chat.escalation_channel_requested

- `ChatEscalationChannelRequestedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { channel, chat_id, seq, 3 more }`

    - `channel: "phone" or "email"`

      - `"phone"`

      - `"email"`

    - `chat_id: string`

    - `seq: number`

      The seq of the customer turn that completed the request.

    - `email: optional string`

      The reply-to address — rides with `channel:"email"`.

    - `message: optional string`

      The customer's message body — rides with `channel:"email"`.

    - `phone: optional string`

      The callback number — rides with `channel:"phone"`.

  - `type: "chat.escalation_channel_requested"`

    - `"chat.escalation_channel_requested"`

### invitation.accepted

- `InvitationAcceptedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { accepted_chat_id, chat_id, invitation_id, seq }`

    - `accepted_chat_id: string`

      The opaque chat the customer landed in — send there.

    - `chat_id: string`

    - `invitation_id: string`

    - `seq: number`

  - `type: "invitation.accepted"`

    - `"invitation.accepted"`

### invitation.declined

- `InvitationDeclinedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, invitation_id, seq }`

    - `chat_id: string`

    - `invitation_id: string`

    - `seq: number`

  - `type: "invitation.declined"`

    - `"invitation.declined"`

### invitation.opted_out

- `InvitationOptedOutWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, seq }`

    - `chat_id: string`

    - `seq: number`

  - `type: "invitation.opted_out"`

    - `"invitation.opted_out"`

### invitation_batch.recipient_failed

- `InvitationBatchRecipientFailedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { batch_id, brand_id, idx, 4 more }`

    - `batch_id: string`

      The run this recipient belongs to (`invb_…`).

    - `brand_id: string`

    - `idx: number`

      This row's zero-based position in the array you submitted. It is the same index each validation error names, and the key the recipients page walks.

    - `reason: object { code, message }`

      Why it will never send. The same `{code, message}` pair the recipients page returns for this row.

      - `code: string`

      - `message: string`

    - `reference_id: string`

      Your correlation handle for this recipient, echoed back.

    - `state: string`

      Always `failed` — the recipient state this row now holds.

    - `to: string`

      The recipient string AS YOU SENT IT, not our canonical form.

  - `type: "invitation_batch.recipient_failed"`

    - `"invitation_batch.recipient_failed"`

### invitation_batch.completed

- `InvitationBatchCompletedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { accepted_count, batch_id, brand_id, 3 more }`

    - `accepted_count: number`

      `recipient_count` minus the rows the submit screen skipped — the same number the submit returned.

    - `batch_id: string`

    - `brand_id: string`

    - `counts: object { cancelled, failed, pending, 2 more }`

      The run's recipients by state, all five keys present. `pending` is zero on a `completed` run by definition.

      - `cancelled: number`

      - `failed: number`

      - `pending: number`

      - `sent: number`

      - `skipped: number`

    - `recipient_count: number`

      The submitted size, frozen at creation. Always `counts`' total.

    - `status: "completed" or "cancelled"`

      How the run ended.

      - `"completed"`

      - `"cancelled"`

  - `type: "invitation_batch.completed"`

    - `"invitation_batch.completed"`

### brand.escalation_handling_changed

- `BrandEscalationHandlingChangedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, new, old }`

    - `brand_id: string`

    - `new: "managed" or "self_managed"`

      - `"managed"`

      - `"self_managed"`

    - `old: "managed" or "self_managed"`

      - `"managed"`

      - `"self_managed"`

  - `type: "brand.escalation_handling_changed"`

    - `"brand.escalation_handling_changed"`

### consent.granted

- `ConsentGrantedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { category, chat_id, seq, state }`

    - `category: string`

      Always `marketing` today — a keyword opt-in re-grants nothing else.

    - `chat_id: string`

    - `seq: number`

    - `state: "granted"`

      - `"granted"`

  - `type: "consent.granted"`

    - `"consent.granted"`

### consent.revoked

- `ConsentRevokedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { category, chat_id, seq, state }`

    - `category: string`

      `marketing` or `account_notification` — or `invitation` for a tel-thread STOP.

    - `chat_id: string`

    - `seq: number`

    - `state: "revoked"`

      - `"revoked"`

  - `type: "consent.revoked"`

    - `"consent.revoked"`

### consent.expired

- `ConsentExpiredWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { category, chat_id, state }`

    - `category: string`

    - `chat_id: string`

    - `state: "expired"`

      - `"expired"`

  - `type: "consent.expired"`

    - `"consent.expired"`

### chat.automation_paused

- `ChatAutomationPausedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, seq }`

    - `chat_id: string`

    - `seq: number`

      The causing inbound message's seq.

  - `type: "chat.automation_paused"`

    - `"chat.automation_paused"`

### chat.automation_resumed

- `ChatAutomationResumedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { chat_id, seq }`

    - `chat_id: string`

    - `seq: number`

      The causing inbound message's seq.

  - `type: "chat.automation_resumed"`

    - `"chat.automation_resumed"`

### reaction.added

- `ReactionAddedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, chat_id, correlation, 12 more }`

    - `brand_id: string`

    - `chat_id: string`

    - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"`

      HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id.

      - `"inferred"`

      - `"ambiguous"`

      - `"unmatched"`

      - `"unavailable"`

    - `kind: "like" or "love" or "dislike" or 5 more`

      What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way.

      - `"like"`

      - `"love"`

      - `"dislike"`

      - `"laugh"`

      - `"emphasize"`

      - `"question"`

      - `"sticker"`

      - `"emoji"`

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing.

    - `seq: number`

      This reaction's own `seq` in the chat.

    - `source_seq: number`

      The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference.

    - `attachment_id: optional string`

      The sticker's image, when `kind` is `sticker` and we retained it. Pass it to `GET /v1/attachments/{att}/content`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker. Do not assume a format; read the response's own content type. A present id is NOT a promise of bytes, exactly as for `att_id`. Retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`.

    - `candidate_count: optional number`

      How many messages carried that exact text, on `ambiguous`. No winner is picked for you.

    - `emoji: optional string`

      The character the customer picked, present only when `kind` is `emoji`. Served verbatim.

    - `generic: optional boolean`

      Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`.

    - `quoted_text: optional string`

      The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against.

    - `target_msg_id: optional string`

      The message reacted to, on `inferred`.

    - `target_seq: optional number`

      That message's seq, on `inferred`.

    - `target_side: optional "business" or "customer"`

      Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event.

      - `"business"`

      - `"customer"`

  - `type: "reaction.added"`

    - `"reaction.added"`

### reaction.removed

- `ReactionRemovedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, chat_id, correlation, 11 more }`

    - `brand_id: string`

    - `chat_id: string`

    - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"`

      HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id.

      - `"inferred"`

      - `"ambiguous"`

      - `"unmatched"`

      - `"unavailable"`

    - `kind: "like" or "love" or "dislike" or 5 more`

      What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way.

      - `"like"`

      - `"love"`

      - `"dislike"`

      - `"laugh"`

      - `"emphasize"`

      - `"question"`

      - `"sticker"`

      - `"emoji"`

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing.

    - `seq: number`

      This reaction's own `seq` in the chat.

    - `source_seq: number`

      The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference.

    - `candidate_count: optional number`

      How many messages carried that exact text, on `ambiguous`. No winner is picked for you.

    - `emoji: optional string`

      The character the customer picked, present only when `kind` is `emoji`. Served verbatim.

    - `generic: optional boolean`

      Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`.

    - `quoted_text: optional string`

      The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against.

    - `target_msg_id: optional string`

      The message reacted to, on `inferred`.

    - `target_seq: optional number`

      That message's seq, on `inferred`.

    - `target_side: optional "business" or "customer"`

      Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event.

      - `"business"`

      - `"customer"`

  - `type: "reaction.removed"`

    - `"reaction.removed"`

### choice.received

- `ChoiceReceivedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, chat_id, item_id, 5 more }`

    - `brand_id: string`

    - `chat_id: string`

    - `item_id: string`

      The chosen option's id, your own id from the sent part. The only match key.

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

    - `seq: number`

      The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

    - `business_id: optional string`

      The Apple business UUID the chat arrived on; present when the inbound named one.

    - `request_id: optional string`

      The identifier of the interactive message this reply answers; present when the send recorded one.

    - `title: optional string`

      The chosen option's display text as the reply carried it; present when it did. Display passthrough, never a match key.

  - `type: "choice.received"`

    - `"choice.received"`

### picker.received

- `PickerReceivedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, chat_id, items, 4 more }`

    - `brand_id: string`

    - `chat_id: string`

    - `items: array of object { item_id, subtitle, title }`

      The chosen entries, in the reply's order.

      - `item_id: string`

        Your own item id from the sent part. The only match key.

      - `subtitle: optional string`

        The item's subtitle; present when the reply carried it.

      - `title: optional string`

        The item's display text; present when the reply carried it.

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

    - `seq: number`

      The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

    - `business_id: optional string`

      The Apple business UUID the chat arrived on; present when the inbound named one.

    - `request_id: optional string`

      The identifier of the interactive message this reply answers; present when the send recorded one.

  - `type: "picker.received"`

    - `"picker.received"`

### timeslot.received

- `TimeslotReceivedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, chat_id, owner, 5 more }`

    - `brand_id: string`

    - `chat_id: string`

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

    - `seq: number`

      The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

    - `start: string`

      The chosen slot's start time, carried as the device sent it.

    - `business_id: optional string`

      The Apple business UUID the chat arrived on; present when the inbound named one.

    - `request_id: optional string`

      The identifier of the interactive message this reply answers; present when the send recorded one.

    - `slot_id: optional string`

      The chosen slot's identifier from your sent part; present when the reply carried it.

  - `type: "timeslot.received"`

    - `"timeslot.received"`

### form.completed

- `FormCompletedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, chat_id, owner, 11 more }`

    - `brand_id: string`

    - `chat_id: string`

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

    - `seq: number`

      The seq of the event that concluded the form, the join to `GET /v1/chats/{chat}/events`.

    - `transport: "native" or "paged"`

      How the form reached the customer. `native` is a single-page card submission; `paged` is the degraded multi-page collection.

      - `"native"`

      - `"paged"`

    - `values: map[string]`

      The flat answers, keyed by your original page ids, multi-select values comma-joined. Byte-congruent with the message-level reply's `values` and with `form.response`.

    - `answers: optional array of object { kind, page_id, field_id, 4 more }`

      The typed per-field record; present when captured at ingest. One entry per answered field, preserving page grouping and field kind.

      - `kind: "input" or "single_select" or "multi_select" or 2 more`

        The field kind, normalized.

        - `"input"`

        - `"single_select"`

        - `"multi_select"`

        - `"picker"`

        - `"date"`

      - `page_id: string`

        Your original page id.

      - `field_id: optional string`

        The field's own id, when the page distinguishes one.

      - `item_ids: optional array of string`

        The selected item ids for a choice field.

      - `question: optional string`

        The page's own prompt text, so the answer reads with its meaning; present when the submission carried it.

      - `value: optional string`

        The answer for a single-valued field.

      - `values: optional array of string`

        The answers for a multi-valued field.

    - `business_id: optional string`

      The Apple business UUID the chat arrived on; present when the inbound named one (native transport only).

    - `closed_at: optional string`

      Paged transport; when the collection concluded.

    - `group_id: optional string`

      Paged transport; the correlation id tying the record to the page messages that asked.

    - `item_ids: optional array of string`

      Every selected item id across the form's choice pages; present when any page was a choice and the ids were captured.

    - `opened_at: optional string`

      Paged transport; when the collection opened.

    - `request_id: optional string`

      Native transport; the identifier of the form message this submission answers, present when the send recorded one.

    - `timezone_offset_minutes: optional number`

      The customer device's UTC offset in minutes, positive west of UTC, exactly as the submission carried it; present when it did (native transport only).

  - `type: "form.completed"`

    - `"form.completed"`

### apple_pay.received

- `ApplePayReceivedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { brand_id, chat_id, owner, 4 more }`

    - `brand_id: string`

    - `chat_id: string`

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

    - `payment_state: string`

      The completion message's state, advisory only. NON-AUTHORITATIVE; reconcile against your own payment record, never this field.

    - `seq: number`

      The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

    - `business_id: optional string`

      The Apple business UUID the chat arrived on; present when the inbound named one.

    - `request_id: optional string`

      The identifier of the payment message this completion answers; present when the send recorded one.

  - `type: "apple_pay.received"`

    - `"apple_pay.received"`

### app_extension.received

- `AppExtensionReceivedWebhookEvent object { id, created_at, data, type }`

  - `id: string`

  - `created_at: string`

  - `data: object { bid, brand_id, chat_id, 8 more }`

    - `bid: string`

      The extension bundle id the reply arrived under.

    - `brand_id: string`

    - `chat_id: string`

    - `owner: string`

      Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

    - `seq: number`

      The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

    - `business_id: optional string`

      The Apple business UUID the chat arrived on; present when the inbound named one.

    - `data: optional unknown`

      The raw dictionary your extension sent, verbatim; present when one exists.

    - `request_id: optional string`

      The identifier of the message this reply answers; present when the reply echoed one.

    - `session_id: optional string`

      The extension's session identifier; present when the reply carried one.

    - `url: optional string`

      The URL-encoded state your extension sent; present when the reply carried one.

    - `url_params: optional array of object { name, value }`

      The `url` query string parsed to pairs, in order, duplicates preserved; present when the URL carried a query.

      - `name: string`

      - `value: string`

  - `type: "app_extension.received"`

    - `"app_extension.received"`

### Unwrap Webhook Event

- `UnwrapWebhookEvent = MessageReceivedWebhookEvent or MessageSentWebhookEvent or MessageFailedWebhookEvent or 28 more`

  - `MessageReceivedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

      Delivery id (`whd_…`), equal to the `webhook-id` header.

    - `created_at: string`

    - `data: object { brand_id, chat_id, message, 3 more }`

      - `brand_id: string`

        The logical brand this chat is scoped under, the value to answer as. Distinct from the Apple business UUID, which is served beside it as `business_id`; `brand_unknown:<business_id>` for an unregistered business.

      - `chat_id: string`

      - `message: object { reply, text, attachments, 4 more }`

        - `reply: object { kind, answers, items, 3 more }`

          The normalized interactive response when the inbound was `type:interactive`; null otherwise (including a customer answering a DEGRADED prompt in plain text).

          - `kind: string`

            Normalized reply kind. One of `app_extension`, `apple_pay`, `choices`, `form`, `invitation_response`, `list_picker`, `time_slots`. Each kind carries its own shape. The six that answer a part you sent are shaped by that part's published schema, served at `GET /v1/parts/{type}`; `invitation_response` answers an invitation rather than a part, and reports the customer's verdict in `item_id`. This set describes replies classified on or after 2026-08-27. `app_extension` was called `imessage_app` before that, and a recorded event is never rewritten, so events recorded earlier keep the old spelling wherever you read them back: the event log, a chat's own events, the transcript, and a redelivered webhook. Accept `imessage_app` as a historical synonym when you read history.

          - `answers: optional array of object { field_id, item_ids, kind, 4 more }`

            One record per answered page, on `form` replies; a page whose submission carried no items emits no record. `question` is the page's title, the prompt the customer answered; `kind` says what shape of answer it is (`input`, `single_select`, `multi_select`, `picker`, `date`). A page answered with one item reports `field_id` and `value`; one answered with several reports `item_ids` and `values`, aligned entry for entry. Records carry the wire's raw fields, while the reply's own `values` map (unchanged) applies a display fallback per item: its value, else its identifier, else its title. So rebuild a page's `values` entry from a record by that fallback, never by joining the record's `values` alone. The one leg a record cannot rebuild is the title: records carry no titles, so when an item brought neither a value nor an identifier, read that page's answer from `values`, where the title already landed. Every field is present only when the submission carried its source.

            - `field_id: optional string`

            - `item_ids: optional array of string`

            - `kind: optional string`

              One of `input`, `single_select`, `multi_select`, `picker`, `date`. Present only when the submission said which.

            - `page_id: optional string`

            - `question: optional string`

            - `value: optional string`

            - `values: optional array of string`

          - `items: optional array of object { id, subtitle, title }`

            The chosen options with their display text, on `list_picker` replies: one entry per selected item, in the order the reply listed them. Display only: `item_ids` is unchanged and stays the set to match on. Present only when at least one chosen item carried display text; each entry's `title` and `subtitle` are themselves present only when set.

            - `id: optional string`

            - `subtitle: optional string`

            - `title: optional string`

          - `request_id: optional string`

            The identifier of the interactive message this reply answers: the same value the sending message's transcript row reports as its own `request_id`, so a tap is matched to the exact send that asked rather than inferred from item ids (the same card sent twice shares item ids, while each send delivers under a fresh identifier). Present when the send recorded one.

          - `timezone_offset_minutes: optional number`

            On `form` replies only: the customer's device timezone offset at submission, verbatim from the wire. Minutes, positive west of UTC (the JavaScript getTimezoneOffset convention, so a US Central device reads +300). Present exactly when the device reported one; never fabricated. Other reply kinds never carry it: the similarly named field on a time-slot reply is an echo of the event your own send defined, not customer data.

          - `title: optional string`

            The chosen option's display text, as the customer saw it, on `choices` replies. Display only: match on `item_id`, never on this text. Present only when the device sent it, so replies recorded before it was captured have no such key.

        - `text: string`

          Plain-text body; null when the inbound carried none.

        - `attachments: optional array of object { decrypted, id, decrypted_size, 6 more }`

          Present when the inbound carried files: one entry per attachment, with the outcome of our decrypt-and-retain pass. Absent when the message carried none, so an ordinary text inbound's shape is unchanged.

          - `decrypted: boolean`

            Whether we could decrypt the file. `false` means no bytes exist to serve and `error` says what happened.

          - `id: optional string`

            The retained file's `att_id`; `GET /v1/attachments/{att}/content` serves the bytes and this part sends them onward. Absent when nothing was retained, and `retention` then says why.

          - `decrypted_size: optional number`

            Plaintext size in bytes; present when decrypted.

          - `error: optional string`

            The decrypt failure, when `decrypted` is `false`.

          - `mime_type: optional string`

          - `name: optional string`

            Filename as the customer sent it.

          - `retention: optional "too_large" or "brand_unresolved" or "store_unavailable" or "disabled"`

            Why no `id` was minted for a file that DID decrypt; present exactly when that happened. `too_large` is yours to act on (the file exceeds what we retain); the other values are platform conditions.

            - `"too_large"`

            - `"brand_unresolved"`

            - `"store_unavailable"`

            - `"disabled"`

          - `sha256: optional string`

            Hex SHA-256 of the plaintext; present when decrypted.

          - `size: optional number`

            Transfer size in bytes, as delivered.

        - `group: optional string`

          The entry point's groupID, same delivery and same present-only-when-set rule as `intent`.

        - `intent: optional string`

          The entry point's intentID, exactly as Apple delivered it. The button, URL or QR code the customer started from sets this, not the message body. It is present only when the entry point set one, so an ordinary chat's shape is unchanged. Route your own skills on it — our brand routing matches the same representation.

        - `raw: optional unknown`

          The full inbound Apple envelope, verbatim, when present. A legacy escape hatch, not a surface to automate on: everything a workflow needs is in the normalized fields (`text`, `reply`, `attachments`, `business_id`), and this key is ABSENT on ordinary deliveries unless the platform is configured to send it. Two promises hold whatever that configuration says: a delivery carrying `reply_unresolved` always includes `raw` (the payload we could not normalize), and the verbatim envelope is always readable from `GET /v1/chats/{chat}/events`, which is the forensic record.

        - `reply_unresolved: optional object { reason, step }`

          Present only when Apple substituted an interactiveDataRef for a >10 KB reply and resolution failed permanently — `reply` is then null.

          - `reason: string`

          - `step: string`

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

      - `seq: number`

        The inbound's seq — the events cursor, and part of the dedupe key.

      - `business_id: optional string`

        The Apple business UUID this message arrived on. Present when the inbound envelope named one, which is every ordinary delivery; when Apple sends none the key is absent, never an empty string. Use this rather than reading the envelope inside `raw`.

    - `type: "message.received"`

      - `"message.received"`

  - `MessageSentWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, message_id, seq, 2 more }`

      - `chat_id: string`

      - `message_id: string`

      - `seq: number`

      - `group_id: optional string`

        The FIRST message's id of a fan-out (a promoted URL) — absent on an ordinary single-message send.

      - `invitation: optional object { id, reference_id, template_id }`

        Present only for an invitation delivery (POST /v1/invitations); `reference_id` is the partner's correlation handle.

        - `id: string`

        - `reference_id: string`

        - `template_id: string`

    - `type: "message.sent"`

      - `"message.sent"`

  - `MessageFailedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, message_id, reason, 4 more }`

      - `chat_id: string`

      - `message_id: string`

      - `reason: "permanent" or "undelivered" or "auth_error" or 5 more`

        `platform_error` and `invitation_undeliverable` occur only for invitation-class sends, so those two are worth resubmitting only there.

        - `"permanent"`

        - `"undelivered"`

        - `"auth_error"`

        - `"suppressed"`

        - `"consent_revoked"`

        - `"automation_paused"`

        - `"platform_error"`

        - `"invitation_undeliverable"`

      - `seq: number`

      - `group_id: optional string`

        Same meaning as on `message.sent` — ties a fan-out member's failure back to the one request.

      - `last_error: optional string`

        Gateway error of the last attempt; rides only on a gateway-verdict failure.

      - `last_status: optional number`

        Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure.

    - `type: "message.failed"`

      - `"message.failed"`

  - `ChatClosedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, seq }`

      - `chat_id: string`

      - `seq: number`

    - `type: "chat.closed"`

      - `"chat.closed"`

  - `ChatReopenedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, seq }`

      - `chat_id: string`

      - `seq: number`

    - `type: "chat.reopened"`

      - `"chat.reopened"`

  - `ChatOwnerChangedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, owner, seq, 2 more }`

      - `chat_id: string`

      - `owner: string`

        The NEW owner — `partner`, `flow`, `human_pending` or `human`. `flow` is a platform flow holding the turn (the survey `/resolve` starts, the question `/clarify` asks): it ends on the customer's answer or its own timeout, and ownership then returns through this same event.

      - `seq: number`

      - `reason: optional string`

        Free-text reason when the causing transition carried one (e.g. a partner handoff).

      - `summary: optional string`

        Partner-supplied summary when the causing transition carried one.

    - `type: "chat.owner_changed"`

      - `"chat.owner_changed"`

  - `ChatHandoffRequestedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, owner, seq, 3 more }`

      - `chat_id: string`

      - `owner: string`

        Always `human_pending` for this kind.

      - `seq: number`

      - `reason: optional string`

        Why — your own free-text reason, or `trigger_word` for an escalation we matched.

      - `summary: optional string`

        Partner-supplied summary when the handoff carried one.

      - `term: optional string`

        The exact standardized word/phrase matched; only on `reason:"trigger_word"`.

    - `type: "chat.handoff_requested"`

      - `"chat.handoff_requested"`

  - `ChatSendForcedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, message_id, overridden_owner, 2 more }`

      - `chat_id: string`

      - `message_id: string`

        Joins the event to the forced send.

      - `overridden_owner: string`

        The owner as it was at commit — who was spoken over.

      - `owner: string`

      - `seq: number`

    - `type: "chat.send_forced"`

      - `"chat.send_forced"`

  - `ChatAuthenticatedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, seq, sub, 2 more }`

      - `chat_id: string`

      - `seq: number`

      - `sub: string`

        The provider's opaque subject identifier. This is the whole of what we pass through from the claims.

      - `message_id: optional string`

        The id of the authenticate message this sign-in completed, equal to the send response's `message_id` and the transcript business row's `message_id`. A correlation id, not a claim. Absent on a sign-in whose bubble was sent before this shipped.

      - `request_id: optional string`

        The requestIdentifier the authenticate message this sign-in completed was delivered under, equal to the transcript business row's `request_id`. A correlation id, not a claim. Every authenticate send has minted one since the flow shipped, so it is present on a sign-in whose bubble predates this field.

    - `type: "chat.authenticated"`

      - `"chat.authenticated"`

  - `ChatAuthenticationClosedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, request_id, seq, 2 more }`

      - `chat_id: string`

      - `request_id: string`

        The requestIdentifier the authenticate message was delivered under, equal to the transcript business row's `request_id` and to `chat.authenticated`'s. A correlation id, not a claim; always present.

      - `seq: number`

      - `status: "cancel" or "failure" or "unknown" or "expired"`

        Why the bubble is dead. `cancel`: the customer dismissed it. `failure`: the sign-in did not complete. `unknown`: the device could not say, or reported a status outside this set. `expired`: the 30-minute sign-in window, counted from when the message was accepted, closed with no `chat.authenticated` and no earlier close.

        - `"cancel"`

        - `"failure"`

        - `"unknown"`

        - `"expired"`

      - `message_id: optional string`

        The id of the authenticate message that closed, equal to the send response's `message_id` and the transcript business row's. A correlation id, not a claim. Absent on a bubble that was sent before this shipped.

    - `type: "chat.authentication_closed"`

      - `"chat.authentication_closed"`

  - `ChatTriggerWordWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { action, chat_id, handled_by, 8 more }`

      - `action: "escalate" or "opt_out" or "opt_in" or 3 more`

        - `"escalate"`

        - `"opt_out"`

        - `"opt_in"`

        - `"menu"`

        - `"preferences"`

        - `"stop_automation"`

      - `chat_id: string`

      - `handled_by: "partner" or "platform"`

        Who the event asks something of. `platform`: informational, nothing owed. `partner`: yours, and the delegation fields say how much. With `queued_for_human` present, routing was already seized and only the acknowledgement is delegated to you, deadline `ack_by`. With `queued_for_human` ABSENT, your brand runs self-managed escalation and the whole escalation is yours: nothing was queued, and the chat moves only on your `/handoff` or `/clarify`.

        - `"partner"`

        - `"platform"`

      - `owner: string`

      - `seq: number`

        The seq of the `message.received` this rides with.

      - `term: string`

        The exact word/phrase matched — the deterministic floor to tune your NLU against.

      - `ack_by: optional string`

        The deadline for your acknowledgement, about 10 seconds out. Send anything before it and yours is what the customer reads; past it we send our own. It is not a routing fallback.

      - `agents_available: optional boolean`

        Honest reachability signal — never fabricated.

      - `escalation_channel: optional "offered"`

        The escalation entered the escalation-channel flow (out-of-hours/exempt brand) — no ack is delegated and no `ack_by` is promised.

        - `"offered"`

      - `queued_for_human: optional boolean`

        Routing was secured at t=0 regardless of partner behaviour; false on the escalation-channel path. ABSENT (not false) on a self-managed brand's escalation, and that absence is the field to branch on.

      - `wait_estimate_seconds: optional number`

        Rides only when a real estimate is configured — never invented.

    - `type: "chat.trigger_word"`

      - `"chat.trigger_word"`

  - `ChatEscalationChannelRequestedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { channel, chat_id, seq, 3 more }`

      - `channel: "phone" or "email"`

        - `"phone"`

        - `"email"`

      - `chat_id: string`

      - `seq: number`

        The seq of the customer turn that completed the request.

      - `email: optional string`

        The reply-to address — rides with `channel:"email"`.

      - `message: optional string`

        The customer's message body — rides with `channel:"email"`.

      - `phone: optional string`

        The callback number — rides with `channel:"phone"`.

    - `type: "chat.escalation_channel_requested"`

      - `"chat.escalation_channel_requested"`

  - `InvitationAcceptedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { accepted_chat_id, chat_id, invitation_id, seq }`

      - `accepted_chat_id: string`

        The opaque chat the customer landed in — send there.

      - `chat_id: string`

      - `invitation_id: string`

      - `seq: number`

    - `type: "invitation.accepted"`

      - `"invitation.accepted"`

  - `InvitationDeclinedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, invitation_id, seq }`

      - `chat_id: string`

      - `invitation_id: string`

      - `seq: number`

    - `type: "invitation.declined"`

      - `"invitation.declined"`

  - `InvitationOptedOutWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, seq }`

      - `chat_id: string`

      - `seq: number`

    - `type: "invitation.opted_out"`

      - `"invitation.opted_out"`

  - `InvitationBatchRecipientFailedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { batch_id, brand_id, idx, 4 more }`

      - `batch_id: string`

        The run this recipient belongs to (`invb_…`).

      - `brand_id: string`

      - `idx: number`

        This row's zero-based position in the array you submitted. It is the same index each validation error names, and the key the recipients page walks.

      - `reason: object { code, message }`

        Why it will never send. The same `{code, message}` pair the recipients page returns for this row.

        - `code: string`

        - `message: string`

      - `reference_id: string`

        Your correlation handle for this recipient, echoed back.

      - `state: string`

        Always `failed` — the recipient state this row now holds.

      - `to: string`

        The recipient string AS YOU SENT IT, not our canonical form.

    - `type: "invitation_batch.recipient_failed"`

      - `"invitation_batch.recipient_failed"`

  - `InvitationBatchCompletedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { accepted_count, batch_id, brand_id, 3 more }`

      - `accepted_count: number`

        `recipient_count` minus the rows the submit screen skipped — the same number the submit returned.

      - `batch_id: string`

      - `brand_id: string`

      - `counts: object { cancelled, failed, pending, 2 more }`

        The run's recipients by state, all five keys present. `pending` is zero on a `completed` run by definition.

        - `cancelled: number`

        - `failed: number`

        - `pending: number`

        - `sent: number`

        - `skipped: number`

      - `recipient_count: number`

        The submitted size, frozen at creation. Always `counts`' total.

      - `status: "completed" or "cancelled"`

        How the run ended.

        - `"completed"`

        - `"cancelled"`

    - `type: "invitation_batch.completed"`

      - `"invitation_batch.completed"`

  - `BrandEscalationHandlingChangedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, new, old }`

      - `brand_id: string`

      - `new: "managed" or "self_managed"`

        - `"managed"`

        - `"self_managed"`

      - `old: "managed" or "self_managed"`

        - `"managed"`

        - `"self_managed"`

    - `type: "brand.escalation_handling_changed"`

      - `"brand.escalation_handling_changed"`

  - `ConsentGrantedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { category, chat_id, seq, state }`

      - `category: string`

        Always `marketing` today — a keyword opt-in re-grants nothing else.

      - `chat_id: string`

      - `seq: number`

      - `state: "granted"`

        - `"granted"`

    - `type: "consent.granted"`

      - `"consent.granted"`

  - `ConsentRevokedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { category, chat_id, seq, state }`

      - `category: string`

        `marketing` or `account_notification` — or `invitation` for a tel-thread STOP.

      - `chat_id: string`

      - `seq: number`

      - `state: "revoked"`

        - `"revoked"`

    - `type: "consent.revoked"`

      - `"consent.revoked"`

  - `ConsentExpiredWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { category, chat_id, state }`

      - `category: string`

      - `chat_id: string`

      - `state: "expired"`

        - `"expired"`

    - `type: "consent.expired"`

      - `"consent.expired"`

  - `ChatAutomationPausedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, seq }`

      - `chat_id: string`

      - `seq: number`

        The causing inbound message's seq.

    - `type: "chat.automation_paused"`

      - `"chat.automation_paused"`

  - `ChatAutomationResumedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { chat_id, seq }`

      - `chat_id: string`

      - `seq: number`

        The causing inbound message's seq.

    - `type: "chat.automation_resumed"`

      - `"chat.automation_resumed"`

  - `ReactionAddedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, chat_id, correlation, 12 more }`

      - `brand_id: string`

      - `chat_id: string`

      - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"`

        HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id.

        - `"inferred"`

        - `"ambiguous"`

        - `"unmatched"`

        - `"unavailable"`

      - `kind: "like" or "love" or "dislike" or 5 more`

        What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way.

        - `"like"`

        - `"love"`

        - `"dislike"`

        - `"laugh"`

        - `"emphasize"`

        - `"question"`

        - `"sticker"`

        - `"emoji"`

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing.

      - `seq: number`

        This reaction's own `seq` in the chat.

      - `source_seq: number`

        The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference.

      - `attachment_id: optional string`

        The sticker's image, when `kind` is `sticker` and we retained it. Pass it to `GET /v1/attachments/{att}/content`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker. Do not assume a format; read the response's own content type. A present id is NOT a promise of bytes, exactly as for `att_id`. Retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`.

      - `candidate_count: optional number`

        How many messages carried that exact text, on `ambiguous`. No winner is picked for you.

      - `emoji: optional string`

        The character the customer picked, present only when `kind` is `emoji`. Served verbatim.

      - `generic: optional boolean`

        Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`.

      - `quoted_text: optional string`

        The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against.

      - `target_msg_id: optional string`

        The message reacted to, on `inferred`.

      - `target_seq: optional number`

        That message's seq, on `inferred`.

      - `target_side: optional "business" or "customer"`

        Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event.

        - `"business"`

        - `"customer"`

    - `type: "reaction.added"`

      - `"reaction.added"`

  - `ReactionRemovedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, chat_id, correlation, 11 more }`

      - `brand_id: string`

      - `chat_id: string`

      - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"`

        HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id.

        - `"inferred"`

        - `"ambiguous"`

        - `"unmatched"`

        - `"unavailable"`

      - `kind: "like" or "love" or "dislike" or 5 more`

        What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way.

        - `"like"`

        - `"love"`

        - `"dislike"`

        - `"laugh"`

        - `"emphasize"`

        - `"question"`

        - `"sticker"`

        - `"emoji"`

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing.

      - `seq: number`

        This reaction's own `seq` in the chat.

      - `source_seq: number`

        The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference.

      - `candidate_count: optional number`

        How many messages carried that exact text, on `ambiguous`. No winner is picked for you.

      - `emoji: optional string`

        The character the customer picked, present only when `kind` is `emoji`. Served verbatim.

      - `generic: optional boolean`

        Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`.

      - `quoted_text: optional string`

        The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against.

      - `target_msg_id: optional string`

        The message reacted to, on `inferred`.

      - `target_seq: optional number`

        That message's seq, on `inferred`.

      - `target_side: optional "business" or "customer"`

        Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event.

        - `"business"`

        - `"customer"`

    - `type: "reaction.removed"`

      - `"reaction.removed"`

  - `ChoiceReceivedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, chat_id, item_id, 5 more }`

      - `brand_id: string`

      - `chat_id: string`

      - `item_id: string`

        The chosen option's id, your own id from the sent part. The only match key.

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

      - `seq: number`

        The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

      - `business_id: optional string`

        The Apple business UUID the chat arrived on; present when the inbound named one.

      - `request_id: optional string`

        The identifier of the interactive message this reply answers; present when the send recorded one.

      - `title: optional string`

        The chosen option's display text as the reply carried it; present when it did. Display passthrough, never a match key.

    - `type: "choice.received"`

      - `"choice.received"`

  - `PickerReceivedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, chat_id, items, 4 more }`

      - `brand_id: string`

      - `chat_id: string`

      - `items: array of object { item_id, subtitle, title }`

        The chosen entries, in the reply's order.

        - `item_id: string`

          Your own item id from the sent part. The only match key.

        - `subtitle: optional string`

          The item's subtitle; present when the reply carried it.

        - `title: optional string`

          The item's display text; present when the reply carried it.

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

      - `seq: number`

        The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

      - `business_id: optional string`

        The Apple business UUID the chat arrived on; present when the inbound named one.

      - `request_id: optional string`

        The identifier of the interactive message this reply answers; present when the send recorded one.

    - `type: "picker.received"`

      - `"picker.received"`

  - `TimeslotReceivedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, chat_id, owner, 5 more }`

      - `brand_id: string`

      - `chat_id: string`

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

      - `seq: number`

        The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

      - `start: string`

        The chosen slot's start time, carried as the device sent it.

      - `business_id: optional string`

        The Apple business UUID the chat arrived on; present when the inbound named one.

      - `request_id: optional string`

        The identifier of the interactive message this reply answers; present when the send recorded one.

      - `slot_id: optional string`

        The chosen slot's identifier from your sent part; present when the reply carried it.

    - `type: "timeslot.received"`

      - `"timeslot.received"`

  - `FormCompletedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, chat_id, owner, 11 more }`

      - `brand_id: string`

      - `chat_id: string`

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

      - `seq: number`

        The seq of the event that concluded the form, the join to `GET /v1/chats/{chat}/events`.

      - `transport: "native" or "paged"`

        How the form reached the customer. `native` is a single-page card submission; `paged` is the degraded multi-page collection.

        - `"native"`

        - `"paged"`

      - `values: map[string]`

        The flat answers, keyed by your original page ids, multi-select values comma-joined. Byte-congruent with the message-level reply's `values` and with `form.response`.

      - `answers: optional array of object { kind, page_id, field_id, 4 more }`

        The typed per-field record; present when captured at ingest. One entry per answered field, preserving page grouping and field kind.

        - `kind: "input" or "single_select" or "multi_select" or 2 more`

          The field kind, normalized.

          - `"input"`

          - `"single_select"`

          - `"multi_select"`

          - `"picker"`

          - `"date"`

        - `page_id: string`

          Your original page id.

        - `field_id: optional string`

          The field's own id, when the page distinguishes one.

        - `item_ids: optional array of string`

          The selected item ids for a choice field.

        - `question: optional string`

          The page's own prompt text, so the answer reads with its meaning; present when the submission carried it.

        - `value: optional string`

          The answer for a single-valued field.

        - `values: optional array of string`

          The answers for a multi-valued field.

      - `business_id: optional string`

        The Apple business UUID the chat arrived on; present when the inbound named one (native transport only).

      - `closed_at: optional string`

        Paged transport; when the collection concluded.

      - `group_id: optional string`

        Paged transport; the correlation id tying the record to the page messages that asked.

      - `item_ids: optional array of string`

        Every selected item id across the form's choice pages; present when any page was a choice and the ids were captured.

      - `opened_at: optional string`

        Paged transport; when the collection opened.

      - `request_id: optional string`

        Native transport; the identifier of the form message this submission answers, present when the send recorded one.

      - `timezone_offset_minutes: optional number`

        The customer device's UTC offset in minutes, positive west of UTC, exactly as the submission carried it; present when it did (native transport only).

    - `type: "form.completed"`

      - `"form.completed"`

  - `ApplePayReceivedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { brand_id, chat_id, owner, 4 more }`

      - `brand_id: string`

      - `chat_id: string`

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

      - `payment_state: string`

        The completion message's state, advisory only. NON-AUTHORITATIVE; reconcile against your own payment record, never this field.

      - `seq: number`

        The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

      - `business_id: optional string`

        The Apple business UUID the chat arrived on; present when the inbound named one.

      - `request_id: optional string`

        The identifier of the payment message this completion answers; present when the send recorded one.

    - `type: "apple_pay.received"`

      - `"apple_pay.received"`

  - `AppExtensionReceivedWebhookEvent object { id, created_at, data, type }`

    - `id: string`

    - `created_at: string`

    - `data: object { bid, brand_id, chat_id, 8 more }`

      - `bid: string`

        The extension bundle id the reply arrived under.

      - `brand_id: string`

      - `chat_id: string`

      - `owner: string`

        Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`).

      - `seq: number`

        The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`.

      - `business_id: optional string`

        The Apple business UUID the chat arrived on; present when the inbound named one.

      - `data: optional unknown`

        The raw dictionary your extension sent, verbatim; present when one exists.

      - `request_id: optional string`

        The identifier of the message this reply answers; present when the reply echoed one.

      - `session_id: optional string`

        The extension's session identifier; present when the reply carried one.

      - `url: optional string`

        The URL-encoded state your extension sent; present when the reply carried one.

      - `url_params: optional array of object { name, value }`

        The `url` query string parsed to pairs, in order, duplicates preserved; present when the URL carried a query.

        - `name: string`

        - `value: string`

    - `type: "app_extension.received"`

      - `"app_extension.received"`
