Get Delivrie events (sent, delivered, bounced, complained, opened, clicked, received, contact and domain changes) pushed to your server, with payload examples, signature verification in every language, retries and replays.
Webhooks push events to your server as they happen, so you don't have to poll. Use them to mark bounced addresses, record deliveries, react to replies and keep your own history.
In Webhooks, click Add endpoint, enter an https:// URL and choose events. Or with the API:
curl -X POST https://delivrie.io/api/webhooks \
-H "Authorization: Bearer $DELIVRIE_API_KEY" -H "Content-Type: application/json" \
-d '{ "endpoint": "https://acme.com/webhooks/delivrie", "events": ["email.delivered", "email.bounced", "email.complained"] }'
The response includes signing_secret (starts with whsec_). It's shown once: store it as DELIVRIE_WEBHOOK_SECRET. Use ["*"] to receive every event.
| Event | When |
|---|---|
email.sent | Delivrie's mail servers accepted the email |
email.delivered | The recipient's server accepted it |
email.delivery_delayed | Delivery is temporarily failing and being retried |
email.bounced | The recipient's server rejected it |
email.complained | The recipient marked it as spam |
email.opened | Opened (open tracking must be on for the domain) |
email.clicked | A link was clicked (click tracking must be on) |
email.failed | It couldn't be sent; failed.reason says why |
email.received | Mail arrived at one of your receiving addresses |
contact.created, contact.updated, contact.deleted | A contact changed |
domain.created, domain.updated | A domain was added or its verification status changed |
Every request is a POST with a JSON body of { type, created_at, data }.
email.delivered
{
"type": "email.delivered",
"created_at": "2026-11-14T09:12:06.511Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"from": "Acme <orders@mail.acme.com>",
"to": ["ada@example.com"],
"subject": "Your order is confirmed",
"created_at": "2026-11-14T09:12:04.120Z",
"tags": { "category": "receipt" }
}
}email.bounced
{
"type": "email.bounced",
"created_at": "2026-11-14T09:12:07.002Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"from": "Acme <orders@mail.acme.com>",
"to": ["nobody@example.com"],
"subject": "Your order is confirmed",
"created_at": "2026-11-14T09:12:04.120Z",
"bounce": {
"type": "Permanent",
"sub_type": "General",
"message": "smtp; 550 5.1.1 user unknown",
"recipients": [{ "emailAddress": "nobody@example.com" }]
}
}
}email.clicked
{
"type": "email.clicked",
"created_at": "2026-11-14T09:30:12.000Z",
"data": {
"email_id": "4ef9a417-...",
"from": "Acme <orders@mail.acme.com>",
"to": ["ada@example.com"],
"subject": "Your order is confirmed",
"created_at": "2026-11-14T09:12:04.120Z",
"click": { "link": "https://acme.com/orders/1042", "user_agent": "Mozilla/5.0 ...", "ip": "102.89.0.1" }
}
}contact.created
{
"type": "contact.created",
"created_at": "2026-11-14T08:00:00.000Z",
"data": { "id": "c_12...", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace", "unsubscribed": false, "created_at": "2026-11-14T08:00:00.000Z" }
}domain.updated
{
"type": "domain.updated",
"created_at": "2026-11-14T08:05:00.000Z",
"data": { "id": "d91e...", "name": "mail.acme.com", "status": "verified", "region": "eu-west-1" }
}
All email events share email_id, from, to, subject and created_at, plus tags (as an object) when the email had tags and broadcast_id for broadcast emails. email.bounced adds bounce, email.clicked adds click, email.failed adds failed: { reason }. The email.received payload is shown in Receiving email.
Every request is signed with the Standard Webhooks scheme, using three headers:
| Header | Contains |
|---|---|
webhook-id | A unique event id, like evt_8c1.... The same for retries of one event |
webhook-timestamp | Unix time in seconds when this attempt was sent |
webhook-signature | v1,<base64 signature> (several, space-separated, during secret rotation) |
The signature is HMAC-SHA256 of ${webhook-id}.${webhook-timestamp}.${raw body}, keyed with the base64-decoded part of your secret after whsec_. Always verify against the raw body, before any JSON parsing, and reject timestamps more than five minutes old.
Node.js
import { verifyWebhook } from "delivrie";
// Next.js route handler, Hono, Workers... (Express: use express.raw and pass req.body)
export async function POST(req: Request) {
try {
const event = await verifyWebhook({
payload: await req.text(),
headers: req.headers,
secret: process.env.DELIVRIE_WEBHOOK_SECRET!,
});
await handle(event);
return new Response("ok");
} catch {
return new Response("invalid signature", { status: 400 });
}
}Python
import base64, hashlib, hmac, json, time
def verify_webhook(payload: bytes, headers: dict, secret: str) -> dict:
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
raise ValueError("timestamp too old")
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + payload, hashlib.sha256).digest()).decode()
if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in headers["webhook-signature"].split()):
raise ValueError("invalid signature")
return json.loads(payload)PHP
function verify_webhook(string $payload, array $h, string $secret): array {
if (abs(time() - (int) $h['webhook-timestamp']) > 300) throw new Exception('timestamp too old');
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "{$h['webhook-id']}.{$h['webhook-timestamp']}.$payload", $key, true));
foreach (explode(' ', $h['webhook-signature']) as $sig) {
if (str_starts_with($sig, 'v1,') && hash_equals($expected, substr($sig, 3))) return json_decode($payload, true);
}
throw new Exception('invalid signature');
}
Ruby, Go and Rust versions are in their guides: Ruby, Go, Rust. Standard Webhooks libraries (standardwebhooks on npm and PyPI, and others) also work with your whsec_ secret.
Return any 2xx status within 15 seconds. Do slow work (database writes, calling other APIs) in a background job after responding. Redirects aren't followed and count as failures.
Failed deliveries (non-2xx, timeout, connection error) are retried with increasing gaps: after about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, then every 10 hours, for up to 72 hours.
webhook-id to skip duplicates, and created_at to order them.GET /webhooks/:id/events.POST /webhook-deliveries/:id/replay.POST /webhooks/:id/test.Expose your local server with a tunnel such as ngrok http 3000 or cloudflared tunnel --url http://localhost:3000, add the tunnel URL as an endpoint, then send to a test address to trigger real events.