# Verifying Signed Webhooks

> Source: https://agilitycms.com/docs/developers/verifying-signed-webhooks

Secure delivery signs every webhook Agility sends you, so your endpoint can prove a request genuinely came from your Agility instance and was not tampered with in transit.

Agility implements the open [Standard Webhooks](https://www.standardwebhooks.com) specification, so you can verify signatures with any off-the-shelf `standard-webhooks` library — you do not need to hand-roll the cryptography.

## Why you want this

A webhook endpoint is a public URL. Without a signature, anyone who learns that URL can post to it and your application has no way to tell their request from ours. If your webhook triggers a rebuild, clears a cache, or writes to a downstream system, that matters.

Signing gives you two guarantees:

- **Authenticity** — the request was sent by your Agility instance.
- **Integrity** — the payload was not modified on the way.

## Turning it on

Secure delivery is **opt-in per webhook**, and turning it on never changes anything else about the webhook.

1. Go to **Settings → Webhooks** and add or edit a webhook.
2. Tick **Enable secure delivery**.
3. Save. Agility generates a **signing secret** and shows it to you straight away.
4. Copy it and store it wherever your endpoint keeps its configuration.

You can look the secret up again at any time by editing the webhook — it is not shown only once. Viewing or rolling it requires **Full Control** permission; users with lower permissions can still manage the webhook, but the secret is hidden from them.

The secret looks like this:

```text
whsec_VCQuxQMntjWIynuP4lSlI4U9t...
```

Existing webhooks are unaffected until you enable this. If secure delivery is off, deliveries go out exactly as they always have.

## The headers

Every signed delivery carries three headers:

| Header | Example | What it is |
| --- | --- | --- |
| `webhook-id` | `2516140263959328992.1d67def1-643a-…` | Unique ID for this event. **Stable across retries.** |
| `webhook-timestamp` | `1788274141` | Unix time (seconds) when the delivery was signed. |
| `webhook-signature` | `v1,FXFAantus+70xZDTqPimI6Bg+…` | The signature, or several space-separated signatures. |

Treat `webhook-id` as an opaque string. Its internal format is not part of the contract and has changed before — do not parse it.

> **`webhook-id` is sent on every delivery, signed or not.** Even if you never turn on secure delivery, you can use it to discard duplicates. See [Handling duplicates](#handling-duplicates).

## How the signature is built

```text
signed_content = "{webhook-id}.{webhook-timestamp}.{raw request body}"
signature      = base64( HMAC_SHA256( key, signed_content ) )
header value   = "v1," + signature
```

The **key** is not the secret string. Strip the `whsec_` prefix and **base64-decode the rest** to get 32 raw bytes:

```text
key = base64_decode( secret.removePrefix("whsec_") )
```

Official `standard-webhooks` libraries do this for you — pass them the whole `whsec_…` string.

### Three things that will trip you up

1. **Use the raw request body, exactly as received.** Do not parse and re-serialize the JSON. Re-serializing changes whitespace and key order, and the signature will not match. Read the body as a string or byte array *before* any JSON middleware touches it.
2. **Compare in constant time.** Use `crypto.timingSafeEqual`, `hmac.compare_digest`, or your platform's equivalent — not `==`.
3. **The header can hold more than one signature.** During a secret roll you will receive two, space-separated. Accept the request if **any** of them matches.

## Verifying — with a library

The simplest correct implementation.

```js
import { Webhook } from "standard-webhooks"

const wh = new Webhook(process.env.AGILITY_WEBHOOK_SECRET) // the whsec_… string

app.post("/webhooks/agility", express.raw({ type: "application/json" }), (req, res) => {
  try {
    // req.body is a Buffer here — the RAW body, which is what we need
    const payload = wh.verify(req.body, {
      "webhook-id": req.header("webhook-id"),
      "webhook-timestamp": req.header("webhook-timestamp"),
      "webhook-signature": req.header("webhook-signature")
    })

    // verified — safe to act on
    console.log(payload.state, payload.referenceName)
    res.sendStatus(200)
  } catch {
    res.sendStatus(401)
  }
})
```

Note `express.raw(...)` rather than `express.json(...)`. If you let a JSON body parser run first, you lose the raw bytes and verification will fail.

## Verifying — by hand

If you would rather not add a dependency.

### Node.js

```js
const crypto = require("crypto")

function verifyAgilityWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  const received = headers["webhook-signature"]
  if (!id || !timestamp || !received) return false

  // reject anything too old or too far in the future
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > 300) return false

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`, "utf8")
    .digest("base64")

  // the header may carry several space-separated "v1,<sig>" values
  return received.split(" ").some((part) => {
    const sig = part.startsWith("v1,") ? part.slice(3) : null
    if (!sig || sig.length !== expected.length) return false
    return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  })
}
```

### C#

```csharp
public static bool VerifyAgilityWebhook(string secret, string id, string timestamp,
                                        string signatureHeader, string rawBody)
{
    if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) ||
        string.IsNullOrEmpty(signatureHeader)) return false;

    if (!long.TryParse(timestamp, out long ts)) return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;

    byte[] key = Convert.FromBase64String(
        secret.StartsWith("whsec_") ? secret.Substring("whsec_".Length) : secret);

    using var hmac = new HMACSHA256(key);
    byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{id}.{timestamp}.{rawBody}"));
    string expected = Convert.ToBase64String(hash);

    foreach (string part in signatureHeader.Split(' '))
    {
        if (!part.StartsWith("v1,")) continue;
        if (CryptographicOperations.FixedTimeEquals(
                Encoding.UTF8.GetBytes(part.Substring(3)),
                Encoding.UTF8.GetBytes(expected)))
            return true;
    }
    return false;
}
```

### Python

```python
import base64, hashlib, hmac, time

