Custom fields
Read each organisation's visit types and form schemas, write answers in custom_fields, and handle validation errors.
This is a published preview. Names and fields may still change before general availability; changes will be listed in the changelog.
Every organisation asks its own questions. A bank wants a contractor's work order number; a school wants to know which pupil a parent is collecting. Admins build these questions in the form builder, and the API carries the answers in a custom_fields object on visits, bookings and people:
{
"id": "visit_01m4k5z4j0fxbte6sn6e8tpgza",
"type": "meeting",
"custom_fields": {
"vehicle_plate": "GR 4521-24",
"bringing_laptop": true
}
}Each form is published as a JSON Schema (draft 2020-12), so you can read which keys exist and what values they accept, and validate answers before you send them.
Which form applies
| Object | Form |
|---|---|
| Visit | The visit form for the visit's type. Every visit type, built-in or added, has its own form |
| Booking | The visit form for the booking type's visit_type, because an in-person booking becomes a visit |
| Person | The organisation's person form |
Visit types
Every organisation starts with six built-in visit types and can add its own in the form builder. The API identifies a type by its key:
| Visit types | Keys |
|---|---|
| Built-in | meeting, interview, delivery, contractor, event, other |
| Added by the organisation | Lowercase letters, digits and underscores, starting with a letter, at most 40 characters: parent_pickup, vendor |
- A key never changes. When an admin renames a type, say Event to Service, its
namechanges and its key staysevent. Keys are the same in live and test mode, so you can hard-code the ones you rely on. - Types can be turned off. A type that's turned off still appears on older visits and in the list below, with
enabled: false. Creating a visit with it, or changing a visit to it, returnsvalidation_failed, and so does a key the organisation doesn't have. - Admins manage types in the console; the API reads them. Treat
typeandvisit_typeas strings, not a fixed list.
GET/visit-types lists them, with the scope forms:read. Each type's form_id points
to its form:
curl "https://api.agoo.ardent.africa/v1/visit-types?enabled=true" \
-H "Authorization: Bearer $AGOO_API_KEY"{
"data": [
{
"key": "meeting",
"name": "Meeting",
"built_in": true,
"enabled": true,
"pre_registration": true,
"form_id": "form_01kk16jcj0fapr5z3qmqxgvpvv",
"public_form": {
"version": 2,
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"vehicle_plate": { "type": "string", "title": "Vehicle number plate", "maxLength": 12 },
"bringing_laptop": { "type": "boolean", "title": "Bringing a laptop?" }
},
"additionalProperties": false
}
},
"created_at": "2026-03-02T08:14:00Z",
"updated_at": "2026-07-21T15:40:00Z"
},
{
"key": "vendor",
"name": "Vendor",
"built_in": false,
"enabled": true,
"pre_registration": false,
"form_id": "form_01kztpxc70fy3v3c0st276bbvj",
"public_form": null,
"created_at": "2026-08-12T10:05:00Z",
"updated_at": "2026-08-12T10:05:00Z"
}
],
"next_cursor": null,
"has_more": false
}Read the schemas
GET/forms returns every active form; filter with entity and visit_type. It needs the
forms:read scope.
curl "https://api.agoo.ardent.africa/v1/forms?entity=visit&visit_type=contractor" \
-H "Authorization: Bearer $AGOO_API_KEY"{
"data": [
{
"id": "form_01kk1602m0errvasw8gkt8f90b",
"name": "Contractor",
"entity": "visit",
"visit_type": "contractor",
"version": 3,
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"contractor_company": { "type": "string", "title": "Contracting company", "maxLength": 120 },
"work_order": { "type": "string", "title": "Work order number", "pattern": "^WO-[0-9]{5}$" },
"areas": {
"type": "array",
"title": "Areas needed",
"items": { "enum": ["server_room", "generator_house", "roof", "banking_hall"] },
"uniqueItems": true
},
"safety_induction_done": { "type": "boolean", "title": "Safety induction completed" }
},
"required": ["contractor_company", "work_order", "safety_induction_done"],
"additionalProperties": false
},
"created_at": "2026-03-06T09:00:00Z",
"updated_at": "2026-09-02T10:15:00Z"
}
],
"next_cursor": null,
"has_more": false
}- Each property key is the key you use in
custom_fields. Keys are set when a field is created and don't change when an admin renames the label, and a key keeps its kind of answer across versions. titleis the label people see, anddescription, when present, is the help text.x-agoo-orderlists the properties in the order they appear on screen (JSON objects don't keep their key order).- The form builder's field types become ordinary JSON Schema: text is a
string, a number is anumberorinteger, a Yes or no or consent question is aboolean, a single choice is anenum, a multiple choice is anarrayof anenum, a person is a string holding a person ID, and dates, emails and phone numbers are strings with aformatorpattern. - Properties also carry
x-agoo-*annotations:x-agoo-type(the builder's field type), and where they applyx-agoo-labels(choice labels by value),x-agoo-unit,x-agoo-consent-text,x-agoo-show-if(asked only after an earlier answer) andx-agoo-sites(asked only at those sites). - Forms can use any JSON Schema 2020-12 keyword: a question required only after an earlier answer is an
allOfif/then rule. Validate with a JSON Schema library rather than reading keywords by hand. - Fields that capture a photo, a file, a signature or an ID document are collected in Agoo's own apps and pages. They aren't in the schema, and you can't set them through the API.
Forms change when an admin publishes a new version, and version goes up. Cache schemas by form ID and version, and refresh them now and then, or when a request fails because of a field the cache doesn't know about.
Public forms for pre-registration
A form can mix questions for the visitor with fields your staff fill in, such as "Safety induction completed". So that a pre-registration form in a browser never sees the staff's fields, each visit type that an admin has opened for pre-registration (pre_registration: true) carries a public_form: the same JSON Schema with only the fields asked of the visitor. It's null for other types.
{
"version": 3,
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"contractor_company": { "type": "string", "title": "Contracting company", "maxLength": 120 },
"work_order": { "type": "string", "title": "Work order number", "pattern": "^WO-[0-9]{5}$" },
"areas": {
"type": "array",
"title": "Areas needed",
"items": { "enum": ["server_room", "generator_house", "roof", "banking_hall"] },
"uniqueItems": true
}
},
"required": ["contractor_company", "work_order"],
"additionalProperties": false
}
}- Publishable keys read visit types, not forms. With a publishable key, GET/visit-types returns only the types that are turned on and open for pre-registration, and GET/visit-types/{visit_type} returns
not_foundfor any other.GET /formsreturnsforbidden. - Pre-registrations are checked against it. With a publishable key,
POST /visitsaccepts only atypeopen for pre-registration (otherwisevalidation_failedatbody.type), and only thecustom_fieldsin itspublic_form(a staff field fails atbody.custom_fields.<key>). - It follows the form.
versionis the visit form's version, so it changes when an admin publishes the form.
Public booking types carry one too. Every booking type in GET/booking-types has a public_form in the same shape: the visitor-asked fields of its visit_type's form, or null for a private booking type. Publishing a booking type makes its intake questions public, whether or not the visit type is open for pre-registration. With a publishable key, POST/bookings accepts only those fields; a staff field fails at body.custom_fields.<key>.
The React components do all of this for you.
Write answers
Send custom_fields when you create a visit, a booking or a person:
curl https://api.agoo.ardent.africa/v1/visits \
-H "Authorization: Bearer $AGOO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"site_id": "site_01kjpt3yw0fz0v414608h9x65s",
"host_id": "person_01kjsexjt0e9rt8r69a60jzx03",
"type": "contractor",
"visitor": { "name": "Ama Owusu", "phone": "+233241234567", "company": "Coastline Consult" },
"expected_at": "2026-10-20T08:00:00Z",
"custom_fields": {
"contractor_company": "Coastline Consult",
"work_order": "WO-20417",
"areas": ["server_room", "generator_house"]
}
}'To change answers later, send only the keys you want to change with PATCH. Keys you send replace their values, a key set to null is removed, and keys you leave out stay as they are:
curl -X PATCH https://api.agoo.ardent.africa/v1/visits/visit_01m4z96g00e2zbq1pwt6zbrtvy \
-H "Authorization: Bearer $AGOO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "custom_fields": { "safety_induction_done": true, "areas": null } }'Validation
Agoo checks every answer you send against the form's schema:
- Types, formats, patterns and choices must match.
"work_order": "WO-123"fails the pattern above. - Keys that aren't in the form are rejected. Typos don't get stored silently.
- Required fields aren't enforced when you write through the API. You can invite someone before you know every answer. Questions the form marks as required are asked again when the visitor checks in or completes their pre-registration, so the record is complete by the time they're on site.
A failed check returns validation_failed, with one entry per problem and a location under body.custom_fields:
{
"type": "https://docs.agoo.ardent.africa/developers/concepts/errors#validation_failed",
"title": "Validation failed",
"status": 422,
"detail": "2 fields aren't valid.",
"instance": "/v1/visits",
"code": "validation_failed",
"request_id": "req_01m4ww0jwge9dsabhh0xdayvfe",
"errors": [
{ "location": "body.custom_fields.work_order", "message": "Must match the pattern ^WO-[0-9]{5}$." },
{ "location": "body.custom_fields.badge_colour", "message": "Isn't a field on the Contractor form." }
]
}A visit type that the organisation doesn't have, or that is turned off, fails in the same way, at body.type.
Validate before you send
If answers come from your own users, validate them against the cached schema first, so you can show errors in your own interface. Remove required from the schema if you want the same leniency as the API.
// npm i ajv ajv-formats
// Typed for bundled apps (Next.js, Vite). With "moduleResolution": "NodeNext", call addFormats.default(ajv).
import { Ajv2020 } from "ajv/dist/2020.js"
import addFormats from "ajv-formats"
const ajv = new Ajv2020({ allErrors: true })
addFormats(ajv)
export function customFieldErrors(schema: object, answers: Record<string, unknown>): string[] {
const validate = ajv.compile(schema)
if (validate(answers)) return []
return (validate.errors ?? []).map((e) => `${e.instancePath || "custom_fields"} ${e.message}`)
}Personal data in custom fields
Custom fields often hold personal data. Admins mark fields as personal data in the form builder, so they're part of access and erasure requests, and can give a field its own retention: its answers are deleted that many days after the visit ends. When a visitor is erased, their custom field answers are erased with the rest of their personal data. If you keep copies, listen for visitor.erased and delete yours too.
Protected answers
Admins can mark a field sensitive (special data, such as health) or masked (such as a reference number). Their answers stay inside Agoo:
custom_fieldsleaves them out when you call the API with an API key or as a connected app, on visits, bookings and people.- Events and webhooks never include them.
- In Agoo's own apps, only Owners, Admins and the roles the admin chose see them.
The field is still in the form's schema, and you can still write an answer to it. If your integration needs an answer, ask your admin not to mark the field sensitive or masked.