Docs
Webhooks

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.

Preview· P9

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:

HeaderExampleMeaning
webhook-idevt_01m4ww0ezgf2bryg9gxb2gbewxThe event ID. The same on every retry and redelivery.
webhook-timestamp1791970262When this attempt was signed, in Unix seconds.
webhook-signaturev1,r62XTf3Xey7gj2QPkX1/+b8KGnG46iVXcut9tPKWjcE=One or more signatures, separated by spaces.

To produce each signature, Agoo:

  1. takes your endpoint secret, removes the whsec_ prefix and base64-decodes the rest to get the key;
  2. joins webhook-id, webhook-timestamp and the raw request body with full stops: {webhook-id}.{webhook-timestamp}.{body};
  3. computes the HMAC-SHA256 of that string with the key, and base64-encodes the result;
  4. 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_equals or hmac.Equal, never ==.
  • Accept any matching signature. webhook-signature can hold several space-separated values, for example during a secret rotation. Ignore versions other than v1.
  • 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 401 and 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.

verify-agoo-webhook.ts
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:

server.ts
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():

app/webhooks/agoo/route.ts
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.

InputValue
Secretwhsec_YWdvbyBkb2NzIGV4YW1wbGUgc2VjcmV0LCBub3QgcmVhbA==
webhook-idevt_01m4ww0ezgf2bryg9gxb2gbewx
webhook-timestamp1791970262
Body (exact bytes){"id":"evt_01m4ww0ezgf2bryg9gxb2gbewx","type":"visit.checked_in"}
webhook-signaturev1,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.

On this page