# REST API

Base URL: `https://pagesystems.ai`. Every request needs `Authorization: Bearer <API key>`, except the two `/v1/page-types` endpoints, which are public. Bodies are JSON, at most 64 KB.

## Endpoints

| Method | Path | What it does | Response |
| --- | --- | --- | --- |
| GET | `/v1/page-types` | List the supported page types, each with a description and when to use it, plus general guidance. Public: no API key needed. | `{ guidance, pageTypes: [{ type, label, description, whenToUse, displayOnly }] }` |
| GET | `/v1/page-types/:type` | One page type: the JSON Schema of the spec to send, the answer's JSON Schema, a minimal example and its guidance. Public: no API key needed. | `{ type, label, description, displayOnly, specSchema, resultSchema, example, guidance }`; `404 not_found` for an unknown or unsupported type. `specSchema` and `example` are the `create_page` tool input: for `POST /v1/pages` send `{ "spec": { "version": 1, ...example } }`. The JSON Schema describes the structure; the server also checks cross-field rules (for example, checkout totals that add up, or https-only links), and `invalid_spec` names any that fail. |
| GET | `/v1/skill` | The Page Systems skill as markdown (SKILL.md): when to send a page instead of asking in chat, and how to use pages well. Add it to your agent's system prompt when the agent starts. Sends an `ETag`; revalidate with `If-None-Match`. Public: no API key needed. | `text/markdown`; `304` when `If-None-Match` matches. |
| POST | `/v1/pages` | Create a page. Body `{ spec, callbackUrl?, agentRef? }`. Send an `Idempotency-Key` header to make retries safe: the same key within 24 hours returns the page it created, without creating another or checking that the body matches. The replay always reports `status: "open"`; read `GET /v1/pages/:id` for the current status. | 201 `{ id, url, status: "open", expiresAt }`, with a `Page-Systems-Usage: <used>/<included>` header for this month (not sent when an `Idempotency-Key` replays an existing page). |
| GET | `/v1/pages/:id` | Read a page's status. | `{ id, url, status, pageType, createdAt, expiresAt, submittedAt?, newPageRequestedAt? }` |
| GET | `/v1/pages/:id/result` | Read the result without consuming it (for polling). From expiry on the content and answer are gone, whether or not they have been deleted yet: `title` is null and there is no `result`. (An answer accepted just before expiry stays readable until its callback has been attempted once.) | `{ status, title, agentRef, result?, submittedAt?, claimedAt?, newPageRequestedAt?, newPageRequestClaimedAt? }` |
| POST | `/v1/pages/:id/claim` | Take the result exactly once. Use it when several workers might process the same callback. On an unanswered page whose link sent `page.new_page_requested`, it claims that request instead, so each request is handled once. | `{ title, result, submittedAt, agentRef }` the first time (for a request: `{ newPageRequestedAt, agentRef }`); `409 already_claimed` after that, `409 not_submitted` before an answer, `410 deleted` once the answer is gone at expiry. |
| DELETE | `/v1/pages/:id` | Cancel an open page. Cancelling twice is fine. | The page, as in `GET /v1/pages/:id`. |

