Skip to main content

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" } }
}
FieldMeaning
idStable per-message id. Reused verbatim across retries — use it as your idempotency key.
typeThe event type (see the event catalogue).
timestampISO-8601 time the action occurred.
dataEvent-specific payload.

Headers

HeaderFormat
webhook-idThe same msg_... id as envelope.id.
webhook-timestampUnix time in seconds (re-stamped on each delivery attempt).
webhook-signaturev1,<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:

  1. 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' }), not express.json().
  2. Constant-time compare. Use crypto.timingSafeEqual (guard the lengths first — it throws on a length mismatch).
  3. Reject stale timestamps. Reject if webhook-timestamp is more than 5 minutes (300s) from now. Combined with the signature, this prevents replay.
  4. Dedupe on webhook-id. Delivery is at-least-once, so the same id can arrive more than once (a retry whose 2xx we never saw). Process each id once; 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.
  5. 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.

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.

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.