PadiMail Docs
Events

Verify signatures

Check that a webhook request really came from PadiMail and hasn't been changed or replayed.

NeedsAny accountTest keys work as soon as you sign up. Nothing is delivered.

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.

Python
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.

Was this page helpful?

On this page