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.