> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deepcontext.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Bookings API

> Take bookings from a custom website: read services, read open times, submit one booking.

Three public, unauthenticated endpoints that let a website take a booking on a tenant's
behalf. The flow is deliberately one-way — show services, show open times, submit, say
thank you. Everything after that (confirming, rescheduling, cancelling, and the booking
record itself) happens in the DPC dashboard, where the operator works.

```
https://deepcontext.app/api/public/tenants/{slug}
```

Our own hosted page at `/book/{slug}` calls these exact endpoints, and shares its config
loader and its answer validator with them. Anything the hosted page does is legal here.

<Note>
  **If the only requirement is different styling, don't build against this API.** The
  hosted page already picks up the tenant's branding, and it can be mounted on
  `book.{customerdomain}` with a single CNAME — no code in their repo. Build against the
  API when the *flow* has to be different.
</Note>

## Before you start

Three things are provisioned by us, per site. Ask for all three before writing code.

<CardGroup cols={3}>
  <Card title="Tenant slug" icon="hashtag">
    The `{slug}` in every URL. There is no endpoint to discover it.
  </Card>

  <Card title="Turnstile site key" icon="shield-check">
    Plus your domain added to that widget's allowlist.
  </Card>

  <Card title="Origin allowlist" icon="globe">
    Your site's origin, added to the tenant's `allowed_origins`.
  </Card>
</CardGroup>

**You cannot bring your own Turnstile key.** Every booking is verified against one
server-side secret, so a token minted by a different site key is rejected with
`turnstile_failed`. You must render the widget with the site key we give you, and we must
add your domain to that widget's allowlist — otherwise the widget refuses to render on
your domain and you never get a token at all.

**Cross-origin submits need the allowlist.** Both `GET` endpoints send
`Access-Control-Allow-Origin: *` and work from anywhere. The `POST` compares your
`Origin` against the tenant's configured list and returns `403 origin_not_allowed` if it
isn't there. (This is browser hygiene, not authentication — the abuse controls are the
real gate.)

## The whole flow

This is the entire integration.

```js theme={null}
const BASE = "https://deepcontext.app/api/public/tenants/YOUR_SLUG";

// 1. Services, their questions, and the tenant's timezone.
const config = await fetch(BASE).then((r) => r.json());
const service = config.services[0];

// 2. Open start times. UTC instants, already filtered for notice/horizon/buffers.
const from = new Date().toISOString();
const to = new Date(Date.now() + 60 * 86_400_000).toISOString();
const { slots } = await fetch(
  `${BASE}/availability?service_id=${service.id}` +
    `&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
).then((r) => r.json());

// 3. Submit. One key per booking attempt — reuse it if you retry the SAME booking,
//    generate a new one when the customer picks a different slot.
const res = await fetch(`${BASE}/bookings`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    service_id: service.id,
    starts_at: slots[0],
    customer_name: "Jane Customer",
    customer_email: "jane@example.com",
    customer_phone: "+14155550123",
    form_data: { "22222222-2222-4222-8222-222222222222": "Wool" },
    turnstile_token: tokenFromWidget,
    website: "", // honeypot — must stay empty
  }),
});

