Schema Reference
schema.json is a map keyed by step id. The structure of each value depends on the step type:
| Step type | Schema 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):
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
id | string | yes | — | ^[a-z][a-z0-9_]*$, 1–64 chars. Becomes the answer key in bookings.answers. |
type | enum | yes | — | One of the 11 types below. |
label | string | yes | — | Customer-facing label. 1–200 chars. |
required | bool | no | false | If true, the renderer blocks Next until the field is filled. |
placeholder | string | no | — | Greyed-out hint inside the input. ≤ 200 chars. |
help_text | string | no | — | Caption shown below the input. ≤ 500 chars. |
options | FieldOption[] | only for type=select | — | 1–200 entries. Each is { label, value }. |
validation | FieldValidation | no | — | Optional { 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
}
consent
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.
| Key | Type | Required | Notes |
|---|---|---|---|
label | string | yes | What the customer sees. 1–120 chars. |
value | string | yes | What gets stored in bookings.answers. 1–120 chars. |
FieldValidation
Optional rules. All keys are optional — combine what you need.
| Key | Type | Range | Applies to |
|---|---|---|---|
regex | string | 1–500 chars | text, email, phone, tel, textarea |
min_length | int | 0–10 000 | string-like fields |
max_length | int | 1–10 000 | string-like fields |
min | number | — | number |
max | number | — | number |
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:
| Variable | Where it comes from |
|---|---|
business.name | The IDAP business that owns this flow |
business.city | Same |
flow.cal_event_type_id | The flow's pinned Cal.com event type |
salesperson.name | Optional 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 name | Category | Steps | Calendar source | Notes |
|---|---|---|---|---|
nail-salon-default | nail_salon | service → staff → slot → contact → confirm | cal.com | Includes GDPR consent + optional marketing opt-in. |
restaurant-default | restaurant | party → slot → contact → confirm | idap://availability | Slot picker filters by party.size. |
sales-call-default | sales_call | qualify → slot → confirm | cal.com | Qualifier 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:
| Rule | Failure mode |
|---|---|
form.fields length 1–40 | Empty form steps are rejected. |
Field id unique inside a single form step | Duplicate ids → 400. |
Field type=select MUST include options (1–200 entries) | Otherwise → 400. |
validation.max_length >= validation.min_length | Logical contradictions → 400. |
validation.max >= validation.min | Same. |
extra="forbid" everywhere | Typos in keys → 400. |
Where to go next: Agent guide — drive all 15 MCP tools end-to-end.