Webhooks & Events
Subscribe an HTTPS endpoint to receive email and contact lifecycle events as they happen, instead of polling the Messages endpoints.
Event catalog
| Event | Fires when | Payload highlights |
|---|---|---|
email.delivered |
The recipient’s mail server accepted the message | Message fields: message, campaign, template and domain identifiers, message class, source, tags, metadata |
email.opened |
The recipient opened the message (first open only) | Message fields |
email.clicked |
The recipient clicked a tracked link in the message (first click only) | Message fields. The clicked URL is not included |
email.bounced |
The recipient’s mail server rejected the message | Message fields plus bounce_class (hard or soft) and reason_code |
email.complained |
The recipient reported the message as spam to their mailbox provider | Message fields |
email.failed |
The message could not be sent: the mail provider refused it before delivery, or the platform stopped it (for example, too many deferrals or a template that could not be rendered) | Message fields plus error_message |
email.deferred |
The recipient’s mail server temporarily delayed the message; delivery continues to be retried automatically | Message fields plus reason_code |
contact.unsubscribed |
A contact unsubscribed | The contact’s email and reason (unsubscribe) |
These eight names are the complete set a subscription can receive, and exactly the values accepted in the events list when you create or update a subscription. Other suppressions (hard bounces, spam complaints, manual additions) do not produce contact.unsubscribed; they are visible through email.bounced, email.complained and the Suppressions endpoints.
email.delivered, email.opened, email.clicked, email.bounced and email.complained are each sent at most once per message. Repeated opens or clicks of the same message produce no further events, and a message that first soft-bounces and later hard-bounces produces no second email.bounced.
Subscribing
Manage subscriptions with the Webhooks endpoints of Account.yaml. They require the webhooks:read or webhooks:write scope on your API key.
| Method | Path | Purpose |
|---|---|---|
| GET | /webhooks |
List your subscriptions |
| POST | /webhooks |
Create a subscription |
| GET | /webhooks/{id} |
Get one subscription |
| PATCH | /webhooks/{id} |
Update a subscription |
| DELETE | /webhooks/{id} |
Delete a subscription (answers 204 with no body) |
Fields you set:
| Field | Meaning |
|---|---|
url |
Your endpoint. Must start with https://; anything else is rejected with 422. The host must be publicly reachable (see URL safety check) |
events |
The event names from the catalog above that this subscription receives. At least one is required |
description |
Optional label of up to 255 characters |
status |
active, paused or disabled. Settable on PATCH only; new subscriptions start active |
Read-only fields returned with every subscription: id, tenant_id (your account), consecutive_failed_deliveries, last_delivery_at, last_success_at, paused_at, created_at and updated_at.
Create a subscription:
curl -X POST https://emails.routeon.io/api/v1/webhooks
-H "Authorization: Bearer <API_KEY>"
-H "Content-Type: application/json"
-d '{
"url": "https://example.com/hooks/routeon",
"events": ["email.delivered", "email.bounced", "contact.unsubscribed"],
"description": "Production delivery events"
}'
The 201 response includes secret, the signing secret of this subscription: whk_ followed by 24 letters and digits. It is returned only in this response and cannot be retrieved later. To change the secret, create a new subscription, switch your endpoint to the new secret, then delete the old subscription.
Each event is delivered to every active subscription of your account whose events list contains that event type. Several subscriptions may point at the same URL.
Subscription status
active: the subscription receives matching events.paused: nothing is delivered. The platform pauses a subscription automatically after repeated failures (see Auto-pause and disable); you can also pause it yourself withPATCH { "status": "paused" }. Resume withPATCH { "status": "active" }, which also resetsconsecutive_failed_deliveriesto0.disabled: a terminal state. A disabled subscription cannot be updated at all: anyPATCH, including one that tries to reactivate it, answers409withmeta.details.code: invalid_state. Delete it and create a new one instead. Settingstatus: "disabled"yourself is just as permanent.
Events that occur while a subscription is paused or disabled are never delivered to it, not even after you resume it. Retries still pending when a subscription is paused are dropped as well. To catch up after a pause, read the current state from the Messages endpoints.
Delivery
Each delivery is an HTTP POST of a JSON body (Content-Type: application/json) to your URL.
Signature
Every delivery carries two headers:
| Header | Value |
|---|---|
X-Timestamp |
Unix time in seconds at which this attempt was signed |
X-Signature |
Lowercase hex HMAC-SHA256 of the string <X-Timestamp>.<raw request body>, keyed with the full subscription secret (including the whk_ prefix) |
Each attempt, including every retry, is signed again with a fresh timestamp. To verify a request, recompute the HMAC over the exact bytes you received (do not re-serialize the parsed JSON), compare it to X-Signature in constant time, and reject timestamps too far from your own clock; five minutes is a reasonable tolerance.
Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(
secret: string,
timestamp: string,
rawBody: Buffer,
signature: string,
toleranceSec = 300,
): boolean {
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
Python:
import hashlib
import hmac
import time
def verify_webhook(secret: str, timestamp: str, raw_body: bytes, signature: str, tolerance: int = 300) -> bool:
try:
if abs(time.time() - int(timestamp)) > tolerance:
return False
except ValueError:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
The Receive webhooks guide shows how to wire this into a web server so that the raw body is still available.
Responding
Answer with any 2xx status within 10 seconds. Everything else counts as a failed attempt: a non-2xx status, a timeout, a connection or TLS error, or a redirect (redirects are never followed). Acknowledge quickly and do heavy processing asynchronously.
Retries
A failed attempt is retried with exponential backoff: 2, 4, 8, 16, 32, 64 and 128 seconds after the previous failure, for up to 8 attempts in total. The last attempt happens roughly four minutes after the first. If the eighth attempt also fails, the event is dropped for that subscription and is not redelivered.
URL safety check
Before every delivery, the platform resolves the host of your URL and connects only if it resolves to a public address. A host that resolves to a private, loopback, link-local or otherwise non-public network address, or that cannot be resolved, is refused. A refused event is not retried: it fails immediately and counts toward auto-pause like an event whose last attempt failed. The check runs at delivery time, so a POST /webhooks with such a URL is accepted but nothing will ever be delivered to it.
Auto-pause and disable
consecutive_failed_deliveries counts events, not attempts. It goes up by one when an event finally fails, that is, when its eighth attempt fails or the URL safety check refuses it. Failed attempts that are later retried do not count. Any successful delivery resets the counter to 0.
- When the counter reaches 10, the platform pauses the subscription:
statusbecomespausedandpaused_atis set. - A subscription paused by the platform that is not resumed within 7 days becomes
disabled. The check runs once a day, so the switch happens up to a day after the seven days have passed. - A subscription you paused yourself keeps
paused_atempty and never expires; it stays paused until you resume it.
last_delivery_at records the most recent attempt of any outcome, and last_success_at the most recent successful one.
Duplicates and ordering
Delivery is at-least-once. The same event can reach your endpoint more than once, for example when your endpoint processed a request but the response was lost. The event id is the same on every attempt and across all your subscriptions, so use it to de-duplicate.
Events are delivered concurrently and retries interleave with newer events, so they can arrive out of order; an email.opened can arrive before the email.delivered of the same message. Use occurred_at to order events, and the Messages endpoints when you need the authoritative current state of a message.
Payloads
Every delivery has the same outer shape:
| Field | Meaning |
|---|---|
id |
Event identifier (UUID). Stable across retries; use it to de-duplicate |
type |
Event name from the catalog |
occurred_at |
ISO 8601 time at which the event happened |
data |
Event-specific fields, described below |
Message fields
All seven email.* events share these fields in data:
| Field | Meaning |
|---|---|
message_id |
The message, as returned by the send call and by GET /messages/{id} |
to_email |
Recipient address |
campaign_id |
The campaign that sent the message, or null |
domain_id |
The sending domain |
message_class |
marketing, transactional or critical |
template_id |
The template the message was rendered from, or null when the message was not built from a template |
source_type |
blast (campaign), automation, transactional, otp, or null |
journey_step_id |
The automation step that sent the message, or null |
tags |
Array of the tags attached to the message when it was sent, de-duplicated and sorted. Always present; [] when there are none |
metadata |
Object of the string key-value metadata attached to the message when it was sent. Always present; {} when there is none |
email.delivered, email.opened, email.clicked, email.complained
These four events carry only the message fields; only type differs.
{
"id": "6f1c8e2a-9b3d-4c7a-8e2f-1a2b3c4d5e6f",
"type": "email.delivered",
"occurred_at": "2026-09-28T09:14:32.000Z",
"data": {
"message_id": "b3b6b0f0-2f0e-4b8a-9c7e-6b6b1f0a0e2d",
"to_email": "jane.doe@example.com",
"campaign_id": null,
"domain_id": "9d8c7b6a-5f4e-4d3c-8b2a-1e0f9d8c7b6a",
"message_class": "transactional",
"template_id": "3c2b1a0f-9e8d-4c7b-8a6f-5e4d3c2b1a0f",
"source_type": "transactional",
"journey_step_id": null,
"tags": ["order-confirmation", "web"],
"metadata": { "order_id": "A-10042" }
}
}
email.bounced
The message fields plus bounce_class and reason_code:
{
"id": "7a2d9f3b-1c4e-4a8d-9b5c-2e3f4a5b6c7d",
"type": "email.bounced",
"occurred_at": "2026-09-28T09:15:01.000Z",
"data": {
"message_id": "c4c7c1a1-3f1f-4c9b-8d8f-7c7c2a1b1f3e",
"to_email": "no-such-user@example.com",
"campaign_id": "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
"domain_id": "9d8c7b6a-5f4e-4d3c-8b2a-1e0f9d8c7b6a",
"message_class": "marketing",
"template_id": "3c2b1a0f-9e8d-4c7b-8a6f-5e4d3c2b1a0f",
"source_type": "blast",
"journey_step_id": null,
"tags": [],
"metadata": {},
"bounce_class": "hard",
"reason_code": "address_not_found"
}
}
bounce_class is hard (permanent; the address is suppressed automatically) or soft (temporary).
reason_code is always present in email.bounced and email.deferred and takes one of these values:
reason_code |
Meaning |
|---|---|
address_not_found |
The recipient address does not exist |
mailbox_full |
The recipient’s mailbox is full or over quota |
blocked_by_recipient |
The receiving side refused the message by policy, or the address is blocked at the mailbox provider |
content_rejected |
The receiving side rejected the message content or an attachment |
server_error |
A network or routing problem reaching the recipient’s mail server |
delivery_expired |
The message could not be delivered before its delivery window ran out |
admin_bounce |
The message was withdrawn from the delivery queue on the sending side |
not_reported |
The receiving side gave no reason |
unrecognized |
A reason was given, but it does not map to any of the values above |
email.deferred
The message fields plus reason_code (same values as above):
{
"id": "8b3e0a4c-2d5f-4b9e-9a6d-3f4a5b6c7d8e",
"type": "email.deferred",
"occurred_at": "2026-09-28T09:16:45.000Z",
"data": {
"message_id": "d5d8d2b2-4020-4d0c-9e9f-8d8d3b2c2f4f",
"to_email": "jane.doe@example.com",
"campaign_id": null,
"domain_id": "9d8c7b6a-5f4e-4d3c-8b2a-1e0f9d8c7b6a",
"message_class": "transactional",
"template_id": "4d3c2b1a-0f9e-4d8c-9b7a-6f5e4d3c2b1a",
"source_type": "transactional",
"journey_step_id": null,
"tags": ["password-reset"],
"metadata": {},
"reason_code": "mailbox_full"
}
}
A deferral is not final. Delivery continues to be retried automatically, and the message may still end in email.delivered, email.bounced or email.failed.
email.failed
The message fields plus error_message:
{
"id": "9c4f1b5d-3e6a-4c0f-8b7e-4a5b6c7d8e9f",
"type": "email.failed",
"occurred_at": "2026-09-28T09:17:10.000Z",
"data": {
"message_id": "e6e9e3c3-5131-4e1d-8f0a-9e9e4c3d3a5a",
"to_email": "jane.doe@example.com",
"campaign_id": "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
"domain_id": "9d8c7b6a-5f4e-4d3c-8b2a-1e0f9d8c7b6a",
"message_class": "marketing",
"template_id": "3c2b1a0f-9e8d-4c7b-8a6f-5e4d3c2b1a0f",
"source_type": "blast",
"journey_step_id": null,
"tags": [],
"metadata": {},
"error_message": "defer_cap_exceeded"
}
}
error_message is either a machine-readable code set by the platform or the rejection text returned by the mail provider. Codes you may see include defer_cap_exceeded (the message was deferred too many times), render_failed (the template could not be rendered for this recipient) and provider_stopped (sending was stopped before the message went out). Treat the value as informational; new codes may be added.
contact.unsubscribed
A contact-level payload, not tied to a single message:
{
"id": "0d5a2c6e-4f7b-4d1a-8c8f-5b6c7d8e9f0a",
"type": "contact.unsubscribed",
"occurred_at": "2026-09-28T09:18:22.000Z",
"data": {
"email": "jane.doe@example.com",
"reason": "unsubscribe"
}
}
reason is always unsubscribe.
Where to go next
- Messages and Suppressions: the entities these events describe.
- Receive webhooks: a step-by-step guide to setting up and verifying an endpoint.
- API Reference and Account.yaml: full subscription endpoint schemas.
- TypeScript and Python examples.
- Errors: error codes returned by the subscription endpoints themselves.