Skip to documentation

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:

HeaderValue
svix-idThe message ID, such as msg_.... Retries and replays of the same event reuse it.
svix-timestampWhen this attempt was sent, in Unix seconds. Each attempt has its own.
svix-signatureOne 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

bash
pnpm add svix
typescript
import { 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

typescript
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 2xx within 15 seconds. Do the slow work after you have stored the event.