Public Form API
The public form API lives under /public/forms/:id/... and requires no authentication — only the form ID. It powers the embed widget but is also available for direct integration when you don’t want to use the widget.
Endpoints at a glance
Section titled “Endpoints at a glance”| Method | Path | Rate limit | Purpose |
|---|---|---|---|
GET | /public/forms/:id/availability | 30/min | Form-published check + captcha provider |
GET | /public/forms/:id/slots | 30/min | Bookable time slots |
GET | /public/forms/:id/appointment-types | 30/min | Allowed appointment types in a calendar |
POST | /public/forms/:id/submit | 5/min | Submit a form |
POST | /public/forms/:id/telemetry | 120/min | Batched analytics beacon (1–50 events) |
Common behavior
Section titled “Common behavior”Every public endpoint runs a shared pre-handler that:
- Loads the form by
:id. - Returns
404if the form doesn’t exist or isn’t published. - Enforces the form’s Erlaubte Domains allowlist for browser requests carrying an
Originheader (see CORS & Allowed Domains).
The standard error envelope:
{ "statusCode": 400, "message": "Field \"Email\" must be a valid email address" }Status codes: 400 validation, 403 forbidden, 404 not found, 429 rate limited, 500 internal.
Rate limits
Section titled “Rate limits”Limits are tracked per formId + IP per minute. When exceeded:
GETendpoints return429with{ "message": "Too Many Requests" }./submitreturns429and the visitor must wait until the next minute./telemetryreturns429and the widget drops the batch silently — telemetry is best-effort.
GET /availability
Section titled “GET /availability”Confirms the form is published and surfaces the configured captcha provider.
Response:
{ "available": true, "captchaProvider": "turnstile" | "recaptcha_v3" | "hcaptcha" | null}available is always true when this responds 200 — unpublished forms 404 instead.
GET /slots
Section titled “GET /slots”Returns the available time slots for a booking-calendar field.
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
appointmentTypeId | UUID | Yes | The appointment type to check |
calendarId | UUID | Yes | The calendar to search |
start | ISO-8601 | Yes | Search window start |
end | ISO-8601 | Yes | Search window end |
resourceId | UUID | No | Narrow to a single resource |
fieldId | string | No | Booking-calendar field ID — used to resolve allowedAppointmentTypes when a form has multiple booking fields |
Response:
{ data: Array<{ resourceId: string; resourceName: string; resourceColor: string; resourceSlotId: string; appointmentTypeId: string; startDateTime: string; // ISO-8601 endDateTime: string; // ISO-8601 maxParallelBookings: number; currentBookings: number; available: boolean; }>, meta: { appointmentTypeName: string; bookingLength: number; // minutes searchLength: number; // minutes }}Only slots whose underlying resource slot is marked publicly bookable are returned.
Common 400 conditions:
Calendar is not allowed for this form—calendarIdisn’t linked to any of the form’s studios.Form is not configured for booking— nobookingConfigon the resolved field.Appointment type is not allowed for this form—appointmentTypeIdnot in the field’s allowlist (unless wildcard['*']).Calendar does not match the selected studio— multi-studio form,calendarIddoesn’t belong to the resolved studio.
GET /appointment-types
Section titled “GET /appointment-types”Returns appointment types available for the given calendar in the context of this form.
Query parameters:
| Param | Type | Required | Description |
|---|---|---|---|
calendarId | UUID | Yes | The calendar to search |
fieldId | string | No | Resolves which field’s allowedAppointmentTypes to apply |
Response: array of { id, name, duration }. Only types with at least one publicly-bookable resource slot on the given calendar are returned.
POST /submit
Section titled “POST /submit”Submit a form.
Body:
{ data: Record<string, unknown>; // keyed by FormField.id (UUID) captchaToken?: string; // required if captchaProvider is set studioId?: string; // required if the form has multiple studios bookingSlot?: { appointmentTypeId: string; calendarId: string; resourceId?: string; startDateTime: string; endDateTime: string; }; // required if a visible booking-calendar field exists}Server-side validation order
Section titled “Server-side validation order”- Captcha verification (if configured) —
403 Captcha verification failedon bad token,400if missing. - Studio resolution — multi-studio forms require
studioId. - Field validation — required, min/max length, regex, format (
email,tel), options. Walks the published config, evaluatingshowIfto skip hidden fields. - Phone normalization —
telvalues are rewritten to E.164. - Appointment-type check — every visible
appointment-typefield’s UUID is re-validated against the same allowlist/appointment-typeswould return. Defends against crafted POSTs that bypass the widget. - Privacy consent —
mode: consentrequires the literal string"true". - Booking slot validation — start/end sanity, allowlist re-check, then a live availability re-fetch (the slot must still be free).
- Capacity recheck inside the transaction — race-condition guard. If the slot fills between availability fetch and insert, you get
400 The selected time slot is no longer available.
Response:
{ "success": true, "submissionId": "<uuid>" }Side effects (atomic, single transaction):
- Lead-pool entry created (unless the form is webhook-only).
- Calendar booking created (if a booking-calendar field was filled).
formSubmissionsrow written.- Webhook delivery row queued (if
webhookUrlis set).
Post-commit (best-effort, doesn’t roll back the submission):
- Automation runs triggered for
table.entry.added,calendar.appointment.{booked, hasStarted, hasEnded}. - Webhook delivery enqueued for processing — see Webhooks.
POST /telemetry
Section titled “POST /telemetry”Batched analytics beacon. The widget calls this; you generally won’t.
Body:
{ events: Array<{ name: string; // see Events doc step?: number; errorCode?: 'validation' | 'captcha' | 'network' | 'server' | 'unknown'; durationMs?: number; studioId?: string; fieldId?: string; }>; // 1–50 events per request}Response: 204 No Content.
The widget batches and flushes every 2 seconds (or at 20 events, whichever comes first) and uses navigator.sendBeacon on visibilitychange: hidden to flush before unload.
Captcha
Section titled “Captcha”The widget calls /availability first to learn the captcha provider, loads the matching SDK, renders the challenge, and sends the resulting token as captchaToken on /submit. The server verifies the token against the provider’s verify endpoint with the secret stored on the form.
| Provider | Verify endpoint |
|---|---|
turnstile | https://challenges.cloudflare.com/turnstile/v0/siteverify |
recaptcha_v3 | https://www.google.com/recaptcha/api/siteverify |
hcaptcha | https://api.hcaptcha.com/siteverify |
The public siteKey is delivered with the published form config; the secret stays server-side. Never expose the secret on the client.
CORS & Allowed Domains
Section titled “CORS & Allowed Domains”Forms can restrict which website origins are allowed to embed the widget under Form Settings → Erlaubte Domains. When the allowlist is non-empty:
- Browser requests with a disallowed
Originheader return403 Origin not allowed. - Origin comparison is case-insensitive and trailing-slash-stripped.
- CORS preflight (
OPTIONS) accepts all origins so browsers can reach the request — the allowlist is enforced on the GET/POST itself.
Errors
Section titled “Errors”Validation errors return the standard envelope with a human-readable message that includes the field’s label:
{ "statusCode": 400, "message": "Field \"Email\" must be a valid email address"}These messages are intended for end-user display. For mapping back to specific fields programmatically, see Field Types.
Related
Section titled “Related”- Embedding — the widget that calls these endpoints for you.
- Field Types — what
data[fieldId]should look like for each type. - Events — what gets emitted to
dataLayerand the telemetry beacon. - Webhooks — what your backend receives after a successful submit.