Docs
SDKs and tools

Webhooks helper

Verify Agoo webhook signatures and get typed events in TypeScript, with a small package that has no dependencies.

Planned· P9For developers

This is designed and scheduled but not built yet. We document it now so you can plan your integration.

@ardent-africa/agoo-webhooks does one job: it checks that a webhook really came from Agoo and hasn't been replayed, then gives you the event with its TypeScript type. Use it when your service only receives webhooks and doesn't need the full TypeScript SDK. The SDK includes the same function as agoo.webhooks.verify.

The install command will work once the package is published to npm.

npm i @ardent-africa/agoo-webhooks

It runs on Node.js 20 or later, Bun, Deno and Cloudflare Workers, using Web Crypto.

verifyWebhook

Signature
function verifyWebhook(
  rawBody: string | Uint8Array,
  headers: Headers | Record<string, string | string[] | undefined>,
  secret: string | string[],
  options?: { toleranceSeconds?: number },
): Promise<AgooEvent>

Prop

Type

It resolves to the parsed event, typed as a union of every event type and narrowed by event.type. It rejects with a WebhookVerificationError when:

error.reasonMeaning
missing_headersOne of the three webhook- headers is missing. The request didn't come from Agoo.
invalid_timestampThe timestamp is more than toleranceSeconds from your clock. It may be a replay, or your server clock may be wrong.
invalid_signatureNo signature matches. The secret is wrong, or the body was changed after it was sent.
invalid_payloadThe signature is valid but the body isn't a valid event.

Respond with a 400 when verification fails, and never process the event.

Examples

app/webhooks/agoo/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ardent-africa/agoo-webhooks"

export async function POST(req: Request) {
  const body = await req.text()

  try {
    const event = await verifyWebhook(body, req.headers, process.env.AGOO_WEBHOOK_SECRET!)

    switch (event.type) {
      case "visit.checked_in":
        await notifyTeam(event.data.object)
        break
      case "booking.created":
        await addToCrm(event.data.object)
        break
    }

    return new Response(null, { status: 204 })
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return new Response(err.reason, { status: 400 })
    }
    throw err // a bug in your handler: return 500 so Agoo retries
  }
}

Handle events safely

Verification proves the event came from Agoo. These habits make your handler correct as well:

  • Respond within 15 seconds. Agoo treats anything else as a failure and retries. Acknowledge quickly and do slow work (CRM calls, emails) in a queue.
  • De-duplicate on webhook-id. The same event can arrive more than once, for example after a timeout. Store the IDs you've processed and skip repeats.
  • Don't rely on order. Events can arrive out of order. If order matters, compare created_at, or fetch the object's current state from the API.
  • Check livemode. Test-mode events have livemode: false. Use a separate endpoint and secret for each mode.

See webhooks for retries, and local testing to receive events on your laptop.

Verifying without the package

The helper implements the Standard Webhooks scheme, so you can also verify by hand or with any Standard Webhooks library. In outline:

  1. Read webhook-id, webhook-timestamp and webhook-signature.
  2. Reject the request if the timestamp is more than 5 minutes from now.
  3. Take the secret without its whsec_ prefix and base64-decode it. That is the HMAC key.
  4. Compute the HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body} and base64-encode it.
  5. webhook-signature holds one or more space-separated v1,<signature> values. Accept the request if any of them equals yours, compared in constant time.
verify.ts (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto"

type HeaderMap = Record<string, string | string[] | undefined>

const header = (headers: HeaderMap, name: string) => {
  const value = headers[name]
  return Array.isArray(value) ? value[0] : value
}

export function verifyAgooSignature(rawBody: string, headers: HeaderMap, secret: string): boolean {
  const id = header(headers, "webhook-id")
  const timestamp = header(headers, "webhook-timestamp")
  const signatures = header(headers, "webhook-signature")
  if (!id || !timestamp || !signatures) return false

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > 300) return false

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest()

  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",")
    if (version !== "v1" || !signature) return false
    const received = Buffer.from(signature, "base64")
    return received.length === expected.length && timingSafeEqual(received, expected)
  })
}

Node.js lowercases incoming header names, so you can pass req.headers directly. For other languages, see verify signatures.

On this page