Webhooks

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.

Add an endpoint

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.

Events

EventWhen
email.sentDelivrie's mail servers accepted the email
email.deliveredThe recipient's server accepted it
email.delivery_delayedDelivery is temporarily failing and being retried
email.bouncedThe recipient's server rejected it
email.complainedThe recipient marked it as spam
email.openedOpened (open tracking must be on for the domain)
email.clickedA link was clicked (click tracking must be on)
email.failedIt couldn't be sent; failed.reason says why
email.receivedMail arrived at one of your receiving addresses
contact.created, contact.updated, contact.deletedA contact changed
domain.created, domain.updatedA domain was added or its verification status changed

Payloads

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.

Verify signatures

Every request is signed with the Standard Webhooks scheme, using three headers:

HeaderContains
webhook-idA unique event id, like evt_8c1.... The same for retries of one event
webhook-timestampUnix time in seconds when this attempt was sent
webhook-signaturev1,<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.

Respond quickly

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.

Retries

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.

Test locally

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.

More guides