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

# The public appointment self-booking model: opt-in gate, payload, and abuse posture

> How an end customer reads a queue's published callback slots and books one without a signed-in session — the tenant-owned opt-in flag, the request/response contract, and how the booking becomes a scheduled callback the existing dispatcher dials.

# The public appointment self-booking model

The public appointment pair answers one question — "can an end customer pick a callback slot on a contact-center queue without an account?" — with **yes, when the tenant turns it on**. Two unauthenticated endpoints expose a queue's published bookable slots (read) and accept a booking (write), identified by the tenant's public key in the URL. A successful booking lands as a scheduled callback: the same dispatcher that drives operator-side appointments calls the customer at the chosen slot.

This page is the concept map for that public surface: what the two endpoints do, the opt-in gate that keeps them off by default, the payload contract a hosted booking page codes against, and the abuse posture for unauthenticated intake. Operator-side scheduling — the signed-in dashboard that defines a queue's bookable hours and books appointments for customers — stays authoritative on the [queue callback model](/concepts/queue-callbacks-model); the public pair only re-exposes the slots the operator published.

Every control named here is **tenant-owned**: your opt-in flag, your branding and copy, your queue's bookable hours. Callback dispatches ride the Devotel softswitch exactly as operator-side callbacks do — enabling public booking never configures an outbound route.

## 1. What the public pair does

Two endpoints, both under `/api/v1/public/queues/:tenantId/:queueId`, both reachable with no session and no API key:

| Endpoint           | Verb | What it returns                                                                                                                                         |
| ------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.../availability` | GET  | The queue's bookable slots over a date window, plus the tenant's sanitised branding (logo, color, business name) and page copy (headline, body, footer) |
| `.../appointments` | POST | Books one slot for a customer phone number; returns the appointment id and slot window                                                                  |

The availability read shapes its window with optional query parameters: `from` / `to` (ISO-8601, defaulting to the next 14 days), `slot_minutes` (5–240, default 30 — the slot granularity), and `lead_minutes` (0–20160, default 60 — the minimum notice a customer must give). The booking write re-checks the same schedule, lead-time, and capacity validation the operator path applies, so a public booker can't land a slot the dashboard would refuse.

The `:tenantId` segment is the tenant's public key — the same identifier the hosted booking page embeds in the URL. Treat it as publishable: it authorizes nothing by itself, and the abuse defence below never depends on its secrecy.

## 2. The opt-in gate — off until the tenant turns it on

Public booking is gated by a tenancy-level flag, `public_appointment_booking.enabled`, stored in the organization's settings. The default is **off**: a tenant that has never opted in has no public surface at all.

The gate fails closed and indistinguishably. A tenant that is disabled, unknown, or pointing at an unknown queue gets the same answer to an anonymous probe:

* The availability GET returns `enabled: false` in the standard `{ data, meta }` envelope — the hosted page renders a friendly not-found, and the probe can't tell whether the tenant exists.
* The booking POST returns `422 INVALID_TENANT` — one generic shape for "not accepting bookings," whether the tenant is missing or merely opted out.

Enable it in the organization's settings under the `public_appointment_booking` key, optionally with branding (`logo_url`, `primary_color`, `business_name`) and copy (`headline`, `body`, `footer`) overrides. Every value the public page serves is sanitised server-side before it reaches a customer: the logo must be an http(s) URL, the color must be a 3- or 6-digit hex value, and free-form copy is truncated at a bound — so a malformed setting can never inject an external script or an off-platform URL into the page.

## 3. Payload contract

Send the booking POST as JSON:

| Field          | Type                 | Required | Notes                                                   |
| -------------- | -------------------- | -------- | ------------------------------------------------------- |
| `slot_start`   | ISO-8601 datetime    | yes      | the slot the customer picked from the availability read |
| `phone`        | E.164 string         | yes      | the number the queue will call back at the slot         |
| `name`         | string, ≤ 200 chars  | no       | shown to the agent when the callback connects           |
| `notes`        | string, ≤ 1000 chars | no       | free-form context for the agent                         |
| `slot_minutes` | integer 5–240        | no       | granularity, default 30                                 |
| `lead_minutes` | integer 0–20160      | no       | minimum notice, default 60                              |

A successful booking returns `201` with the appointment `id`, its `status`, the `queue_id`, and the `slot_start` / `slot_end` window. The failure shapes a hosted page handles:

| Status | Code                   | When                                                                        |
| ------ | ---------------------- | --------------------------------------------------------------------------- |
| 409    | `SLOT_ALREADY_BOOKED`  | another customer took the slot first — prompt for a new pick                |
| 409    | `SLOT_NOT_AVAILABLE`   | the slot moved out of the queue's published hours                           |
| 422    | `NO_BOOKABLE_SCHEDULE` | the queue publishes no booking hours at all                                 |
| 422    | `INVALID_TENANT`       | the tenant or queue isn't accepting public bookings (the enumeration guard) |
| 422    | `VALIDATION_ERROR`     | the payload failed the shape above, with per-field details                  |

Per-DID routing needs no configuration on this surface: the booking resolves to the queue's saved place in line, and dispatch uses the caller-id and routing already configured on that queue — the public payload never picks an outbound route.

## 4. Abuse posture for unauthenticated intake

An endpoint anyone on the internet can reach needs a defence that doesn't depend on a secret. The public pair layers three, and the hosted page should survive each failing:

1. **The opt-in gate** (off by default) narrows the reachable surface to tenants who chose to publish.
2. **A per-IP rate limit** on the shared public-unauthenticated bucket caps each caller at a small request budget per minute — the first defence against scripted slot-scraping and booking floods. A page that exceeds it receives a 429 and should back off.
3. **The service validation** re-checks schedule hours, lead time, and per-slot capacity on every write, so a scripted POST can't manufacture a slot the availability read would never have offered.

There is no CSRF token on the pair by design: CSRF protects browser sessions that carry credentials, and this surface carries none — a cross-site form could no more book a slot than the attacker's own curl could. The rate limit and the validation above are the relevant controls, and they apply to server-to-server abuse the same way.

## 5. How this interacts with operator-side scheduling

The public pair is a read and write window into data the operator already owns:

* The **queue's bookable hours** are defined by operator-side scheduling (the signed-in dashboard surface that publishes slots); the public availability read only re-exposes them, and a queue with no published hours returns `NO_BOOKABLE_SCHEDULE` to public bookers.
* A public booking persists as a **scheduled callback request** — the same row type an agent's wrap-up appointment or an operator-booked slot produces — and moves through the same dispatcher and outcome statuses (`pending` → `dialing` → `connected` / `failed` / `abandoned` / `canceled`).
* Operators watch and control it from the same **Voice → Callbacks** list the [queue callback model](/concepts/queue-callbacks-model) describes, with the same cancel / retry / re-prioritize controls.

Nothing on the public surface creates a queue, alters bookable hours, or picks a route — it consumes the operator's published schedule and reports back into the operator's callback list.

## 6. Worked example

Read the slots (tenant `acme`, queue `q_support`, granular 15-minute slots):

```bash theme={null}
curl "https://orbit.devotel.io/api/v1/public/queues/acme/q_support/availability?slot_minutes=15"
```

```json theme={null}
{
  "data": {
    "enabled": true,
    "branding": { "logo_url": "https://example.com/logo.png", "primary_color": "#0ea5e9", "business_name": "Acme Support" },
    "copy": { "headline": "Pick a time and we'll call you", "body": null, "footer": null },
    "queue_id": "q_support",
    "queue_name": "Support",
    "timezone": "America/New_York",
    "slot_minutes": 15,
    "lead_time_minutes": 60,
    "from": "2026-09-27T14:00:00Z",
    "to": "2026-10-11T14:00:00Z",
    "slots": [
      { "start": "2026-09-27T15:00:00Z", "end": "2026-09-27T15:15:00Z", "available": true }
    ],
    "reason": "ok"
  },
  "meta": { "request_id": "req_01H…", "timestamp": "2026-09-27T14:00:00Z" }
}
```

Book the first available slot:

```bash theme={null}
curl -X POST "https://orbit.devotel.io/api/v1/public/queues/acme/q_support/appointments" \
  -H "Content-Type: application/json" \
  -d '{"slot_start":"2026-09-27T15:00:00Z","phone":"+14155552671","name":"Sam"}'
