Webhooks helper
Verify Agoo webhook signatures and get typed events in TypeScript, with a small package that has no dependencies.
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-webhooksIt runs on Node.js 20 or later, Bun, Deno and Cloudflare Workers, using Web Crypto.
verifyWebhook
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.reason | Meaning |
|---|---|
missing_headers | One of the three webhook- headers is missing. The request didn't come from Agoo. |
invalid_timestamp | The timestamp is more than toleranceSeconds from your clock. It may be a replay, or your server clock may be wrong. |
invalid_signature | No signature matches. The secret is wrong, or the body was changed after it was sent. |
invalid_payload | The signature is valid but the body isn't a valid event. |
Respond with a 400 when verification fails, and never process the event.
Examples
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 havelivemode: 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:
- Read
webhook-id,webhook-timestampandwebhook-signature. - Reject the request if the timestamp is more than 5 minutes from now.
- Take the secret without its
whsec_prefix and base64-decode it. That is the HMAC key. - Compute the HMAC-SHA256 of
{webhook-id}.{webhook-timestamp}.{raw body}and base64-encode it. webhook-signatureholds one or more space-separatedv1,<signature>values. Accept the request if any of them equals yours, compared in constant time.
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.