API Documentation
Webhooks
Real-time delivery event notifications via HTTP POST. Nine event types covering the message lifecycle and IP reputation changes, HMAC-SHA256 signed payloads, exponential backoff retry over 24 hours, and the receiver patterns that hold up in production.
Overview
Webhooks deliver event notifications to your endpoint as HTTP POST requests, in near-real-time. They are the recommended way to track delivery state for production senders — polling the events API works, but it consumes rate limit budget, lags behind the actual event by however long your polling interval is, and produces stale data the moment between polls.
Two design choices are worth flagging up front. First, webhook delivery is at-least-once, not exactly-once. Your receiver must be idempotent, because retries can cause duplicate deliveries. Second, events are sent in the order they occur on our side, but ordering on your side depends on your queue topology — if you fan out to workers, events for the same message can be processed in different order than they arrived. The occurred_at timestamp in the payload is the authoritative ordering signal.
GET /v1/events) is the same data exposed as a pull endpoint, useful for backfill after an outage or for cold-start of analytics pipelines. Production integrations typically use webhooks for live processing and the events API for reconciliation; using only the events API tends to mask delivery problems behind your polling interval.Configuration
Webhook endpoints are configured in the dashboard under Settings → Webhooks, or programmatically via the admin API. Each endpoint has:
- URL — HTTPS only. The endpoint must respond with a 2xx status code within 10 seconds. Plain HTTP is rejected at configuration time.
- Signing secret — a 64-byte secret used to sign every payload with HMAC-SHA256. Store this exactly the way you store API keys: in a secrets manager, never in source code.
- Event subscription list — subscribe only to the events you actually consume. A receiver subscribed to all nine event types receives roughly 5x the traffic of one subscribed only to bounces and complaints.
- Active flag — allows pausing without deleting the endpoint or losing the signing secret.
You can configure multiple endpoints with different subscription lists — one URL receiving only message.bounced and message.complained for the suppression processor, another receiving message.delivered and message.opened for the analytics pipeline, a third receiving ip.blacklisted for the on-call alerting system. The fan-out happens on our side; you do not have to multiplex.
Event types
Nine events cover the message lifecycle and the two IP-level reputation events that affect operations. Subscribe to the ones your application actually needs — the opened and clicked events in particular generate volume that is rarely worth the storage cost unless you have a real engagement-tracking use case.
| Event | Triggered when | Typical handler |
|---|---|---|
| message.delivered | Recipient MTA accepted the message (250 OK from final hop) | Analytics, send-success metrics |
| message.deferred | Soft bounce or temporary deferral (4xx from receiving MTA, will retry) | Usually ignored; useful for diagnosing throttling |
| message.bounced | Hard bounce received (5xx from receiving MTA, permanent failure) | Suppression list — mandatory handler |
| message.opened | Recipient opened the message (tracking pixel loaded) | Engagement analytics; volume is high |
| message.clicked | Recipient clicked a tracked link in the message | Engagement analytics; volume is high |
| message.complained | Spam complaint received via FBL (Yahoo, Microsoft JMRP) | Suppression list — mandatory handler |
| message.unsubscribed | Recipient clicked the unsubscribe link or used List-Unsubscribe | Suppression list — mandatory handler |
| ip.blacklisted | One of your sending IPs added to a major DNS blacklist (Spamhaus, Barracuda, SORBS) | Operations alerting |
| ip.delisted | One of your sending IPs removed from a DNS blacklist | Operations alerting |
Payload structure
Every webhook arrives as JSON with a consistent envelope. The data field contains event-specific fields; the envelope is shared across all event types so a generic receiver can dispatch on event without parsing the payload twice.
Envelope
message.bounced data
message.complained data
ip.blacklisted data
Signature verification
Every webhook request includes an X-CSE-Signature header with an HMAC-SHA256 signature over the raw request body. The receiver must verify this signature before processing the payload — an unverified webhook handler is an open RPC endpoint exposed to the internet, and treating it that way is how data corruption incidents start.
Three implementation rules matter:
- Sign and verify the raw bytes, not a re-serialised JSON.
JSON.stringify(req.body)after the framework has parsed the body produces different bytes than what was signed (key order, whitespace, escaping all change). Read the request body as raw bytes before any framework parses it. - Use constant-time comparison.
===or==in most languages short-circuits on the first byte mismatch, which leaks signature information through timing. Usecrypto.timingSafeEqualin Node,hmac.compare_digestin Python,hash_equalsin PHP,subtle.ConstantTimeComparein Go. - Verify before you respond. Returning 200 before the signature is verified means an attacker who guesses the URL has confirmed your endpoint exists and accepts requests. Verify, then process, then respond.
Node.js
Python (Flask + standard library)
PHP
Go
Replay protection
Signatures prove a payload was generated by us. They do not prove it is a fresh payload — an attacker who captures a valid signed payload can replay it. Two mechanisms together make replay attacks impractical:
- Timestamp window. The
X-CSE-Timestampheader carries the Unix epoch when we sent the request. Reject any request whose timestamp drifts more than 5 minutes from your server clock. The signature covers the timestamp, so an attacker cannot forge a fresh timestamp without the secret. - Event ID deduplication. The
X-CSE-Event-Idheader (also present asevent_idin the payload) is unique per event. Persist it in a deduplication store (Redis, your database) when first seen; reject subsequent deliveries of the same event ID. Keep the dedup store entries for at least 25 hours — longer than the 24-hour retry window.
Retry behaviour
Failed deliveries are retried with exponential backoff over 24 hours. A delivery is considered failed if your endpoint returns a non-2xx status, fails to respond within 10 seconds, or has a TLS handshake error. The retry schedule:
| Attempt | Delay after previous | Cumulative time |
|---|---|---|
| 1 | (initial) | 0s |
| 2 | 30 seconds | 30s |
| 3 | 2 minutes | 2m 30s |
| 4 | 10 minutes | 12m 30s |
| 5 | 30 minutes | 42m 30s |
| 6 | 1 hour | 1h 42m |
| 7 | 2 hours | 3h 42m |
| 8 | 4 hours | 7h 42m |
| 9 | 8 hours | 15h 42m |
| 10 | 8 hours | 23h 42m |
After the tenth attempt fails, the event is moved to the dead-letter queue and a notification is sent to the configured operations email. The event remains retrievable via GET /v1/events for 30 days, so you can replay events manually after fixing the receiver.
The X-CSE-Delivery-Attempt header on each request tells you which retry this is. Use it for diagnostic logging; the attempt number is also useful as part of an idempotency key in case your dedup store is somehow stale.
Idempotency
At-least-once delivery means your receiver can see the same event more than once. Two scenarios produce duplicates: the network drops a 2xx response before we receive it (we retry the same event), and a misconfiguration of two endpoints subscribed to the same event from different sources (each receives a copy). Both are normal; idempotent receivers handle them silently.
The pattern that works:
- Extract the
event_idfrom the payload. - Atomically insert the event ID into a deduplication store; if the insert fails because the ID already exists, treat the event as already-processed and return 202 without further work.
- Process the event — suppression list update, analytics insert, alert dispatch.
- Mark the event as fully processed (optional; useful for debugging incomplete processing).
Redis is a good fit for the dedup store because the SETNX primitive (or SET ... NX EX 90000) is atomic and supports TTL natively. PostgreSQL with a unique constraint on event_id works equally well; the insert fails fast if the ID already exists.
Common pitfalls
The following patterns recur in support tickets often enough to call out explicitly. Each of them looks reasonable in isolation; each of them produces incidents in production.
- Verifying signature against a re-serialised body. The framework parses the JSON, you call
JSON.stringifyon the parsed object to verify the signature. This fails because key ordering, whitespace, and number formatting all change. Always verify against the raw bytes. - Returning 200 before the work completes. The receiver acknowledges the webhook, then crashes during processing. The event is lost — we received a 2xx, so we will not retry. Either return 200 only after persistence, or return 202 immediately and use a durable queue.
- Storing webhook payloads in plaintext logs. Payloads contain recipient email addresses, message subjects, and sometimes IP addresses. PII logging is a compliance issue under GDPR; logs in a typical centralised aggregation pipeline are accessible to a wide audience inside the company. Redact or hash before logging.
- No backoff on receiver errors. Your endpoint is failing with 500 errors; we retry 10 times over 24 hours. If your receiver is generating a 500 because of an upstream dependency outage, the retries will keep failing — but the request volume can mask the recovery when the dependency comes back. Design your receiver to fail fast and return 200 to acknowledge events you cannot currently process, then reconcile via the events API.
- Trusting the source IP for authentication. Source-IP allowlisting is brittle — we do not publish a stable IP range, and CDNs/NAT mangle the apparent source. The HMAC signature is the authentication primitive; treat any other check (IP, hostname, user-agent) as defence-in-depth, not as primary auth.
- Synchronous external calls in the handler. The webhook handler calls a third-party API, which takes 8 seconds. We time out at 10 seconds and retry. The third-party API now sees double traffic. Push to a queue first; do external calls in the worker.