Webhooks

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.

Webhook versus events API. Webhooks push state changes within seconds of the event. The events API (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.

EventTriggered whenTypical handler
message.deliveredRecipient MTA accepted the message (250 OK from final hop)Analytics, send-success metrics
message.deferredSoft bounce or temporary deferral (4xx from receiving MTA, will retry)Usually ignored; useful for diagnosing throttling
message.bouncedHard bounce received (5xx from receiving MTA, permanent failure)Suppression list — mandatory handler
message.openedRecipient opened the message (tracking pixel loaded)Engagement analytics; volume is high
message.clickedRecipient clicked a tracked link in the messageEngagement analytics; volume is high
message.complainedSpam complaint received via FBL (Yahoo, Microsoft JMRP)Suppression list — mandatory handler
message.unsubscribedRecipient clicked the unsubscribe link or used List-UnsubscribeSuppression list — mandatory handler
ip.blacklistedOne of your sending IPs added to a major DNS blacklist (Spamhaus, Barracuda, SORBS)Operations alerting
ip.delistedOne of your sending IPs removed from a DNS blacklistOperations alerting
The three events you cannot afford to drop. Bounces, complaints, and unsubscribes must reach your suppression list. Dropping them causes you to mail addresses you should not, which produces complaints, which damages reputation, which produces more dropped events — a feedback loop that is operationally painful to recover from. Treat these three as mandatory and instrument the receiver to alert on processing failures.

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

POST /your-webhook-endpoint HTTP/1.1 Host: your-server.com Content-Type: application/json X-CSE-Signature: sha256=a4f9c2e1b8d6... X-CSE-Timestamp: 1714912800 X-CSE-Event-Id: evt_01HV8K2P9F3MZQX X-CSE-Delivery-Attempt: 1 { "event": "message.bounced", "event_id": "evt_01HV8K2P9F3MZQX", "occurred_at": "2026-05-09T18:00:00.123Z", "received_at": "2026-05-09T18:00:00.456Z", "data": { /* event-specific fields */ } }

message.bounced data

{ "data": { "message_id": "msg_01HV8KP9F3MZQX", "to": "recipient@example.com", "from": "alerts@yourdomain.com", "subject": "Welcome", "bounce_type": "hard", "bounce_classification": "invalid_recipient", "smtp_code": "550", "smtp_status": "5.1.1", "smtp_response": "5.1.1 The email account does not exist", "receiving_mta": "mx.example.com", "sending_ip": "185.123.45.67", "tags": ["transactional", "welcome-flow"] } }

message.complained data

{ "data": { "message_id": "msg_01HV8KP9F3MZQX", "to": "recipient@example.com", "complaint_source": "yahoo_fbl", "complaint_type": "abuse", "feedback_type": "abuse", "reported_at": "2026-05-09T18:00:00Z", "sending_ip": "185.123.45.67", "tags": ["marketing"] } }

ip.blacklisted data

{ "data": { "ip_address": "185.123.45.67", "blacklist_name": "Spamhaus SBL", "blacklist_zone": "sbl.spamhaus.org", "listing_reason": "Manual listing — review at https://check.spamhaus.org", "detected_at": "2026-05-09T18:00:00Z", "affected_pools": ["transactional-eu"] } }

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:

  1. 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.
  2. Use constant-time comparison. === or == in most languages short-circuits on the first byte mismatch, which leaks signature information through timing. Use crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hash_equals in PHP, subtle.ConstantTimeCompare in Go.
  3. 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

// Express receiver. Use express.raw() to keep the body as bytes. const crypto = require('crypto'); const express = require('express'); const app = express(); app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-cse-signature']; const timestamp = req.headers['x-cse-timestamp']; const rawBody = req.body; // Buffer of raw bytes // Reject stale requests (5 minute window) const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)); if (age > 300) return res.status(401).send('stale'); // Compute expected signature over: timestamp + "." + raw body const expected = 'sha256=' + crypto .createHmac('sha256', process.env.WEBHOOK_SECRET) .update(timestamp + '.') .update(rawBody) .digest('hex'); // Constant-time comparison const sigBuf = Buffer.from(signature || '', 'utf8'); const expBuf = Buffer.from(expected, 'utf8'); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { return res.status(401).send('invalid signature'); } // Now safe to parse and process const event = JSON.parse(rawBody.toString('utf8')); enqueue(event); // async processing; respond fast res.status(202).send(); } );

Python (Flask + standard library)

import hmac import hashlib import time from flask import Flask, request, abort app = Flask(__name__) WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode() @app.route("/webhook", methods=["POST"]) def webhook(): signature = request.headers.get("X-CSE-Signature", "") timestamp = request.headers.get("X-CSE-Timestamp", "") raw_body = request.get_data() # bytes, before parsing # Reject stale requests (5 minute window) if abs(time.time() - int(timestamp)) > 300: abort(401) # Compute expected signature over: timestamp + "." + raw body payload = timestamp.encode() + b"." + raw_body expected = "sha256=" + hmac.new( WEBHOOK_SECRET, payload, hashlib.sha256 ).hexdigest() # Constant-time comparison if not hmac.compare_digest(signature, expected): abort(401) event = request.get_json() enqueue(event) # async processing; respond fast return "", 202

PHP

<?php $secret = getenv('WEBHOOK_SECRET'); $signature = $_SERVER['HTTP_X_CSE_SIGNATURE'] ?? ''; $timestamp = $_SERVER['HTTP_X_CSE_TIMESTAMP'] ?? ''; $rawBody = file_get_contents('php://input'); // Reject stale requests (5 minute window) if (abs(time() - (int)$timestamp) > 300) { http_response_code(401); exit; } // Compute expected signature over: timestamp + "." + raw body $expected = 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $rawBody, $secret ); // Constant-time comparison if (!hash_equals($expected, $signature)) { http_response_code(401); exit; } $event = json_decode($rawBody, true); enqueue($event); // async processing; respond fast http_response_code(202);

Go

package main import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/hex" "io" "net/http" "os" "strconv" "time" ) func handler(w http.ResponseWriter, r *http.Request) { sig := r.Header.Get("X-CSE-Signature") ts := r.Header.Get("X-CSE-Timestamp") body, _ := io.ReadAll(r.Body) // Reject stale requests tsInt, _ := strconv.ParseInt(ts, 10, 64) if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401) return } // Compute expected signature h := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET"))) h.Write([]byte(ts + ".")) h.Write(body) expected := "sha256=" + hex.EncodeToString(h.Sum(nil)) if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 { http.Error(w, "invalid signature", 401) return } enqueue(body) w.WriteHeader(202) }

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-Timestamp header 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-Id header (also present as event_id in 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.
Clock skew. The 5-minute window assumes your server clock is reasonably synchronised with NTP. Servers running with significant clock drift will reject legitimate webhooks. If you operate in environments where NTP is unreliable, increase the tolerance to 15 minutes — the security loss is minor compared to the operational pain of false rejections.

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:

AttemptDelay after previousCumulative time
1(initial)0s
230 seconds30s
32 minutes2m 30s
410 minutes12m 30s
530 minutes42m 30s
61 hour1h 42m
72 hours3h 42m
84 hours7h 42m
98 hours15h 42m
108 hours23h 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.

Return 2xx fast. The 10-second timeout is generous, but it is not infinite. Receivers that do significant work synchronously (database writes, external API calls, file I/O) regularly time out at 99th percentile. The right pattern is to verify the signature, push the event to a queue, return 202 within milliseconds, and process asynchronously. Receivers that try to do everything inline are the most common cause of webhook delivery failures.

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:

  1. Extract the event_id from the payload.
  2. 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.
  3. Process the event — suppression list update, analytics insert, alert dispatch.
  4. 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.

# Python with Redis import redis r = redis.Redis() def process_webhook(event): event_id = event["event_id"] # Atomically claim the event ID for 25 hours if not r.set(f"webhook_dedup:{event_id}", "1", nx=True, ex=90000): return # already processed # Now safe to do the actual work if event["event"] == "message.bounced": suppress(event["data"]["to"])

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.stringify on 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.
Use the dashboard's webhook tester. The dashboard has a "Test webhook" button that sends a synthetic event to your configured endpoint and shows the response. It is the fastest way to verify signature handling, retry behaviour, and timeout characteristics without waiting for real events. Run it after every receiver deployment.