Webhooks
Create an endpoint, the eight events zSender can send you, verifying a Standard Webhooks signature by hand or with the SDK, the retry schedule, and where to read delivery history.
After this page you'll be able to create a webhook endpoint, know which of the eight events it can receive, verify a delivery's signature correctly (the part worth getting right the first time, since a subtly wrong verifier fails silently until the moment you actually need it) and read what a delivery attempt (and its retries) looked like.
Outbound delivery events and inbound mail arriving at one of your receiving domains share one delivery system: up to five endpoints per tenant, each with its own URL, its own event filter, and its own signing secret. There is no separate inbound webhook mechanism, and no per-domain destination: a receiving domain's DNS and verification live on the Domains page; where its mail gets delivered is decided here, by which endpoint's filter includes email.received.
Create an endpoint
Endpoint management is a dashboard feature, not part of the /v1 API-key surface: the routes require a logged-in session (JWT), not an API key. Add one from Dashboard > Webhooks > Outbound: a URL (must be https://), an optional description, and which events it should receive. Leave the event filter empty to receive every outbound event: email.received is the one exception; see The event list below.
The signing secret (whsec_...) is shown next to the endpoint and stays visible. Unlike an API key, it isn't a one-time reveal. You need to be able to read it back indefinitely to verify future deliveries, so there's nothing to hide it behind. A maximum of five endpoints per tenant is enforced on create; deleting one frees a slot. Each endpoint also has a Send test button: a live, synchronous, correctly-signed email.delivered probe to that URL. It's useful for confirming your receiver is reachable and your verifier accepts a real signature, but it persists nothing: a test send won't show up in the delivery log below.
The event list
Eight events, all subscribable from the same endpoint's filter:
| Event | What it means |
|---|---|
| email.delivered | The recipient's mail server accepted the message. There is no separate “delivered” event: this is the mail server's own MTA-acceptance signal, and it's the strongest delivery confirmation zSender has. |
| email.delivery_delayed | Delivery is temporarily delayed at the recipient's mail server. |
| email.bounced | The message bounced, hard or soft. |
| email.failed | The send failed outright. |
| email.held | The mail server held the message: a spam or policy hold, not a delivery outcome. |
| email.opened | The recipient opened the message (tracking pixel). |
| email.clicked | The recipient clicked a tracked link. |
| email.received | Mail arrived at one of your verified, receiving-enabled domains. See Receiving email below. |
email.received is an ordinary event on this same list, not a second delivery system. See Receiving email for what its data object carries (metadata only; fetch the body and attachments separately through /docs/api/emails). It is also the one exception to "an empty event filter means all events": an endpoint only receives it if its filter explicitly includes email.received. A catch-all endpoint you set up before you ever enabled a receiving domain does not suddenly start forwarding strangers' mail to you: you have to opt in.
Verify a signature
Every delivery, outbound or email.received, is signed the same way, following Standard Webhooks. Three headers travel with the POST:
webhook-id: the delivery's own id, stable across every retry of the same delivery.webhook-timestamp: unix seconds, as a decimal string.webhook-signature: one or more space-separatedv1,<signature>entries (more than one only during a secret rotation on our side; check whether any entry matches, not just the first).
The signed content is {webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256'd with the key bytes you get by base64-decoding the part of your endpoint's secret after whsec_. The signature is base64-encoded and sent as v1,<that>. The body you sign must be the exact bytes we sent: not a value you got back out by parsing the JSON and calling JSON.stringify() on it again. Re-serializing is not guaranteed to reproduce the same bytes (key order, whitespace, number formatting), and a verifier that does it will fail against real deliveries while looking correct in a hand-written test. Capture the raw body before any JSON parsing happens.
const crypto = require('crypto');
function verifyZsenderWebhook(rawBody, headers, secret) {
// secret is the whsec_... value from the endpoint in the dashboard
const keyBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signedContent = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${rawBody}`;
const expected = crypto.createHmac('sha256', keyBytes).update(signedContent).digest('base64');
return headers['webhook-signature']
.split(' ')
.some((entry) => {
const [version, sig] = entry.split(',');
// use a timing-safe comparison in production, not ===
return version === 'v1' && sig === expected;
});
}import { Zsender } from 'zsender-sdk';
const zsender = new Zsender(process.env.ZSENDER_API_KEY!);
// Inside your webhook receiver -- rawBody must be the EXACT bytes zSender sent, not a
// JSON.parse()'d-and-re-stringified object. A re-serialized body is not guaranteed to
// round-trip byte-for-byte and will not verify.
const rawBody =
'{"event":"email.delivered","message_id":"<abc123@yourdomain.com>","uuid":"evt_9f2c",' +
'"timestamp":1735689600,"data":{}}';
const headers = new Headers({
'webhook-id': 'evt_9f2c',
'webhook-timestamp': '1735689600',
'webhook-signature': 'v1,BASE64SIGNATURE',
});
const result = await zsender.webhooks.verify(
rawBody,
headers,
process.env.ZSENDER_WEBHOOK_SECRET!, // the endpoint's whsec_... secret
);
if (!result.ok) {
// A forged or replayed request is the expected failure case, not an exceptional one --
// verify() never throws. `result.reason` says which check failed.
console.log('rejected:', result.reason);
} else {
// result.event stays snake_case on every field (event, message_id, uuid, timestamp,
// data) -- the one deliberate exception to the SDK's camelCase surface, because this IS
// the wire delivery contract your server receives, not a translated /v1 response.
console.log(result.event.event, result.event.message_id, result.event.data);
}The TypeScript SDK's zsender.webhooks.verify() does this for you: HMAC comparison via crypto.subtle rather than hand-rolled string equality, a five-minute default timestamp tolerance (matching the server's own), and it checks every v1,... entry for you. It also never throws: a forged or replayed request is the expected failure case here, not an exceptional one, so a bad signature is a typed result, not a caught exception that might slip past a try/catch you forgot to add.
On success it returns one of two shapes:
{ ok: true, recognized: true, event }:event.eventis one of the eight names above.{ ok: true, recognized: false, event }: the signature checked out but the event name isn't one this SDK version knows about yet. Still genuinely from zSender; don't discard it, just don't assume its shape.
Otherwise it's { ok: false, reason }, where reason is one of missing-headers, bad-secret, bad-timestamp, timestamp-out-of-tolerance, or no-matching-signature.
The verified payload stays snake_case: the one deliberate exception to the SDK's otherwise-camelCase surface. Every other SDK method translates the wire's snake_case into camelCase for you, but result.event is handed back exactly as it arrived on the wire: event.event, event.message_id, event.uuid, event.timestamp, and event.data. Reach for event.messageId out of habit and you'll get undefined, not a type error, because data's shape depends on which event it is. This is deliberate: the SDK types this as the actual delivery contract your server receives, not a re-shaped client type, so what you log or inspect on the wire matches what the type checker shows you.
Retries and backoff
A delivery counts as successful only on a 2xx response. Anything else (a non-2xx status, a timeout, a connection error) is retried automatically, with increasing delay between attempts: 1 second, 5 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, then 6 hours. That's eight attempts in total (the original try plus seven retries) spread over about 7 hours 12 minutes. If the eighth attempt still doesn't succeed, the delivery is marked permanently failed and we stop: there is no further automatic retry after that.
The window is deliberately long enough to cover a deploy of your own, a short outage, or an incident that starts while nobody is watching: an endpoint that comes back within seven hours catches up on its own, with no events lost. Past that the event is stale enough that the right answer is a deliberate one: every failed delivery can be retried by hand from the dashboard's Deliveries tab, however long ago it failed, for as long as the row is kept (30 days).
Three cases never retry at all
A handful of failures are permanent on the first attempt, with no backoff schedule involved, because retrying wouldn't change the outcome:
- The endpoint was deleted after the delivery was queued: nothing to sign or send to.
- The endpoint URL resolves to a private, loopback, or otherwise blocked address: the SSRF guard rejects it before any request is sent. If you're testing against a local server, put a public tunnel URL in front of it.
- The endpoint's stored secret can't be decrypted: this shouldn't happen in normal operation, but if it does, the delivery fails immediately rather than sending an unsigned request.
A failed delivery, whether it exhausted its retries or hit one of the cases above, can be retried manually from the Deliveries tab; see below.
Reading deliveries
Dashboard > Webhooks > Deliveries is a single, unified log of every delivery attempt for the tenant: outbound and email.received alike, filterable by direction and status (pending/delivered/failed). Opening a row shows its full stored payload; a failed row has a Retry action that re-queues it for immediate delivery. Delivery rows are kept for 30 days and swept after that. Pull anything you need out of the log before then.
Like endpoint management, this log is a dashboard feature: the underlying /api/webhooks/deliveries routes require a logged-in session and aren't callable with an API key, so there's no /v1 reference page for them.