Skip to main content

Verifying webhooks with the SDK

Both SDKs ship a webhook toolkit that verifies a delivery's signature and narrows it to a typed event — the receiving side of Slothbox webhooks. This page covers the toolkit itself; the delivery semantics (retries, ordering, auto-disable, replay) live in the webhooks section and are not repeated here.

Two functions

TypeScriptPython
Verify, return parsed JSONverifyWebhook(rawBody, headers, secret)verify_webhook(raw_body, headers, secret)
Verify and narrow to a typed eventparseWebhookEvent(rawBody, headers, secret)parse_webhook_event(raw_body, headers, secret)

Both take the raw request body (bytes or string), the delivery's headers (a WHATWG Headers, an httpx.Headers, or any plain mapping — lookup is case-insensitive), and your endpoint's whsec_… signing secret, passed whole — don't strip the prefix yourself. On any failure they throw/raise a WebhookVerificationError carrying a machine-readable code (missing_headers, invalid_timestamp, timestamp_too_old, timestamp_too_new, invalid_secret, no_matching_signature, invalid_envelope).

Prefer parseWebhookEvent / parse_webhook_event: after verification it checks the envelope shape and types the result, so you can switch on event.type (TypeScript narrows event.data per type; Python returns a TypedDict union — event["type"], event["data"]). Event types newer than your SDK release still verify and parse — handle them in a default branch, never as an error.

The TypeScript verifier is async (it uses WebCrypto only, so it runs on Node 18+, Cloudflare Workers, Deno, Bun, and browsers); the Python verifier is synchronous (stdlib hmac) and safe to call from sync and async handlers alike.

The raw-body rule

The signature covers the exact bytes Slothbox sent. The classic mistake is letting your framework parse the JSON and re-serializing it — key order and whitespace change, the HMAC no longer matches, and every delivery fails verification. Always hand the toolkit the unparsed body:

FrameworkRaw bodyNever
Expressexpress.raw({ type: "application/json" })req.body is a Bufferexpress.json() + JSON.stringify(req.body)
Cloudflare Workers / fetch handlersawait request.text()await request.json()
Flaskrequest.get_data()jsonify(request.json)
FastAPI / Starletteawait request.body()re-serializing a parsed model

Receivers

import express from "express";
import { parseWebhookEvent, WebhookVerificationError } from "@slothbox/sdk";

const app = express();

app.post(
"/webhooks/slothbox",
express.raw({ type: "application/json" }), // req.body stays a Buffer
async (req, res) => {
let event;
try {
event = await parseWebhookEvent(
req.body,
req.headers,
process.env.SLOTHBOX_WEBHOOK_SECRET!,
);
} catch (err) {
if (err instanceof WebhookVerificationError) {
return res.status(400).send(`invalid webhook: ${err.code}`);
}
throw err;
}
// Delivery is at-least-once: dedupe on event.id before acting.
res.sendStatus(200); // ack fast; do slow work asynchronously
},
);

If a global express.json() is mounted, exclude the webhook route or mount the raw parser first — once express.json() has consumed the stream, the original bytes are gone.

For a Cloudflare Workers variant (request.text() + ctx.waitUntil() for post-ack work), see the example gallery.

Whatever the framework, the same three rules apply — raw body, fast ack (each delivery attempt has a hard deadline; do slow work after responding), and idempotent handling keyed on the event id, because delivery is at-least-once.

Handling the event

switch (event.type) {
case "environment.stopped":
// event.data is narrowed for this type
console.log(`${event.data.resource.id} went to sleep`);
break;
case "webhook.endpoint.disabled":
// The one event to treat as an incident: deliveries to you have stopped.
page(`endpoint disabled: ${event.data.reason}`);
break;
default:
// Event types newer than this SDK release still verify and parse.
break;
}

The full list of event types and their payloads is in the event catalogue; both SDKs export it as WEBHOOK_EVENT_TYPES with an isWebhookEventType / is_webhook_event_type guard.

What verification enforces

Under the hood the toolkit implements the open Standard Webhooks scheme — HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed by the base64-decoded secret — with constant-time comparison. Two semantics are worth knowing rather than trusting blindly:

  • Replay tolerance. Deliveries whose webhook-timestamp is more than 5 minutes from your clock — in either direction — are rejected (timestamp_too_old / timestamp_too_new). The timestamp is re-stamped on every retry attempt, so honest retries always pass; only replays and badly skewed clocks fail. The window is configurable (toleranceSeconds / tolerance_seconds) — widen it only to accommodate clock skew you can't fix.
  • Secret rotation. While a rotation overlap is in effect, deliveries are signed with both the new and old secret and the webhook-signature header carries a space-delimited list of signatures. The toolkit accepts a match against any of them, so a receiver holding either secret keeps verifying throughout the overlap — rotate at your own pace within it. Unknown version prefixes in the list are skipped, not errors.

Both verifiers are tested against the same production-signed vector file as the API's signing code, so the signer and the two verifiers cannot drift apart silently.

If you're not using the SDK

The signing scheme is standard, so the svix libraries or a few lines of your own crypto verify it too — see Receiving & verifying for those variants and the full delivery contract.