How to verify a Runtime webhook signature
Compare v1 in Runtime-Signature with the hex HMAC-SHA256 of {t}.{raw body} under your secret, and refuse ones over five minutes old.
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, 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, so 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,
express.raw({ type: "application/json" }), and pass req.body as it is. A
JSON body parser that runs first changes the bytes, and 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 under way: 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 full stop, 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 and 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), so 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>The old secret keeps signing beside the new one for a day by default, from 0
to a week with keepPreviousSeconds, so each delivery carries two v1
values. Deploy the new secret, and until the old one expires pass both:
verifyWebhook(body, header, [newSecret, oldSecret]).
Mistakes and how Runtime handles them
- Verifying parsed JSON. Re-serialising an object changes spacing and key order. Verify the raw text, then parse; 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 synchronised. - 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 the delivery came from Runtime; a delivery can also only reach a public HTTPS address, never a private one.
Related
- Receive webhooks: creating one, the event types,
- How to read sandbox metrics
- Background agents retries and the delivery log.
- 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. New accounts get 50 free sandbox hours, no card.
Facts on this page were checked on 25 September 2026.