Receiving & verifying
Slothbox delivers a signed HTTPS POST to your endpoint whenever a subscribed
event fires. This page is everything you need to receive and verify those
deliveries correctly.
The request
Every delivery is an HTTPS POST with a Content-Type: application/json body —
the envelope. (Endpoint URLs must be https://; non-TLS URLs are rejected
when you create the endpoint, so deliveries only ever arrive over HTTPS.)
{
"id": "msg_01HXYZ",
"type": "environment.started",
"timestamp": "2026-05-30T14:23:00.000Z",
"data": { "resource": { "type": "environment", "id": "env_...", "name": "my-box" } }
}
| Field | Meaning |
|---|---|
id | Stable per-message id. Reused verbatim across retries — use it as your idempotency key. |
type | The event type (see the event catalogue). |
timestamp | ISO-8601 time the action occurred. |
data | Event-specific payload. |
Headers
| Header | Format |
|---|---|
webhook-id | The same msg_... id as envelope.id. |
webhook-timestamp | Unix time in seconds (re-stamped on each delivery attempt). |
webhook-signature | v1,<base64> — an HMAC-SHA256 signature. During a secret rotation it's a space-delimited list of v1,... tokens; a match against any is valid. |
This is the open Standard Webhooks format,
so you can verify with the Slothbox SDKs' built-in verifier
(TypeScript or Python), the svix libraries in any language, or a few lines
of your own crypto. All of them are shown below.
Verifying a delivery
Your signing secret looks like whsec_..., and you saw it once when the
endpoint was created. Feed the whole
string, prefix included, to the verifier — don't strip whsec_ yourself.
The signature is computed over exactly:
{webhook-id}.{webhook-timestamp}.{raw request body}
Five rules that matter:
- Verify the RAW body bytes. Re-serializing parsed JSON changes whitespace
and key order and will break the signature. In Express use
express.raw({ type: 'application/json' }), notexpress.json(). - Constant-time compare. Use
crypto.timingSafeEqual(guard the lengths first — it throws on a length mismatch). - Reject stale timestamps. Reject if
webhook-timestampis more than 5 minutes (300s) from now. Combined with the signature, this prevents replay. - Dedupe on
webhook-id. Delivery is at-least-once, so the sameidcan arrive more than once (a retry whose2xxwe never saw). Process eachidonce; keep a dedupe window that comfortably outlives our retry horizon — the last retry lands ~9 hours after the first attempt, so a day is plenty. - Accept any token in the list. During a 24h rotation overlap we sign with both the new and old secret. If you hold either, one token will match.
- @slothbox/sdk
- slothbox (Python)
- svix
- node:crypto (no dependency)
The TypeScript SDK's webhook toolkit implements all five
rules and narrows the verified envelope to a typed event union, so
event.data is typed once you switch on event.type. (The SDK is
pre-release — see the SDK quickstart.)
import { parseWebhookEvent } from "@slothbox/sdk";
app.post("/webhooks/slothbox", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
// Verifies the signature AND the 5-minute timestamp tolerance — accepting
// any token in the list during a rotation — then narrows the envelope to
// the typed WebhookEvent union. Throws WebhookVerificationError on any
// failure.
event = await parseWebhookEvent(
req.body, // Buffer (raw bytes)
req.headers, // Headers object or plain record
process.env.SLOTHBOX_WEBHOOK_SECRET, // the whole whsec_... string
);
} catch {
return res.status(400).send("invalid signature");
}
if (alreadyProcessed(event.id)) return res.sendStatus(200); // idempotency
handle(event); // switch on event.type to narrow event.data
res.sendStatus(200); // ack fast; do slow work async
});
Prefer verifyWebhook (same arguments) if you want the verified, parsed JSON
without the typed-event narrowing — for example on an endpoint that just
forwards payloads. The SDK webhooks page covers the
toolkit in depth, including a Cloudflare Workers receiver.
The Python SDK's parse_webhook_event implements the same
five rules — hand it the raw body bytes, the request headers, and the whole
whsec_… string. (The SDK is pre-release — see the
SDK quickstart.)
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:
# Verifies the signature AND the 5-minute timestamp tolerance —
# accepting any token in the list during a rotation — then narrows
# the envelope to the typed WebhookEvent union.
event = parse_webhook_event(
request.get_data(), # the raw bytes
request.headers, # any string mapping
os.environ["SLOTHBOX_WEBHOOK_SECRET"], # the whole whsec_... string
)
except WebhookVerificationError:
return "invalid signature", 400
if already_processed(event["id"]): # idempotency
return "", 200
handle(event) # branch on event["type"]
return "", 200 # ack fast; do slow work async
verify_webhook (same arguments) returns the verified, parsed JSON without
the typed-event narrowing. The SDK webhooks page covers
the toolkit in depth, including a FastAPI receiver.
import { Webhook } from "svix";
const wh = new Webhook(process.env.SLOTHBOX_WEBHOOK_SECRET); // the whole whsec_... string
app.post("/webhooks/slothbox", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
// Verifies the signature AND the 5-minute timestamp tolerance, then returns
// the parsed envelope. Throws on any failure.
event = wh.verify(req.body, { // req.body is a Buffer (raw bytes)
"webhook-id": req.header("webhook-id"),
"webhook-timestamp": req.header("webhook-timestamp"),
"webhook-signature": req.header("webhook-signature"),
});
} catch {
return res.status(400).send("invalid signature");
}
if (alreadyProcessed(event.id)) return res.sendStatus(200); // idempotency
handle(event);
res.sendStatus(200); // ack fast; do slow work async
});
import crypto from "node:crypto";
function verify(secret, headers, rawBody /* Buffer */) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"];
if (!id || !ts || !sigHeader) return false;
// 5-minute replay window.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
// Key = base64-decode of the secret after stripping the whsec_ prefix.
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signed = `${id}.${ts}.${rawBody.toString("utf8")}`;
const expected = crypto.createHmac("sha256", key).update(signed).digest(); // raw bytes
// The header may carry multiple space-delimited tokens (rotation overlap).
return sigHeader.split(" ").some((token) => {
const b64 = token.startsWith("v1,") ? token.slice(3) : token;
let provided;
try {
provided = Buffer.from(b64, "base64");
} catch {
return false;
}
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
});
}
Responding
Respond 2xx quickly — each attempt has a hard 20-second deadline — and
do any slow work asynchronously. Any non-2xx response, a timeout, or a connection
error is treated as a failed delivery and retried. Because
the same webhook-id arrives on every attempt, idempotent handling (rule 4
above) keeps retries from double-processing an event.