Webhooks
Get a signed HTTPS request the moment something happens in Agoo, such as a visitor checking in, a parcel arriving or a roll call starting.
This is a published preview. Names and fields may still change before general availability; changes will be listed in the changelog.
Webhooks push events to your systems as they happen, so you don't have to poll. When a visitor checks in at Ridge HQ, Agoo can tell your access control system to open the barrier, post in your security team's channel and update your CRM, all within seconds.
Webhooks are on Growth, Pro and Enterprise in live mode, and on every plan in test mode. If your organisation moves to a plan without webhooks, Agoo keeps recording events but stops delivering them, and your endpoints stay as they are: you can turn them off or delete them, but not change or turn on an endpoint. Delivery resumes for new events when you move back to a plan with webhooks.
How it works
Something happens
For example, Abena at reception checks Ama Owusu in. Agoo records an event of type visit.checked_in.
Agoo sends a signed POST
Every enabled endpoint subscribed to that type receives an HTTPS POST with a JSON body and three Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature.
You verify and acknowledge
Your endpoint verifies the signature, stores or queues the event, and returns any 2xx status within 15 seconds.
Agoo retries if needed
If your endpoint doesn't acknowledge in time, Agoo retries with backoff for about 27 hours.
The payload
Every event has the same envelope. data.object is the object the event is about, in the same shape its own endpoint returns.
{
"id": "evt_01m4ww0ezgf2bryg9gxb2gbewx",
"type": "visit.checked_in",
"created_at": "2026-10-14T09:31:02Z",
"organisation_id": "org_01kjpsrza0etzb0a4vf72x8ang",
"livemode": true,
"data": {
"object": {
"id": "visit_01m4k5z4j0fxbte6sn6e8tpgza",
"status": "checked_in",
"type": "meeting",
"source": "invitation",
"site_id": "site_01kjpt3yw0fz0v414608h9x65s",
"gate_id": "gate_01kjpt7m20fpf9vcsaexsxgqr3",
"host_id": "person_01kjsesxm0e4zbyvkr7bcfxbfg",
"visitor": {
"id": "visitor_01krdtb870fxj8t6ntdtcxwdk5",
"name": "Ama Owusu",
"phone": "+233241234567",
"email": "ama@coastline.example",
"company": "Coastline Consult"
},
"purpose": "Quarterly treasury review",
"expected_at": "2026-10-14T09:30:00Z",
"expected_until": "2026-10-14T11:30:00Z",
"checked_in_at": "2026-10-14T09:31:02Z",
"checked_out_at": null,
"cancelled_at": null,
"decision": {
"outcome": "approved",
"decided_at": "2026-10-14T09:31:02Z",
"decided_by": "person_01kjsesxm0e4zbyvkr7bcfxbfg",
"reason": null
},
"booking_id": null,
"pass": {
"channels": ["whatsapp"],
"last_sent_at": "2026-10-10T15:12:44Z"
},
"custom_fields": {
"vehicle_plate": "GR 4521-24",
"bringing_laptop": true
},
"created_at": "2026-10-10T15:12:40Z",
"updated_at": "2026-10-14T09:31:02Z"
}
}
}Prop
Type
data.object is a snapshot. By the time you process a visit.checked_in, the visitor might already have left. When you need the current state, fetch the object with its ID.
The event catalogue lists all 30 event types, and the API reference has the full schema of each.
Create an endpoint
You need a public HTTPS URL that accepts POST requests. To receive events on your laptop while you build, see local testing.
Create the endpoint with POST/webhook-endpoints (scope webhooks:manage), or in Console → Developers:
curl https://api.agoo.ardent.africa/v1/webhook-endpoints \
-H "Authorization: Bearer $AGOO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://hooks.voltabank.example/agoo",
"description": "Front desk integration",
"enabled_events": ["visit.checked_in", "visit.checked_out", "delivery.received"]
}'{
"id": "whep_01m2jf0cg0ffyrmjbzsk9t0gtc",
"url": "https://hooks.voltabank.example/agoo",
"description": "Front desk integration",
"enabled_events": ["visit.checked_in", "visit.checked_out", "delivery.received"],
"status": "enabled",
"disabled_reason": null,
"secret": "whsec_…",
"created_at": "2026-09-15T12:00:00Z",
"updated_at": "2026-09-15T12:00:00Z"
}The secret is shown only in this response. Store it with your other secrets, for example as AGOO_WEBHOOK_SECRET; you'll use it to verify every delivery. If you lose it, rotate to a new one.
- Choose events deliberately. Subscribe to the types you handle.
["*"]subscribes to every type, including types added in future, so your handler must ignore types it doesn't know. The one exception iswatchlist.matched: an endpoint receives it only by listing it, so watchlist alerts never reach a catch-all endpoint by accident. - Endpoints belong to one mode. An endpoint created with a test key receives only test-mode events; create your live endpoints with a live key.
- The URL must be public HTTPS with a valid certificate (TLS 1.2 or later), without a user name or password in it. Its host name must resolve, and only to public addresses: Agoo checks when you save the endpoint and again before every delivery, and doesn't deliver to private or local addresses or follow redirects.
- Several endpoints are fine. Each enabled endpoint subscribed to a type gets its own copy of the event, delivered and retried independently.
Respond quickly
Return a 2xx status within 15 seconds. Agoo ignores the response body.
Do the minimum before you respond: verify the signature, store or queue the event, then return 200 or 204. Do the real work, such as calling other systems or sending messages, afterwards from your queue. A handler that does everything inline will time out when a downstream system is slow, and Agoo will send the event again.
Retries
If an attempt fails (a status other than 2xx, a redirect, a timeout after 15 seconds, or a connection or TLS error), Agoo tries again on this schedule:
| Attempt | When, after the previous attempt |
|---|---|
| 1 | Immediately |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
That's about 27 hours from the first attempt to the last. Each wait varies by up to 10% either way, so retries after an outage don't all arrive at once. Every retry has the same webhook-id and body, with a new webhook-timestamp and signature.
Disabled endpoints
If an endpoint fails every attempt for 5 days, Agoo disables it (status: "disabled", disabled_reason: "failing") and emails your organisation's admins. To recover:
- Fix the endpoint, and check it with a test event.
- Re-enable it with PATCH/webhook-endpoints/{whep_id} and
{ "status": "enabled" }. - Catch up: list what you missed with GET/events (events are kept for 30 days) and either process them from that list or replay them with POST/events/{evt_id}/redeliver.
Ordering
Agoo doesn't guarantee that events arrive in the order they happened. Retries, several endpoints and network delays can all reorder them: a visit.checked_out can arrive before the visit.checked_in that preceded it, if the first attempt of the check-in failed.
- Use
created_atto put events in order, and ignore an event that is older than the state you already have. - For decisions that depend on the latest state, fetch the object rather than trusting the order of arrival.
Handle events idempotently
The same event can arrive more than once: after a timeout, a retry or a redelivery. Make processing safe to repeat:
- Read the
webhook-idheader (the event ID). - If you've already processed that ID, return
200and stop. - Otherwise process the event and record the ID, in the same database transaction if you can.
Keep processed IDs for at least 30 days, which covers the retry window and redeliveries of anything still in GET/events.
CREATE TABLE processed_agoo_events (
id text PRIMARY KEY, -- the webhook-id (evt_…)
processed_at timestamptz NOT NULL DEFAULT now()
);import type { Pool, PoolClient } from "pg"
/** Runs `work` once per event ID, even if the event is delivered several times. */
export async function handleOnce(db: Pool, eventId: string, work: (tx: PoolClient) => Promise<void>): Promise<void> {
const tx = await db.connect()
try {
await tx.query("BEGIN")
const { rowCount } = await tx.query("INSERT INTO processed_agoo_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING", [eventId])
if (rowCount === 1) await work(tx) // first time we've seen this event
await tx.query("COMMIT")
} catch (err) {
await tx.query("ROLLBACK")
throw err
} finally {
tx.release()
}
}Call it after verifying the signature, with the webhook-id header, and keep work short, for example inserting a row into your own job queue table.
Security
- Verify every signature. Anyone can send a POST to your URL; only Agoo knows your secret. See verify signatures for code in TypeScript, Python, PHP and Go.
- Reject old timestamps. Requests signed more than 5 minutes from your clock could be replays.
- Don't filter by IP address. Agoo doesn't publish a fixed list of sending addresses, and they can change. The signature is what proves a request came from Agoo.
- Keep the secret secret. Store it like a password, never log it, and rotate it if you think it has leaked.
- Treat payloads as data. Visitor names and custom field answers are typed by people; escape them before you display them.
Test your endpoint
- POST/webhook-endpoints/{whep_id}/test sends one signed event of any type, with a sample object, and tells you how your endpoint responded.
- In test mode, create and check in test visits to fire real events. See environments.
- To receive events on your own machine, see local testing.