Verify signatures
Check that every webhook really came from Agoo, with complete verification code for TypeScript, Python, PHP and Go, and rotate secrets without downtime.
This is a published preview. Names and fields may still change before general availability; changes will be listed in the changelog.
Your webhook URL is public, so anyone can send it a request. Every delivery from Agoo is signed with your endpoint's secret using the Standard Webhooks scheme. Verify the signature on every request before you trust anything in it.
How Agoo signs a delivery
Each delivery carries three headers:
| Header | Example | Meaning |
|---|---|---|
webhook-id | evt_01m4ww0ezgf2bryg9gxb2gbewx | The event ID. The same on every retry and redelivery. |
webhook-timestamp | 1791970262 | When this attempt was signed, in Unix seconds. |
webhook-signature | v1,r62XTf3Xey7gj2QPkX1/+b8KGnG46iVXcut9tPKWjcE= | One or more signatures, separated by spaces. |
To produce each signature, Agoo:
- takes your endpoint secret, removes the
whsec_prefix and base64-decodes the rest to get the key; - joins
webhook-id,webhook-timestampand the raw request body with full stops:{webhook-id}.{webhook-timestamp}.{body}; - computes the HMAC-SHA256 of that string with the key, and base64-encodes the result;
- prefixes it with the version,
v1,.
To verify, you do the same and compare.
The rules
- Use the raw body. Compute the HMAC over the exact bytes you received. If your framework parses JSON first and you serialise it again, spacing or key order can change and the signature won't match.
- Compare in constant time, with
timingSafeEqual,hmac.compare_digest,hash_equalsorhmac.Equal, never==. - Accept any matching signature.
webhook-signaturecan hold several space-separated values, for example during a secret rotation. Ignore versions other thanv1. - Check the timestamp. Reject requests signed more than 5 minutes from your clock, in either direction, so a captured request can't be replayed later. Keep your server's clock synchronised with NTP.
- Fail closed. If a header is missing or anything doesn't match, return
401and don't process the request. Agoo treats it as a failed attempt and retries, so a misconfigured secret doesn't lose events while you fix it.
Verification code
Each function below takes the raw body, the request headers and a list of secrets, and returns the parsed event or raises an error. The optional "now" argument is there for tests.
Node.js 20 or later, using only node:crypto. It also works on Bun, Deno and other runtimes with node:crypto.
import { createHmac, timingSafeEqual } from "node:crypto"
const TOLERANCE_SECONDS = 5 * 60
export interface AgooEvent<T = Record<string, unknown>> {
id: string
type: string
created_at: string
organisation_id: string
livemode: boolean
data: { object: T }
}
export class WebhookVerificationError extends Error {}
type HeaderSource = Headers | Record<string, string | string[] | undefined>
function readHeader(headers: HeaderSource, name: string): string | undefined {
if (typeof headers.get === "function") return (headers as Headers).get(name) ?? undefined // fetch-style Headers
const value = (headers as Record<string, string | string[] | undefined>)[name] // Node: names are lower case
return Array.isArray(value) ? value[0] : value
}
/**
* Verifies an Agoo webhook and returns the parsed event.
* Pass the raw request body exactly as received, and every secret that is currently valid
* (during a rotation, the new one and the old one).
*/
export function verifyAgooWebhook(
rawBody: Uint8Array | string,
headers: HeaderSource,
secrets: string | string[],
nowSeconds: number = Date.now() / 1000,
): AgooEvent {
const id = readHeader(headers, "webhook-id")
const timestamp = readHeader(headers, "webhook-timestamp")
const signatureHeader = readHeader(headers, "webhook-signature")
if (!id || !timestamp || !signatureHeader) {
throw new WebhookVerificationError("Missing webhook-id, webhook-timestamp or webhook-signature")
}
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) {
throw new WebhookVerificationError("Timestamp is more than 5 minutes from this server's clock")
}
const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : Buffer.from(rawBody)
for (const secret of Array.isArray(secrets) ? secrets : [secrets]) {
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest()
for (const part of signatureHeader.split(" ")) {
const [version, signature] = part.split(",", 2)
if (version !== "v1" || !signature) continue
const received = Buffer.from(signature, "base64")
if (received.length === expected.length && timingSafeEqual(received, expected)) {
return JSON.parse(body.toString("utf8")) as AgooEvent
}
}
}
throw new WebhookVerificationError("No signature matches")
}Express. Read the body with express.raw on the webhook route only:
import express from "express"
import { verifyAgooWebhook, WebhookVerificationError } from "./verify-agoo-webhook"
const app = express()
// The previous secret too, while you rotate: [process.env.AGOO_WEBHOOK_SECRET!, process.env.AGOO_WEBHOOK_SECRET_PREVIOUS!]
const secrets = [process.env.AGOO_WEBHOOK_SECRET!]
// express.raw keeps the body as the exact bytes Agoo signed. Register it on this route,
// and make sure no global express.json() runs before it.
app.post("/webhooks/agoo", express.raw({ type: "application/json" }), (req, res) => {
let event
try {
event = verifyAgooWebhook(req.body, req.headers, secrets)
} catch (err) {
if (err instanceof WebhookVerificationError) return res.status(401).send(err.message)
throw err
}
// Queue the event for processing, then acknowledge straight away
console.log("Received", event.type, event.id)
res.sendStatus(204)
})
app.listen(3000, () => console.log("Listening on http://localhost:3000"))Next.js route handler. Read the body with arrayBuffer(), not json():
import { verifyAgooWebhook, WebhookVerificationError } from "@/lib/verify-agoo-webhook"
export const runtime = "nodejs" // node:crypto isn't available on the Edge runtime
const secrets = [process.env.AGOO_WEBHOOK_SECRET!]
export async function POST(request: Request) {
const rawBody = new Uint8Array(await request.arrayBuffer())
let event
try {
event = verifyAgooWebhook(rawBody, request.headers, secrets)
} catch (err) {
if (err instanceof WebhookVerificationError) return new Response(err.message, { status: 401 })
throw err
}
// Queue the event for processing, then acknowledge straight away
console.log("Received", event.type, event.id)
return new Response(null, { status: 204 })
}Libraries
Agoo follows the Standard Webhooks specification, so the Standard Webhooks reference libraries verify Agoo
deliveries too. A dedicated TypeScript helper, @ardent-africa/agoo-webhooks, is planned.
Test your code
Run your verification against this example. It uses a made-up secret that isn't connected to any organisation. Its timestamp is in the past, so pass 1791970262 as the current time (the optional argument above) or the timestamp check will reject it.
| Input | Value |
|---|---|
| Secret | whsec_YWdvbyBkb2NzIGV4YW1wbGUgc2VjcmV0LCBub3QgcmVhbA== |
webhook-id | evt_01m4ww0ezgf2bryg9gxb2gbewx |
webhook-timestamp | 1791970262 |
| Body (exact bytes) | {"id":"evt_01m4ww0ezgf2bryg9gxb2gbewx","type":"visit.checked_in"} |
webhook-signature | v1,r62XTf3Xey7gj2QPkX1/+b8KGnG46iVXcut9tPKWjcE= |
Your code should accept it, and reject it if you change one character of the body, use a different secret, or move "now" more than 300 seconds away. For an end-to-end check, send a real signed test event with POST/webhook-endpoints/{whep_id}/test.
Rotate a secret
Rotate an endpoint's secret on a schedule, when someone with access leaves, or straight away if it may have leaked. Agoo signs with the old and the new secret side by side for a while, so you can switch without missing a delivery.
Create the new secret
Call POST/webhook-endpoints/{whep_id}/rotate-secret. The response contains the new secret (shown once) and previous_secret_expires_at.
curl -X POST https://api.agoo.ardent.africa/v1/webhook-endpoints/whep_01m2jf0cg0ffyrmjbzsk9t0gtc/rotate-secret \
-H "Authorization: Bearer $AGOO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "previous_secret_ttl_hours": 24 }'From now until the old secret expires, every delivery has two signatures in webhook-signature, one for each secret.
Deploy the new secret
Add the new secret to your configuration next to the old one, for example AGOO_WEBHOOK_SECRET and AGOO_WEBHOOK_SECRET_PREVIOUS, and pass both to your verification function. Deliveries verify whichever of your servers has been updated.
Remove the old secret
After previous_secret_expires_at, Agoo signs only with the new secret. Remove the old one from your configuration.
If a secret has leaked, rotate with "previous_secret_ttl_hours": 0. Agoo stops signing with the old secret at once, so deliveries fail until you deploy the new one, then succeed on retry.