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. - Every type has an
icon. It's the key of one icon in Agoo's set for visit types, such ashandshakeorpackage, which an admin picks in the console. See Icons for the keys and how to draw them.
GET/visit-types lists them, with the scope forms:read. Each type's form_id points
to its form:
curl "https://api.agoohq.com/v1/visit-types?enabled=true" \
-H "Authorization: Bearer $AGOO_API_KEY"{
"data": [
{
"key": "meeting",
"name": "Meeting",
"icon": "handshake",
"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",
"icon": "shopping_bag",
"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
}Icons
A visit type's icon is a key, never an image or a URL. Agoo's own screens draw each key with an icon from their platform's set, and your form can do the same with the icons you already use, so a type looks the same on your website as it does on the kiosk.
| Suits | Key | Suits | Key |
|---|---|---|---|
| Meeting | handshake | Maintenance | hammer |
| Interview or business | briefcase | Cleaning | spray |
| Delivery or parcel | package | IT or technology | laptop |
| Contractor or repairs | wrench | Security | shield |
| Event | calendar | Audit or inspection | clipboard_check |
| Other | sparkles | Medical | stethoscope |
| Visitor | user | Patient visit | heart_pulse |
| Group | users | Vendor or sales | shopping_bag |
| VIP or special guest | star | Courier or haulage | truck |
| Candidate or applicant | id_card | Dispatch rider | bike |
| Family or personal | heart | Driver or ride | car |
| Resident's guest | home | Pickup or drop-off | arrow_left_right |
| School pickup | backpack | Food or catering | utensils |
| Student or parent | graduation_cap | Finance or bank | banknote |
| Training or class | book | Legal | scale |
| Tour | map | Government or official | landmark |
| Day pass or member | ticket | Volunteer | hand_heart |
| Enquiry | chat | Press or media | mic |
- Built-in types start with their own icon:
handshake,briefcase,package,wrench,calendarandsparklesfor Meeting, Interview, Delivery, Contractor, Event and Other. A type an organisation adds starts withuser. Admins can change any of them. - A key never changes or goes away, and new ones can be added. Draw
userfor a key your code doesn't know. - The API reads icons; admins pick them in Console → Settings → Forms.
Read the schemas
GET/forms returns every active form; filter with entity and visit_type. It needs the
forms:read scope.
curl "https://api.agoohq.com/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 (
x-agoo-typephoto,file,signature,id_scan) are taken on a device: never on a visitor's own page, so they're never in a public form, and a booking can't carry them. With a secret key or in your own app you can set them on a visit:- Photo and file: the ID of a file you registered first with
POST /files(kindanswer_photooranswer_file) and uploaded. The file then belongs to the visit: it's deleted with the visit, or when the answer is changed or removed. - Signature: the strokes,
[[[x, y], …], …]in a 1000 × 400 box, up to 60 strokes. - ID document (
id_scan): any ofdocument_type(ghana_card,passport,drivers_licence,other),numberandimage(anid_imagefile). The full number is kept encrypted with the visitor and never returned through the API. - A Ghana Card never has an image. Under Ghana's L.I. 2523, Agoo never keeps an image of a Ghana Card. An answer whose
document_typeisghana_card, or whosenumberhas the Ghana Card's form (GHA, nine digits and one more character, however it's spaced or hyphenated, in any case), is refused withvalidation_failedatbody.custom_fields.<key>.imageif it carries animage. A number in that form is recorded as a Ghana Card whateverdocument_typesays. Only the form is checked: Agoo records the number, it doesn't verify it. - ID document answers are always protected (sensitive and masked), so you can write one but never read it back with an API key or as a connected app, and events and webhooks never include it.
- Photo and file: the ID of a file you registered first with
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 planned React components will do all of this for you.
Write answers
Send custom_fields when you create a visit, a booking or a person:
curl https://api.agoohq.com/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.agoohq.com/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.agoohq.com/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), and ID document fields are always both. 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.
Related
Passes and pass rules
Set when and where a visit's pass admits with pass_rules, check visitors in despite a rule with override_pass_rules, and let them back in on the same pass with re-enter.
Files and uploads
Upload photos, selfies, signatures and documents straight to Agoo's private storage with short-lived links, then use the file's ID on a record.