Skip to main content

Webhooks & events

Webhooks send email status updates to your backend, so you don't have to poll GET /v3/emails/{id}.

Create a webhook

curl -X POST https://api.unlayer.com/v3/webhooks \
-H "Authorization: Bearer unlayer_sk_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourapp.com/hooks/unlayer",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'

Leave events empty (or omit it) to receive everything. The response includes a signing secret, shown only this once. Store it now; you can't read it back later, only rotate it.

{
"data": {
"id": 7,
"url": "https://api.yourapp.com/hooks/unlayer",
"events": ["email.delivered", "email.bounced", "email.complained"],
"active": true,
"secret": "whsec_3f9a8b...store-me"
}
}

The URL must be HTTPS in production, and it can't point at a private or internal address. A project can register up to five webhook URLs. Registering the same URL twice returns 409.

Event types

EventFires when
email.acceptedQueued by Unlayer; one Email Credit consumed.
email.failedDelivery failed or could not be confirmed after retries.
email.sentAccepted by the sending provider.
email.deliveredAccepted by the recipient's mail server.
email.bouncedDelivery bounced, including exhausted soft bounces.
email.complainedRecipient marked the message as spam.

Event payload

Every event is a JSON POST with this shape:

{
"schema_version": 1,
"id": "evt_8f3c2a1b9d4e5f6a7b8c9d0e1f2a3b4c",
"type": "email.delivered",
"data": {
"email_id": "9b2c2f4e-7e3a-4f1d-9a8b-1c2d3e4f5a6b",
"timestamp": "2026-06-18T12:00:03.000Z"
},
"created_at": "2026-06-18T12:00:04.000Z"
}
  • schema_version identifies the webhook envelope contract. Additive fields may appear without a version change; incompatible shape changes use a new version.
  • id is a stable evt_… identifier (see Duplicates).
  • type is one of the events above.
  • data.email_id matches the id from your send response.
  • data.timestamp is when the event occurred. Acceptance and failed-send events originate in Unlayer; delivery, bounce, and complaint events originate at the provider.

Each request also carries two headers: X-Webhook-Signature and X-Webhook-Id.

Verify the signature

Always verify the signature before trusting a payload. It's the HMAC-SHA256 of the raw request body, keyed with your webhook secret and hex-encoded, in X-Webhook-Signature:

import { createHmac, timingSafeEqual } from 'crypto';

function verify(rawBody, signatureHeader, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || '');
return a.length === b.length && timingSafeEqual(a, b);
}
Sign the raw body

Compute the HMAC over the exact bytes you received, before any JSON parsing. Re-stringifying the body changes those bytes and the signature won't match.

Duplicates are normal

The same event can arrive more than once. Every copy carries the same id, so dedupe on it and keep your handler idempotent. Reply 2xx quickly. Network failures, timeouts, 401, 403, 408, 429, and 5xx responses are retried with backoff; other 4xx responses are treated as permanent. A valid Retry-After response header is honored up to 15 minutes.

Accepted and failed sends

email.accepted reports acceptance and credit consumption. email.failed reports a failure before confirmed provider handoff, with data.failure_reason and data.email_id. Their event IDs and timestamps stay the same across retries. Events can arrive out of order. The stats endpoint includes accepted and failed in both totals and daily rows, grouped by original acceptance date.

Keep a longer history

Email details and event timelines are available from the API for 90 days after an email is accepted. If you need a longer audit trail, store each verified webhook event in your own system after deduplicating it by id. Do not treat the email history endpoints as a long-term archive.

Manage webhooks

List your webhooks (secrets are never returned here):

curl https://api.unlayer.com/v3/webhooks \
-H "Authorization: Bearer unlayer_sk_xxx"

Get one, update it, or delete it:

# Update the URL, events, or active flag
curl -X PATCH https://api.unlayer.com/v3/webhooks/7 \
-H "Authorization: Bearer unlayer_sk_xxx" \
-H "Content-Type: application/json" \
-d '{ "active": false }'

# Delete it
curl -X DELETE https://api.unlayer.com/v3/webhooks/7 \
-H "Authorization: Bearer unlayer_sk_xxx"

Rotate the signing secret

Prepare a fresh secret. The current secret keeps signing deliveries. The prepared secret is returned just once:

curl -X POST https://api.unlayer.com/v3/webhooks/7/rotate-secret \
-H "Authorization: Bearer unlayer_sk_xxx"
{
"data": {
"id": 7,
"secret": "whsec_7c1d4f...store-me",
"updatedAt": "2026-06-18T12:00:00.000Z"
}
}

Configure your receiver to accept signatures from both the current and prepared secrets, then activate the prepared secret:

curl -X POST https://api.unlayer.com/v3/webhooks/7/activate-secret \
-H "Authorization: Bearer unlayer_sk_xxx" \
-H "Content-Type: application/json" \
-d '{ "secret": "whsec_PREPARED_SECRET" }'

Keep accepting the old secret briefly for HTTP requests already in flight. Activation switches later delivery attempts, including retries, to the new secret. Preparing another rotation replaces the pending secret without affecting current signing. Activation of a superseded pending secret returns 409. Console provides the same prepare, update-receiver, and activate sequence.

Authentication failures (401/403) retry with bounded backoff. After six failed attempts, automatic retries stop. Contact support after repairing the receiver to request recovery of exhausted deliveries. Deduplicate by event id: replay can deliver an event again, but never sends another customer email.

Aggregate stats

For dashboards, read rolled-up totals instead of counting events yourself:

curl "https://api.unlayer.com/v3/emails/stats?period=30d" \
-H "Authorization: Bearer unlayer_sk_xxx"
{
"data": {
"period": "30d",
"accepted": 1210,
"failed": 10,
"sent": 1200,
"delivered": 1180,
"bounced": 12,
"complained": 1,
"deliveryRate": 98.33,
"bounceRate": 1
}
}

period accepts 7d, 30d, or 90d (default 30d). Add groupBy=day for a daily breakdown you can chart. Events are grouped by the date the email was accepted, including later deliveries, bounces, and complaints. Rates are percentages from 0 to 100. Statistics update asynchronously and may lag behind individual events. Use webhooks or the email detail endpoint to follow a specific email.

Next

That's the full flow. For field-by-field detail on every endpoint, see the API reference.