Every webhook Run a Call sends is signed with your subscription's signing secret. Your receiving code should check that signature before trusting the event — otherwise anyone who learns your URL could send fake events. This article is for the developer writing that code.
Using Zapier, Make or n8n without custom code? Those tools can't easily verify signatures. Keep the webhook URL private, and don't let a webhook alone trigger anything sensitive (like moving money) without a check back against the API.
The signature header
Each delivery includes:
Run-A-Call-Signature: t=1758035045,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
| Part | Meaning |
|---|---|
t | When Run a Call signed the request, in Unix seconds |
v1 | Hex-encoded HMAC-SHA256 of the string <t>.<raw request body>, keyed with your signing secret |
The signing secret is the full whsec_... value you saved when you created (or last rotated) the subscription.
How to verify
- Read the raw request body exactly as received — before any JSON parsing.
- Split the header on
,and taketandv1. - Reject the request if
tis more than 5 minutes from your server's current time. This stops old captured requests from being replayed. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Compare it to
v1with a constant-time comparison. If they don't match, reject the request (for example with HTTP 400) and don't process it. - If it matches, respond with a
2xxquickly and do any slow work afterwards. Deliveries that take longer than 10 seconds count as failures.
Node.js example
import crypto from "node:crypto";
export function verifyRunACall(rawBody, header, secret, toleranceSec = 300) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=", 2)),
);
const t = Number.parseInt(parts.t, 10);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
if (expected.length !== v1.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
}
Getting the raw body
The signature covers the exact bytes Run a Call sent. If your framework parses the JSON first and you re-serialize it, spacing can change and the check fails.
| Framework | How to get the raw body |
|---|---|
| Express | app.post("/hook", express.raw({ type: "application/json" }), handler) — req.body is a Buffer; use req.body.toString("utf8") |
| Next.js route handler | const rawBody = await request.text() before parsing |
Duplicates
Retries mean the same event can occasionally arrive more than once. Every delivery has a unique id in the body (also in the Run-A-Call-Event-Id header) — store the ids you've handled and skip repeats.
Rotating the secret
If a secret may have leaked:
- Go to Settings → Integrations → Webhooks and open the subscription.
- Click Rotate secret and confirm.
- Copy the new secret from Save your new signing secret.
- Update your receiving code with it.
The old secret stops working immediately — there's no grace period. Deliveries sent before you update your code will fail the check. To avoid missing events, click Pause first, rotate, update your code, then Resume. Events that happen while paused aren't sent later, so keep the pause short.
Testing
Click Send test event on an active subscription. It sends a signed webhook.test event you can use to confirm your verification works, and the result shows under Recent deliveries.