# 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](/docs/observability#check-the-signature)). ## With the SDK ```ts check import { 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 }); } } ``` ```python check import os from withruntime import verify_webhook def 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 204 ``` With 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 ```text Runtime-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: ```ts import { 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)); // true console.log(verify(body + " ", header, secret)); // false: the body changed ``` ```python import hashlib import hmac import json import time secret = "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)) # True print(verify(body + " ", header, secret)) # False: the body changed ``` Compare 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 ```ts check import { Runtime } from "withruntime"; const runtime = new Runtime(); const hook = await runtime.webhooks.rotateSecret("", { keepPreviousSeconds: 86_400 }); console.log(hook.secret); // the new secret, shown once ``` ```bash check npx withruntime webhooks rotate-secret ``` 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 `t` with 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 `v1` proves the delivery came from Runtime; a delivery can also only reach a public HTTPS address, never a private one. ## Related - [Receive webhooks](/how-to/receive-webhooks): creating one, the event types, - [How to read sandbox metrics](/how-to/read-sandbox-metrics) - [Background agents](/use-cases/background-agents) retries and the delivery log. - [Use identity tokens with AWS](/how-to/use-identity-tokens-with-aws), the other direction: code in a sandbox proving who it is. - [Security](/docs/security) for keys and secrets. ## Start ```bash no-run npx withruntime webhooks create https://example.com/hooks/runtime ``` It 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.