On Runtime, the check is one SDK call, and it refuses replays for you.
verifyWebhook in TypeScript and verify_webhook in Python check the
signature against the raw body. They accept any of several secrets while you
rotate one, refuse a signature older than five minutes, and return the parsed
event. The scheme is plain HMAC-SHA256 over a timestamp and the body. Any
language with an HMAC library can check it without the SDK
(check the signature).
With the SDK
TypeScriptimport { verifyWebhook } from "withruntime";export async function POST(request: Request) { try { const event = await verifyWebhook( await request.text(), // the raw body, before any JSON parsing request.headers.get("runtime-signature"), process.env.RUNTIME_WEBHOOK_SECRET!, ); console.log(event.type, event.id); return new Response(null, { status: 204 }); } catch { return new Response("invalid signature", { status: 400 }); }}Pythonimport osfrom withruntime import verify_webhookdef handle(body: bytes, headers: dict) -> int: try: event = verify_webhook(body, headers.get("runtime-signature"), os.environ["RUNTIME_WEBHOOK_SECRET"]) except Exception: return 400 print(event["type"], event["id"]) return 204With Express, read the body raw for that route with
express.raw({ type: "application/json" }). Pass req.body as it is. A JSON
body parser that runs first changes the bytes, so the signature no longer
matches.
What the header says
textRuntime-Signature: t=1758650000,v1=5f2b…| Part | Meaning |
|---|---|
t |
When the delivery was signed, in Unix seconds |
v1 |
Hex HMAC-SHA256 of {t}.{body}, keyed with the webhook's secret |
Two v1 values |
A rotation is in progress: one per live secret |
Runtime-Webhook-Id |
The event's id, the same on every retry |
Runtime-Event-Type |
The event's type, such as sandbox.stopped |
Runtime-Delivery-Id, Runtime-Delivery-Attempt |
Which delivery and which attempt this is |
The signed string is the timestamp, a period, and the body exactly as sent.
Signing the timestamp is what makes the age check meaningful. An attacker who
captured a delivery cannot change t without breaking v1.
Without the SDK
These samples sign a body the way Runtime does, then check it. So they run
anywhere. In a real endpoint, header and body come from the request:
TypeScriptimport { createHmac, timingSafeEqual } from "node:crypto";const secret = "whsec_example";const body = JSON.stringify({ id: "evt_1", type: "webhook.test", createdAt: "2026-09-25T12:00:00Z", data: {},});const t = Math.floor(Date.now() / 1000);const header = `t=${t},v1=${createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")}`;function verify(raw: string, signature: string, key: string, toleranceSeconds = 300): boolean { const parts = signature.split(",").map((p) => p.trim().split("=") as [string, string]); const stamp = Number(parts.find(([k]) => k === "t")?.[1]); if (!Number.isSafeInteger(stamp) || Math.abs(Date.now() / 1000 - stamp) > toleranceSeconds) return false; const expected = Buffer.from(createHmac("sha256", key).update(`${stamp}.${raw}`).digest("hex")); return parts .filter(([k, v]) => k === "v1" && v) .some(([, v]) => v.length === expected.length && timingSafeEqual(Buffer.from(v), expected));}console.log(verify(body, header, secret)); // trueconsole.log(verify(body + " ", header, secret)); // false: the body changedPythonimport hashlibimport hmacimport jsonimport timesecret = "whsec_example"body = json.dumps({"id": "evt_1", "type": "webhook.test", "data": {}})t = int(time.time())header = f"t={t},v1=" + hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()def verify(raw: str, signature: str, key: str, tolerance: int = 300) -> bool: parts = [p.strip().split("=", 1) for p in signature.split(",") if "=" in p] stamp = next((v for k, v in parts if k == "t"), "") if not stamp.isdigit() or abs(time.time() - int(stamp)) > tolerance: return False expected = hmac.new(key.encode(), f"{stamp}.{raw}".encode(), hashlib.sha256).hexdigest() return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts)print(verify(body, header, secret)) # Trueprint(verify(body + " ", header, secret)) # False: the body changedCompare in constant time (timingSafeEqual, hmac.compare_digest). Then the
time a comparison takes reveals nothing about the right signature.
Rotate the secret without dropping deliveries
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const hook = await runtime.webhooks.rotateSecret("<webhook id>", { keepPreviousSeconds: 86_400 });console.log(hook.secret); // the new secret, shown onceTerminalnpx withruntime webhooks rotate-secret <id>By default, the old secret keeps signing beside the new one for a day. You can
set this from 0 to a week with keepPreviousSeconds. So each delivery carries
two v1 values. Deploy the new secret. Until the old one expires, pass both:
verifyWebhook(body, header, [newSecret, oldSecret]).
Mistakes and how Runtime handles them
- Verifying parsed JSON. Re-serializing an object changes spacing and key order. Verify the raw text, then parse it. The SDK returns the parsed event.
- Skipping the age check. Without it, a captured delivery could be sent again later. The SDKs refuse a signature older than five minutes.
- A clock that drifts. The age check compares
twith your server's clock, so keep it synchronized. - Leaking the secret. It is shown once, at create or rotation. Keep it in your secret manager, and rotate it if it is ever exposed.
- Trusting the body's claims about who sent it. Only a matching
v1proves that the delivery came from Runtime. A delivery can also reach only a public HTTPS address, never a private one.
Related
Receive webhooks: creating one, the event types,
Use identity tokens with AWS, the other direction: code in a sandbox proving who it is.
Security for keys and secrets.
Start
Terminalnpx withruntime webhooks create https://example.com/hooks/runtimeIt prints the webhook's id and its secret once. Every account gets 100 free hours of a 1 GB machine in its first 30 days, with no card required.
Facts on this page were checked on 26 October 2026.