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
| Event | Fires when |
|---|---|
email.accepted | Queued by Unlayer; one Email Credit consumed. |
email.failed | Delivery failed or could not be confirmed after retries. |
email.sent | Accepted by the sending provider. |
email.delivered | Accepted by the recipient's mail server. |
email.bounced | Delivery bounced, including exhausted soft bounces. |
email.complained | Recipient 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_versionidentifies the webhook envelope contract. Additive fields may appear without a version change; incompatible shape changes use a new version.idis a stableevt_…identifier (see Duplicates).typeis one of the events above.data.email_idmatches theidfrom your send response.data.timestampis 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);
}
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.