Runtime

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 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

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 changed
Pythonimport 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 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

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 once
Terminalnpx 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 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.

Start

Terminalnpx 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.