Skip to content
RelationsAI
Guides

Verifying webhook signatures

Preview

Every webhook request is signed with your endpoint's secret. Verify the signature before trusting an event. This guide explains the signature format, gives verification code and covers clock tolerance and secret rotation.

Your webhook URL is public, so anyone can send it a request. The signature is what proves a request came from RelationsAI and was not changed on the way. Never act on an event whose signature you have not verified.

Before you begin

  • The endpoint's signing secret (whsec_...), returned once when you created the endpoint or rotated its secret. Keep it in a secret manager and pass it to your server as configuration, never in source code.
  • Access to the raw request body, the exact bytes received, before any JSON parsing. Most frameworks parse JSON for you; turn that off for the webhook route or read the raw body first.

The signature header

Text
RelationsAI-Signature: t=1792575900,v1=ae0eab64988220d98b2bce1e1bc69ba13218cd445650f3ec6e57c66bb5e93c9b
PartMeaning
tWhen the delivery was signed, in Unix seconds
v1Hex-encoded HMAC-SHA256 of the string <t>.<raw body>, keyed with your signing secret

Details that matter:

  • The HMAC key is the whole secret string, including the whsec_ prefix, as UTF-8 bytes. Do not strip the prefix or decode it.
  • The signed message is the value of t, a full stop, then the raw body bytes exactly as received.
  • During a secret rotation the header carries two v1= values, one per secret, in any order: t=...,v1=<new>,v1=<old>. Accept the event if any of them matches.
  • Ignore parts you do not recognise; future signature schemes may add them.

Requests also carry RelationsAI-Event-Id, RelationsAI-Event-Type and RelationsAI-Delivery-Id. They are convenient for logging and routing, but they are not signed: take the id and type you act on from the verified body.

Verify step by step

  1. Read the raw body as bytes. Do not parse and re-serialise it first: whitespace, key order and Unicode escaping would change and the signature would not match.
  2. Parse the header: split on ,, then each part on the first =. Take t and every v1.
  3. Reject the request if there is no t or no v1.
  4. Reject it if t is more than 5 minutes (300 seconds) away from your clock, in either direction. This stops an attacker who captured a delivery from replaying it later. RelationsAI signs every delivery attempt with a fresh t, retries included, so legitimate retries pass. Keep your server clock synchronised.
  5. Compute HMAC-SHA256 over <t>.<raw body> with your secret.
  6. Compare it with each v1 in constant time (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python). A normal string comparison can leak, through timing, how much of a guess was right.
  7. Accept the request if any v1 matches. Otherwise answer 400 and do not process it.

Then deduplicate by the event id and process it, as in Receiving webhooks.

Here is the verification as a function you can import, with no dependencies. Run directly, it checks a saved delivery from the command line, which helps when you are debugging a mismatch:

import { createHmac, timingSafeEqual } from "node:crypto"; // rawBody: the request body exactly as received (a Buffer). secrets: the endpoint's whsec_... secret(s). function verifySignature(rawBody, header = "", secrets, toleranceSeconds = 300) { const t = /(?:^|,)t=(\d+)/.exec(header)?.[1]; const signatures = [...header.matchAll(/v1=([0-9a-f]{64})/g)].map((m) => Buffer.from(m[1], "hex")); if (!t || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; return secrets.some((secret) => { const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest(); return signatures.some((signature) => timingSafeEqual(signature, expected)); // any v1 may match }); }
Complete runnable example

Verify a RelationsAI-Signature header. Node.js 20+, no dependencies.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

Both versions are tested against the signature test vectors RelationsAI's own signer is checked with: a body with non-ASCII characters, a rotation header with two signatures, a tampered body, a wrong secret, a changed timestamp and a timestamp outside the tolerance. Check your own implementation with this one (the secret is a fictional test value):

Text
secret whsec_test_vector_secret_0001_Ab3dEf6hIj9kLm2nOp5q t 1792575900 raw body {"id":"evt_test0000000000000001","object":"event","type":"webhook_endpoint.test","created_at":"2026-10-21T09:45:00.000Z","livemode":false,"api_version":"2026-10-21","data":{"object":{"id":"we_test0000000000000001","object":"webhook_endpoint"}}} expected header t=1792575900,v1=ae0eab64988220d98b2bce1e1bc69ba13218cd445650f3ec6e57c66bb5e93c9b

Verify it with now set to 1792575900; with your real clock it is (correctly) rejected as too old.

