Webhooks

Verifying webhooks

Standard Webhooks HMAC-SHA256 verification — headers, replay protection, key rotation, and the delivery retry/DLQ contract. Includes a Node verify sample.

Verifying webhooks

Batohi signs every delivery with Standard Webhooks.

Headers

HeaderValue
content-typeapplication/json
webhook-idthe delivery id (use for idempotent dedup)
webhook-timestampunix seconds at delivery time
webhook-signaturev1,<base64 HMAC-SHA256>

Verification

The signature is HMAC-SHA256 over the canonical string ${id}.${timestamp}.${body}, where:

  • id is the webhook-id header,
  • timestamp is the webhook-timestamp header (unix seconds, as a string),
  • body is the exact raw request body — do not re-serialize the JSON, or key reorder will break the signature.

The signing key is the whsec_<base64> secret shown once when you create or rotate an endpoint. Decode the base64 before using it as the HMAC key.

Replay protection

Reject any delivery whose |now - webhook-timestamp| exceeds your tolerance — Batohi's verifier uses 5 minutes. There is no nonce; the deterministic webhook-id is the dedup key.

Node verification sample

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret: string, body: string, headers: Record<string, string>): boolean {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  const sig = headers['webhook-signature']; // "v1,<base64>"
  if (!id || !ts || !sig) return false;

  // Reject stale deliveries.
  const ageSec = Math.abs(Date.now() / 1000 - Number(ts));
  if (ageSec > 5 * 60) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64');

  // Allow space-delimited multiple tokens (rotation overlap, per spec).
  return sig
    .split(' ')
    .some((token) => token.startsWith('v1,') && safeEqual(token.slice(3), expected));
}

function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  return ab.length === bb.length && timingSafeEqual(ab, bb);
}

Key rotation

Rotate an endpoint's secret with POST /api/webhooks/[id]/rotate. The response returns the new secret once; the old secret is invalidated immediately. There is no overlap window and no key-id header — consumers verify with the current secret and treat a failure as a revoked key.

Delivery contract

MethodPOST
Timeout10 seconds
SuccessHTTP status in [200, 300)
Max attempts5
Backoffexponential, 2 ** attempts minutes (2, 4, 8, 16)
Dead-letterafter the final failure: processedAt set, lastError prefixed DEAD:
Auditdead-lettered rows are retained, never deleted

A 3xx, 4xx, 5xx, network error, or timeout is a failure that retries. A delivery whose endpoint was deactivated after queueing is dropped (not retried), with lastError = "endpoint inactive".

Configuring an endpoint

In the dashboard at /dashboard/webhooks, or via the management API:

MethodPath
ListGET /api/webhooks
CreatePOST /api/webhooks — body { url, eventTypes? }, returns the secret once
DeleteDELETE /api/webhooks/[id]
Pause / resumePATCH /api/webhooks/[id] — body { active: boolean }
Rotate secretPOST /api/webhooks/[id]/rotate

Subscription filter: eventTypes is an array of public event names; an empty array means "subscribe to all". Validation: the URL must be https:, not localhost / *.local, and not a private/loopback/metadata IP. Cap: 10 endpoints per user.

On this page