# Verifying webhook signatures

**Availability: 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
```

| Part | Meaning |
|---|---|
| `t` | When the delivery was signed, in Unix seconds |
| `v1` | Hex-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](/guides/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:

```js

// 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
  });
}
```
```python

# raw_body: the request body exactly as received (bytes). secrets: the endpoint's whsec_... secret(s).
def verify_signature(raw_body, header, secrets, tolerance_seconds=300):
    t = re.search(r"(?:^|,)t=(\d+)", header or "")
    signatures = re.findall(r"v1=([0-9a-f]{64})", header or "")
    if not t or not signatures:
        return False
    if abs(time.time() - int(t.group(1))) > tolerance_seconds:
        return False
    signed = t.group(1).encode() + b"." + raw_body
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, s) for s in signatures):  # any v1 may match
            return True
    return False
```

Complete runnable example: [JavaScript](/examples/guides/verifying-webhook-signatures/verify.mjs) · [Python](/examples/guides/verifying-webhook-signatures/verify.py)

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

```js
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`](/api/webhook-endpoints#rotate-a-webhook-signing-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`.

```bash
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
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/rotate-secret", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "grace_period_hours": 24
  }),
});
const endpoint = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/rotate-secret",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "grace_period_hours": 24,
    },
    timeout=30,
)
endpoint = response.json()
```

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: [cURL script](/examples/guides/verifying-webhook-signatures/rotate-secret.sh) · [JavaScript](/examples/guides/verifying-webhook-signatures/rotate-secret.mjs) · [Python](/examples/guides/verifying-webhook-signatures/rotate-secret.py)

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

```bash
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: [cURL script](/examples/guides/verifying-webhook-signatures/sign-and-send.sh)

- **With RelationsAI**: send a `webhook_endpoint.test` event with [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event) (or from the console) and check in the delivery log that it `succeeded` (see [Receiving webhooks](/guides/receiving-webhooks#test-and-inspect-deliveries)).
- **Negative tests**: change one byte of the body, use a wrong secret, or send a `t` ten minutes old. Each must be rejected with `400`.

## 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](/guides/retry-and-reliability).