To check a delivery you saved (body in a file, header copied from your logs), put the endpoint's secret in place of <webhook-signing-secret> and the saved header in place of <signature>, raise toleranceSeconds above the delivery's age, and run node verify.mjs < saved-body.json. Raise the tolerance only for this offline check, never in your server.

Using a framework

The rule is the same in every framework: give the verifier the raw bytes. For example, with Express (optional; the examples above need no framework):

JavascriptJavaScript
router.post("/webhooks/relationsai", express.raw({ type: "application/json" }), (req, res) => { const check = verifySignature(req.body, req.get("RelationsAI-Signature"), ["<webhook-signing-secret>"]); if (!check.valid) return res.status(400).send("invalid signature"); // record the event by id, answer 200, process later res.sendStatus(200); });

With Flask (optional), use request.get_data() for the raw bytes, not request.json. Register the raw-body handling only on the webhook route so the rest of your app keeps its usual JSON parsing.

During secret rotation

Rotate the secret on a schedule, whenever someone who knew it leaves, and immediately if it may have leaked. POST/webhook-endpoints/{webhook_endpoint_id}/rotate-secret issues a new secret, returned once, and keeps the old one signing for a grace period you choose: grace_period_hours of 0 (retire the old secret immediately), 1, 24 or 168.

curl -X POST https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/rotate-secret \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "grace_period_hours": 24 }'

The response is the endpoint with the new secret (shown once; null only if you sent an idempotency key and this is a replay of an earlier request with it).

Complete runnable example

Rotate an endpoint's signing secret. The old one keeps signing for GRACE_HOURS (0 retires every older secret now).

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

During the grace period every delivery carries two v1 signatures, one made with each secret. A safe rollout:

  1. Rotate with a grace period long enough to deploy (for example 24 hours) and store the new secret.
  2. Deploy the new secret to your servers. Old servers (with the old secret) and new servers (with the new one) both verify successfully, because each finds its own signature among the two v1 values.
  3. After the grace period only the new secret signs. Remove the old one from your configuration.

Rules when you rotate more than once:

  • grace_period_hours: 0 is always allowed, even while an earlier grace period runs, and retires every previous secret at once. Use it when a secret may have leaked, and deploy the new secret immediately: deliveries fail verification until it is live and are retried, so nothing is lost if you deploy within the retry window.
  • A rotation with a grace period while another grace period is still running keeps the oldest secret that is still valid (the one your receivers most likely still use) and discards the intermediate one. At most two secrets ever sign: that oldest one and the newest.
  • The new secret is shown only in the first response. A retry of the same request with the same optional idempotency key returns the endpoint with secret: null, because the secret is stored only in encrypted form. If you lost the response, rotate again and use that secret. With a grace period, your current secret keeps working meanwhile (the lost intermediate one is discarded).

The verifier accepts a list of secrets, so you can also configure both during your own rollout. Remove the old one afterwards.

Test your verifier

  • Without RelationsAI: sign events yourself and post them to your local receiver. These commands sign with your own secret using openssl:
TerminalShell
body='{"id":"evt_local0001","object":"event","type":"webhook_endpoint.test","created_at":"2026-10-21T09:45:00.000Z","livemode":false,"api_version":"2026-10-21","data":{"object":{"id":"we_local","object":"webhook_endpoint"}}}' t=$(date +%s) v1=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "whsec_..." | sed 's/^.*= *//') curl -X POST http://localhost:8000/webhooks/relationsai \ -H "Content-Type: application/json" \ -H "RelationsAI-Signature: t=$t,v1=$v1" \ -d "$body"
Complete runnable example

Send a correctly signed test event to your own receiver, for local development.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

What can go wrong

  • Every signature fails: The body was parsed and re-serialised, or decoded and re-encoded, before verifying. Fix: Verify the raw bytes exactly as received.
  • Every signature fails: The whsec_ prefix was stripped, or the secret was decoded. Fix: Use the whole secret string as the key.
  • Every signature fails after a deploy: The server has the secret of another endpoint or the other mode. Fix: Each endpoint has its own secret; check secret_last4.
  • Some signatures fail with "timestamp outside the tolerance": The server clock is off, or deliveries are queued on your side before verification. Fix: Synchronise the clock; verify on receipt.
  • Failures right after a rotation: The old secret was retired (grace_period_hours: 0) before the new one was deployed. Fix: Deploy the new secret; failed deliveries are retried.
  • Signature valid but the same event processed twice: Duplicate deliveries are normal. Fix: Deduplicate by the event id.

Failed verifications show in the delivery log as http_error with response_status: 400 (the status your receiver answered).

Next steps

Retry and reliability practices.