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
| TypeScript | Python | |
|---|---|---|
| Verify, return parsed JSON | verifyWebhook(rawBody, headers, secret) | verify_webhook(raw_body, headers, secret) |
| Verify and narrow to a typed event | parseWebhookEvent(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:
| Framework | Raw body | Never |
|---|---|---|
| Express | express.raw({ type: "application/json" }) → req.body is a Buffer | express.json() + JSON.stringify(req.body) |
| Cloudflare Workers / fetch handlers | await request.text() | await request.json() |
| Flask | request.get_data() | jsonify(request.json) |
| FastAPI / Starlette | await request.body() | re-serializing a parsed model |
Receivers
- TypeScript (Express)
- Python (Flask)
- Python (FastAPI)
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.
import os
from flask import Flask, request
from slothbox import parse_webhook_event, WebhookVerificationError
app = Flask(__name__)
@app.post("/webhooks/slothbox")
def slothbox_webhook():
try:
event = parse_webhook_event(
request.get_data(), # the raw bytes — never re-serialize request.json
request.headers,
os.environ["SLOTHBOX_WEBHOOK_SECRET"],
)
except WebhookVerificationError as err:
return f"invalid webhook: {err.code}", 400
# Delivery is at-least-once: dedupe on event["id"] before acting.
return "", 200 # ack fast; do slow work asynchronously
import os
from fastapi import FastAPI, Request, Response
from slothbox import parse_webhook_event, WebhookVerificationError
app = FastAPI()
@app.post("/webhooks/slothbox")
async def slothbox_webhook(request: Request):
raw_body = await request.body() # the raw bytes — not a parsed model
try:
event = parse_webhook_event(
raw_body, request.headers, os.environ["SLOTHBOX_WEBHOOK_SECRET"]
)
except WebhookVerificationError as err:
return Response(f"invalid webhook: {err.code}", status_code=400)
# Delivery is at-least-once: dedupe on event["id"] before acting.
return Response(status_code=200) # ack fast; do slow work asynchronously
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
- TypeScript
- Python
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;
}
match event["type"]:
case "environment.stopped":
print(f"{event['data']['resource']['id']} went to sleep")
case "webhook.endpoint.disabled":
# The one event to treat as an incident: deliveries to you have stopped.
page(f"endpoint disabled: {event['data']['reason']}")
case _:
# Event types newer than this SDK release still verify and parse.
pass
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-timestampis 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-signatureheader 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.