Webhooks
Verify webhook requests
Check the Svix-compatible signature on every request before you trust its payload.
What Opensend sends
Every delivery is a POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
svix-id | The message ID, such as msg_.... Retries and replays of the same event reuse it. |
svix-timestamp | When this attempt was sent, in Unix seconds. Each attempt has its own. |
svix-signature | One or more signatures separated by spaces, each v1,<base64>. |
Opensend does not set a custom User-Agent header.
The signature is an HMAC-SHA256 over the string {svix-id}.{svix-timestamp}.{raw body}, keyed with the bytes of the signing secret after whsec_ decoded from base64, and encoded as base64. It is the same scheme Svix and Resend use, so their verification libraries accept Opensend deliveries unchanged.
During the 24 hours after a secret rotation the header holds two signatures: the new secret's, then the previous secret's. A request is valid when any one of them matches.
Verify with the svix package
pnpm add sviximport { Webhook } from "svix";
const webhook = new Webhook(process.env.OPENSEND_WEBHOOK_SECRET!);
export async function POST(request: Request) {
const body = await request.text();
let event: { type: string; created_at: string; data: unknown };
try {
event = webhook.verify(body, {
"svix-id": request.headers.get("svix-id") ?? "",
"svix-timestamp": request.headers.get("svix-timestamp") ?? "",
"svix-signature": request.headers.get("svix-signature") ?? "",
}) as typeof event;
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Implement persistEvent with your database or durable queue.
// Deduplicate by svix-id and return 2xx only after persistence succeeds.
await persistEvent(request.headers.get("svix-id")!, event);
return new Response("OK");
}Implement persistEvent in your application; it is not supplied by svix.
verify checks every signature in the header and rejects timestamps more than five minutes from your server's clock.
Verify with Node crypto
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyOpensendWebhook(
secret: string,
headers: { id: string; timestamp: string; signature: string },
rawBody: string
): boolean {
const age = Math.abs(Date.now() / 1000 - Number(headers.timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${headers.id}.${headers.timestamp}.${rawBody}`)
.digest();
return headers.signature.split(" ").some((part) => {
const [version, value] = part.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Call it with the raw request body as a string, before parsing JSON.
Avoid common failures
- Sign the raw body. Parsing and re-serializing the JSON changes the bytes and breaks the signature.
- Check every signature in the header, not just the first, or deliveries fail during a secret rotation.
- Keep a timestamp tolerance. Retries send a fresh
svix-timestamp, so a late retry still passes. - Deduplicate by
svix-id. A retry or replay carries the same ID as the first attempt. - Answer
2xxwithin 15 seconds. Do the slow work after you have stored the event.