`displayOnly` in the page type list is `"never"` (the person always answers), `"depends"` (display-only in some modes, such as when `readOnly` is `true`; see the page type's doc) or `"always"` (display-only: no answer to wait for).

## Page fields

The `spec` you send to `POST /v1/pages`. `page` holds the page type and its own fields; see [Page types](/docs/pages).

| Field | Description |
| --- | --- |
| `version` | *1, required.* Spec version. Always 1. |
| `title` | *string, required.* Page heading, e.g. "Pick your flight to Lisbon". Limits: 1-120 characters. |
| `description` | *string.* One or two sentences of context under the title. Limits: 1-500 characters. |
| `branding` | *object.* Optional tenant branding. |
| `branding.name` | *string.* Business or agent name shown in the header. Limits: 1-60 characters. |
| `branding.logoUrl` | *https URL.* https URL of a square logo. A monogram is shown if it fails to load. |
| `branding.accentColor` | *string.* Brand color as "#rrggbb", used for buttons and selection. Text on it is chosen for contrast. Limits: matches `^#[0-9a-fA-F]{6}$`. |
| `cover` | *object.* An optional photo at the top of the page, e.g. the restaurant for a dinner choice or the hotel for a stay. Leave it out if you don't have a real image; never use it for text. When the page type has its own picture (an itinerary cover) that one is shown instead; an itinerary uses this photo as its cover when its own is unset. |
| `cover.url` | *https URL, required.* https URL of a landscape JPEG or PNG photo (WebP shows on the page but not in link previews), e.g. "https://example.com/rooftop.jpg". |
| `cover.alt` | *string, required.* What the photo shows, read to screen readers, e.g. "Long table set for dinner on a rooftop at dusk". Plain text. Limits: 1-200 characters, matches `^[^<>]*$`. |
| `cover.focalPoint` | *object.* Optional point that stays in view when the photo is cropped, e.g. { "x": 0.5, "y": 0.3 }. Omit to keep the center. |
| `cover.focalPoint.x` | *number, required.* 0 is the left edge, 1 the right edge. Limits: 0-1. |
| `cover.focalPoint.y` | *number, required.* 0 is the top edge, 1 the bottom edge. Limits: 0-1. |
| `expiresAt` | *ISO 8601 timestamp.* ISO 8601 time after which the page stops accepting answers. |
| `returnLink` | *object.* Optional link back to the conversation, shown on the confirmation after the answer is accepted. Navigation only: the answer is already delivered, the page never opens it automatically, and the person can always return on their own. |
| `returnLink.url` | *string, required.* https URL of your chat, or "sms:+14155550132" to open the messaging app addressed to your number. No other schemes. Limits: at most 2000 characters. |
| `returnLink.label` | *string.* Button text, e.g. "Back to the chat". Defaults to "Return to your agent". Limits: 1-40 characters, matches `^[^<>]*$`. |
| `page (variant 1).type` | *"poll", required.* |
| `page (variant 1).question` | *string.* Optional question shown above the choices. Omit when the page title already asks it. Limits: 1-200 characters. |
| `page (variant 1).options` | *object[], required.* The choices, in display order (2-30). Limits: 2-30 items. |
| `page (variant 1).options[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 1).options[].label` | *string, required.* Short choice text shown on the card. Required. Limits: 1-120 characters. |
| `page (variant 1).options[].description` | *string.* Optional secondary line under the label. Include when it helps the person choose. Limits: 1-280 characters. |
| `page (variant 1).options[].imageUrl` | *https URL.* Optional https image shown as a square thumbnail. |
| `page (variant 1).multiple` | *boolean.* true lets the person pick several choices (checkboxes). Omit or false for exactly one. |
| `page (variant 1).minSelections` | *integer.* Only when multiple is true: the fewest choices required. Defaults to 1. Limits: at least 1. |
| `page (variant 1).maxSelections` | *integer.* Only when multiple is true: the most choices allowed. Defaults to all of them. Limits: at least 1. |
| `page (variant 1).allowOther` | *boolean.* true adds an "Other" choice with a free-text field. It counts as one selection. |
| `page (variant 2).type` | *"flight_picker", required.* |
| `page (variant 2).options` | *object[], required.* Flight options in display order (1-30). Limits: 1-30 items. |
| `page (variant 2).options[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 2).options[].airline` | *string, required.* Marketing airline name, e.g. "Skyward Air". Required. Limits: 1-60 characters. |
| `page (variant 2).options[].flightNumbers` | *string[].* Optional flight number for each segment, in order, e.g. ["SA 123", "SA 456"]. Include when known. Limits: 1-6 items. |
| `page (variant 2).options[].depart` | *object, required.* Departure airport and time. airport and time are required. |
| `page (variant 2).options[].depart.airport` | *string, required.* IATA airport code, e.g. "SFO". Limits: matches `^[A-Z]{3}$`. |
| `page (variant 2).options[].depart.city` | *string.* Optional city name shown next to the code. Include when known; omit otherwise. Limits: 1-60 characters. |
| `page (variant 2).options[].depart.time` | *ISO 8601 timestamp, required.* Scheduled time as ISO 8601 with UTC offset, e.g. "2026-11-03T08:15:00-08:00". |
| `page (variant 2).options[].depart.timeZone` | *string.* Optional IANA time zone of the airport, e.g. "America/Los_Angeles". Include when known; omit otherwise and the time is shown at the UTC offset written in time. |
| `page (variant 2).options[].arrive` | *object, required.* Final arrival airport and time. airport and time are required. |
| `page (variant 2).options[].arrive.airport` | *string, required.* IATA airport code, e.g. "SFO". Limits: matches `^[A-Z]{3}$`. |
| `page (variant 2).options[].arrive.city` | *string.* Optional city name shown next to the code. Include when known; omit otherwise. Limits: 1-60 characters. |
| `page (variant 2).options[].arrive.time` | *ISO 8601 timestamp, required.* Scheduled time as ISO 8601 with UTC offset, e.g. "2026-11-03T08:15:00-08:00". |
| `page (variant 2).options[].arrive.timeZone` | *string.* Optional IANA time zone of the airport, e.g. "America/Los_Angeles". Include when known; omit otherwise and the time is shown at the UTC offset written in time. |
| `page (variant 2).options[].stops` | *object[].* Optional connection airports in order. Use [] for a nonstop flight; omit when you do not know the stops. Limits: at most 4 items. |
| `page (variant 2).options[].stops[].airport` | *string, required.* IATA airport code, e.g. "SFO". Limits: matches `^[A-Z]{3}$`. |
| `page (variant 2).options[].stops[].city` | *string.* Optional city name. Include when known; omit otherwise. Limits: 1-60 characters. |
| `page (variant 2).options[].stops[].layoverMinutes` | *integer, required.* Time on the ground at this stop, in minutes. Limits: 1-2880. |
| `page (variant 2).options[].price` | *object, required.* Total price for the itinerary, including taxes. Required. |
| `page (variant 2).options[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 2).options[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 2).options[].cabin` | *"economy" \| "premium_economy" \| "business" \| "first".* Optional cabin class. Include when known; omit otherwise. |
| `page (variant 2).options[].badges` | *string[].* Optional short highlights such as "Cheapest" or "Fastest" (up to 3). Limits: at most 3 items. |
| `page (variant 2).options[].notes` | *string.* Optional short note, e.g. "Bags not included". Limits: 1-200 characters. |
| `page (variant 2).options[].recommended` | *boolean.* Optional. Mark at most one option you recommend, when you have a reason: true shows a "Recommended" label on it. Omit on the others. |
| `page (variant 2).options[].recommendedReason` | *string.* Optional short reason shown with the label, e.g. "Nonstop, and only $30 more". Only with recommended: true. Limits: 1-80 characters. |
| `page (variant 3).type` | *"date_range", required.* |
| `page (variant 3).mode` | *"single" \| "range", required.* "single" picks one date; "range" picks a start and an end date. |
| `page (variant 3).minDate` | *string.* Earliest selectable date. Days before it are disabled. Defaults to no limit. |
| `page (variant 3).maxDate` | *string.* Latest selectable date. Days after it are disabled. Defaults to no limit. |
| `page (variant 3).disabledDates` | *string[].* Dates that cannot be chosen, e.g. days with no availability. A range cannot span a disabled date. Limits: at most 400 items. |
| `page (variant 3).minNights` | *integer.* Range mode only: fewest nights (endDate minus startDate). Defaults to 1. Limits: 1-365. |
| `page (variant 3).maxNights` | *integer.* Range mode only: most nights. Defaults to no limit. Limits: 1-365. |
| `page (variant 3).stay` | *boolean.* Range mode only. Omit or true for a stay (check-in to check-out): the page counts and shows nights. false for any other period (a trip, time off, a deadline window): no night count, and minNights/maxNights read as days between the dates. |
| `page (variant 3).dayNotes` | *object[].* Optional small note per date, usually a price, e.g. { "date": "2026-11-03", "note": "$129" }. Limits: at most 400 items. |
| `page (variant 3).dayNotes[].date` | *string, required.* Calendar date as "YYYY-MM-DD", e.g. "2026-11-03". No time or time zone. |
| `page (variant 3).dayNotes[].note` | *string, required.* Very short text under the day number, e.g. "$129". Max 8 characters. Limits: 1-8 characters. |
| `page (variant 3).defaultValue` | *object.* Optional preselected answer. Use { date } in single mode or { startDate, endDate } in range mode. |
| `page (variant 3).defaultValue (variant 1).date` | *string, required.* The chosen date, "YYYY-MM-DD". |
| `page (variant 3).defaultValue (variant 2).startDate` | *string, required.* First day of the range (check-in), "YYYY-MM-DD". |
| `page (variant 3).defaultValue (variant 2).endDate` | *string, required.* Last day of the range (check-out), "YYYY-MM-DD". Nights = endDate minus startDate. |
| `page (variant 4).type` | *"time_slots", required.* |
| `page (variant 4).timeZone` | *string.* Optional IANA time zone the times are shown in, e.g. "America/Los_Angeles". It is displayed on the page. Include when known; omit otherwise and each time is shown at the UTC offset written in it. |
| `page (variant 4).days` | *object[], required.* Days with their slots, in display order (1-14 days). Limits: 1-14 items. |
| `page (variant 4).days[].date` | *string, required.* The day these slots fall on, "YYYY-MM-DD", in timeZone (or in the slot start's own UTC offset when timeZone is omitted). Days are shown in the order given. |
| `page (variant 4).days[].slots` | *object[], required.* Slots on this day, earliest first (1-48). Limits: 1-48 items. |
| `page (variant 4).days[].slots[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 4).days[].slots[].start` | *ISO 8601 timestamp, required.* Start time as ISO 8601 with UTC offset, e.g. "2026-11-03T19:30:00-08:00". |
| `page (variant 4).days[].slots[].end` | *ISO 8601 timestamp.* Optional end time as ISO 8601 with UTC offset, e.g. "2026-11-03T19:30:00-08:00". |
| `page (variant 4).days[].slots[].label` | *string.* Short extra text on the chip, e.g. "Bar seating" or "Dr. Rivera". Limits: 1-60 characters. |
| `page (variant 4).days[].slots[].price` | *object.* Optional price for this slot. Include only when slots cost different amounts. |
| `page (variant 4).days[].slots[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 4).days[].slots[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 4).days[].slots[].available` | *boolean.* false shows the slot as disabled (taken). Omit for an open slot. Keep taken slots so the person sees them. |
| `page (variant 4).multiple` | *boolean.* true lets the person pick several slots. Omit or false for exactly one. |
| `page (variant 4).minSelections` | *integer.* Only when multiple is true: the fewest slots required. Defaults to 1. Limits: at least 1. |
| `page (variant 4).maxSelections` | *integer.* Only when multiple is true: the most slots allowed. Defaults to all available. Limits: at least 1. |
| `page (variant 5).type` | *"restaurant_picker", required.* |
| `page (variant 5).partySize` | *integer.* Optional party size the listed times are for. Shown above the options. Limits: 1-20. |
| `page (variant 5).options` | *object[], required.* Restaurants in display order (1-30). Limits: 1-30 items. |
| `page (variant 5).options[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 5).options[].name` | *string, required.* Restaurant name, e.g. "Lotus Thai Kitchen". Required. Limits: 1-80 characters. |
| `page (variant 5).options[].cuisine` | *string.* Optional cuisine or style, e.g. "Thai". Include when known. Limits: 1-40 characters. |
| `page (variant 5).options[].priceLevel` | *integer.* Optional price level from 1 ($) to 4 ($$$$). Include when known. Limits: 1-4. |
| `page (variant 5).options[].rating` | *number.* Optional average rating from 0 to 5, e.g. 4.6. Limits: 0-5. |
| `page (variant 5).options[].reviewCount` | *integer.* Optional number of reviews behind the rating. Needs rating. Limits: 0-10000000. |
| `page (variant 5).options[].distance` | *object.* Optional distance from the person or a reference point. |
| `page (variant 5).options[].distance.value` | *number, required.* Distance from the person, e.g. 0.4. Limits: at most 1000. |
| `page (variant 5).options[].distance.unit` | *"mi" \| "km", required.* "mi" or "km". |
| `page (variant 5).options[].address` | *string.* Optional neighborhood or street address, e.g. "Mission District" or "412 Valencia St". Include when known. Limits: 1-120 characters. |
| `page (variant 5).options[].photoUrl` | *https URL.* Optional https URL of one photo. A blank box is shown if it fails to load. Omit if you have none. |
| `page (variant 5).options[].timeZone` | *string.* Optional IANA time zone of the restaurant; times are shown in this zone. Omit and each time is shown at the UTC offset written in it. |
| `page (variant 5).options[].times` | *ISO 8601 timestamp[].* Optional table times you found for the party size, earliest first (1-12). When given, the person must pick one. Page Systems does not hold them. Omit if you have no availability data. Limits: 1-12 items. |
| `page (variant 5).options[].recommended` | *boolean.* Optional. Mark at most one option you recommend, when you have a reason: true shows a "Recommended" label on it. Omit on the others. |
| `page (variant 5).options[].recommendedReason` | *string.* Optional short reason shown with the label, e.g. "Nonstop, and only $30 more". Only with recommended: true. Limits: 1-80 characters. |
| `page (variant 6).type` | *"hotel_picker", required.* |
| `page (variant 6).checkIn` | *date (YYYY-MM-DD).* Optional check-in date, "YYYY-MM-DD". Same for every option. Include when known. |
| `page (variant 6).checkOut` | *date (YYYY-MM-DD).* Optional check-out date, "YYYY-MM-DD", after checkIn. Include when known. |
| `page (variant 6).options` | *object[], required.* Hotels in display order (1-30). Limits: 1-30 items. |
| `page (variant 6).options[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 6).options[].name` | *string, required.* Hotel name, e.g. "Harbor Light Hotel". Required. Limits: 1-80 characters. |
| `page (variant 6).options[].photos` | *https URL[].* Optional, up to 5 https photo URLs. The person swipes through them; ones that fail to load show a blank box. Omit if you have none. Limits: at most 5 items. |
| `page (variant 6).options[].stars` | *integer.* Optional official star class, 1-5. Include when known. Limits: 1-5. |
| `page (variant 6).options[].guestRating` | *number.* Optional guest rating on a 0-10 scale, e.g. 8.7. Limits: 0-10. |
| `page (variant 6).options[].neighborhood` | *string.* Optional neighborhood or area, e.g. "Old Town". Include when known. Limits: 1-80 characters. |
| `page (variant 6).options[].nightlyPrice` | *object, required.* Price per night, including taxes if you know them. Required. |
| `page (variant 6).options[].nightlyPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 6).options[].nightlyPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 6).options[].totalPrice` | *object.* Optional price for the whole stay, in the same currency as nightlyPrice. Include when known. |
| `page (variant 6).options[].totalPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 6).options[].totalPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 6).options[].amenities` | *string[].* Optional, up to 8 short amenities, e.g. ["Free Wi-Fi", "Pool"]. Limits: at most 8 items. |
| `page (variant 6).options[].cancellationPolicy` | *string.* Optional cancellation policy in one sentence, e.g. "Free cancellation until Nov 1." Include when known. Limits: 1-200 characters. |
| `page (variant 6).options[].refundable` | *boolean.* Optional: true if the rate can be cancelled for a refund, false if not. Omit when you do not know. |
| `page (variant 6).options[].badges` | *string[].* Optional short highlights such as "Best value" (up to 3). Limits: at most 3 items. |
| `page (variant 6).options[].recommended` | *boolean.* Optional. Mark at most one option you recommend, when you have a reason: true shows a "Recommended" label on it. Omit on the others. |
| `page (variant 6).options[].recommendedReason` | *string.* Optional short reason shown with the label, e.g. "Nonstop, and only $30 more". Only with recommended: true. Limits: 1-80 characters. |
| `page (variant 7).type` | *"class_booking", required.* |
| `page (variant 7).groupByDay` | *boolean.* true groups sessions under a heading for each day. Use it for a week or more of sessions. |
| `page (variant 7).sessions` | *object[], required.* Sessions in display order, earliest first (1-60). Limits: 1-60 items. |
| `page (variant 7).sessions[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 7).sessions[].className` | *string, required.* Class name, e.g. "Sunrise Vinyasa". Required. Limits: 1-80 characters. |
| `page (variant 7).sessions[].instructor` | *string.* Optional instructor name. Include when known. Limits: 1-60 characters. |
| `page (variant 7).sessions[].start` | *ISO 8601 timestamp, required.* Start time as ISO 8601 with UTC offset, e.g. "2026-11-04T07:00:00-08:00". |
| `page (variant 7).sessions[].timeZone` | *string.* Optional IANA time zone of the studio; times and days are shown in this zone. Omit and the UTC offset written in start is used. |
| `page (variant 7).sessions[].durationMinutes` | *integer.* Optional length of the class in minutes. Include when known. Limits: 5-480. |
| `page (variant 7).sessions[].location` | *string.* Optional studio or room, e.g. "Mission Studio, Room 2". Limits: 1-80 characters. |
| `page (variant 7).sessions[].level` | *"all_levels" \| "beginner" \| "intermediate" \| "advanced".* Optional difficulty level. |
| `page (variant 7).sessions[].spotsLeft` | *integer.* Optional open spots. 0 means the class is full and cannot be chosen. Omit when you do not track spots; the session is then open. Limits: 0-500. |
| `page (variant 7).sessions[].price` | *object.* Optional price per person. Use amount 0 for free; omit to show no price. |
| `page (variant 7).sessions[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 7).sessions[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 7).sessions[].photo` | *object.* Optional photo shown as a square thumbnail on the session card. Include only a real photo of the class, studio or instructor. |
| `page (variant 7).sessions[].photo.url` | *https URL, required.* https URL of a photo of the class or studio, e.g. "https://example.com/vinyasa.jpg". |
| `page (variant 7).sessions[].photo.alt` | *string.* Optional description of the photo, e.g. "Studio with mats in rows". Omit and the class name is used. Limits: 1-120 characters. |
| `page (variant 8).type` | *"rsvp", required.* |
| `page (variant 8).event` | *object, required.* The event the person is replying to. name and start are required. |
| `page (variant 8).event.name` | *string, required.* Event name, e.g. "Maya's 30th birthday". Limits: 1-120 characters. |
| `page (variant 8).event.start` | *ISO 8601 timestamp, required.* Start time as ISO 8601 with UTC offset, e.g. "2026-11-14T18:00:00-08:00". |
| `page (variant 8).event.timeZone` | *string.* Optional IANA time zone of the event, e.g. "America/Los_Angeles"; times are shown in this zone. Include when known; omit otherwise and the UTC offset written in start is used. |
| `page (variant 8).event.end` | *ISO 8601 timestamp.* Optional end time, same format as start. Must be after start. |
| `page (variant 8).event.location` | *string.* Optional place, as plain text, e.g. "Ember & Stone Pizza, 12 Alder St". Include when known. Limits: 1-200 characters. |
| `page (variant 8).event.host` | *string.* Optional host name, shown as "Hosted by ...". Limits: 1-80 characters. |
| `page (variant 8).event.note` | *string.* Optional extra details for guests, e.g. "Parking is behind the building." Limits: 1-500 characters. |
| `page (variant 8).allowMaybe` | *boolean.* false hides the "Maybe" choice. Omit or true to offer Yes, No and Maybe. |
| `page (variant 8).guests` | *object.* Include to ask "How many guests are you bringing?" (shown only after Yes). Omit to not ask. |
| `page (variant 8).guests.max` | *integer, required.* Most additional guests one person may bring (1-20). Limits: 1-20. |
| `page (variant 8).name` | *"required" \| "optional".* Include to ask for the respondent's name. "required" blocks submit until filled. Omit when you already know who is replying. |
| `page (variant 8).dietaryNote` | *boolean.* true adds a free-text "Dietary needs or notes" field for Yes and Maybe replies. |
| `page (variant 9).type` | *"checklist", required.* |
| `page (variant 9).prompt` | *string.* Instruction shown above the list, e.g. "Check what you have packed". Defaults to a generic line. Limits: 1-200 characters. |
| `page (variant 9).items` | *object[], required.* The items, in display order (1-60). Limits: 1-60 items. |
| `page (variant 9).items[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 9).items[].label` | *string, required.* The item, e.g. "Passport". Limits: 1-160 characters. |
| `page (variant 9).items[].description` | *string.* Optional secondary line under the label. Limits: 1-280 characters. |
| `page (variant 9).items[].preChecked` | *boolean.* true starts the item checked. Use for sensible defaults the person can untick. |
| `page (variant 9).items[].sectionId` | *string.* id of the section this item belongs to. Required on every item when sections is given. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 9).sections` | *object[].* Optional headings that group the items (1-12). If given, every item needs a sectionId. Limits: 1-12 items. |
| `page (variant 9).sections[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 9).sections[].title` | *string, required.* Section heading, e.g. "Documents". Limits: 1-80 characters. |
| `page (variant 9).requireAll` | *boolean.* true means submit stays disabled until every item is checked. Use for confirmations. Omit for "which of these apply". |
| `page (variant 10).type` | *"form", required.* |
| `page (variant 10).fields` | *object[], required.* The fields, in display order (1-12). For more, use several pages. Limits: 1-12 items. |
| `page (variant 10).fields[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 10).fields[].label` | *string, required.* Question or field name shown above the input, e.g. "Delivery address". Limits: 1-120 characters. |
| `page (variant 10).fields[].kind` | *"short_text" \| "long_text" \| "number" \| "email" \| "phone" \| "select" \| "toggle", required.* short_text: one line. long_text: several lines. number: numeric keypad. email, phone: matching keyboards. select: dropdown, needs options. toggle: a yes/no checkbox; with required true it must be ticked (e.g. "I agree"). |
| `page (variant 10).fields[].required` | *boolean.* true blocks submit until the field is filled (for a toggle: until it is ticked). Omit or false for an optional field. |
| `page (variant 10).fields[].placeholder` | *string.* Example text shown inside an empty input. Ignored for toggle and select. Limits: 1-120 characters. |
| `page (variant 10).fields[].helpText` | *string.* Optional hint shown under the field. Limits: 1-280 characters. |
| `page (variant 10).fields[].min` | *number.* number only: the smallest allowed value. |
| `page (variant 10).fields[].max` | *number.* number only: the largest allowed value. |
| `page (variant 10).fields[].maxLength` | *integer.* short_text and long_text only: most characters allowed. Defaults to 200 (short_text) or 2000 (long_text). Limits: 1-5000. |
| `page (variant 10).fields[].options` | *object[].* select only: 2-30 choices. The chosen option id is returned as the value. Limits: 2-30 items. |
| `page (variant 10).fields[].options[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 10).fields[].options[].label` | *string, required.* Option text. Limits: 1-120 characters. |
| `page (variant 10).fields[].autocomplete` | *"name" \| "given-name" \| "family-name" \| "organization" \| "street-address" \| "postal-code".* short_text only: browser autofill hint, e.g. "name" or "postal-code". email and phone are handled automatically. |
| `page (variant 11).type` | *"event", required.* |
| `page (variant 11).title` | *string, required.* Event name, shown large, e.g. "Rooftop supper club". Required. Limits: 1-120 characters. |
| `page (variant 11).cover` | *https URL.* Optional https URL of a landscape cover photo. Decorative. Omit to show a plain panel. |
| `page (variant 11).host` | *object.* Optional host. Include when known; name is required inside it. |
| `page (variant 11).host.name` | *string, required.* Host name, shown as "Hosted by ...", e.g. "Maya Okafor". Limits: 1-80 characters. |
| `page (variant 11).host.avatarUrl` | *https URL.* Optional https URL of a square host photo. Initials are shown if omitted. |
| `page (variant 11).start` | *ISO 8601 timestamp, required.* Start time as ISO 8601 with UTC offset, e.g. "2026-11-14T19:00:00-08:00". Required. |
| `page (variant 11).end` | *ISO 8601 timestamp.* Optional end time, same format as start. Must be after start. |
| `page (variant 11).timeZone` | *string.* Optional IANA time zone of the event, e.g. "America/Los_Angeles"; times are shown in this zone. Include when known; omit otherwise and the UTC offset written in start is used. |
| `page (variant 11).venue` | *object.* Optional place. Include when known; name is required inside it. Omit for an online or to-be-announced event. |
| `page (variant 11).venue.name` | *string, required.* Venue name, e.g. "The Lantern Room". Limits: 1-120 characters. |
| `page (variant 11).venue.address` | *string.* Optional street address, e.g. "220 Pine St, Portland". Limits: 1-200 characters. |
| `page (variant 11).venue.mapsUrl` | *https URL.* Optional https map link. Omit to link a map search for the name and address. |
| `page (variant 11).description` | *string.* Optional plain-text "About" section. Separate paragraphs with a blank line. No HTML or Markdown; it is shown as typed. Limits: 1-4000 characters. |
| `page (variant 11).price` | *object.* Optional ticket price per person. Use amount 0 to show "Free". Omit to show no price. |
| `page (variant 11).price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 11).price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 11).goingCount` | *integer.* Optional number of people already going, shown as "12 going". Limits: 0-100000. |
| `page (variant 11).spotsLeft` | *integer.* Optional number of places left. 0 is only valid with status "full". Limits: 0-100000. |
| `page (variant 11).status` | *"scheduled" \| "cancelled" \| "full" \| "ended".* Optional; omit for "scheduled". "scheduled" accepts replies. "full" accepts only "maybe" (if offered) and "not_going". "cancelled" and "ended" show the event but accept no reply. |
| `page (variant 11).approvalRequired` | *boolean.* true tells the person the host approves each guest, and a "going" reply becomes a request to join. |
| `page (variant 11).allowMaybe` | *boolean.* false hides the "Maybe" choice. Omit or true to offer it. |
| `page (variant 11).guests` | *object.* Include to ask how many extra guests they bring (shown only after "Going"). Omit to not ask. |
| `page (variant 11).guests.max` | *integer, required.* Most additional guests one person may bring (1-20). Limits: 1-20. |
| `page (variant 11).note` | *boolean.* true adds an optional free-text "Note for the host" field. |
| `page (variant 12).type` | *"host_profile", required.* |
| `page (variant 12).host` | *object, required.* The host whose profile this is. |
| `page (variant 12).host.name` | *string, required.* Host name, e.g. "Maya Okafor". Limits: 1-60 characters. |
| `page (variant 12).host.avatarUrl` | *https URL.* Optional https URL of a square photo. Initials are shown if omitted. |
| `page (variant 12).host.bio` | *string.* Optional short plain-text bio, one or two sentences. Limits: 1-300 characters. |
| `page (variant 12).host.location` | *string.* Optional home base, e.g. "Portland, OR". Limits: 1-80 characters. |
| `page (variant 12).host.links` | *object[].* Optional links (up to 5). Limits: at most 5 items. |
| `page (variant 12).host.links[].label` | *string, required.* Link text, e.g. "Instagram". Limits: 1-40 characters. |
| `page (variant 12).host.links[].url` | *https URL, required.* https URL the link opens in a new tab. |
| `page (variant 12).host.stats` | *string[].* Optional short facts (up to 4), e.g. ["12 events hosted", "Hosting since 2023"]. Limits: at most 4 items. |
| `page (variant 12).events` | *object[].* Optional upcoming events (1-8), soonest first; id, title and start are required on each. The person may pick one to RSVP to. Omit when the host has none; the person can still follow the host. Limits: 1-8 items. |
| `page (variant 12).events[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 12).events[].title` | *string, required.* Event name, e.g. "Rooftop supper club". Limits: 1-120 characters. |
| `page (variant 12).events[].cover` | *https URL.* Optional https URL of a cover photo, shown as a thumbnail. Decorative. |
| `page (variant 12).events[].start` | *ISO 8601 timestamp, required.* Start time as ISO 8601 with UTC offset, e.g. "2026-11-14T19:00:00-08:00". |
| `page (variant 12).events[].timeZone` | *string.* Optional IANA time zone of the event, e.g. "America/Los_Angeles". Omit and the UTC offset written in start is used. |
| `page (variant 12).events[].venue` | *string.* Optional venue name, e.g. "The Lantern Room". Include when known. Limits: 1-120 characters. |
| `page (variant 12).events[].spotsLeft` | *integer.* Optional places left. 0 shows the event as full and it cannot be picked. Limits: 0-100000. |
| `page (variant 12).events[].price` | *object.* Optional price per person. Amount 0 shows "Free". Omit to show no price. |
| `page (variant 12).events[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 12).events[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 13).type` | *"connect_account", required.* |
| `page (variant 13).provider` | *"google" \| "apple" \| "microsoft" \| "github" \| "slack" \| "other", required.* Who the account belongs to. Use "other", with providerLabel, for any other service. |
| `page (variant 13).providerLabel` | *string.* Optional product name the person will recognize, e.g. "Gmail", "Google Calendar", "Outlook". Omit to use the provider name ("Google", "Apple", ...). Required when provider is "other". Limits: 1-40 characters. |
| `page (variant 13).purpose` | *string, required.* Required. One plain sentence on why you need the connection, e.g. "So I can draft replies to your landlord's emails." Limits: 1-200 characters. |
| `page (variant 13).permissions` | *object[], required.* Required. Each permission you will ask for when they sign in (1-6), matching the scopes you will request. label is required. Limits: 1-6 items. |
| `page (variant 13).permissions[].label` | *string, required.* What access is granted, in plain words, e.g. "Read your email". Limits: 1-80 characters. |
| `page (variant 13).permissions[].reason` | *string.* Optional reason you need it, e.g. "To find the messages from your landlord." Include when purpose does not already explain it. Limits: 1-200 characters. |
| `page (variant 13).permissions[].access` | *"read" \| "write".* Optional: "read" if it only looks, "write" if it can create or change things. Shown to the person as text. |
| `page (variant 13).wontDo` | *string[].* 0-4 honest limits you will keep, e.g. "Won't send email without asking you". Only promise what you enforce. Limits: at most 4 items. |
| `page (variant 13).afterConnect` | *string.* Optional: what you will do if they say yes, e.g. "I'll send you a Google sign-in link in our chat, then show you each draft first." Limits: 1-300 characters. |
| `page (variant 13).privacyUrl` | *https URL.* Optional https link to your privacy policy. |
| `page (variant 13).dataRetention` | *string.* Optional plain statement of what you keep and for how long, e.g. "Drafts are deleted after 30 days." Limits: 1-300 characters. |
| `page (variant 13).connectLabel` | *string.* Label for the "yes" choice. Defaults to "Yes, connect <account>". Limits: 1-40 characters. |
| `page (variant 13).declineLabel` | *string.* Label for the "no" choice. Defaults to "Not now". Limits: 1-30 characters. |
| `page (variant 14).type` | *"weather", required.* |
| `page (variant 14).location` | *string, required.* Place name shown as the heading, e.g. "Lisbon, Portugal". Required. Limits: 1-80 characters. |
| `page (variant 14).unit` | *"C" \| "F", required.* Unit of every temperature in this spec: "C" (Celsius) or "F" (Fahrenheit). Required. |
| `page (variant 14).timeZone` | *string.* IANA time zone of the location, e.g. "Europe/Lisbon". Optional, but required when you send sunrise, sunset or hourly; times are shown in this zone. |
| `page (variant 14).current` | *object, required.* Conditions now. Only condition and temperature are required; anything left out is hidden. |
| `page (variant 14).current.condition` | *"clear" \| "partly_cloudy" \| "cloudy" \| "rain" \| "drizzle" \| "thunderstorm" \| "snow" \| "sleet" \| "fog" \| "wind" \| "hail", required.* Current sky condition. Required. |
| `page (variant 14).current.temperature` | *number, required.* Current temperature in the page unit. Required. Limits: -130-140. |
| `page (variant 14).current.feelsLike` | *number.* Optional. Apparent ("feels like") temperature. Limits: -130-140. |
| `page (variant 14).current.high` | *number.* Optional. Today's high. Limits: -130-140. |
| `page (variant 14).current.low` | *number.* Optional. Today's low. Limits: -130-140. |
| `page (variant 14).current.humidity` | *number.* Optional. Relative humidity in percent, 0-100. Limits: 0-100. |
| `page (variant 14).current.wind` | *object.* Optional. Current wind. |
| `page (variant 14).current.wind.speed` | *number, required.* Sustained wind speed. Limits: 0-500. |
| `page (variant 14).current.wind.unit` | *"km/h" \| "mph" \| "m/s" \| "kn", required.* Unit of speed. |
| `page (variant 14).current.wind.direction` | *number.* Optional. Direction the wind blows FROM, in degrees: 0 = north, 90 = east. Limits: 0-360. |
| `page (variant 14).current.precipitationChance` | *number.* Optional. Chance of precipitation today in percent, 0-100. Limits: 0-100. |
| `page (variant 14).current.uvIndex` | *number.* Optional. UV index, usually 0-11. Limits: 0-20. |
| `page (variant 14).current.sunrise` | *ISO 8601 timestamp.* Optional. Today's sunrise, ISO 8601 with offset. Needs timeZone. |
| `page (variant 14).current.sunset` | *ISO 8601 timestamp.* Optional. Today's sunset, ISO 8601 with offset. Needs timeZone. |
| `page (variant 14).hourly` | *object[].* Optional hourly forecast, up to 24 entries in time order. Needs timeZone. Limits: 1-24 items. |
| `page (variant 14).hourly[].time` | *ISO 8601 timestamp, required.* Start of the hour, ISO 8601 with offset. |
| `page (variant 14).hourly[].condition` | *"clear" \| "partly_cloudy" \| "cloudy" \| "rain" \| "drizzle" \| "thunderstorm" \| "snow" \| "sleet" \| "fog" \| "wind" \| "hail", required.* Sky condition. Day and night icons are chosen automatically from the time and sunrise/sunset. |
| `page (variant 14).hourly[].temperature` | *number, required.* Temperature in the page unit, e.g. 18 or 64.5. Limits: -130-140. |
| `page (variant 14).hourly[].precipitationChance` | *number.* Optional. Chance of precipitation in percent. Limits: 0-100. |
| `page (variant 14).daily` | *object[].* Optional daily forecast, up to 10 days in date order, starting today. Limits: 1-10 items. |
| `page (variant 14).daily[].date` | *date (YYYY-MM-DD), required.* Local date, "YYYY-MM-DD". |
| `page (variant 14).daily[].condition` | *"clear" \| "partly_cloudy" \| "cloudy" \| "rain" \| "drizzle" \| "thunderstorm" \| "snow" \| "sleet" \| "fog" \| "wind" \| "hail", required.* Sky condition. Day and night icons are chosen automatically from the time and sunrise/sunset. |
| `page (variant 14).daily[].high` | *number, required.* The day's high. Limits: -130-140. |
| `page (variant 14).daily[].low` | *number, required.* The day's low. Limits: -130-140. |
| `page (variant 14).daily[].precipitationChance` | *number.* Optional. Chance of precipitation in percent. Limits: 0-100. |
| `page (variant 14).asOf` | *ISO 8601 timestamp.* Optional. When the observation was made, ISO 8601 with offset; shown as "Updated 9:40 AM". |
| `page (variant 14).source` | *string.* Optional attribution text, e.g. "Data: Open-Meteo". Limits: 1-120 characters. |
| `page (variant 15).type` | *"flight_route", required.* |
| `page (variant 15).origin` | *object, required.* Departure airport. Required. |
| `page (variant 15).origin.code` | *string, required.* IATA airport code, e.g. "SFO". Required. Limits: matches `^[A-Z]{3}$`. |
| `page (variant 15).origin.name` | *string.* Optional. Airport name, e.g. "San Francisco International". Limits: 1-80 characters. |
| `page (variant 15).origin.city` | *string.* Optional. City shown under the code, e.g. "San Francisco". Limits: 1-60 characters. |
| `page (variant 15).origin.lat` | *number.* Optional. Latitude in degrees. Send with lng for airports not in the built-in list of major airports. Limits: -90-90. |
| `page (variant 15).origin.lng` | *number.* Optional. Longitude in degrees, east positive. Send with lat. Limits: -180-180. |
| `page (variant 15).destination` | *object, required.* Arrival airport. Required. |
| `page (variant 15).destination.code` | *string, required.* IATA airport code, e.g. "SFO". Required. Limits: matches `^[A-Z]{3}$`. |
| `page (variant 15).destination.name` | *string.* Optional. Airport name, e.g. "San Francisco International". Limits: 1-80 characters. |
| `page (variant 15).destination.city` | *string.* Optional. City shown under the code, e.g. "San Francisco". Limits: 1-60 characters. |
| `page (variant 15).destination.lat` | *number.* Optional. Latitude in degrees. Send with lng for airports not in the built-in list of major airports. Limits: -90-90. |
| `page (variant 15).destination.lng` | *number.* Optional. Longitude in degrees, east positive. Send with lat. Limits: -180-180. |
| `page (variant 15).stops` | *object[].* Optional. Connection airports in order, up to 4. Omit for nonstop. Limits: at most 4 items. |
| `page (variant 15).stops[].code` | *string, required.* IATA airport code, e.g. "SFO". Required. Limits: matches `^[A-Z]{3}$`. |
| `page (variant 15).stops[].name` | *string.* Optional. Airport name, e.g. "San Francisco International". Limits: 1-80 characters. |
| `page (variant 15).stops[].city` | *string.* Optional. City shown under the code, e.g. "San Francisco". Limits: 1-60 characters. |
| `page (variant 15).stops[].lat` | *number.* Optional. Latitude in degrees. Send with lng for airports not in the built-in list of major airports. Limits: -90-90. |
| `page (variant 15).stops[].lng` | *number.* Optional. Longitude in degrees, east positive. Send with lat. Limits: -180-180. |
| `page (variant 15).airline` | *string.* Optional. Airline name, e.g. "Skyward Air". Limits: 1-60 characters. |
| `page (variant 15).flightNumber` | *string.* Optional. Flight number, e.g. "SA 123". Limits: matches `^[A-Z0-9]{2,3} ?\d{1,4}[A-Z]?$`. |
| `page (variant 15).depart` | *object.* Optional. Departure time (the latest estimate when delayed) and the origin time zone. |
| `page (variant 15).depart.time` | *ISO 8601 timestamp, required.* ISO 8601 timestamp with UTC offset. |
| `page (variant 15).depart.timeZone` | *string, required.* IANA time zone of the airport, e.g. "America/Los_Angeles". The time is shown in this zone. |
| `page (variant 15).arrive` | *object.* Optional. Arrival time and the destination time zone. |
| `page (variant 15).arrive.time` | *ISO 8601 timestamp, required.* ISO 8601 timestamp with UTC offset. |
| `page (variant 15).arrive.timeZone` | *string, required.* IANA time zone of the airport, e.g. "America/Los_Angeles". The time is shown in this zone. |
| `page (variant 15).durationMinutes` | *integer.* Optional. Total travel time in minutes. Computed from depart/arrive when omitted. Limits: 1-2880. |
| `page (variant 15).status` | *"scheduled" \| "boarding" \| "departed" \| "delayed" \| "landed" \| "cancelled".* Optional. Flight status. |
| `page (variant 15).terminal` | *string.* Optional. Departure terminal, e.g. "2". Limits: 1-10 characters. |
| `page (variant 15).gate` | *string.* Optional. Departure gate, e.g. "B12". Limits: 1-10 characters. |
| `page (variant 15).seat` | *string.* Optional. Seat, e.g. "14A". Limits: 1-10 characters. |
| `page (variant 15).progress` | *number.* Optional. Share of the route flown, 0-1, for a flight in the air; draws the plane on the route. Limits: 0-1. |
| `page (variant 16).type` | *"itinerary", required.* |
| `page (variant 16).cover` | *https URL.* Optional https URL of a landscape cover photo shown at the top. Decorative. |
| `page (variant 16).summary` | *string.* Optional one-line overview under the cover, e.g. "3 days in Lisbon · 2 travelers". Limits: 1-120 characters. |
| `page (variant 16).timeZone` | *string.* Optional IANA time zone the times are shown in, e.g. "Europe/Lisbon". Flight and train times are always shown at the UTC offset written in them (local departure and arrival). Omit to show every time at its written offset. |
| `page (variant 16).readOnly` | *boolean.* Optional. true shows the plan for reference only: no buttons, and no answer is accepted. Omit to ask the person to approve or request changes. |
| `page (variant 16).days` | *object[], required.* The plan, one entry per day in order (1-14). Limits: 1-14 items. |
| `page (variant 16).days[].date` | *date (YYYY-MM-DD).* Optional calendar date, "YYYY-MM-DD". Include when known; days are then shown as "Fri, May 15". |
| `page (variant 16).days[].label` | *string.* Optional day label, e.g. "Day 1" or "Saturday night". Omit to show "Day 1", "Day 2", and so on. Limits: 1-40 characters. |
| `page (variant 16).days[].title` | *string.* Optional theme for the day, e.g. "Alfama and the river". Limits: 1-80 characters. |
| `page (variant 16).days[].items` | *object[], required.* What happens that day, in order (1-12). Limits: 1-12 items. |
| `page (variant 16).days[].items[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 16).days[].items[].title` | *string, required.* Required. What happens, e.g. "Dinner at Lumen" or "Flight to Lisbon". Limits: 1-120 characters. |
| `page (variant 16).days[].items[].kind` | *"flight" \| "train" \| "transport" \| "stay" \| "meal" \| "activity" \| "event" \| "note".* Optional category; picks the icon on the timeline. One of flight, train, transport (taxi, ferry, car), stay, meal, activity, event, note. Omit for a plain dot. |
| `page (variant 16).days[].items[].start` | *ISO 8601 timestamp.* Optional start time, ISO 8601 with UTC offset, e.g. "2026-05-15T19:30:00+01:00". Include when the time is set; use timeLabel for a loose time instead. |
| `page (variant 16).days[].items[].end` | *ISO 8601 timestamp.* Optional end time, same format as start. Needs start, and must be after it. |
| `page (variant 16).days[].items[].timeLabel` | *string.* Optional loose time shown instead of a clock time, e.g. "Morning" or "After lunch". Do not combine with start. Limits: 1-40 characters. |
| `page (variant 16).days[].items[].place` | *object.* Optional place. Include when known; name is required inside it. |
| `page (variant 16).days[].items[].place.name` | *string, required.* Place name, e.g. "Time Out Market". Limits: 1-120 characters. |
| `page (variant 16).days[].items[].place.address` | *string.* Optional street address. Include when known. Limits: 1-200 characters. |
| `page (variant 16).days[].items[].place.mapsUrl` | *https URL.* Optional https map link. Omit to link a map search for the name and address. |
| `page (variant 16).days[].items[].photo` | *https URL.* Optional https URL of a photo of the place or activity. Decorative. Omit if you have none. |
| `page (variant 16).days[].items[].description` | *string.* Optional plain-text detail, one or two sentences. No HTML or Markdown; it is shown as typed. Limits: 1-600 characters. |
| `page (variant 16).days[].items[].price` | *object.* Optional cost of this item, total for the group. Use amount 0 to show "Free". Omit when unknown. |
| `page (variant 16).days[].items[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 16).days[].items[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 16).days[].items[].bookingStatus` | *"booked" \| "to_book" \| "optional".* Optional status you report: "booked" (already booked), "to_book" (not booked yet; approving the plan does not book it), "optional" (nice to have, skip if short on time). Omit for no label. |
| `page (variant 16).days[].items[].confirmation` | *string.* Optional confirmation or booking code, shown in monospace, e.g. "TP 8RX4QK". Limits: 1-40 characters. |
| `page (variant 16).days[].items[].links` | *object[].* Optional, up to 3 links (tickets, menu, booking page). Limits: at most 3 items. |
| `page (variant 16).days[].items[].links[].label` | *string, required.* Link text, e.g. "Tickets" or "Menu". Limits: 1-40 characters. |
| `page (variant 16).days[].items[].links[].url` | *https URL, required.* https URL. |
| `page (variant 17).type` | *"seat_picker", required.* |
| `page (variant 17).flight` | *object.* Optional flight shown above the map, e.g. "Skyward Air ZQ 101 · SFO → JFK". Include the parts you know. |
| `page (variant 17).flight.airline` | *string.* Optional airline, e.g. "Skyward Air". Limits: 1-60 characters. |
| `page (variant 17).flight.flightNumber` | *string.* Optional flight number, e.g. "ZQ 101". Limits: 1-10 characters. |
| `page (variant 17).flight.from` | *string.* Optional origin IATA code. Limits: matches `^[A-Z]{3}$`. |
| `page (variant 17).flight.to` | *string.* Optional destination IATA code. Limits: matches `^[A-Z]{3}$`. |
| `page (variant 17).flight.aircraft` | *string.* Optional aircraft type, e.g. "Airbus A321neo". Limits: 1-40 characters. |
| `page (variant 17).subtitle` | *string.* Optional context line above the map when this is not a flight, e.g. "Coach 5 · Paris → Lyon" or "Sat, Nov 7, 8:00 PM". Limits: 1-120 characters. |
| `page (variant 17).mapStyle` | *"aircraft" \| "train" \| "plain".* Optional look: "aircraft" (fuselage outline), "train" (carriage outline), "plain" (theaters, venues). Defaults to "aircraft" when flight is set, else "plain". |
| `page (variant 17).seatsToPick` | *integer.* Optional number of seats to choose (1-9). Defaults to the number of passengers, or 1. Limits: 1-9. |
| `page (variant 17).passengers` | *string[].* Optional names, one per seat; the person seats each one. The result lists seat ids in this order. Limits: 1-9 items. |
| `page (variant 17).sections` | *object[], required.* The seat map in sections front to back, e.g. cabins "Business" then "Economy" (1-8). Limits: 1-8 items. |
| `page (variant 17).sections[].name` | *string.* Optional section name shown as a heading, e.g. "Business", "Economy", "Orchestra", "Car 5". Limits: 1-40 characters. |
| `page (variant 17).sections[].priceTier` | *string.* Optional price tier for every seat in the section (a row or seat tier wins). Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 17).sections[].rows` | *object[], required.* Rows front to back (1-80). Limits: 1-80 items. |
| `page (variant 17).sections[].rows[].label` | *string, required.* Row number or letter shown beside the row, e.g. "12". Seats written as a shorthand string get ids from it ("12A"). Limits: matches `^[A-Za-z0-9-]{1,4}$`. |
| `page (variant 17).sections[].rows[].seats` | *string \| object[], required.* The seats in this row, left to right: a shorthand string like "ABC_DEF" (compact) or a list of seats and aisles. |
| `page (variant 17).sections[].rows[].seats[] (variant 1).id` | *string, required.* Seat id, unique across the whole map and returned in the result, e.g. "12A". Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 17).sections[].rows[].seats[] (variant 1).label` | *string.* Optional text for the seat, e.g. "12A". Defaults to id. Limits: 1-8 characters. |
| `page (variant 17).sections[].rows[].seats[] (variant 1).status` | *"available" \| "taken" \| "blocked".* Optional. "taken" (someone has it) and "blocked" (not for sale) cannot be chosen. Omit for available. |
| `page (variant 17).sections[].rows[].seats[] (variant 1).price` | *object.* Optional price for this seat. Use amount 0 for an included seat. Do not combine with priceTier. |
| `page (variant 17).sections[].rows[].seats[] (variant 1).price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 17).sections[].rows[].seats[] (variant 1).price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 17).sections[].rows[].seats[] (variant 1).priceTier` | *string.* Optional id of a price tier from priceTiers. Do not combine with price. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 17).sections[].rows[].seats[] (variant 1).features` | *("window" \| "aisle" \| "exit_row" \| "extra_legroom" \| "bassinet" \| "no_recline" \| "near_lavatory")[].* Optional features of this seat, e.g. ["window", "extra_legroom"]. Limits: at most 7 items. |
| `page (variant 17).sections[].rows[].seats[] (variant 2).aisle` | *true, required.* An aisle or gap between seats: { "aisle": true }. |
| `page (variant 17).sections[].rows[].taken` | *string[].* Optional, shorthand rows only: letters of taken seats, e.g. ["A", "F"]. Limits: at most 40 items. |
| `page (variant 17).sections[].rows[].blocked` | *string[].* Optional, shorthand rows only: letters of seats that are not for sale, e.g. ["E"]. Limits: at most 40 items. |
| `page (variant 17).sections[].rows[].priceTier` | *string.* Optional price tier for every seat in the row (a seat's own price or priceTier wins). Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 17).sections[].rows[].features` | *("window" \| "aisle" \| "exit_row" \| "extra_legroom" \| "bassinet" \| "no_recline" \| "near_lavatory")[].* Optional features for every seat in the row, e.g. ["exit_row", "extra_legroom"]. Limits: at most 7 items. |
| `page (variant 17).priceTiers` | *object[].* Optional price tiers (up to 3). The page gives each tier its own shade and lists it in the legend. Limits: 1-3 items. |
| `page (variant 17).priceTiers[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 17).priceTiers[].label` | *string, required.* Tier name, e.g. "Preferred", "Extra legroom". Limits: 1-30 characters. |
| `page (variant 17).priceTiers[].price` | *object, required.* Price of a seat in this tier. Use amount 0 for included seats. |
| `page (variant 17).priceTiers[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 17).priceTiers[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 17).preselectedSeatIds` | *string[].* Optional seats already assigned, shown as chosen (in passenger order). Each must exist and be available. Limits: 1-9 items. |
| `page (variant 18).type` | *"feedback", required.* |
| `page (variant 18).subject` | *string, required.* Required. What is being rated, shown above the rating, e.g. "Dinner at Kumo Sushi Bar" or "Pottery basics, Tuesday class". Limits: 1-120 characters. |
| `page (variant 18).scale` | *"stars" \| "thumbs" \| "emoji" \| "nps".* Optional rating control. "stars": 1-5 stars (default). "thumbs": up or down. "emoji": five faces from 1 "Very unhappy" to 5 "Very happy". "nps": 0-10, "How likely are you to recommend…". It decides the result's rating: a number for stars, emoji and nps, "up" or "down" for thumbs. |
| `page (variant 18).question` | *string.* Optional question over the rating, e.g. "How did the date go?". Omit for the default: "How was it?", or for nps "How likely are you to recommend it to a friend?". Limits: 1-160 characters. |
| `page (variant 18).tags` | *string[].* Optional. Aspects the person can pick after rating, whatever the rating, e.g. ["Food", "Service", "Ambience", "Value"]. Short labels (1-40 characters each, 1-12 of them), returned as written in the result's tags. Limits: 1-12 items. |
| `page (variant 18).positiveTags` | *string[].* Optional. Tags shown only after a positive rating (4-5 stars or faces, thumbs up, nps 7-10), e.g. ["Friendly staff", "Great food"]. Use instead of tags. Short labels (1-40 characters each, 1-12 of them), returned as written in the result's tags. Limits: 1-12 items. |
| `page (variant 18).negativeTags` | *string[].* Optional. Tags shown only after a negative rating (1-3 stars or faces, thumbs down, nps 0-6), e.g. ["Slow service", "Too loud"]. Use instead of tags. Short labels (1-40 characters each, 1-12 of them), returned as written in the result's tags. Limits: 1-12 items. |
| `page (variant 18).tagsPrompt` | *string.* Optional heading over the tags. Omit for "What stood out?" (or "What went well?" / "What could be better?" with positiveTags/negativeTags). Limits: 1-120 characters. |
| `page (variant 18).comment` | *object.* Optional free-text comment box shown after the person rates. Send {} for the defaults. Omit for no comment box. |
| `page (variant 18).comment.prompt` | *string.* Optional label over the text box, e.g. "Anything the chef should know?". Omit for "Anything else?". Limits: 1-120 characters. |
| `page (variant 18).comment.maxLength` | *integer.* Optional character limit (10-2000). Omit for 500. Limits: 10-2000. |
| `page (variant 18).comment.requiredBelow` | *integer.* Optional. A comment is required when the rating is below this number, e.g. 3 requires one for 1-2 stars. For thumbs, down counts as 0 and up as 1, so 1 requires a comment on thumbs down. Omit to keep it optional. Limits: 1-10. |
| `page (variant 18).followUp` | *string.* Optional yes/no question shown after the rating, e.g. "Would you go back?". Answering it is optional; the result has followUp: true/false. Limits: 1-120 characters. |
| `page (variant 18).anonymousNote` | *string.* Optional privacy note shown under the form, e.g. "Only you and your assistant see this." Omit to show nothing. Limits: 1-200 characters. |
| `page (variant 19).type` | *"profile_edit", required.* |
| `page (variant 19).name` | *string, required.* The person's current display name, e.g. "Maya". Limits: 1-40 characters. |
| `page (variant 19).age` | *integer.* Optional current age in years (18 or older). Limits: 18-120. |
| `page (variant 19).pronouns` | *string.* Optional current pronouns, e.g. "she/her". Limits: 1-30 characters. |
| `page (variant 19).location` | *string.* Optional current neighborhood or city, e.g. "Mission District". Limits: 1-80 characters. |
| `page (variant 19).photos` | *object[].* Optional current photos (0-6), main photo first. The person can reorder and remove them. Adding photos is not possible on the page; collect new photos in the conversation. Limits: at most 6 items. |
| `page (variant 19).photos[].id` | *string.* Optional id for the photo, returned in photosOrder and removedPhotoIds. Without it, the URL is used. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 19).photos[].url` | *https URL, required.* https URL of the photo. |
| `page (variant 19).photos[].alt` | *string.* Optional description for screen readers. Omit and "Photo 1" etc. is used. Limits: 1-200 characters. |
| `page (variant 19).bio` | *string.* Optional current "About me" text, up to 500 characters. Limits: 1-500 characters. |
| `page (variant 19).prompts` | *object[].* Optional prompts the person has answered (up to 3). Limits: at most 3 items. |
| `page (variant 19).prompts[].id` | *string, required.* id of the prompt question, e.g. "perfect-sunday". Returned in the result. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 19).prompts[].question` | *string, required.* The prompt, e.g. "A perfect Sunday". Limits: 1-120 characters. |
| `page (variant 19).prompts[].answer` | *string, required.* Current answer, up to 200 characters. Limits: 1-200 characters. |
| `page (variant 19).availablePrompts` | *object[].* Optional prompt questions the person can pick from (up to 40). Omit and the person can only edit the answers they have. Limits: at most 40 items. |
| `page (variant 19).availablePrompts[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 19).availablePrompts[].question` | *string, required.* The prompt, e.g. "Two truths and a lie". Limits: 1-120 characters. |
| `page (variant 19).interests` | *string[].* Optional current interests, e.g. ["Bouldering", "Natural wine"]. Limits: at most 20 items. |
| `page (variant 19).suggestedInterests` | *string[].* Optional more interests the person can add (up to 40). Omit and they can only remove current ones. Limits: at most 40 items. |
| `page (variant 19).maxInterests` | *integer.* Optional most interests the person may keep. Omit for 5 (or the current count, if higher). Limits: 1-20. |
| `page (variant 19).basics` | *object[].* Optional facts such as height, job, school, drinking, smoking (up to 12 rows). Limits: at most 12 items. |
| `page (variant 19).basics[].id` | *string, required.* id of the row, e.g. "height". Returned in the result. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 19).basics[].label` | *string, required.* Row label, e.g. "Height". Limits: 1-30 characters. |
| `page (variant 19).basics[].value` | *string.* Optional current value, e.g. "5′7″". Omit when not set. Limits: 1-60 characters. |
| `page (variant 19).basics[].options` | *string[].* Optional fixed choices, shown as a menu, e.g. ["Never", "Socially", "Often"]. Omit for free text. Limits: 2-20 items. |
| `page (variant 19).basics[].icon` | *"height" \| "work" \| "education" \| "home" \| "drinking" \| "smoking" \| "children" \| "pets" \| "religion" \| "languages" \| "exercise" \| "zodiac".* Optional icon for the chip, one of: height, work, education, home, drinking, smoking, children, pets, religion, languages, exercise, zodiac. Omit for a plain dot. |
| `page (variant 19).basics[].editable` | *boolean.* Optional. false shows the row read-only. Omit to let the person edit it. |
| `page (variant 19).lookingFor` | *object.* Optional "Looking for" choice, e.g. a long-term relationship or new friends. |
| `page (variant 19).lookingFor.options` | *object[], required.* 2-8 choices. Limits: 2-8 items. |
| `page (variant 19).lookingFor.options[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 19).lookingFor.options[].label` | *string, required.* e.g. "A long-term relationship". Limits: 1-60 characters. |
| `page (variant 19).lookingFor.selected` | *string[].* Optional ids chosen now. Omit when nothing is chosen yet. Limits: at most 8 items. |
| `page (variant 19).lookingFor.multiple` | *boolean.* Optional. true allows several choices. Omit for one. |
| `page (variant 19).editable` | *object.* Optional per-field switches. A field is editable unless set to false here, e.g. { "name": false, "age": false }. Set true for a field you did not send (pronouns, location, bio, photos) to let the person add it. |
| `page (variant 19).editable.name` | *boolean.* |
| `page (variant 19).editable.age` | *boolean.* |
| `page (variant 19).editable.pronouns` | *boolean.* |
| `page (variant 19).editable.location` | *boolean.* |
| `page (variant 19).editable.photos` | *boolean.* |
| `page (variant 19).editable.bio` | *boolean.* |
| `page (variant 19).editable.prompts` | *boolean.* |
| `page (variant 19).editable.interests` | *boolean.* |
| `page (variant 19).editable.lookingFor` | *boolean.* |
| `page (variant 20).type` | *"profile_view", required.* |
| `page (variant 20).profiles` | *object[], required.* Dating profiles to review (1-10), shown one at a time in this order. The person likes or passes on each. Fictional or consenting adults only. Limits: 1-10 items. |
| `page (variant 20).profiles[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 20).profiles[].name` | *string, required.* Name shown on the profile, usually a first name, e.g. "Maya". Limits: 1-40 characters. |
| `page (variant 20).profiles[].photos` | *object[], required.* 1-6 photos, the main photo first. Shown as a swipeable carousel. Limits: 1-6 items. |
| `page (variant 20).profiles[].photos[].url` | *https URL, required.* https URL of the photo. Portrait (4:5 or 3:4) photos look best. |
| `page (variant 20).profiles[].photos[].alt` | *string.* Optional description for screen readers, e.g. "Maya laughing at a picnic table". Omit and "Photo of <name>" is used. Limits: 1-200 characters. |
| `page (variant 20).profiles[].age` | *integer.* Optional age in years (adults only, 18 or older). Include when known. Limits: 18-120. |
| `page (variant 20).profiles[].pronouns` | *string.* Optional pronouns, e.g. "she/her". Include only when the person shared them. Limits: 1-30 characters. |
| `page (variant 20).profiles[].location` | *string.* Optional neighborhood or city, e.g. "Mission District". Limits: 1-80 characters. |
| `page (variant 20).profiles[].distance` | *string.* Optional distance as text, e.g. "3 miles away". Limits: 1-40 characters. |
| `page (variant 20).profiles[].verified` | *boolean.* Optional. true shows a "Verified" badge; only set it when the platform verified the photos. |
| `page (variant 20).profiles[].matchNote` | *string.* Optional one-line reason you think they would get along, e.g. "You both love long-table dinners". Limits: 1-120 characters. |
| `page (variant 20).profiles[].mutuals` | *string.* Optional shared connections as text, e.g. "You both know Priya and Jon". Omit when there are none. Limits: 1-80 characters. |
| `page (variant 20).profiles[].bio` | *string.* Optional short "About me" text, plain text. Limits: 1-500 characters. |
| `page (variant 20).profiles[].prompts` | *object[].* Optional prompt answers (up to 3), shown as quote cards. Limits: at most 3 items. |
| `page (variant 20).profiles[].prompts[].question` | *string, required.* The prompt, e.g. "A perfect Sunday". Limits: 1-120 characters. |
| `page (variant 20).profiles[].prompts[].answer` | *string, required.* Their answer, up to 200 characters. Limits: 1-200 characters. |
| `page (variant 20).profiles[].basics` | *object[].* Optional facts shown as chips with icons (up to 12): height, job, school, drinking, and so on. Limits: at most 12 items. |
| `page (variant 20).profiles[].basics[].label` | *string, required.* What the value is, read by screen readers, e.g. "Height". Limits: 1-30 characters. |
| `page (variant 20).profiles[].basics[].value` | *string, required.* The value shown on the chip, e.g. "5′7″" or "Product designer". Limits: 1-60 characters. |
| `page (variant 20).profiles[].basics[].icon` | *"height" \| "work" \| "education" \| "home" \| "drinking" \| "smoking" \| "children" \| "pets" \| "religion" \| "languages" \| "exercise" \| "zodiac".* Optional icon for the chip, one of: height, work, education, home, drinking, smoking, children, pets, religion, languages, exercise, zodiac. Omit for a plain dot. |
| `page (variant 20).profiles[].interests` | *string[].* Optional interests (up to 12), e.g. ["Bouldering", "Natural wine"]. Limits: at most 12 items. |
| `page (variant 20).allowNotes` | *boolean.* Optional. true lets the person add a short note (up to 140 characters) when they like someone. Omit for likes without notes. |
| `page (variant 20).allowPartial` | *boolean.* Optional. true lets the person send decisions before deciding on every profile. Omit to require a decision for each. |
| `page (variant 21).type` | *"qr_code", required.* |
| `page (variant 21).value` | *string, required.* The text the QR code encodes, up to 1,000 characters. Required. An https URL ("https://tickets.example.com/t/8F3K2"), a Wi-Fi login ("WIFI:T:WPA;S:Cafe Guest;P:espresso42;;"), or plain text such as a ticket or pickup number. Web links must be https; javascript:, data: and file: URLs are rejected. Not shown in link previews. Limits: 1-1000 characters. |
| `page (variant 21).label` | *string, required.* What the code is, shown above it, e.g. "Your ticket" or "Guest Wi-Fi". Required. Limits: 1-60 characters. |
| `page (variant 21).caption` | *string.* Optional sentence under the label, e.g. "Show this at the door." Include when helpful; omit otherwise. Limits: 1-200 characters. |
| `page (variant 21).code` | *string.* Optional short code shown in monospace under the QR for typing in by hand, e.g. "8F3K-2Q". Omit and the page shows value as text instead. Limits: 1-32 characters. |
| `page (variant 21).details` | *object[].* Optional facts shown under the code, up to 6, e.g. [{ "label": "Name", "value": "Alex Kim" }, { "label": "Seat", "value": "12B" }]. They appear in the link preview, so never put passwords or other secrets here; those belong only in value. Limits: 1-6 items. |
| `page (variant 21).details[].label` | *string, required.* Row name, e.g. "Seat". Limits: 1-40 characters. |
| `page (variant 21).details[].value` | *string, required.* Row value, e.g. "12B". Limits: 1-80 characters. |
| `page (variant 21).validFrom` | *ISO 8601 timestamp.* Optional start of validity, ISO 8601 with offset, e.g. "2026-11-14T18:00:00-08:00". Shown as text. |
| `page (variant 21).validUntil` | *ISO 8601 timestamp.* Optional end of validity, same format. Must be after validFrom. Shown as text; the code itself does not expire. |
| `page (variant 21).timeZone` | *string.* Optional IANA time zone for validFrom and validUntil, e.g. "America/Los_Angeles". Omit and the offset written in them is used. |
| `page (variant 21).errorCorrection` | *"L" \| "M" \| "Q" \| "H".* Optional error correction: L (7%), M (15%), Q (25%) or H (30%) of the code can be damaged and still scan. Omit for M. |
| `page (variant 22).type` | *"chart", required.* |
| `page (variant 22).charts` | *object[], required.* 1 to 4 charts, shown stacked in this order, e.g. a stat row, then a line, then a bar. Limits: 1-4 items. |
| `page (variant 22).charts[].kind` | *"line" \| "area" \| "bar" \| "bar_horizontal" \| "stacked_bar" \| "donut" \| "stat", required.* line or area: change over time. bar: compare categories or periods (vertical columns). bar_horizontal: compare categories with long names or ranked lists. stacked_bar: totals split into parts per category. donut: parts of one whole (2-8 segments). stat: one headline number per series with its change and a sparkline. |
| `page (variant 22).charts[].series` | *object[], required.* 1 to 6 series. donut takes exactly one. For stat, each series is one tile. Limits: 1-6 items. |
| `page (variant 22).charts[].series[].id` | *string.* Optional stable key. Series with the same id keep the same color across the charts on a page (e.g. "revenue" in a stat row and a line). Without ids, colors go in order within each chart. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 22).charts[].series[].label` | *string, required.* Name of what is measured, shown in the legend, tooltip and summary, e.g. "Revenue" or "Steps". Limits: 1-60 characters. |
| `page (variant 22).charts[].series[].points` | *object[], required.* Data points, up to 500, in x order for dates and numbers. An empty list shows "Nothing to show". For donut: one point per segment, x the segment label and y its positive value (up to 8). For stat: the last point is the big number; earlier points draw the sparkline and the change vs the previous point. Limits: at most 500 items. |
| `page (variant 22).charts[].series[].points[].x` | *number \| string, required.* Position on the x axis: a date ("2026-03-14", or "2026-03" for a month), a number, or a category label ("Groceries"). All x values in one chart use the same type. |
| `page (variant 22).charts[].series[].points[].y` | *number, required.* The value, in the unit of yFormat (e.g. 1250.5 dollars, 12.5 for 12.5%). Limits: -1000000000000-1000000000000. |
| `page (variant 22).charts[].title` | *string.* Optional heading that states the takeaway, e.g. "Spending fell 12% in June". Limits: 1-80 characters. |
| `page (variant 22).charts[].subtitle` | *string.* Optional line under the title: period, source or unit, e.g. "Last 6 months, USD". Limits: 1-140 characters. |
| `page (variant 22).charts[].yFormat` | *"number" \| "money" \| "percent".* Optional. How values are written: number (default), money (needs currency), or percent (y is already in percent: 12.5 means 12.5%). |
| `page (variant 22).charts[].currency` | *string.* ISO 4217 code for yFormat "money", e.g. "USD". Required with money, not allowed otherwise. Limits: matches `^[A-Z]{3}$`. |
| `page (variant 22).charts[].unit` | *string.* Optional unit written after numbers, e.g. "kg" or "steps". Not used with money or percent. Limits: 1-16 characters. |
| `page (variant 22).charts[].xFormat` | *"day" \| "month" \| "year".* Optional, for date x values only: write dates as days ("Mar 14"), months ("Mar") or years ("2026"). Chosen from the data when omitted. |
| `page (variant 22).charts[].xLabel` | *string.* Optional x axis title, e.g. "Month". Leave out when the values speak for themselves. Limits: 1-40 characters. |
| `page (variant 22).charts[].yLabel` | *string.* Optional y axis title, e.g. "Weight (kg)". Limits: 1-40 characters. |
| `page (variant 22).charts[].annotations` | *object[].* Optional notes pinned to x values (up to 4), drawn as a thin vertical line with a label. Not for donut or stat. Limits: at most 4 items. |
| `page (variant 22).charts[].annotations[].x` | *number \| string, required.* Position on the x axis: a date ("2026-03-14", or "2026-03" for a month), a number, or a category label ("Groceries"). All x values in one chart use the same type. |
| `page (variant 22).charts[].annotations[].label` | *string, required.* Short note, e.g. "Price change". Limits: 1-32 characters. |
| `page (variant 22).charts[].goal` | *object.* Optional target drawn as a horizontal line, e.g. { "value": 10000 } steps a day. Not for donut or stat. |
| `page (variant 22).charts[].goal.value` | *number, required.* Target value in the chart unit. Limits: -1000000000000-1000000000000. |
| `page (variant 22).charts[].goal.label` | *string.* Optional label, defaults to "Goal". Limits: 1-32 characters. |
| `page (variant 22).charts[].higherIsBetter` | *boolean.* Optional, default true. Set false when a rise is bad (spending, weight), so changes and goals are colored and worded correctly. |
| `page (variant 23).type` | *"artifact", required.* |
| `page (variant 23).markdown` | *string, required.* The document, in markdown (GitHub-flavored), up to 20,000 characters: headings, paragraphs, lists, tables, code blocks, quotes, bold, emphasis and links. Only https:// and mailto: links work; any other link shows as its text. No images, and HTML shows as plain text. Limits: 1-20000 characters. |
| `page (variant 24).type` | *"wallet", required.* |
| `page (variant 24).card` | *object, required.* The card shown at the top. Only the last 4 digits; never the full number, CVV or full expiry date. |
| `page (variant 24).card.last4` | *string, required.* Required. The last 4 digits of the card, exactly 4 digits, e.g. "4821". Never send the full card number or the CVV. Limits: matches `^\d{4}$`. |
| `page (variant 24).card.name` | *string.* Optional card or account name, e.g. "Juniper Card" or "Everyday Debit". Omit to show the brand. Limits: 1-40 characters. |
| `page (variant 24).card.brand` | *"visa" \| "mastercard" \| "amex" \| "discover" \| "other".* Optional network: visa, mastercard, amex, discover or other. Shown as a plain text wordmark. Omit when unknown. |
| `page (variant 24).card.holder` | *string.* Optional name printed on the card, e.g. "Alex Kim". Omit otherwise. Limits: 1-40 characters. |
| `page (variant 24).card.expiry` | *string.* Optional expiry as MM/YY, e.g. "08/29". Never send the CVV or a full expiry date. Limits: matches `^(0[1-9]\|1[0-2])\/\d{2}$`. |
| `page (variant 24).card.color` | *"graphite" \| "midnight" \| "forest" \| "plum" \| "silver".* Optional card color: graphite (default), midnight, forest, plum or silver. Pick the one closest to the real card. |
| `page (variant 24).card.frozen` | *boolean.* Optional. true shows the card as frozen (new purchases are declined). Omit otherwise. |
| `page (variant 24).balance` | *object, required.* Required. The balance shown on the card, e.g. { "amount": 1284.12, "currency": "USD" }. For a credit card, what is owed. |
| `page (variant 24).balance.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 24).balance.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 24).balanceLabel` | *string.* Optional label for the balance, e.g. "Current balance" or "Available". Omit for "Balance". Limits: 1-30 characters. |
| `page (variant 24).available` | *object.* Optional available credit or spendable amount. Include when known. |
| `page (variant 24).available.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 24).available.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 24).creditLimit` | *object.* Optional credit limit (credit cards). Include when known. |
| `page (variant 24).creditLimit.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 24).creditLimit.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 24).dueDate` | *date (YYYY-MM-DD).* Optional payment due date (credit cards), "YYYY-MM-DD", e.g. "2026-10-25". |
| `page (variant 24).minimumDue` | *object.* Optional minimum payment due (credit cards). Shown with dueDate. |
| `page (variant 24).minimumDue.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 24).minimumDue.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 24).asOf` | *ISO 8601 timestamp.* Optional time the data was fetched, ISO 8601 with offset. Shown as "Updated …"; "Today" and "Yesterday" are counted from it. Omit to count from now. |
| `page (variant 24).timeZone` | *string.* Optional IANA time zone for days and times, e.g. "America/Los_Angeles". Omit to use the offset written in each date. |
| `page (variant 24).transactions` | *object[].* Optional recent transactions, up to 100, in any order (the page sorts newest first and groups by day). Omit or [] for an empty state. Limits: at most 100 items. |
| `page (variant 24).transactions[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 24).transactions[].merchant` | *string, required.* Required. Who was paid or who paid, as the person would recognize it, e.g. "Lumen Market". Limits: 1-80 characters. |
| `page (variant 24).transactions[].amount` | *object, required.* Required. Negative for money spent, positive for refunds and credits, e.g. { "amount": -42.1, "currency": "USD" }. |
| `page (variant 24).transactions[].amount.amount` | *number, required.* Signed amount in major units: negative for money spent (-42.1 for a $42.10 purchase), positive for refunds and credits. Limits: -10000000-10000000. |
| `page (variant 24).transactions[].amount.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 24).transactions[].date` | *ISO 8601 timestamp, required.* Required. When it happened, ISO 8601 with offset, e.g. "2026-10-09T08:42:00-07:00". Transactions are grouped by day. |
| `page (variant 24).transactions[].status` | *"posted" \| "pending" \| "declined" \| "refunded".* Optional: posted (default), pending (shown muted with a "Pending" label), declined (struck through) or refunded. |
| `page (variant 24).transactions[].category` | *"food" \| "travel" \| "shopping" \| "transport" \| "bills" \| "entertainment" \| "health" \| "other".* Optional category, shown as an icon: food, travel, shopping, transport, bills, entertainment, health or other. |
| `page (variant 24).transactions[].note` | *string.* Optional short note under the merchant, e.g. "Split with Sam" or "2 × coffee". Plain text. Limits: 1-120 characters. |
| `page (variant 24).transactions[].merchantLogo` | *https URL.* Optional https URL of a square merchant logo. Omit to show the category icon or initials. |
| `page (variant 24).select` | *object.* Optional. Lets the person pick transactions and send them back. Omit to only show the card and transactions (no answer). Result: { transactionIds } for "mark", { disputes: [{ transactionId, reasonId?, note? }] } for "dispute". |
| `page (variant 24).select.purpose` | *"mark" \| "dispute", required.* "mark": the person picks transactions (e.g. to review or split). "dispute": the person picks transactions to dispute, each with an optional reason and note. |
| `page (variant 24).select.prompt` | *string.* Optional instruction above the list, e.g. "Which of these did you split with Sam?". Omit for a default per purpose. Limits: 1-120 characters. |
| `page (variant 24).select.actionLabel` | *string.* Optional verb for the button, e.g. "Split" gives "Split 2 transactions". Omit for "Mark" or "Dispute". Limits: 1-20 characters. |
| `page (variant 24).select.max` | *integer.* Optional most transactions the person may pick. Omit for no limit. Limits: 1-100. |
| `page (variant 24).select.reasons` | *object[].* Optional dispute reasons the person picks from for each transaction (dispute only), e.g. [{ "id": "unknown", "label": "I don’t recognize it" }]. Limits: 1-8 items. |
| `page (variant 24).select.reasons[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 24).select.reasons[].label` | *string, required.* Reason shown to the person, e.g. "I don’t recognize it". Limits: 1-60 characters. |
| `page (variant 24).select.selectable` | *("posted" \| "pending" \| "declined" \| "refunded")[].* Optional statuses that can be picked. Omit for posted and refunded only (pending and declined are not selectable). Limits: 1-4 items. |
| `page (variant 25).type` | *"table", required.* |
| `page (variant 25).columns` | *object[], required.* The columns, in display order (1-8). Limits: 1-8 items. |
| `page (variant 25).columns[].id` | *string, required.* Column id, used as the key in every row's values, e.g. "total". Unique. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 25).columns[].label` | *string, required.* Header text, e.g. "Total". Required. Limits: 1-40 characters. |
| `page (variant 25).columns[].type` | *"text" \| "number" \| "money" \| "percent" \| "date" \| "datetime" \| "status" \| "link" \| "image", required.* Cell type, which fixes the value shape: text = string; number, money (major units, e.g. 24.5) and percent (12.5 means 12.5%) = number; date = "2026-11-03"; datetime = ISO 8601 with offset; status = { label, tone: "neutral" \| "good" \| "warn" \| "bad" }; link = { url (https), label? }; image = { url (https thumbnail), alt? }. |
| `page (variant 25).columns[].align` | *"start" \| "center" \| "end".* Optional. Defaults to "end" for number, money and percent, and "start" for everything else. |
| `page (variant 25).columns[].format` | *object.* Optional number formatting. Money columns need { currency }. |
| `page (variant 25).columns[].format.currency` | *string.* ISO 4217 code for a money column, e.g. "USD". Required when type is "money". |
| `page (variant 25).columns[].format.decimals` | *integer.* Optional fixed number of decimals for number, money and percent. Limits: 0-4. |
| `page (variant 25).columns[].priority` | *"high" \| "normal".* Optional. On phones each row becomes a card: "high" columns are shown large at the top, the rest as label/value pairs below. Mark the 1-3 columns a person scans for. Defaults to the first non-image column only. |
| `page (variant 25).columns[].sortable` | *boolean.* Optional. true lets the person sort by this column. |
| `page (variant 25).rows` | *object[], required.* The rows, in default order (0-200; at least 1 with select). An empty list shows an empty state. Limits: at most 200 items. |
| `page (variant 25).rows[].id` | *string, required.* Row id. It is returned in the result when the person selects this row. Unique. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 25).rows[].values` | *object, required.* Cell values keyed by column id, e.g. { "item": "Desk lamp", "total": 49 }. Omit a key for an empty cell. |
| `page (variant 25).caption` | *string.* Optional table name read by screen readers and shown above it, e.g. "Orders since March". Limits: 1-120 characters. |
| `page (variant 25).summaryRow` | *object.* Optional totals row at the bottom, e.g. { "values": { "total": 412.5 } }. It is never sorted or filtered. |
| `page (variant 25).summaryRow.label` | *string.* Optional label in the first column. Defaults to "Total". Limits: 1-40 characters. |
| `page (variant 25).summaryRow.values` | *object, required.* Cell values keyed by column id, e.g. { "item": "Desk lamp", "total": 49 }. Omit a key for an empty cell. |
| `page (variant 25).sort` | *object.* Optional initial sort. Without it rows keep the order you sent. |
| `page (variant 25).sort.columnId` | *string, required.* A sortable column id. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 25).sort.direction` | *"asc" \| "desc", required.* |
| `page (variant 25).search` | *boolean.* Optional. true shows a search box that filters rows as the person types. Useful above ~15 rows. |
| `page (variant 25).groupBy` | *string.* Optional column id (text or status) whose value groups the rows under headings. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 25).select` | *object.* Optional. Makes rows selectable and adds a Submit button; the result is { rowIds }. Without it the table only shows data. |
| `page (variant 25).select.mode` | *"single" \| "multiple", required.* "single" for one row, "multiple" for several. |
| `page (variant 25).select.max` | *integer.* Optional, multiple only: the most rows the person may pick. Limits: 1-200. |
| `page (variant 25).select.prompt` | *string.* Optional instruction above the table, e.g. "Pick the orders to return". Limits: 1-120 characters. |
| `page (variant 26).type` | *"product", required.* |
| `page (variant 26).id` | *string, required.* Your product id. Returned as productId. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 26).name` | *string, required.* Product name as a shopper would say it, e.g. "Linen camp shirt". No brand, size or color in the name. Limits: 1-120 characters. |
| `page (variant 26).price` | *object, required.* Current price for one item. With variants, the price of combinations without their own price. |
| `page (variant 26).price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 26).price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 26).compareAtPrice` | *object.* Optional original price when the product is on sale; it must be higher than price. Shown struck through with the saving. |
| `page (variant 26).compareAtPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 26).compareAtPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 26).photos` | *object[].* Optional product photos (1-8), the main photo first. The person swipes through them. Limits: 1-8 items. |
| `page (variant 26).photos[].url` | *https URL, required.* https URL of a product photo (JPEG, PNG or WebP), e.g. "https://shop.example/linen-shirt-front.jpg". |
| `page (variant 26).photos[].alt` | *string.* Optional. What the photo shows, for screen readers, e.g. "Sage linen shirt, front view". Omit to use the product name. Limits: 1-200 characters. |
| `page (variant 26).brand` | *string.* Optional brand or maker, shown above the name, e.g. "Juniper Market". Limits: 1-60 characters. |
| `page (variant 26).description` | *string.* Optional plain-text description. Separate paragraphs with a blank line. No HTML or Markdown. Limits: 1-2000 characters. |
| `page (variant 26).rating` | *number.* Optional average review score from 0 to 5, e.g. 4.6. Include only real reviews. Limits: 0-5. |
| `page (variant 26).reviewCount` | *integer.* Optional number of reviews behind rating, e.g. 128. Only with rating. Limits: 0-10000000. |
| `page (variant 26).badges` | *string[].* Optional short labels such as "New" or "Organic cotton" (up to 3). Limits: at most 3 items. |
| `page (variant 26).variantOptions` | *object[].* Optional choices the person must make, such as Size and Color (1-3 options). The result then has selections. Limits: 1-3 items. |
| `page (variant 26).variantOptions[].name` | *string, required.* Option name shown above its choices, e.g. "Size" or "Color". Limits: 1-24 characters. |
| `page (variant 26).variantOptions[].values` | *object[], required.* The choices in display order (1-20), e.g. [{ "value": "S" }, { "value": "M" }]. Limits: 1-20 items. |
| `page (variant 26).variantOptions[].values[].value` | *string, required.* Choice text, e.g. "M", "Sage", "32 x 30". It is shown on the chip and returned in selections. Limits: 1-32 characters. |
| `page (variant 26).variantOptions[].values[].swatch` | *string.* Optional color for a color choice, "#rrggbb". The chip shows the color next to the name, never the color alone. Limits: matches `^#[0-9a-fA-F]{6}$`. |
| `page (variant 26).variants` | *object[].* Optional. Every combination you sell, each with its own id, availability, price or photo. Needs variantOptions. Combinations you leave out cannot be chosen. Without variants every combination is available at the product price. Limits: 1-100 items. |
| `page (variant 26).variants[].id` | *string, required.* Your id for this exact combination (SKU), e.g. "shirt-sage-m". Returned as variantId. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 26).variants[].selections` | *object, required.* One value per option, by option name, e.g. { "Color": "Sage", "Size": "M" }. |
| `page (variant 26).variants[].available` | *boolean.* Optional. false shows this combination as sold out. Omit when it can be bought. |
| `page (variant 26).variants[].price` | *object.* Optional price for this combination when it differs from the product price. |
| `page (variant 26).variants[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 26).variants[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 26).variants[].compareAtPrice` | *object.* Optional original price for this combination when it is on sale. |
| `page (variant 26).variants[].compareAtPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 26).variants[].compareAtPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 26).variants[].photoIndex` | *integer.* Optional index into photos (0 is the first) shown when this combination is chosen. Limits: 0-7. |
| `page (variant 26).quantity` | *object.* Optional quantity stepper. Omit for a quantity of exactly one. |
| `page (variant 26).quantity.min` | *integer.* Optional smallest quantity. Default 1. Limits: 1-99. |
| `page (variant 26).quantity.max` | *integer.* Optional largest quantity, e.g. stock or a per-order limit. Default 10. Limits: 1-99. |
| `page (variant 26).quantity.default` | *integer.* Optional starting quantity. Default min. Limits: 1-99. |
| `page (variant 26).details` | *object[].* Optional facts listed under the description, e.g. Material, Fit, Shipping, Returns (1-10 rows). Limits: 1-10 items. |
| `page (variant 26).details[].label` | *string, required.* e.g. "Material". Limits: 1-40 characters. |
| `page (variant 26).details[].value` | *string, required.* e.g. "100% European linen". Limits: 1-300 characters. |
| `page (variant 26).stock` | *string.* Optional stock note from your data, shown as written, e.g. "Only 3 left" or "Ships in 2 days". Page Systems does not check or hold stock. Limits: 1-60 characters. |
| `page (variant 26).submitLabel` | *string.* Optional button text for the choice, e.g. "Choose" (the default) or "Choose this". Not "Buy" or "Add to bag": the page buys nothing. Limits: 1-24 characters, matches `^[^<>]*$`. |
| `page (variant 27).type` | *"product_list", required.* |
| `page (variant 27).products` | *object[], required.* The products in display order (2-24). For one product use the product page type. Limits: 2-24 items. |
| `page (variant 27).products[].id` | *string, required.* Your product id (or SKU, when each card is one size or color). Returned as productId. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 27).products[].name` | *string, required.* Product name, e.g. "Canvas market tote". Keep it short: cards are narrow. Limits: 1-120 characters. |
| `page (variant 27).products[].price` | *object, required.* Price for one item. |
| `page (variant 27).products[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 27).products[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 27).products[].photo` | *object.* Optional main photo, shown at 4:5. Strongly recommended: a grid without photos is hard to scan. |
| `page (variant 27).products[].photo.url` | *https URL, required.* https URL of a product photo (JPEG, PNG or WebP), e.g. "https://shop.example/linen-shirt-front.jpg". |
| `page (variant 27).products[].photo.alt` | *string.* Optional. What the photo shows, for screen readers, e.g. "Sage linen shirt, front view". Omit to use the product name. Limits: 1-200 characters. |
| `page (variant 27).products[].brand` | *string.* Optional brand, shown above the name. Limits: 1-60 characters. |
| `page (variant 27).products[].description` | *string.* Optional one-line detail, e.g. "Sage · Size M" or "Serves 4". Shown in list layout and under the name. Limits: 1-200 characters. |
| `page (variant 27).products[].compareAtPrice` | *object.* Optional original price when on sale; must be higher than price. |
| `page (variant 27).products[].compareAtPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 27).products[].compareAtPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 27).products[].rating` | *number.* Optional average review score from 0 to 5, e.g. 4.6. Include only real reviews. Limits: 0-5. |
| `page (variant 27).products[].reviewCount` | *integer.* Optional number of reviews behind rating, e.g. 128. Only with rating. Limits: 0-10000000. |
| `page (variant 27).products[].badge` | *string.* Optional short label on the photo, e.g. "New", "Bestseller". Limits: 1-20 characters. |
| `page (variant 27).products[].available` | *boolean.* Optional. false shows the product as sold out and it cannot be picked. Omit when it can be bought. |
| `page (variant 27).products[].categoryId` | *string.* Optional id of one of categories, for the filter chips. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 27).mode` | *"single" \| "multiple".* Optional. "single" (default): the person picks one product. "multiple": they pick several, e.g. to fill a cart. |
| `page (variant 27).maxSelections` | *integer.* Optional, mode "multiple" only: the most products the person may pick. Limits: 2-24. |
| `page (variant 27).maxQuantity` | *integer.* Optional, mode "multiple" only: show a quantity stepper on picked products, up to this many each. Omit for one of each. Limits: 2-99. |
| `page (variant 27).layout` | *"grid" \| "list".* Optional. "grid" (default, 2 columns on phones) for photo-led products; "list" for rows with more text. |
| `page (variant 27).categories` | *object[].* Optional filter chips (2-8) above the products, with an "All" chip first. Give each product a categoryId. Limits: 2-8 items. |
| `page (variant 27).categories[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 27).categories[].label` | *string, required.* Chip text, e.g. "Tops". Limits: 1-24 characters. |
| `page (variant 27).submitLabel` | *string.* Optional button text for the choice. Default "Choose". Not "Buy" or "Add to bag": the page buys nothing. Limits: 1-24 characters, matches `^[^<>]*$`. |
| `page (variant 28).type` | *"checkout", required.* |
| `page (variant 28).lineItems` | *object[], required.* What is in the cart, in display order (1-30 lines). Limits: 1-30 items. |
| `page (variant 28).lineItems[].productId` | *string, required.* Your product id. Returned in the result. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 28).lineItems[].variantId` | *string.* Optional variant (SKU) id when the product has options. Returned in the result. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 28).lineItems[].name` | *string, required.* Product name, e.g. "Linen camp shirt". Limits: 1-120 characters. |
| `page (variant 28).lineItems[].variantLabel` | *string.* Optional chosen options in plain words, e.g. "Sage · M". Limits: 1-80 characters. |
| `page (variant 28).lineItems[].photo` | *object.* Optional product photo, shown as a small thumbnail. |
| `page (variant 28).lineItems[].photo.url` | *https URL, required.* https URL of a product photo (JPEG, PNG or WebP), e.g. "https://shop.example/linen-shirt-front.jpg". |
| `page (variant 28).lineItems[].photo.alt` | *string.* Optional. What the photo shows, for screen readers, e.g. "Sage linen shirt, front view". Omit to use the product name. Limits: 1-200 characters. |
| `page (variant 28).lineItems[].unitPrice` | *object, required.* Price for one item, after any per-item discount. |
| `page (variant 28).lineItems[].unitPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 28).lineItems[].unitPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 28).lineItems[].quantity` | *integer, required.* How many. Limits: 1-99. |
| `page (variant 28).lineItems[].editable` | *boolean.* Optional. true lets the person change the quantity or remove this line. |
| `page (variant 28).lineItems[].maxQuantity` | *integer.* Optional, with editable only: the most the person may order. Default 10. Limits: 1-99. |
| `page (variant 28).merchant` | *object, required.* Who sells the items. |
| `page (variant 28).merchant.name` | *string, required.* The seller, as the person knows it, e.g. "Juniper Market". Limits: 1-80 characters. |
| `page (variant 28).merchant.supportEmail` | *email.* Optional email for questions. Limits: at most 200 characters. |
| `page (variant 28).merchant.supportUrl` | *https URL.* Optional https URL of a help or returns page. |
| `page (variant 28).totals` | *object, required.* Cart totals as sent. All amounts in one currency. |
| `page (variant 28).totals.subtotal` | *object.* Optional. If given it must equal the sum of unitPrice × quantity. |
| `page (variant 28).totals.subtotal.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 28).totals.subtotal.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 28).totals.discount` | *object.* Optional discount as a positive amount; it is subtracted. |
| `page (variant 28).totals.discount.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 28).totals.discount.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 28).totals.shipping` | *object.* Optional shipping cost. With shippingOptions it must equal the default option price. |
| `page (variant 28).totals.shipping.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 28).totals.shipping.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 28).totals.tax` | *object.* Optional tax total. |
| `page (variant 28).totals.tax.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 28).totals.tax.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 28).totals.total` | *object, required.* The cart total: subtotal - discount + shipping + tax, for the default shipping option. |
| `page (variant 28).totals.total.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 28).totals.total.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 28).shippingOptions` | *object[].* Optional delivery choices (1-6); the person picks one. Limits: 1-6 items. |
| `page (variant 28).shippingOptions[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 28).shippingOptions[].label` | *string, required.* e.g. "Standard" or "Express". Limits: 1-60 characters. |
| `page (variant 28).shippingOptions[].price` | *object, required.* Shipping cost; 0 shows "Free". |
| `page (variant 28).shippingOptions[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `page (variant 28).shippingOptions[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `page (variant 28).shippingOptions[].eta` | *string.* Optional delivery estimate, e.g. "Arrives Tue, Nov 10" or "3–5 business days". Limits: 1-60 characters. |
| `page (variant 28).defaultShippingOptionId` | *string.* Optional id of the preselected shipping option. Default: the first. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `page (variant 28).address` | *object.* Optional: ask for a shipping address. The browser can autofill it. Omit when you already have the address or nothing ships. |
| `page (variant 28).address.label` | *string.* Optional heading, e.g. "Shipping address" (the default) or "Delivery details". Limits: 1-40 characters. |
| `page (variant 28).address.fields` | *("name" \| "line1" \| "line2" \| "city" \| "region" \| "postalCode" \| "country" \| "phone" \| "email")[].* Optional fields to show, in this order. Default: name, line1, line2, city, region, postalCode, country. Limits: 1-9 items. |
| `page (variant 28).address.required` | *("name" \| "line1" \| "line2" \| "city" \| "region" \| "postalCode" \| "country" \| "phone" \| "email")[].* Optional fields that must be filled. Default: every shown field except line2. Limits: at most 9 items. |
| `page (variant 28).address.defaultValue` | *object.* Optional address you already know, prefilled for the person to confirm or edit. |
| `page (variant 28).address.defaultValue.name` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.line1` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.line2` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.city` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.region` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.postalCode` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.country` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.phone` | *string.* Limits: 1-200 characters. |
| `page (variant 28).address.defaultValue.email` | *string.* Limits: 1-200 characters. |
| `page (variant 28).promoNote` | *string.* Optional display-only note, e.g. "SPRING10 applied: 10% off". Codes cannot be entered on the page. Limits: 1-120 characters. |
| `page (variant 28).submitLabel` | *string.* Optional button text, e.g. "Confirm details". Default "Send cart". Do not imply a purchase ("Pay", "Place order"). Limits: 1-24 characters, matches `^[^<>]*$`. |
| `page` | *object, required.* What the person is asked to do. Pick one page type by `type`. |

Each branding field set in the dashboard (name, logo, accent color) overrides the same field in the spec; a field left empty in the dashboard falls back to the spec. Without either, the page shows the account name.

## Callbacks

Pass `callbackUrl` (https) when creating a page. When the person submits, Page Systems sends `POST <callbackUrl>` with `{ "type": "page.submitted", "pageId": "..." }` and the user agent `PageSystems-Webhook/1`. The callback carries no result, so it needs no signature: read the result with your API key (`GET /v1/pages/:id/result`, or `POST /v1/pages/:id/claim` to take it once).

When an input page expires unanswered, its link offers "Request a new page" for 24 hours. If the person presses it, Page Systems sends `POST <callbackUrl>` with `{ "type": "page.new_page_requested", "pageId": "..." }`, at most once per page. It is not an answer and does not prove who pressed it; your agent decides whether to send a new page. Opening an expired link never sends anything, and nothing is sent or retried 24 hours after expiry. Answered, cancelled and display-only pages never offer the button.

**The account remembers the last callback URL.** Each create with a `callbackUrl` also saves it on your account, and a page created *without* one is called back at that saved URL. If several agents (or a local copy of your agent) share one account, give every page its own `callbackUrl`, or use separate accounts. Pages with no callback URL at all, on an account that never sent one, get no callback: poll `GET /v1/pages/:id/result` instead. The dashboard shows the saved URL and can clear it.

- A 2xx response marks it delivered.
- 503 is retried for up to 1 hour.
- Other 5xx, timeouts and network errors are retried with backoff (30 seconds, doubling, at most 1 hour apart) for up to 24 hours.
- 410, 401, 403, redirects and other 4xx stop retrying.
- The first attempt is sent right away; retries run once a minute, up to 50 deliveries per run. Each attempt times out after 10 seconds and does not follow redirects.
- Callback URLs must use https, be at most 2048 characters, contain no username or password, and not point to a private or local address.

## Limits

- 120 requests a minute per API key, counted in fixed one-minute windows (a burst across a window boundary can briefly reach twice that).
- 300 create attempts per 10 minutes per account.
- Pages expire 1 hour after creation by default; `expiresAt` can be at most 30 days away.
- At expiry the link stops showing the page and any answer, and Page Systems deletes the page content and the answer within about a minute (an answer waits for its callback's first attempt). Read the answer before then: it cannot be recovered afterwards. The callback URL and `agentRef` are deleted 24 hours after expiry.
- Free plan: 1,000 pages a month. Pro: 5,000 included, then $0.01 a page, up to a spend cap of 10× the included pages by default. Months are calendar months in UTC, and every page created counts, whatever happens to it later.
- Anyone with a page's link can open it while it is open. Once the person answers, or the page expires or is cancelled, the link shows only a short notice, never the page content or the answer.
- The plan limits and the burst limit only pause creating pages: reading, claiming and cancelling keep working, and pages already sent stay open. The per-key rate limit applies to every call.

## Errors

Errors are `{ "error": { "code": "...", "message": "..." } }`. Messages are written to be shown to a model, which can usually act on them.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | The body is not JSON, has unknown keys, a bad `callbackUrl`, or a bad `Idempotency-Key`. |
| 400 | `invalid_spec` | The spec failed validation. The message lists each problem with its path; fix them and retry. |
| 401 | `unauthorized` | The API key is missing, malformed or revoked. |
| 402 | `payment_required` | The Free plan's 1,000 pages this month are used up. The error has `upgradeUrl` and `resetsAt`. |
| 402 | `spend_cap_reached` | A paid account reached its monthly spend cap: 10× its included pages unless the developer changed or removed it in the dashboard. The error has `upgradeUrl` and `resetsAt`. |
| 404 | `not_found` | No such page for this key (another account's page also reads as not found). |
| 409 | `already_claimed` | The result was already claimed. |
| 409 | `not_submitted` | Claim was called before the person answered. |
| 409 | `already_submitted` | Cancel was called on a page that was already answered. |
| 409 | `expired` | Cancel was called on a page that has expired. |
| 410 | `deleted` | Claim was called after the answer was deleted at expiry. An answer not read before expiry cannot be recovered. |
| 413 | `payload_too_large` | The body is larger than 64 KB. |
| 429 | `rate_limited` | Too many requests: more than 120 a minute with one key (or, on the public submit route, from one IP address). Wait `Retry-After` seconds. |
| 429 | `burst_limit` | More than 300 create attempts in 10 minutes, which looks like a runaway agent. Attempts refused by the plan limits count too. Creating resumes after `Retry-After` seconds. |
| 500 | `internal_error` | Something failed on our side. Retrying with the same `Idempotency-Key` is safe. |