```

```json theme={null}
{
  "data": {
    "id": "cb_01J…",
    "status": "pending",
    "queue_id": "q_support",
    "slot_start": "2026-09-27T15:00:00Z",
    "slot_end": "2026-09-27T15:15:00Z"
  },
  "meta": { "request_id": "req_01H…", "timestamp": "2026-09-27T14:00:01Z" }
}
```

The customer now holds a 201 booking the dispatcher will dial at 15:00; an operator sees the same row on **Voice → Callbacks**.

## 7. Compliance posture

Enabling public booking is a **tenant-owned control**: the opt-in flag lives in the tenant's own organization settings, and the platform neither mandates nor defaults it on. The tenant decides which queues to publish, what their bookable hours are, and whether the public surface exists at all; disabling the flag returns the surface to its probe-proof disabled shape with no residue.

A customer's booking is recorded as their consent to be called at the chosen slot, in parity with the consent the IVR callback offer records; the platform's audit trail keeps the caller's number masked. Calling windows and emergency-number guards that apply to any outbound callback apply here unchanged — a booked slot never dispatches outside the recipient's permitted window. Consult your own counsel on the calling-consent rules that apply in your jurisdiction; the platform provides the recording mechanism, not the legal determination.

## Related

* [The queue callback model](/concepts/queue-callbacks-model) — the operator-side lifecycle and outcome statuses a public booking joins.
* [Set up and run voice queues](/guides/voice-queues) — define the queue whose hours the public page publishes.
* [Voice queue callbacks with SLA policies and scheduling](/voice/callbacks-scheduling) — the API field inventory for scheduled callbacks, including the `scheduled_for` window the public pair writes.
