Labs · webhook signatures

Raw bytes, or it didn’t happen.

A webhook signature proves who sent the bytes — the exact bytes. Change one space and the proof no longer holds. Sign a synthetic delivery event, choose what your handler receives, and watch the receiver decide.

Synthetic secret and payload — nothing is sent or received

1 · The sender signs Oveyon, simulated

x-oveyon-event
delivered
x-oveyon-timestamp
1793196000
x-oveyon-signature
sha256=b9d35141aa1c55df7c50541170092c37c9f13d462c721f46d82c8d5f760c3409

HMAC-SHA256 over timestamp + "." + body with the fixture secret synthetic-fixture-only-not-a-production-secret.

2 · Your handler receives The transport

What does your code verify?

Bytes handed to verify 222 bytes

{
  "event": "delivered",
  "messageId": "mock-message-0001",
  "deliveryId": "mock-recipient-0001",
  "recipient": "[email protected]",
  "status": "delivered",
  "detail": {},
  "timestamp": "2026-10-28T12:00:00.000Z"
}

3 · The receiver decides Your application

Signature verified

The handler hashed the exact signed bytes inside the 300-second window. Store the event, then deduplicate it.

received
sha256=b9d35141aa1c55df7c50541170092c37c9f13d462c721f46d82c8d5f760c3409
computed
sha256=b9d35141aa1c55df7c50541170092c37c9f13d462c721f46d82c8d5f760c3409
age
0 s of 300 s

Precomputed at build time. Change the body or the transport to verify in your browser.

Verify before you parse

Most frameworks parse JSON before your handler runs. Ask for the raw body on the webhook route, verify it, and only then call JSON.parse. Compare signatures in constant time and reject stale timestamps; a valid signature on an old request is still a replay.

This is the receiver used by the signed delivery webhook recipe. It also stores each event once and keeps the newest state per recipient.

receiver.ts — verifySignature
export function verifySignature(raw: Buffer, headers: Headers, secret: string, nowSeconds: number) {
  if (raw.byteLength > 65_536 || secret.length < 16 || !/^\d{10,12}$/.test(headers.timestamp) || !/^sha256=[a-f0-9]{64}$/.test(headers.signature)) return false;
  const timestamp = Number(headers.timestamp);
  if (!Number.isSafeInteger(timestamp) || !Number.isFinite(nowSeconds) || Math.abs(nowSeconds - timestamp) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${headers.timestamp}.`).update(raw).digest();
  const given = Buffer.from(headers.signature.slice(7), 'hex');
  return given.length === expected.length && timingSafeEqual(expected, given);
}

What this lab shows

  • The header format follows the Oveyon API contract: x-oveyon-signature: sha256=… over timestamp.body.
  • The 300-second window is this recipe’s choice. Pick yours deliberately.
  • The secret is a published fixture. Never paste a real webhook secret into a web page — this one does not ask.
  • Your browser’s Web Crypto computes every signature. The unit tests prove it matches node:crypto and the recipe receiver.

Source: Oveyon API reference — webhooks.

Type two or more characters. Try “duplicate”, “webhook” or “retry”.