Errors
Every Agoo API error is an RFC 9457 problem details object with a stable code. Here is each code, what causes it and what to do.
This is a published preview. Names and fields may still change before general availability; changes will be listed in the changelog.
When a request fails, Agoo responds with an HTTP status of 400 or above and a body in the RFC 9457 problem details format, with the content type application/problem+json:
{
"type": "https://docs.agoo.ardent.africa/developers/concepts/errors#validation_failed",
"title": "Validation failed",
"status": 422,
"detail": "1 field isn't valid.",
"instance": "/v1/visits",
"code": "validation_failed",
"request_id": "req_01m4ww0hx8f0wa8fyh0evr8050",
"errors": [
{
"location": "body.visitor.phone",
"message": "Must be an E.164 phone number, such as +233241234567."
}
]
}Prop
Type
Branch on code, not on title, detail or the status alone. Several codes share a status (scope_missing, plan_required and consent_required are all 403) and the wording of detail can change at any time. New codes may be added, so handle codes you don't recognise by their status.
location says where the problem is, as body.…, query.…, path.… or header.…, for example body.custom_fields.work_order, query.limit or header.Idempotency-Key.
All codes
| Code | Status | Retry? |
|---|---|---|
validation_failed | 422 | No. Fix the request. |
bad_request | 400 | No. Fix the request. |
unauthorized | 401 | No. Fix the key or token. |
forbidden | 403 | No |
scope_missing | 403 | No. Use a key with the scope. |
plan_required | 403 | No. Upgrade, or use test mode. |
consent_required | 403 | No. Record consent, or use PIN. |
not_found | 404 | No |
conflict | 409 | Sometimes (see below) |
invalid_state | 409 | No. Re-read the object first. |
idempotency_key_reused | 422 | No. Use a new key. |
location_check_failed | 422 | No. Clock in at the kiosk. |
rate_limited | 429 | Yes, after Retry-After |
internal_error | 500 | Yes, with backoff |
unavailable | 503 | Yes, with backoff |
When you retry a POST, send the same Idempotency-Key, so a request that actually succeeded isn't repeated. See idempotency.
validation_failed
422. The request is well formed, but one or more values aren't valid: a required field is missing, a value has the wrong type or format, a custom field doesn't match its form, or an ID points to something that can't be used here (such as a gate at another site). With a publishable key, a pre-registration for a visit type that isn't open for pre-registration fails here too, at body.type, and so does a custom field that isn't in the visit type's or booking type's public form. errors lists every problem, so you can show them all at once.
Requests without the required Idempotency-Key header on a POST also fail with this code, at header.Idempotency-Key.
bad_request
400. Agoo couldn't read the request at all: the body isn't valid JSON, the Content-Type isn't application/json, or the query string is malformed. Check what your HTTP client actually sends.
unauthorized
401. The Authorization header is missing, isn't Bearer <token>, or the key or token is unknown, revoked or expired. OAuth access tokens expire after an hour; refresh and retry once. For an API key, check it was copied in full and hasn't been revoked.
forbidden
403. The credential is valid but isn't allowed to do this, for a reason other than scope or plan:
- a publishable key called an endpoint that publishable keys can't use;
- an OAuth token acts for a person whose role doesn't allow the action;
- your organisation's IP allow-list (Enterprise) doesn't include the caller's address.
detail says which.
scope_missing
403. The key or token doesn't have the scope this endpoint needs. detail names the scope. Create a key with it (a key's scopes can't be changed), or ask for it in your OAuth authorization request. Each endpoint in the API reference lists its scope.
plan_required
403. Your organisation's plan doesn't include this in live mode. Growth can manage webhook endpoints and read events; the rest of the API needs Pro or Enterprise. Test mode works on every plan. The same code is returned when an action would take you over a plan limit, such as the number of hosts.
It's also returned when you read or act on a record that's older than your plan's history: a visit created before your plan's visitor history, or a visitor whose visits all are. Moving to a lower plan never deletes data, so the record still exists. It's kept, hidden, and comes back if you move to a plan with a longer history. Lists simply leave these records out. Don't treat this as a deletion: keep your own copy, and act on erasures only when you receive visitor.erased.
consent_required
403. Your organisation records this attendance evidence (face, selfie or location) only with each employee's consent, and this person hasn't consented or has withdrawn. Record their consent with POST /people/{person_id}/attendance-evidence if they give it, or record the punch without that evidence (another method, or the kiosk's fallback after a failed face match). Evidence your organisation requires, rather than asking consent for, never returns this. See evidence and notices.
not_found
404. There's no object with this ID in this organisation and mode. The usual causes are a live ID used with a test key (or the other way round), a typo, an object from another organisation, or a visitor whose data was erased. Agoo returns not_found, not forbidden, for other organisations' objects, so IDs can't be probed. With a publishable key, a visit type that isn't open for pre-registration is also not_found. A record hidden by your plan's history isn't not_found: it returns plan_required.
conflict
409. The request clashes with something else:
- the booking slot you asked for has just been taken (fetch availability again);
- another person already uses that email address;
- a roll call is already active at that site;
- a request with the same
Idempotency-Keyis still being processed. Wait a moment and retry with the same key.
Only the last case is worth retrying unchanged.
invalid_state
409. The object's current status doesn't allow this action, for example checking in a visit that is already checked_out, approving one that isn't awaiting_approval, or collecting a delivery twice. detail says what the status is. Fetch the object, decide what should happen now, and don't retry blindly. Each endpoint's description in the API reference lists the statuses it accepts.
idempotency_key_reused
422. You sent an Idempotency-Key that was used in the last 24 hours with a different request (a different method, path or body). Generate a new key for each new operation, and reuse a key only to retry exactly the same request.
location_check_failed
422. A phone clock-in wasn't inside the site's geofence and wasn't on one of the site's Wi-Fi networks, so Agoo didn't record it. The employee clocks in at the kiosk instead. Terminal punches through the API never return this.
rate_limited
429. Your organisation has used up its requests for the current minute in this mode. Wait for the number of seconds in the Retry-After header, then retry. See rate limits for limits per plan and a backoff example.
internal_error
500. Something went wrong on Agoo's side. Retry with exponential backoff, reusing the same Idempotency-Key. If it keeps happening, contact support with the request_id.
unavailable
503. Agoo is briefly unavailable, for example during maintenance or a spike in load. Retry with backoff, honouring Retry-After when it's present.
Handle errors in code
export interface Problem {
type: string
title: string
status: number
detail?: string
instance?: string
code: string
request_id: string
errors?: { location: string; message: string }[]
}
export class AgooApiError extends Error {
readonly problem: Problem
constructor(problem: Problem) {
super(`${problem.code}: ${problem.detail ?? problem.title} (${problem.request_id})`)
this.problem = problem
}
}
export async function check(res: Response): Promise<Response> {
if (res.ok) return res
const type = res.headers.get("content-type") ?? ""
if (type.includes("application/problem+json")) throw new AgooApiError((await res.json()) as Problem)
throw new Error(`Agoo API ${res.status} (${res.headers.get("agoo-request-id")}): ${await res.text()}`)
}
// Usage: a local phone number instead of E.164 fails validation
const res = await fetch("https://api.agoo.ardent.africa/v1/visits", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AGOO_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
site_id: "site_01kjpt3yw0fz0v414608h9x65s",
host_id: "person_01kjsesxm0e4zbyvkr7bcfxbfg",
visitor: { name: "Ama Owusu", phone: "024 123 4567" },
expected_at: "2026-10-14T09:30:00Z",
}),
})
try {
await check(res)
} catch (err) {
if (err instanceof AgooApiError && err.problem.code === "validation_failed") {
for (const e of err.problem.errors ?? []) console.warn(e.location, e.message)
} else {
throw err
}
}Errors that aren't problem details
- The OAuth token endpoint returns errors in the OAuth format, such as
{ "error": "invalid_grant" }. See authentication. - Network failures, timeouts and responses from proxies between you and Agoo may have no problem body. Treat them like
unavailable: retry with backoff and the sameIdempotency-Key.