Verify signatures
Check that a webhook request really came from PadiMail and hasn't been changed or replayed.
Anyone can send a request to your webhook URL. The signature proves it came from us. Deliveries follow the Standard Webhooks format, so any Standard Webhooks library works too.
With the Node SDK
import { verifyWebhook, WebhookVerificationError } from "@padimail/node";
export async function POST(req: Request) {
try {
const event = await verifyWebhook({ payload: await req.text(), headers: req.headers, secret: process.env.PADIMAIL_WEBHOOK_SECRET! });
// handle event…
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 400 });
throw err;
}
}verifyWebhook checks the signature, rejects deliveries more than 5 minutes old or in the future (so a captured request can't be replayed later), and returns the parsed event with types for every event.
By hand
The signature is an HMAC-SHA256, in base64, of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret after whsec_. The webhook-signature header holds one or more space-separated v1,<signature> values; accept the request if any of them matches.
import base64, hashlib, hmac, time
def verify(body: bytes, headers, secret: str) -> bool:
msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected, s.split(",", 1)[1]) for s in sigs.split() if s.startswith("v1,"))To check your handler by hand, sign a body the same way with openssl:
SECRET_B64=${PADIMAIL_WEBHOOK_SECRET#whsec_}
TS=$(date +%s); ID=msg_test; BODY='{"type":"email.sent","created_at":"2026-10-27T13:05:09Z","data":{}}'
SIG=$(printf '%s' "$ID.$TS.$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$(printf '%s' "$SECRET_B64" | base64 -d | xxd -p -c 256) -binary | base64)
curl -X POST http://localhost:3000/api/webhooks/padimail -H "webhook-id: $ID" -H "webhook-timestamp: $TS" -H "webhook-signature: v1,$SIG" -d "$BODY"Rotating the secret
POST /v1/webhooks/{id}/rotate-secret (or Rotate secret in the dashboard) returns a new secret. The old one keeps working for 24 hours, and during that time deliveries carry both signatures, so you can deploy the new secret without missing any.
Resend format
Endpoints with the Resend payload format send the same signature under svix-id, svix-timestamp and svix-signature. Your existing Resend or Svix verification code works, and so does verifyWebhook.