# Events you can verify before you trust

> Email webhooks signed with timestamped HMAC-SHA256, a five-minute replay window, up to 15 retries with backoff, and SSRF-guarded delivery to your endpoint.

Canonical: https://emailfast.dev/features/webhooks

## Every message lifecycle, pushed to you

Email webhooks deliver the full lifecycle to your endpoint: `email.sent`,
`email.bounced`, `email.opened`, `email.clicked`, `email.complained`,
`email.unsubscribed`, and `email.inbound`. Events arrive as one envelope shape —
`{ "id": "evt_…", "type", "created_at", "data" }` — and as webhooks signed with timestamped HMAC-SHA256 (Stripe-style t=…,v1=…), with delivery hardened against server-side request forgery.

## How it works

1. Subscribe an HTTPS endpoint to the event types you want.
2. On each delivery, read three headers: `x-emailfast-signature` (`t=<unix>,v1=<hmac>`), `x-emailfast-timestamp`, and `x-emailfast-event`.
3. Recompute the HMAC-SHA256 over the timestamp and raw body with your endpoint secret; reject mismatches and anything older than the five-minute replay tolerance.
4. Return a `2xx`. Anything else is retried — up to 15 attempts with backoff.

Delivery is SSRF-guarded: destination URLs are resolved and checked before we
connect, so the webhook system can't be aimed at private networks and used as a
probe.

## The evidence

:::panel One delivery, verifiable offline
```
x-emailfast-event: email.bounced
x-emailfast-timestamp: 1752750000
x-emailfast-signature: t=1752750000,v1=8f1c2a...
```
```json
{
  "id": "evt_01h9...",
  "type": "email.bounced",
  "created_at": "2026-07-17T09:00:00Z",
  "data": { "message_id": "msg_01h9..." }
}
```
Verification needs nothing from us at request time: your secret, the timestamp, the
raw body. A tampered byte or a five-minute-old replay fails verification.
:::

## Honest limits

:::tradeoffs At-least-once, not exactly-once
Delivery is at-least-once: rare duplicates happen, so make handlers idempotent on the
`evt_` id. Ordering across event types isn't guaranteed — don't build state machines
on arrival order. And 15 retries is a lot, but not forever: an endpoint that stays
down will miss events, which is what message timelines are for. Reconcile by pulling,
not only by listening.
:::

## Where to go next

Wire a verified handler in minutes with the [Node quickstart](/docs/quickstart-node).
Webhooks pair with the [send API](/features/email-api) on the way out and
[inbound parsing](/features/inbound) on the way in. Weighing providers on the event
surface? The [Resend comparison](/compare/resend-alternative) covers it honestly.

## How do I verify a webhook?

Recompute the HMAC-SHA256 over the timestamp and raw request body with your endpoint secret, compare it to the v1= value in x-emailfast-signature , and reject anything whose timestamp is outside the five-minute tolerance. No call to us is needed — verification is offline.

## What stops replay attacks?

The timestamp is inside the signed material. An attacker replaying a captured delivery can't refresh the timestamp without breaking the signature, and anything older than five minutes is rejected.

## What's the retry policy?

Any non-2xx response is retried — up to 15 attempts with backoff. After that, delivery stops for that event; reconcile by pulling the message timeline with GET /v1/messages/:id .

## Will I ever see the same event twice?

Rarely, yes — delivery is at-least-once. Make handlers idempotent on the evt_ id and duplicates become harmless.
