Receive webhooks
Goal: subscribe an HTTPS endpoint to delivery events, verify each delivery’s signature, handle retries and duplicates correctly, and recover a subscription the platform paused after repeated failures.
Prerequisites: a publicly reachable HTTPS endpoint that accepts POST requests; an API key with the webhooks:read and webhooks:write scopes; Webhooks & Events for the full event catalog and payload reference.
Steps
1. Create a webhook subscription
curl -X POST https://emails.routeon.io/api/v1/webhooks
-H "Authorization: Bearer <API_KEY>"
-H "Content-Type: application/json"
-d '{
"url": "https://app.example.com/hooks/routeon",
"events": ["email.delivered", "email.bounced", "email.complained", "email.failed", "contact.unsubscribed"],
"description": "Production delivery events"
}'
Expected response (201 Created):
{
"status": "success",
"http_code": 201,
"payload": {
"id": "<WEBHOOK_ID>",
"tenant_id": "<TENANT_ID>",
"url": "https://app.example.com/hooks/routeon",
"events": ["email.delivered", "email.bounced", "email.complained", "email.failed", "contact.unsubscribed"],
"description": "Production delivery events",
"status": "active",
"consecutive_failed_deliveries": 0,
"last_delivery_at": null,
"last_success_at": null,
"paused_at": null,
"created_at": "2026-09-28T09:00:00Z",
"updated_at": "2026-09-28T09:00:00Z",
"secret": "<WEBHOOK_SECRET>"
},
"meta": {}
}
url must start with https://. secret is returned only in this response — store it securely now, it cannot be read again. It is a string of the form whk_ followed by 24 letters and digits. Available event types: email.delivered, email.opened, email.clicked, email.bounced, email.complained, email.failed, email.deferred, contact.unsubscribed.
2. Verify the signature on each delivery
Every delivery is a POST with Content-Type: application/json and these headers:
| Header | Meaning |
|---|---|
X-Timestamp |
Unix time in seconds when the request was signed |
X-Signature |
Hex-encoded HMAC-SHA256 of "<X-Timestamp>.<raw request body>", keyed with the subscription’s secret |
TypeScript (Node.js):
import { createHmac, timingSafeEqual } from "crypto";
export function verifyWebhook(rawBody: string, timestamp: string, signature: string, secret: string): boolean {
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Python:
import hashlib
import hmac
def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
message = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Compute the HMAC over the raw request body, before any JSON parsing — re-serializing a parsed object can change the bytes and fail verification for a genuine delivery. To reject replayed requests, also refuse a timestamp far from your current time (for example more than five minutes).
3. Process the payload
{
"id": "<EVENT_ID>",
"type": "email.delivered",
"occurred_at": "2026-09-28T09:00:04Z",
"data": {
"message_id": "<MESSAGE_ID>",
"to_email": "customer@example.com",
"campaign_id": null,
"domain_id": "<DOMAIN_ID>",
"message_class": "transactional",
"template_id": "<TEMPLATE_ID>",
"source_type": "transactional",
"journey_step_id": null,
"tags": ["receipt"],
"metadata": { "order_id": "48213" }
}
}
data carries the tags and metadata you attached when sending, so you can correlate events with your own records without an extra lookup. template_id can be null for a message that carries its own content instead of a template. The fields specific to each event type are in Webhooks & Events.
Answer with any 2xx within 10 seconds once you have durably stored or queued the event; do the heavy work afterwards. A non-2xx answer, a timeout or a redirect counts as a failed attempt — redirects are never followed.
4. Handle retries and duplicates
A failed attempt is retried with exponential backoff starting at 2 seconds and doubling (2, 4, 8, 16, 32, 64, 128 s), up to 8 attempts in total; after that the event is given up. Delivery is at-least-once: the same event can arrive more than once (for example when your 2xx was lost), so deduplicate on the payload’s id before acting. Because retries interleave, events can also arrive out of order — use occurred_at when order matters.
5. Watch the subscription’s health and recover it
curl https://emails.routeon.io/api/v1/webhooks/<WEBHOOK_ID>
-H "Authorization: Bearer <API_KEY>"
Look at status, consecutive_failed_deliveries, last_success_at and paused_at.
consecutive_failed_deliveriescounts events that failed all 8 attempts in a row; any successful delivery resets it to0.- When it reaches 10, the platform sets
status: "paused"andpaused_at, and stops delivering. Events that occur while the subscription is paused are not delivered later — fetch what you missed through the API (for exampleGET /messages?from=<PAUSED_AT>). - A subscription the platform paused and that stays paused for 7 days becomes
disabled. Adisabledsubscription cannot be changed or reactivated (PATCHanswers409 invalid_state); delete it and create a new one.
Once your endpoint works again, reactivate the subscription:
curl -X PATCH https://emails.routeon.io/api/v1/webhooks/<WEBHOOK_ID>
-H "Authorization: Bearer <API_KEY>"
-H "Content-Type: application/json"
-d '{ "status": "active" }'
200 OK returns the subscription with status: "active", consecutive_failed_deliveries: 0 and paused_at: null. You can also pause a subscription yourself with { "status": "paused" }; events during your own pause are not delivered later either, but it is not disabled automatically.
6. Update or remove a subscription
curl -X PATCH https://emails.routeon.io/api/v1/webhooks/<WEBHOOK_ID>
-H "Authorization: Bearer <API_KEY>"
-H "Content-Type: application/json"
-d '{ "events": ["email.delivered", "email.bounced"] }'
curl -X DELETE https://emails.routeon.io/api/v1/webhooks/<WEBHOOK_ID>
-H "Authorization: Bearer <API_KEY>"
PATCH returns 200 OK with the updated subscription; DELETE returns 204 No Content. The secret stays the same across updates; to get a new one, create a new subscription and delete the old one.
Verify
List your subscriptions and confirm the new one is active with the events you expect:
curl https://emails.routeon.io/api/v1/webhooks
-H "Authorization: Bearer <API_KEY>"
Then trigger a real event — send a transactional email and let it deliver (see Send a transactional email) — check that your endpoint received it with a valid signature, and that last_success_at on the subscription advanced.
Common failures
| Symptom | Cause | Fix |
|---|---|---|
422 on POST /webhooks or PATCH /webhooks/{id} |
url is not https://, events is empty, or an unknown event type |
Use an https:// URL and event types from the list above |
| Signature never matches | HMAC computed over a re-serialized body, wrong secret, or timestamp not included | Sign "<X-Timestamp>.<raw body>" with the secret from the 201 response |
| No deliveries ever arrive | The URL does not resolve to a public address, or answers with a redirect | Deliveries go only to publicly reachable endpoints and redirects count as failures; check GET /webhooks/{id} |
| Attempts fail although your endpoint works | Your endpoint answers after more than 10 seconds | Acknowledge fast with 2xx, process asynchronously |
| Deliveries stopped after endpoint downtime | 10 events in a row failed all attempts; the subscription was paused | Fix the endpoint, PATCH status to active, and re-fetch missed data through the API |
409 invalid_state on PATCH |
The subscription is disabled |
Delete it and create a new subscription |
| The same event processed twice | At-least-once delivery redelivered it | Deduplicate on the payload id |