Skip to content

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.

MethodPathRate limitPurpose
GET/public/forms/:id/availability30/minForm-published check + captcha provider
GET/public/forms/:id/slots30/minBookable time slots
GET/public/forms/:id/appointment-types30/minAllowed appointment types in a calendar
POST/public/forms/:id/submit5/minSubmit a form
POST/public/forms/:id/telemetry120/minBatched analytics beacon (1–50 events)

Every public endpoint runs a shared pre-handler that:

  1. Loads the form by :id.
  2. Returns 404 if the form doesn’t exist or isn’t published.
  3. Enforces the form’s Erlaubte Domains allowlist for browser requests carrying an Origin header (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.

Limits are tracked per formId + IP per minute. When exceeded:

  • GET endpoints return 429 with { "message": "Too Many Requests" }.
  • /submit returns 429 and the visitor must wait until the next minute.
  • /telemetry returns 429 and the widget drops the batch silently — telemetry is best-effort.

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.

Returns the available time slots for a booking-calendar field.

Query parameters:

ParamTypeRequiredDescription
appointmentTypeIdUUIDYesThe appointment type to check
calendarIdUUIDYesThe calendar to search
startISO-8601YesSearch window start
endISO-8601YesSearch window end
resourceIdUUIDNoNarrow to a single resource
fieldIdstringNoBooking-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 formcalendarId isn’t linked to any of the form’s studios.
  • Form is not configured for booking — no bookingConfig on the resolved field.
  • Appointment type is not allowed for this formappointmentTypeId not in the field’s allowlist (unless wildcard ['*']).
  • Calendar does not match the selected studio — multi-studio form, calendarId doesn’t belong to the resolved studio.

Returns appointment types available for the given calendar in the context of this form.

Query parameters:

ParamTypeRequiredDescription
calendarIdUUIDYesThe calendar to search
fieldIdstringNoResolves 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.

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
}
  1. Captcha verification (if configured) — 403 Captcha verification failed on bad token, 400 if missing.
  2. Studio resolution — multi-studio forms require studioId.
  3. Field validation — required, min/max length, regex, format (email, tel), options. Walks the published config, evaluating showIf to skip hidden fields.
  4. Phone normalizationtel values are rewritten to E.164.
  5. Appointment-type check — every visible appointment-type field’s UUID is re-validated against the same allowlist /appointment-types would return. Defends against crafted POSTs that bypass the widget.
  6. Privacy consentmode: consent requires the literal string "true".
  7. Booking slot validation — start/end sanity, allowlist re-check, then a live availability re-fetch (the slot must still be free).
  8. 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).
  • formSubmissions row written.
  • Webhook delivery row queued (if webhookUrl is 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.

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.

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.

ProviderVerify endpoint
turnstilehttps://challenges.cloudflare.com/turnstile/v0/siteverify
recaptcha_v3https://www.google.com/recaptcha/api/siteverify
hcaptchahttps://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.

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 Origin header return 403 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.

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.

  • 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 dataLayer and the telemetry beacon.
  • Webhooks — what your backend receives after a successful submit.