Docs
Concepts

Custom fields

Read each organisation's visit types and form schemas, write answers in custom_fields, and handle validation errors.

Preview· P9

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

ObjectForm
VisitThe visit form for the visit's type. Every visit type, built-in or added, has its own form
BookingThe visit form for the booking type's visit_type, because an in-person booking becomes a visit
PersonThe 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 typesKeys
Built-inmeeting, interview, delivery, contractor, event, other
Added by the organisationLowercase 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 name changes and its key stays event. 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, returns validation_failed, and so does a key the organisation doesn't have.
  • Admins manage types in the console; the API reads them. Treat type and visit_type as 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"
Response (trimmed)
{
  "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"
Response
{
  "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.
  • title is the label people see, and description, when present, is the help text. x-agoo-order lists 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 a number or integer, a Yes or no or consent question is a boolean, a single choice is an enum, a multiple choice is an array of an enum, a person is a string holding a person ID, and dates, emails and phone numbers are strings with a format or pattern.
  • Properties also carry x-agoo-* annotations: x-agoo-type (the builder's field type), and where they apply x-agoo-labels (choice labels by value), x-agoo-unit, x-agoo-consent-text, x-agoo-show-if (asked only after an earlier answer) and x-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 allOf if/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.

public_form of the Contractor type
{
  "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_found for any other. GET /forms returns forbidden.
  • Pre-registrations are checked against it. With a publishable key, POST /visits accepts only a type open for pre-registration (otherwise validation_failed at body.type), and only the custom_fields in its public_form (a staff field fails at body.custom_fields.<key>).
  • It follows the form. version is 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:

Invite a contractor
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:

Update one answer, remove another
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:

422 Unprocessable Content
{
  "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.

validate-custom-fields.ts
// 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_fields leaves 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.

On this page