Extensibility
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.
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:
Secure delivery is opt-in per webhook, and turning it on never changes anything else about the webhook.
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.
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.
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.
crypto.timingSafeEqual, hmac.compare_digest, or your platform's equivalent — not ==.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.
If you would rather not add a dependency.
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))
})
}
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;
}
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
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;
}
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.
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.
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 are opt-in per webhook, alongside secure delivery. With retries off, Agility attempts each delivery exactly once.
When enabled, you choose:
| 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:
Every webhook has a History action in Settings → Webhooks, showing recent delivery attempts newest-first. Each row tells you:
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.
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.