Skip to main content

Schema Reference

schema.json is a map keyed by step id. The structure of each value depends on the step type:

Step typeSchema shape
select{ id, label, source, display?, depends_on?, filter? } (see Flow ref § select)
calendar{ id, label, source, cal_event_type_id?, depends_on?, slot_duration_from?, filter_by? } (see Flow ref § calendar)
form{ id, label, fields: FieldSchema[] }
confirm{ id, label, show? } (see Flow ref § confirm)

This page documents the field schema used inside form steps — the part that defines what an input looks like and how it's validated.


FieldSchema

Every field has these keys (validated by app/schemas/booking.py):

KeyTypeRequiredDefaultNotes
idstringyes^[a-z][a-z0-9_]*$, 1–64 chars. Becomes the answer key in bookings.answers.
typeenumyesOne of the 11 types below.
labelstringyesCustomer-facing label. 1–200 chars.
requiredboolnofalseIf true, the renderer blocks Next until the field is filled.
placeholderstringnoGreyed-out hint inside the input. ≤ 200 chars.
help_textstringnoCaption shown below the input. ≤ 500 chars.
optionsFieldOption[]only for type=select1–200 entries. Each is { label, value }.
validationFieldValidationnoOptional { regex?, min_length?, max_length?, min?, max? } (see below).

extra="forbid" is set on every field — unknown keys are rejected.


Field types (11 total)

text

Single-line text input.

{ "id": "name", "type": "text", "label": "Full name", "required": true }

With validation:

{
"id": "vat_id",
"type": "text",
"label": "VAT number",
"validation": { "regex": "^[A-Z]{2}[0-9A-Z]+$", "min_length": 4, "max_length": 14 }
}

email

Single-line email input. The renderer adds an @ keyboard hint on mobile and basic format validation client-side; the server re-validates with Pydantic EmailStr.

{ "id": "email", "type": "email", "label": "Work email", "required": true }

phone

Phone number input with country-code dropdown. Server-side normalizes to E.164 (+4915155512345).

{ "id": "phone", "type": "phone", "label": "Phone number", "required": true }

tel

Plain phone-style input without normalization. Use when you need a non-mobile number (e.g. an extension).

{ "id": "extension", "type": "tel", "label": "Extension", "required": false }

textarea

Multi-line text input.

{ "id": "notes", "type": "textarea", "label": "Anything to add?", "required": false }

select

Dropdown / list. options is required for this type.

{
"id": "team_size",
"type": "select",
"label": "Team size",
"required": true,
"options": [
{ "label": "1–10", "value": "1-10" },
{ "label": "11–50", "value": "11-50" },
{ "label": "51–200", "value": "51-200" },
{ "label": "200+", "value": "200+" }
]
}

The seed templates ship options as a plain string array (["1","2","3","8+"]) — the renderer expands those to {label,value} automatically when it loads the template.

checkbox

A single boolean tickbox. Used for soft opt-ins.

{
"id": "marketing_opt_in",
"type": "checkbox",
"label": "Send me occasional offers and news from {{ business.name }}.",
"required": false
}

A boolean tickbox specifically for GDPR / CASL legal consent. The renderer styles it differently and surfaces it in audit exports.

{
"id": "consent",
"type": "consent",
"label": "I agree to receive booking-related messages from {{ business.name }}.",
"required": true
}

In practice the official seeds use type=checkbox for both — the dedicated consent type is reserved for jurisdictions that require explicit consent records.

number

Numeric input. Use validation.min / validation.max to bound it.

{
"id": "guests",
"type": "number",
"label": "How many guests?",
"required": true,
"validation": { "min": 1, "max": 50 }
}

date

Date picker (no time). ISO YYYY-MM-DD.

{ "id": "event_date", "type": "date", "label": "Event date", "required": true }

time

Time-of-day picker (no date). 24-hour HH:MM.

{ "id": "preferred_time", "type": "time", "label": "Preferred time", "required": false }

FieldOption

Used inside options for type=select.

KeyTypeRequiredNotes
labelstringyesWhat the customer sees. 1–120 chars.
valuestringyesWhat gets stored in bookings.answers. 1–120 chars.

FieldValidation

Optional rules. All keys are optional — combine what you need.

KeyTypeRangeApplies to
regexstring1–500 charstext, email, phone, tel, textarea
min_lengthint0–10 000string-like fields
max_lengthint1–10 000string-like fields
minnumbernumber
maxnumbernumber

Example:

{
"id": "company",
"type": "text",
"label": "Company",
"required": true,
"validation": { "min_length": 2, "max_length": 100 }
}

Liquid in labels and placeholders

Labels and placeholders pass through Liquid before render. You can reference any variable available to the booking block:

VariableWhere it comes from
business.nameThe IDAP business that owns this flow
business.citySame
flow.cal_event_type_idThe flow's pinned Cal.com event type
salesperson.nameOptional prefill from the parent dynamic-landing page
{
"id": "consent",
"type": "checkbox",
"label": "I agree to receive booking-related messages from {{ business.name }}.",
"required": true
}

The renderer escapes Liquid output before insertion — no XSS risk from business names.


Official seed templates

Three templates ship in app/database/migrations/seed/booking_templates/ and are loaded into booking_templates_global by migration 124_booking_templates_global.sql. Each is is_official=true.

Template nameCategoryStepsCalendar sourceNotes
nail-salon-defaultnail_salonservice → staff → slot → contact → confirmcal.comIncludes GDPR consent + optional marketing opt-in.
restaurant-defaultrestaurantparty → slot → contact → confirmidap://availabilitySlot picker filters by party.size.
sales-call-defaultsales_callqualify → slot → confirmcal.comQualifier collects company / team size / budget.

Agents discover them via booking_template_list({ category }) — see Agent guide.

nail-salon-default (excerpt)

{
"name": "nail-salon-default",
"category": "nail_salon",
"flow": {
"steps": [
{ "type": "select", "id": "service" },
{ "type": "select", "id": "staff" },
{ "type": "calendar", "id": "slot" },
{ "type": "form", "id": "contact" },
{ "type": "confirm", "id": "summary" }
]
},
"schema": {
"service": {
"id": "service",
"label": "What are you coming in for?",
"source": "idap://services",
"display": "card_grid",
"filter": { "active": true }
},
"staff": {
"id": "staff",
"label": "Choose your nail tech",
"source": "idap://staff",
"display": "avatar_list",
"depends_on": "service"
},
"slot": {
"id": "slot",
"label": "Pick a time",
"source": "cal.com",
"cal_event_type_id": "{{ flow.cal_event_type_id }}",
"depends_on": "staff",
"slot_duration_from": "service.duration_minutes"
},
"contact": { "/* … see Flow reference for full contact step …": null },
"summary": {
"id": "summary",
"label": "Confirm your appointment",
"show": ["service", "staff", "slot", "contact.name", "contact.phone"]
}
}
}

Full JSONs (and restaurant-default, sales-call-default) live in app/database/migrations/seed/booking_templates/.


Validation rules (server-side)

In addition to the per-field rules above, these flow-level rules apply:

RuleFailure mode
form.fields length 1–40Empty form steps are rejected.
Field id unique inside a single form stepDuplicate ids → 400.
Field type=select MUST include options (1–200 entries)Otherwise → 400.
validation.max_length >= validation.min_lengthLogical contradictions → 400.
validation.max >= validation.minSame.
extra="forbid" everywhereTypos in keys → 400.

Where to go next: Agent guide — drive all 15 MCP tools end-to-end.