See Agility CMS in action. Watch a product demo
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 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.
- Go to Settings → Webhooks and add or edit a webhook.
- Tick Enable secure delivery.
- Save. Agility generates a signing secret and shows it to you straight away.
- 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:
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-idis 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.
How the signature is built
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:
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
- 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.
- Compare in constant time. Use
crypto.timingSafeEqual,hmac.compare_digest, or your platform's equivalent — not==. - 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.
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
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#
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
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
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.
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.