Scribase Mail docs

Webhooks and signature verification

Receive delivery, bounce, complaint, open and click events from Scribase Mail. Event list, payload shape, retry schedule, and how to verify the HMAC-SHA256 signature in Node, Next.js, Cloudflare Workers, Python or Go.

A webhook is an HTTPS URL we POST to whenever something happens to an email you sent. Create one with POST /webhooks, keep the signing_secret, and verify every request before trusting it.

Events

EventWhen
email.sentthe message was accepted by the next mail server
email.deliveredthe recipient's server confirmed delivery
email.delivery_delayeda temporary failure; we keep retrying (1, 2, 4, 8 minutes ... up to 4 hours, 8 attempts)
email.bouncedpermanent failure; the recipient is added to your suppression list
email.complainedthe recipient marked it as spam; added to the suppression list
email.failedwe gave up after the retry schedule
email.openedtracking pixel loaded (only when open tracking is on for the domain)
email.clickeda rewritten link was followed (only when click tracking is on)

Create an endpoint

curl -X POST https://mail-api.scribase.com/webhooks \
  -H "Authorization: Bearer $SCRIBASE_MAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "endpoint": "https://acme.com/hooks/mail", "events": ["email.delivered", "email.bounced", "email.complained"] }'
{ "object": "webhook", "id": "dd5a4311-...", "signing_secret": "whsec_3s6npId7AItCYHnmu98/wpzimCb7u/UH" }

The endpoint must be a public http(s) host. GET /webhooks/{id} returns the secret again if you lose it; PATCH /webhooks/{id} changes endpoint, events or status (enabled / disabled); DELETE removes it.

What you receive

POST /hooks/mail HTTP/1.1
Content-Type: application/json
webhook-id: msg_7c1e0d8b2d1c4b0f9a4f2e1c3d5b6a70
webhook-timestamp: 1794518113
webhook-signature: v1,K1xM8w4wq0q4b1Yk3cJm7Y0b9M9b3xk0eZ3Qd1aQF4E=
svix-id: msg_7c1e0d8b2d1c4b0f9a4f2e1c3d5b6a70
svix-timestamp: 1794518113
svix-signature: v1,K1xM8w4wq0q4b1Yk3cJm7Y0b9M9b3xk0eZ3Qd1aQF4E=
{
  "type": "email.bounced",
  "created_at": "2026-11-12T21:15:12.560Z",
  "data": {
    "created_at": "2026-11-12T21:15:12.548Z",
    "email_id": "7af8001f-54d6-4607-b951-ee1cc2ad60d5",
    "message_id": "<7af8001f-54d6-4607-b951-ee1cc2ad60d5@acme.com>",
    "from": "Acme <hello@acme.com>",
    "to": ["jane@example.com"],
    "cc": [],
    "bcc": [],
    "subject": "Your receipt",
    "tags": { "category": "receipt" },
    "bounce": { "type": "Permanent", "subType": "General", "message": "550 5.1.1 user unknown", "code": 550, "recipients": ["jane@example.com"] }
  }
}

Extra keys by event: bounce on email.bounced, complaint on email.complained, delivery on email.delivered, click: { link } on email.clicked.

Answer with any 2xx within 10 seconds. Anything else is retried at 5 s, 30 s, 2 min, 10 min, 30 min, 2 h, 8 h, 8 h (8 attempts), then marked failed. Deliveries can arrive out of order and, rarely, twice; use webhook-id to de-duplicate.

Verify the signature

The scheme is the Standard Webhooks / Svix scheme, so Resend's verifier and any Svix library accept it unchanged:

  1. Take the raw request body as a string, exactly as received.
  2. Build the signed content: "{webhook-id}.{webhook-timestamp}.{body}".
  3. The key is the base64-decoded part of the secret after whsec_.
  4. Compute HMAC-SHA256, base64-encode it, prefix with v1,.
  5. Compare in constant time against each space-separated value in webhook-signature.
  6. Reject if webhook-timestamp is more than 5 minutes from now.

TypeScript (any runtime)

import { ScribaseMail, WebhookVerificationError } from "@scribase/mail";
const mail = new ScribaseMail();

// Next.js App Router / Cloudflare Workers / Deno / Bun
export async function POST(req: Request) {
  const raw = await req.text();
  try {
    const event = await mail.webhooks.verify(raw, req.headers, process.env.MAIL_WEBHOOK_SECRET!);
    // event.type, event.data.email_id ...
    return new Response("{}", { status: 200 });
  } catch (e) {
    if (e instanceof WebhookVerificationError) return new Response("invalid signature", { status: 400 });
    throw e;
  }
}
// Express: keep the raw body
app.post("/hooks/mail", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await mail.webhooks.verify(req.body.toString("utf8"), req.headers, process.env.MAIL_WEBHOOK_SECRET!);
    res.status(200).json({});
  } catch {
    res.status(400).send("invalid signature");
  }
});

Keep using the resend package

resend.webhooks.verify({
  payload: raw,
  headers: { id: h["webhook-id"], timestamp: h["webhook-timestamp"], signature: h["webhook-signature"] },
  webhookSecret: process.env.MAIL_WEBHOOK_SECRET!,
});

Python

import base64, hmac, hashlib, time

def verify(body: bytes, headers: dict, secret: str, tolerance=300) -> bool:
    msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
    if abs(time.time() - int(ts)) > tolerance:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    for candidate in sigs.split():
        version, _, value = candidate.partition(",")
        if version == "v1" and hmac.compare_digest(expected, base64.b64decode(value)):
            return True
    return False

Go

func verify(body []byte, h http.Header, secret string) bool {
    id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
    t, err := strconv.ParseInt(ts, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 { return false }
    key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(id + "." + ts + "."))
    mac.Write(body)
    want := mac.Sum(nil)
    for _, s := range strings.Fields(sigs) {
        if v, ok := strings.CutPrefix(s, "v1,"); ok {
            got, _ := base64.StdEncoding.DecodeString(v)
            if hmac.Equal(want, got) { return true }
        }
    }
    return false
}

Feeding events back in

If you process bounces yourself (for example from an inbound mailbox we do not run yet), tell us so the suppression list stays right:

curl -X POST https://mail-api.scribase.com/emails/$EMAIL_ID/events \
  -H "Authorization: Bearer $FULL_ACCESS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "bounced", "recipient": "jane@example.com", "code": 550, "message": "user unknown" }'

This sets the email to bounced, suppresses the address and fires email.bounced to your endpoints. Types: bounced, complained, delivered.