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_deliveries counts events that failed all 8 attempts in a row; any successful delivery resets it to 0.
  • When it reaches 10, the platform sets status: "paused" and paused_at, and stops delivering. Events that occur while the subscription is paused are not delivered later — fetch what you missed through the API (for example GET /messages?from=<PAUSED_AT>).
  • A subscription the platform paused and that stays paused for 7 days becomes disabled. A disabled subscription cannot be changed or reactivated (PATCH answers 409 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