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.
  • Every type has an icon. It's the key of one icon in Agoo's set for visit types, such as handshake or package, 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"
Response (trimmed)
{
  "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.

SuitsKeySuitsKey
MeetinghandshakeMaintenancehammer
Interview or businessbriefcaseCleaningspray
Delivery or parcelpackageIT or technologylaptop
Contractor or repairswrenchSecurityshield
EventcalendarAudit or inspectionclipboard_check
OthersparklesMedicalstethoscope
VisitoruserPatient visitheart_pulse
GroupusersVendor or salesshopping_bag
VIP or special gueststarCourier or haulagetruck
Candidate or applicantid_cardDispatch riderbike
Family or personalheartDriver or ridecar
Resident's guesthomePickup or drop-offarrow_left_right
School pickupbackpackFood or cateringutensils
Student or parentgraduation_capFinance or bankbanknote
Training or classbookLegalscale
TourmapGovernment or officiallandmark
Day pass or memberticketVolunteerhand_heart
EnquirychatPress or mediamic
  • Built-in types start with their own icon: handshake, briefcase, package, wrench, calendar and sparkles for Meeting, Interview, Delivery, Contractor, Event and Other. A type an organisation adds starts with user. Admins can change any of them.
  • A key never changes or goes away, and new ones can be added. Draw user for 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"
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 (x-agoo-type photo, 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 (kind answer_photo or answer_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 of document_type (ghana_card, passport, drivers_licence, other), number and image (an id_image file). 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_type is ghana_card, or whose number has the Ghana Card's form (GHA, nine digits and one more character, however it's spaced or hyphenated, in any case), is refused with validation_failed at body.custom_fields.<key>.image if it carries an image. A number in that form is recorded as a Ghana Card whatever document_type says. 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.

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 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:

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

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

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

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), and ID document fields are always both. 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