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
| Event | When |
|---|---|
email.sent | the message was accepted by the next mail server |
email.delivered | the recipient's server confirmed delivery |
email.delivery_delayed | a temporary failure; we keep retrying (1, 2, 4, 8 minutes ... up to 4 hours, 8 attempts) |
email.bounced | permanent failure; the recipient is added to your suppression list |
email.complained | the recipient marked it as spam; added to the suppression list |
email.failed | we gave up after the retry schedule |
email.opened | tracking pixel loaded (only when open tracking is on for the domain) |
email.clicked | a 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:
- Take the raw request body as a string, exactly as received.
- Build the signed content:
"{webhook-id}.{webhook-timestamp}.{body}". - The key is the base64-decoded part of the secret after
whsec_. - Compute HMAC-SHA256, base64-encode it, prefix with
v1,. - Compare in constant time against each space-separated value in
webhook-signature. - Reject if
webhook-timestampis 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.