def verify_agility_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    wid = headers.get("webhook-id")
    ts = headers.get("webhook-timestamp")
    received = headers.get("webhook-signature")
    if not (wid and ts and received):
        return False

    try:
        if abs(time.time() - int(ts)) > 300:
            return False
    except ValueError:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{wid}.{ts}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    for part in received.split(" "):
        if part.startswith("v1,") and hmac.compare_digest(part[3:], expected):
            return True
    return False
```

### PHP

```php
function verify_agility_webhook(string $secret, array $headers, string $rawBody): bool {
    $id = $headers['webhook-id'] ?? null;
    $ts = $headers['webhook-timestamp'] ?? null;
    $received = $headers['webhook-signature'] ?? null;
    if (!$id || !$ts || !$received) return false;

    if (abs(time() - (int)$ts) > 300) return false;

    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$rawBody", $key, true));

    foreach (explode(' ', $received) as $part) {
        if (str_starts_with($part, 'v1,') && hash_equals(substr($part, 3), $expected)) {
            return true;
        }
    }
    return false;
}
```

## Timestamp tolerance

Reject deliveries whose `webhook-timestamp` is more than about **five minutes** from your clock, in either direction. This stops someone replaying a delivery they captured earlier.

Five minutes is a convention, not something Agility enforces — tune it if your servers have real clock skew.

## Rolling a secret

If a secret is exposed, roll it. Editing the webhook and choosing **Roll Secret** generates a new one immediately.

For **24 hours** afterwards, Agility signs each delivery with **both** the new and the previous secret, sending two space-separated signatures. That gives you a window to deploy the new secret without dropping deliveries. After 24 hours the old secret stops being used.

If your verification accepts *any* matching signature in the header — as every example above does — a roll requires no code change at all.

## Handling duplicates

Webhook delivery is **at-least-once**. Design your endpoint to tolerate receiving the same event more than once — a retry after a network timeout, or an internal redelivery, can both do it.

Use **`webhook-id`** as your idempotency key. It is unique per event, stable across every retry of that event, and sent on every delivery whether signed or not.

```js
if (await alreadyProcessed(headers["webhook-id"])) return res.sendStatus(200)
await process(payload)
await markProcessed(headers["webhook-id"])
```

Agility also collapses mechanically-repeated events on its side before they are sent, but treat that as an optimisation rather than a guarantee. Your endpoint should still be idempotent.

## Retries

Retries are **opt-in per webhook**, alongside secure delivery. With retries off, Agility attempts each delivery exactly once.

When enabled, you choose:

- **Retry count** — how many retries follow the first attempt.
- **Retry speed** — how quickly they back off.

| Speed | First retry after | Then |
| --- | --- | --- |
| Fast | ~30 seconds | ×4 each time |
| Standard | ~5 minutes | ×4 each time |
| Slow | ~30 minutes | ×4 each time |

Each delay carries a small random jitter, and delays are capped at 24 hours.

What counts:

- **Success is a 2xx response.** Anything else is a failure and will be retried.
- **Redirects are failures.** Agility does not follow them — point the webhook at the final URL.
- **The delivery timeout is 30 seconds.** Do your real work asynchronously: acknowledge with a 2xx immediately, then process in the background. A slow endpoint reads as a failed one.

## Delivery history

Every webhook has a **History** action in **Settings → Webhooks**, showing recent delivery attempts newest-first. Each row tells you:

- **Status and HTTP response code** — or "Network error / timeout" when no response came back.
- **Whether the delivery was signed** — marked **Signed** or **Unsigned**. This is what the delivery actually did, not what the webhook is set to now, so turning secure delivery on today does not relabel yesterday's deliveries. A row marked *Signed (2 keys)* went out during the window after a secret roll.
- **Whether it was a retry** — *Retry — attempt 2 of 4*. A first attempt is not labelled.
- **What happens next** — either the time the next retry is due, or *No further attempts*.
- **When it was queued and last attempted.**

Expand a row for the `webhook-id`, the target URL, the payload that was sent, the response body, and the last error.

That `webhook-id` is the value your endpoint received in the header, so it is how you match a row here to a line in your own logs.

This is the fastest way to answer "did that event actually reach my endpoint?" — and, when signature verification is failing, to see exactly what was sent and whether it was signed at all.

History is retained for **90 days**.

## Troubleshooting

**Signature never matches.** Almost always the raw body. Confirm you are hashing the exact bytes received, before any JSON parsing or re-serialization. Log the raw body length and compare it against `content-length`.

**Worked in testing, fails in production.** Check for a proxy, load balancer, or API gateway that re-encodes the body or strips headers.

**Verification fails only right after rolling a secret.** Your code is probably reading just the first signature. Split `webhook-signature` on spaces and accept any match.

**Everything fails after a while, with no code change.** Check the timestamp tolerance and your server clock.

**Nothing arrives at all.** Open **History**. If attempts are listed as failed, the response code and body will say why. If nothing is listed, the webhook is not subscribed to that event type, or is disabled.
