Skip to content
Runtime

How to verify a Runtime webhook signature

Compare v1 in Runtime-Signature with the hex HMAC-SHA256 of {t}.{raw body}, keyed with your secret. Refuse any signature that is more than 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. 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 204

With 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 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). 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 once
Terminalnpx 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 t with 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 v1 proves that the delivery came from Runtime. A delivery can also reach only 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. 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.

100 free hoursin your first 30 days.

of a 1 GB machine

  • No credit card
  • Four sandboxes at once, 2 vCPU and 4 GiB each
  • Then prepaid credit from $10, no plan fee
Start free, no card
Start free, no card