const body = await res.json();
if (res.status === 201) {
  showSuccess(body.booking.status, body.acknowledgment);
}
```

## Endpoints

Full schemas, parameters, and response shapes live in the
**[API reference](/reference/config)** — generated from the Zod schemas the routes
actually enforce, so it cannot drift from the running code.

|                                             |                                         |
| ------------------------------------------- | --------------------------------------- |
| [Get configuration](/reference/config)      | Services, questions, branding, timezone |
| [Get availability](/reference/availability) | Open start times for one service        |
| [Create booking](/reference/create-booking) | The single write                        |

The two `GET` endpoints are unauthenticated and send `Access-Control-Allow-Origin: *`, so
the playground on those pages will happily run against a real slug. The `POST` playground
is read-only — it can't mint a Turnstile token, and firing it would write a real booking.

## Questions

Questions belong to a **service**, not to the tenant. Each entry in `services[]` carries
its own `form_fields`, and there is no tenant-wide list. If the customer switches service,
re-render from the new service's list and drop answers the new service doesn't ask —
submitting a question id this service doesn't ask is a hard `400 invalid_form_data`.

`form_data` is keyed by the question's `id`. Five types:

| `type`    | Send                     | Rules                                                                               |
| --------- | ------------------------ | ----------------------------------------------------------------------------------- |
| `text`    | string                   | Trimmed. Whitespace-only counts as unanswered.                                      |
| `number`  | number or numeric string | Both are stored as a number. Values above 2^53−1 are rejected.                      |
| `date`    | `"YYYY-MM-DD"`           | Must be a real calendar date — `2026-02-31` is rejected.                            |
| `select`  | string                   | Must match one of `options` exactly, after trimming.                                |
| `boolean` | `true` / `false`         | An unchecked box is a real `false`, never omitted. A boolean can't fail `required`. |

Optional answers left blank are simply omitted — don't send `""`.

<Note>
  The server returns only the **first** validation failure, as a single string
  (`"<question-id>: Choose one of the listed options."`). To show per-field messages,
  mirror the table above client-side. Validating before you submit is worth the effort
  anyway: a server-side rejection burns the customer's single-use Turnstile token, so
  they have to solve a second challenge.
</Note>

This mirrors the server's rules. Pass it the selected service's `form_fields` and your
raw form state; send `values` as `form_data` and render `errors` under each input.

```js theme={null}
function normalizeAnswers(fields, answers) {
  const values = {};
  const errors = {};

  for (const field of fields) {
    const raw = answers[field.id];

    // An unchecked box is a real `false`, not a missing answer, so a boolean is
    // never blank and can never fail `required`. Anything that isn't a boolean is
    // an error, not a falsy value — coercing it would store `false` for an answer
    // the server rejects.
    if (field.type === "boolean") {
      if (raw == null) values[field.id] = false;
      else if (typeof raw === "boolean") values[field.id] = raw;
      else errors[field.id] = "Choose yes or no.";
      continue;
    }

    if (raw == null || (typeof raw === "string" && raw.trim() === "")) {
      if (field.required) errors[field.id] = "This is required.";
      continue; // omit optional blanks — never send ""
    }

    if (field.type === "number") {
      // Inputs give you strings; both a number and a numeric string are accepted.
      // Past 2^53−1 a JS number silently rounds, so the server rejects it.
      const n = Number(raw);
      if (!Number.isFinite(n) || Math.abs(n) > Number.MAX_SAFE_INTEGER) {
        errors[field.id] = "Enter a valid number.";
      } else {
        values[field.id] = n;
      }
    } else if (field.type === "select") {
      const value = typeof raw === "string" ? raw.trim() : null;
      if (value === null || !(field.options ?? []).includes(value)) {
        errors[field.id] = "Choose one of the listed options.";
      } else {
        values[field.id] = value;
      }
    } else if (field.type === "date") {
      // Round-tripping catches dates the regex accepts but the calendar doesn't:
      // 2026-02-31 rolls forward to March.
      const value = typeof raw === "string" ? raw.trim() : "";
      const real =
        /^\d{4}-\d{2}-\d{2}$/.test(value) &&
        new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value;
      if (!real) errors[field.id] = "Enter a valid date.";
      else values[field.id] = value;
    } else {
      // Don't coerce: the server requires a string here, so `String(raw)` would
      // accept an answer it rejects.
      if (typeof raw !== "string") errors[field.id] = "Enter a valid answer.";
      else values[field.id] = raw.trim();
    }
  }

  return { values, errors };
}
```

Iterating `fields` rather than your form state is what keeps a stale answer — from a
customer who switched service — out of the payload.

## What to show afterwards

Branch on `booking.status` from the `201`. A newly created booking is always one of these
two, and the `acknowledgment` string is already worded correctly for each if you'd rather
just print it. Handle the rest of the union as "we couldn't confirm this" — a retried
`Idempotency-Key` [replays the stored booking](/reference/create-booking), which the
operator may have cancelled in the meantime.

<Tabs>
  <Tab title="confirmed">
    Instant-booking tenant. The customer is booked, and a confirmation email is on its
    way. *"You're booked — check your email for the details."*
  </Tab>

  <Tab title="pending">
    Request-mode tenant. The slot is **requested**, not held. The customer gets a
    "we received your request" email now, and the operator confirms by hand.
    *"Thanks — {business} will get back to you to confirm."*
  </Tab>
</Tabs>

<Warning>
  **Don't promise a confirmation email on a `pending` booking.** The system sends exactly
  one customer email, at submit time. When the operator later confirms the booking in the
  dashboard, nothing is sent to the customer — the operator follows up themselves. Copy
  like "we'll email you once it's confirmed" is a promise the platform does not currently
  keep.
</Warning>

## Errors

Every error has the same shape:

```json theme={null}
{ "error": { "code": "invalid_request", "message": "Human-readable explanation" } }
```

The ones a submit form should actually handle:

| Status | Code                     | What to do                                                                            |
| ------ | ------------------------ | ------------------------------------------------------------------------------------- |
| 400    | `invalid_form_data`      | Show the message against the form. Reset the Turnstile widget.                        |
| 400    | `turnstile_failed`       | Reset the widget and ask them to try again.                                           |
| 400    | `invalid_service`        | Reload the config — the service was deactivated.                                      |
| 403    | `origin_not_allowed`     | Your origin isn't allowlisted. Not a customer-facing error — call us.                 |
| 409    | `booking_conflict`       | Someone took the slot. Refetch availability with a cache-buster, ask them to re-pick. |
| 429    | `rate_limit_exceeded`    | Short-window IP limit. Ask them to wait a moment.                                     |
| 429    | `daily_quota_exceeded`   | 5 bookings/day per contact, 50/day per IP. Resets at UTC midnight.                    |
| 503    | `abuse_gate_unavailable` | Our side. Ask them to try again shortly.                                              |

Neither `429` sends a `Retry-After` header, so back off on a fixed delay.

## Not in this API

There is no public read-back, cancel, or reschedule. The `201` is the only receipt a
website ever gets, and a booking cannot be looked up again once the page is closed.
Confirming, rescheduling, cancelling, and the full booking history all live in the DPC
dashboard. Point operators there; don't build a "manage my booking" page against this API.

Server-to-server credentials — a Turnstile-free authenticated flow for non-browser
clients — are not available yet.
