Skip to main content

Webhook payload cookbook: one payload per event family

For each of the nine event families: the exact JSON your endpoint receives, the signature header shape, the dedupe key, and one receiver example. Mechanics (retry schedule, dead-letter, rotation) are deferred to the durable webhook consumer guide; the event catalog page lists all ~300 event types and the events reference carries field-level detail.

Request shape (all families)

  • Signature header: t=<epoch-seconds>,v1=<hmac-sha256-hex> over ${t}.${raw-body} with your whsec_... secret. Verify with the polyglot signature recipe. During a rotation window the header carries multiple v1 candidates — accept if any matches.
  • Envelope: { id, type, created_at, data }id is the dedupe key (stable across retries of the same event, at-least-once delivery). Persist seen ids; a hit is a duplicate, ack 200 and stop.

1. Message lifecycle (message.*)

message.expired and message.submitted_no_receipt never dispatch as their own type — they ride on message.failed, branch on data.status. Engagement events (email.opened, email.clicked, short_link.click) are siblings, not lifecycle transitions.

2. Voice / call lifecycle (call.*, recording.*, voicemail.*)

3. Porting (porting.request.*)

porting.request.cancelled flips cancelled_from_status; porting.request.manual_review_required adds manual_reason for routing by failure mode.

4. Verify (verification.*)

channel is sms / voice / silent. verification.checked fires on every persisted attempt — including non-terminal pending — check data.outcome before acting.

5. CDP events (cdp.*, contact.*)

Opt-outs arrive as contact.opted_in / contact.opted_out with { channel, phone, keyword, timestamp }; segment flips as contact.segment_changed with { contact_id, previous_label, new_label, computed_at }.

6. Delivery receipts (submitted_no_receipt / expired)

The carrier accepted the submission but never confirmed the handset; both statuses fan out on message.failed.
submitted_no_receipt is intermediate — do not mark yourself delivered. expired is terminal. Dedupe stays on envelope id even when one message_id re-reports.

7. Campaign (campaign.*)

8. Flows (flow.*)

status is completed, failed, or timeout. Sibling flow.execution.started / .completed / .failed add error and duration_ms.

9. Number lifecycle (number.*)

number.purchased / number.released carry { number_id }; a mid-flight amendment arrives as porting.request.supplement_submitted, never as number.ported with a half-status.

Order: verification before the parser

An unauthenticated body is wire noise — parser-first hands attacker bytes to your JSON stack.
  1. Read the raw body bytes (before any JSON parser runs).
  2. Verify X-Orbit-Signature — the one-function-call recipe in seven languages is in Verify webhook signatures in every language; during rotation accept any v1 candidate.
  3. Parse the JSON envelope.
  4. Dedupe on envelope id — a seen id gets a 200 ack and stops.
  5. Route type to the family handler (the receiver examples above).

See also