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 with PATCH { "status": "paused" }. Resume with PATCH { "status": "active" }, which also resets consecutive_failed_deliveries to 0.
  • disabled: a terminal state. A disabled subscription cannot be updated at all: any PATCH, including one that tries to reactivate it, answers 409 with meta.details.code: invalid_state. Delete it and create a new one instead. Setting status: "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: status becomes paused and paused_at is 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_at empty 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