# Viali API

Public HTTP endpoints for connecting external systems (Make, Zapier, ad platforms, custom backends, AI agents) to a Viali clinic.

> This document is the single source of truth for Viali's public API.
> It is served raw at `/api.md` and rendered at `/api` for humans.

## Authentication

Each hospital generates its own API key. The key is passed as a `?key=` query parameter on every request.

**Getting a key:** ask a Viali admin at your clinic to open `/admin/integrations` → **API Key** tab and share the hospital ID and generated key with you.

## Base URL

Replace `https://<your-viali-host>` in the examples below with the host your clinic runs on (for example `https://use.viali.app`).

---

## Booking link (/book)

The public booking page each hospital publishes at:

```
/book/<HOSPITAL_BOOKING_TOKEN>
```

No API key needed — the booking token in the URL identifies the hospital. Append query parameters to preselect a service, prefill patient fields, or attach campaign tracking.

### Service selection

| Name | Type | Example | Notes |
|---|---|---|---|
| `service` | string | `rhinoplasty` | Service code to preselect |
| `service_group` | string | `aesthetic-face` | Filter services that belong to this group (a service may belong to multiple groups) |
| `provider` | uuid | `a1b2c3d4-…` | Preselect a specific provider |

### Patient prefill

| Name | Type | Example | Notes |
|---|---|---|---|
| `firstName` | string | `Maria` | Prefill patient first name |
| `surname` | string | `Müller` | Prefill patient surname |
| `email` | string | `maria@example.com` | |
| `phone` | string | `+41791234567` | |

### UTM tracking

| Name | Type | Example |
|---|---|---|
| `utm_source` | string | `google` |
| `utm_medium` | string | `cpc` |
| `utm_campaign` | string | `spring-2026` |
| `utm_term` | string | `brustvergroesserung` |
| `utm_content` | string | `ad-variant-a` |

### Ad click IDs

Captured on the lead record and echoed back through the Conversions API so ad platforms can match conversions to clicks.

| Name | Type | Example | Notes |
|---|---|---|---|
| `gclid` | string | `abc123…` | Google Ads click ID |
| `gbraid` | string | `0AAAAA…` | Google Ads (iOS app) |
| `wbraid` | string | `0AAAAA…` | Google Ads (web→app) |
| `fbclid` | string | `IwAR0…` | Meta click ID |
| `ttclid` | string | `E.C.…` | TikTok click ID |
| `msclkid` | string | `abc123` | Microsoft Ads click ID |
| `igshid` | string | `MzRlO…` | Instagram share ID |
| `li_fat_id` | string | `abc123` | LinkedIn click ID |
| `twclid` | string | `abc123` | Twitter/X click ID |

### Misc

| Name | Type | Example | Notes |
|---|---|---|---|
| `ref` | string | `partner-site` | Free-form referrer label (marketing/lead-source attribution) |
| `refcode` | string | `A3KD9F` | Internal referral program: a patient's personal share code. When a friend books with it, the patient→patient referral is recorded automatically (reward unlocks on the friend's first treatment). Distinct from `ref`. Invalid/self/duplicate codes are silently ignored — the booking still succeeds. |
| `campaign_id` | string | `12345` | |
| `adset_id` | string | `67890` | |
| `ad_id` | string | `24680` | |
| `promo` | string | `SPRING20` | Promo code to apply |
| `pcode` | string | `ACME10` | One typed field for any of the three code kinds a clinic hands out — a discount promo code, a patient's personal share code, or a partner's referral code. Resolve it client-side with `GET /api/public/booking/<TOKEN>/code/<CODE>` before submitting the booking; the endpoint reports which kind matched (or `unknown`) without ever revealing whether a code exists to someone who isn't allowed to know. |
| `embed` | boolean | `true` | Hides chrome for iframe embedding. `true`, `1`, `yes`, `on` and a bare `?embed` all mean the same thing; `false`, `0` and `no` mean not embedded. |
| `fe` | string | `<HMAC-signed token>` | Marketing flow per-execution attribution token (generated by Flows A/B send loop). Binds the booking to a specific campaign send + variant. Integrators should NOT forge this — invalid tokens are silently ignored and the booking falls back to UTM attribution. |
| `lang` | string | `fr` | Page language: `de`, `en`, `it`, `es` or `fr`. Pass your own website's language when you embed the page. See "Page language" below. Also accepted on the group link `/book/g/<TOKEN>`, which carries it on to the chosen location. |

### Page language

The booking page speaks the patient's language. It picks the first of these that
is one of the five supported languages (`de`, `en`, `it`, `es`, `fr`; a region
such as `fr-CH` counts as `fr`):

1. the `lang` parameter;
2. the language stored on the lead, when the link came from a lead invitation e-mail;
3. the visitor's browser languages, in their order of preference;
4. the clinic's default language (Settings → Default Language);
5. German.

An unsupported value is skipped, never an error. The chosen day and time are shown
in the clinic's own date and hour format and in the clinic's timezone — the
`hospital.dateFormat` (`european` | `american`) and `hospital.hourFormat`
(`24h` | `12h`) fields of `GET /api/public/booking/<TOKEN>`. The page sends
its language with the booking (`language` on `POST …/book`, see "Optional: the
booking language" below), so the confirmation e-mail and SMS are written in the
language the patient booked in.

### Example

```
https://<your-viali-host>/book/<HOSPITAL_BOOKING_TOKEN>?service=rhinoplasty&firstName=Maria&email=maria@example.com&utm_source=google&utm_campaign=spring-2026&gclid=abc123
```

### Embedding the page behind your own URL

A clinic website that opens the booking page in an iframe, a modal or a
redirect **must copy its own query string onto the booking URL**. Every
parameter in this section is read from the address of the booking page
itself, not from the page around it, so a parameter left on the outer URL is
lost in silence — the booking still succeeds, with no code and no
attribution.

```js
const target = new URL("https://<your-viali-host>/book/<TOKEN>");
new URL(location.href).searchParams.forEach((v, k) => target.searchParams.set(k, v));
target.searchParams.set("embed", "true");
iframe.src = target.toString();
```

Copy everything rather than an allow-list: the set grows, and a parameter
nobody remembered to add is indistinguishable from a visitor who never had
one. This matters most for `pcode` — a partner who links to
`yourclinic.example/appointment?pcode=ACME10` earns no commission if the
wrapper drops it.

**Framing is allowed from any origin.** Both public pages — `/book/<TOKEN>`
and the vacancies page `/careers/<TOKEN>` — send
`Content-Security-Policy: frame-ancestors *` and no `X-Frame-Options`, so a
clinic can embed either one on its own website without asking for its domain
to be added anywhere. No other page of the application can be framed.

---

## Group booking link (/book/g/:token)

For hospital chains (multi-location groups), a group-level booking link renders a location picker before forwarding to the selected location's per-hospital booking flow:

```
/book/g/<GROUP_BOOKING_TOKEN>
```

The patient picks a location; the page then navigates to `/book/<HOSPITAL_BOOKING_TOKEN>?group=<GROUP_ID>`, re-entering the standard per-hospital booking flow. Services that belong to the group's shared catalog appear on every member location's booking page; the provider list is always scoped to the picked location.

### JSON endpoint

| Method | Path | Purpose |
|---|---|---|
| GET | `/api/public/group-booking/:token` | Resolve a group booking token → group + member hospitals |

**Response:**

```json
{
  "group": { "id": "<GROUP_ID>", "name": "Beauty2Go" },
  "hospitals": [
    { "id": "<HOSP_ID_A>", "name": "Stuttgart", "address": "…", "bookingToken": "<HOSPITAL_BOOKING_TOKEN_A>" },
    { "id": "<HOSP_ID_B>", "name": "München",  "address": "…", "bookingToken": "<HOSPITAL_BOOKING_TOKEN_B>" }
  ]
}
```

Returns `404 { code: "GROUP_NOT_FOUND" }` if the token doesn't match any group. No API key required — the group booking token in the URL identifies the group, matching the per-hospital `/book/:token` precedent.

---

## Vacancies page (/careers/:token)

The clinic's open positions and its application form, on one public URL. The
token identifies the clinic; no API key is required, matching the
`/book/:token` precedent.

```
https://<your-viali-host>/careers/<CAREERS_TOKEN>
```

The job board, a single posting and the application form are all internal
states of that one URL — there is nothing else to link to, and no per-posting
address to keep up to date.

The application form accepts documents as PDF or Word only (`.pdf`, `.doc`,
`.docx`). Other formats, such as Apple Pages files, images or archives, are
refused with a message asking the applicant to export them as PDF first.

### URL parameters

| Parameter | Type | Example | Notes |
|---|---|---|---|
| `embed` | boolean | `1` | Hides chrome for iframe embedding — the language switcher, the theme toggle, the clinic logo and the heading — and drops the full-height background to transparent so the page sits inside your own card. Same accepted values as on `/book`. |

### Embedding

```html
<iframe id="viali-careers"
        src="https://<your-viali-host>/careers/<CAREERS_TOKEN>?embed=1"
        style="width:100%;height:600px;border:0"></iframe>
<script>
  addEventListener("message", (e) => {
    if (e.data?.type === "careers_height" && typeof e.data.height === "number") {
      document.getElementById("viali-careers").style.height = e.data.height + "px";
    }
  });
</script>
```

In embed mode the page posts its own content height to the parent window as
`{ type: "careers_height", height: <pixels> }` — on load, on resize, and
whenever the content grows, which is what happens when a visitor opens a
posting or switches to the application form. Size the iframe from that message
rather than fixing a height, or the job list gets an inner scrollbar.

Framing is allowed from any origin (see below), so no domain has to be
registered anywhere first.

---

## Short links (/CODE/q, /CODE/s, /CODE/b, /CODE/w, /CODE/k, /CODE/p, /CODE/o, /CODE/r)

Each clinic may set a short, memorable code (3–5 uppercase letters/digits, e.g. `MUE`) that namespaces its public links for posters, SMS, and QR codes:

```
/<CODE>/q   → the clinic questionnaire
/<CODE>/s   → the external surgery reservation page
/<CODE>/b   → the booking page (/book/:token)
/<CODE>/w   → the worker portal login
/<CODE>/k   → the worktime kiosk (/kiosk/:token)
/<CODE>/p   → the partner portal login (/partner-login/:token) — one clinic-wide link for referral partners
/<CODE>/o   → the patient portal login (/patient-login/:token) — one clinic-wide link for patients; the patient types an email address or mobile number and receives a one-time code
/<CODE>/r   → the public cleaning status of the clinic's common areas (/cleaning/:token) — see "Cleaning status of common areas" below
```

The code resolves server-side against the clinic's **current** token on every request — it is a stable alias, not a copy of the token. Rotating the underlying token does not break an already-printed short link.

A clinic may rename its code. Every code the clinic has ever held keeps resolving to that clinic forever, and a retired code can never be claimed by a different clinic — old printed material stays valid indefinitely.

An unresolvable code, kind letter, or missing token redirects (`302`) to `/link-unavailable` instead of erroring.

The booking short link keeps its query string: `/<CODE>/b?embed=1&service=<code>&provider=<id>&utm_source=…` redirects to `/book/<token>?embed=1&service=<code>&provider=<id>&utm_source=…` unchanged. Every `/book` URL parameter therefore works on the short link too. The other short-link kinds do not carry a query string.

To put the booking page on a clinic website, embed it in an iframe:

```html
<iframe src="https://<your-viali-host>/book/<TOKEN>?embed=1"
        title="Online appointment booking"
        width="100%" height="820" style="border:0;max-width:760px" loading="lazy"></iframe>
```

New clinics get a booking token and a short code automatically at sign-up; both are shown in the setup wizard's "Online booking" step together with this embed code.

---

## Booking API (JSON)

For agents, backends, and automation tools that want to create an appointment
*without* rendering the `/book` HTML page, Viali exposes 11 JSON endpoints
under `/api/public/booking/:token/*`. The booking token in the URL
identifies the hospital — no API key is required.

> A machine-readable OpenAPI 3.1 schema is served at `/api/openapi.json`
> (YAML at `/api/openapi.yaml`). AI agents: that is the fastest path to
> a working client.

### Happy-path flow

1. `GET /api/public/booking/<TOKEN>/services` — list services
2. `GET /api/public/booking/<TOKEN>/providers/<PROVIDER_ID>/slots?date=YYYY-MM-DD` — list slots on a date
3. `POST /api/public/booking/<TOKEN>/book` — create the appointment

### Endpoints

| Method | Path | Purpose |
|---|---|---|
| GET | `/api/public/booking/:token` | Hospital + providers |
| GET | `/api/public/booking/:token/services` | Service list |
| GET | `/api/public/booking/:token/closures` | Blocked dates |
| GET | `/api/public/booking/:token/providers/:providerId/available-dates?month=YYYY-MM&serviceId=<id>` | Dates with free slots in one month (`serviceId` optional) |
| GET | `/api/public/booking/:token/providers/:providerId/slots?date=YYYY-MM-DD&serviceId=<id>` | Slots on a given date (`serviceId` optional) |
| GET | `/api/public/booking/:token/best-provider?service=<code>&date=YYYY-MM-DD` | Next-available heuristic |
| GET | `/api/public/booking/:token/prefill?token=<prefill-token>` | Prefill from short-lived token |
| GET | `/api/public/booking/:token/promo/:code` | Validate a promo code |
| GET | `/api/public/booking/:token/code/:code` | Resolve the unified `pcode` field to its owner's kind (promo, patient, or partner) |
| POST | `/api/public/booking/:token/referral-precheck` | Whether to skip the referral-source step for a known, already-sourced patient |
| POST | `/api/public/booking/:token/book` | Create appointment |

### The service list

`GET /api/public/booking/<TOKEN>/services` returns `{ "services": [...] }`.
Only services with at least one bookable provider are listed.

```json
{
  "services": [
    {
      "id": "a1b2c3d4-…",
      "name": "Augenbrauenlifting & Stirnlifting",
      "description": "Anhebung der Brauen für jugendlicheres Erscheinungsbild",
      "durationMinutes": 30,
      "code": "brow-lift",
      "serviceGroup": "gesicht",
      "serviceGroups": ["gesicht"],
      "sortOrder": 0,
      "price": "450.00",
      "providerIds": ["e5f6a7b8-…"]
    }
  ]
}
```

`price` is a decimal string in the clinic's currency, or `null` when the
service is unpriced. Treat `null` and `"0.00"` alike — both mean the
consultation is free. Use `code` with `?service=` deep links.

**Appointment length.** A service's `durationMinutes` defines how long the
appointment lasts. Pass the chosen service's `id` as `serviceId` to
`available-dates` and `slots` (and in the `POST /book` body): only start
times where the whole duration fits are returned and accepted, and each slot's
`endTime` is `startTime` + `durationMinutes`. Without a service, or for a
service without a duration, the clinic's slot length applies (30 minutes unless
the clinic set another).

The currency itself comes from `GET /api/public/booking/<TOKEN>`, whose
`hospital.currency` field carries the clinic's regional setting
(`CHF` | `EUR` | `USD`, defaulting to `CHF`).

### Resolving a typed code

`GET /api/public/booking/<TOKEN>/code/<CODE>` backs the single `pcode`
field on the booking page. A clinic hands out three kinds of code — a
discount promo code, a patient's personal share code, and a partner's
referral code — and this endpoint is the one place that tells them apart,
so a client never has to fire three lookups (which would itself leak which
namespace matched, by timing).

```json
{ "kind": "promo", "code": "SPRING20", "discountType": "percent", "discountValue": "15" }
{ "kind": "partner", "label": "Acme Aesthetics" }
{ "kind": "patient" }
{ "kind": "unknown" }
```

`kind` is always one of `promo` | `patient` | `partner` | `unknown`, and
the response is always `200` — never `404`, even when nothing matches.
A 404 would let a caller walk the code space and learn which codes exist
at a clinic. For the same reason a `patient` result never carries a name,
initials, or id: a card printed with a share code must not become a way
to look up who holds it. A partner's `label` is safe to show because it
already appears on the partner's own marketing material, and a paused or
ended partner's code resolves as `unknown` so it cannot accrue new
claims. A promo code that has expired, has reached its usage limit, or
belongs to another clinic of the group without being group-wide also
resolves as `unknown` — the same rules `/promo/<CODE>` applies.
`currency` is present only alongside a `fixed`-type promo discount.

The `code` field on a `promo` result is the stored code, which is not
always what was sent: the lookup ignores case and punctuation on both sides,
so `spring-20` resolves `SPRING20` and `birthd7f3k9q` resolves
`BIRTHD-7F3K9Q`. Redemption at `POST …/book` matches exactly, so submit
`promoCode` with the value this field returns.

A clinic can hand each recipient of a campaign their own single-use code
(e.g. `BIRTHD-7F3K9Q`), issued from a template offer. Such a personal code
resolves like any other promo code until it has been used or has expired.
The template's own code is never redeemable: `/promo/<CODE>` answers
`PROMO_INVALID` and `/code/<CODE>` answers `unknown` for it.

#### What the booking does with each kind

Send the raw text as `typedCode` on `POST …/book` **whatever the lookup
said**, including `unknown`. The server resolves it again itself and acts on
the result; the lookup above only decides what to show the visitor. An
unrecognised code never fails a booking.

| Resolved kind | What the booking records |
|---|---|
| `partner` | A partner claim, plus a referral event with `source: "partner"` and the partner's name as the detail. A paused or ended partner, or a claim a staff member has voided, records nothing. |
| `patient` | The patient-to-patient referral, exactly as `?refcode=` does. The reward mints later, on the referred patient's first treatment. |
| `promo` | Nothing on its own — send `promoCode` as well, using the resolved `code`. |
| `unknown` | The raw text on the referral event, so the front desk can still see it. |

Also send `codeSource`: `"url"` when a `?pcode=` value reached the form
untouched, `"typed"` when the visitor entered or edited it. A partner claim
records the first as `link` and the second as `code`, and that distinction
decides a later dispute over a rejected claim. Omitting the field is allowed
and records the weaker grade.

### Creating a booking

```bash
curl -X POST https://<your-viali-host>/api/public/booking/<TOKEN>/book \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f2c1e9a-bf6d-4c2a-9a1e-91b9c5d1e3a4" \
  -d '{
    "providerId": "a1b2c3d4-…",
    "date": "2026-05-12",
    "startTime": "10:00",
    "endTime": "10:30",
    "firstName": "Maria",
    "surname": "Müller",
    "email": "maria@example.com",
    "phone": "+41791234567"
  }'
```

A new booking answers `201` with
`{ "success": true, "appointmentId": "…", "confirmationChannel": "sms" }`.
`confirmationChannel` is `"sms"`, `"email"` or `null` — the channel the patient's
confirmation is sent on first (the clinic's SMS-first or e-mail-first setting and the
contact data decide it), so a booking form can say "confirmation sent by SMS to …"
instead of always naming the e-mail address. It is the first choice, not a delivery
receipt, and an idempotent replay (`200`) does not carry it.

### Optional: what the appointment is for

- `intent` — optional. One of `consultation`, `follow_up`, `treatment`.
  Records what the appointment is for. Omitting it is valid and is the normal case:
  the clinic's own booking page no longer asks, and the server then classifies the
  appointment itself from the patient's history at that clinic. The classification is
  never returned in the response.

### Optional: the booking language

- `language` — optional. The language the visitor booked in: `de`, `en`, `it`,
  `es` or `fr` (a region such as `fr-CH` counts as `fr`). A value outside these
  five is ignored, never an error. The booking's confirmation e-mail and SMS are written
  in this language. When the booking creates a new patient, it is also stored as that
  patient's preferred language, so later reminders follow it; an existing patient's
  stored preference is never changed. Omitted, messages use the patient's stored
  preference, else the clinic's default language.

### Idempotency

Send an `Idempotency-Key` header (any unique string, ≤ 200 chars — a v4 UUID
is a safe default) to make the booking retriable:

- Same key + same body within 24h → returns the original appointment with status
  `200` and header `X-Idempotent-Replay: true`.
- Same key + different body → returns `409` with `code: "IDEMPOTENCY_CONFLICT"`.

### No-show fee acknowledgement

If `GET /api/public/booking/:token` returns a non-null `hospital.noShowFeeMessage`,
agents **must**:

1. Surface that message to the user before booking and include
   `noShowFeeAcknowledged: true` in the `POST /book` body. Omitting it
   returns `400 NOSHOW_FEE_ACK_REQUIRED`.
2. Collect an invoiceable address and include `street`, `postalCode`,
   and `city` in the same body — the clinic needs them to bill a no-show.
   Missing any of the three returns `400 INVALID_BOOKING_DATA` with
   field errors on `street`, `postalCode`, or `city`.

### Cancelling an appointment

Two endpoints, both under `/api/clinic/appointments/`. These accept the
single-use action token delivered to the patient by email/SMS after
booking. An agent with access to the patient's inbox has this token.

```bash
# 1) Inspect the appointment + any fee notice BEFORE cancelling.
curl https://<your-viali-host>/api/clinic/appointments/cancel-info/<TOKEN>

# 2) Once the user has acknowledged any fee, cancel.
curl -X POST https://<your-viali-host>/api/clinic/appointments/cancel-by-token \
  -H "Content-Type: application/json" \
  -d '{ "token": "<TOKEN>", "reason": "patient requested" }'
```

Agents **must** fetch `cancel-info` first and relay `noShowFeeMessage` to the
user — the clinic may charge a no-show fee for late cancellations.

If the clinic has `hidePatientCancel` set, `cancel-by-token` returns
`403 CANCELLATION_DISABLED`. `cancel-info` returns `hidePatientCancel: true`
in the body so agents can surface this before attempting the cancel.

### Error responses

Every error response under `/api/public/booking/*` and the `/api/clinic/appointments/cancel-*` endpoints returns:

```json
{ "code": "SLOT_TAKEN", "message": "The selected slot is no longer available." }
```

Stable codes:

| Code | HTTP | When |
|---|---|---|
| `SLOT_TAKEN` | 409 | Slot was taken between availability query and book |
| `INVALID_BOOKING_DATA` | 400 | Body failed schema validation; response also contains `fieldErrors` |
| `BOOKING_IN_PAST` | 400 | The requested start lies in the past (checked in the clinic's timezone) |
| `REFERRAL_REQUIRED` | 400 | Hospital requires UTM / referral source |
| `NOSHOW_FEE_ACK_REQUIRED` | 400 | Clinic has a no-show fee notice; `noShowFeeAcknowledged: true` missing from payload |
| `PROVIDER_NOT_BOOKABLE` | 404 | Provider not public / not bookable |
| `HOSPITAL_NOT_FOUND` | 404 | Booking token invalid or disabled |
| `PROMO_INVALID` | 404 | Promo code unknown or expired |
| `CANCELLATION_DISABLED` | 403 | Clinic has `hidePatientCancel` enabled |
| `RATE_LIMITED` | 429 | Rate limiter tripped |
| `IDEMPOTENCY_CONFLICT` | 409 | Same `Idempotency-Key` with a different body |

Note: the cancellation endpoints also return a small set of non-catalog state-transition shapes (`{ message, alreadyUsed: true }` on 410, `{ message, status }` on 409) that predate the catalog and are kept for backwards compatibility with the existing SPA.

### Rate limits

- Reads (`GET /api/public/booking/*`): 300 req/min/IP (shared with the rest of the API)
- Booking submissions (`POST /book`): 30 per 15 min per IP
- Responses include standard `RateLimit-*` headers

### CORS

All `/api/public/booking/*` and `/api/clinic/appointments/cancel-*` responses include `Access-Control-Allow-Origin: *`.
Browser-based agents and site chatbots can call these endpoints directly.

---

## MCP server for SOPs, checklists, inventory and support requests (/mcp)

An MCP (Model Context Protocol) endpoint that lets an AI agent work on ONE clinic's
SOPs / guidelines, recurring checklists, inventory and support requests to Viali. No patient
data is reachable: every tool result passes a patient-data guard that refuses a result carrying
patient identifiers.

- **URL:** `https://use.viali.app/mcp` — Streamable HTTP, stateless, `POST` only.
- **Auth:** a bearer token in the `Authorization` header, never as a query parameter. Two kinds:
  - **OAuth 2.1 (Claude and ChatGPT connectors):** `Bearer vot_…`, issued by the flow below.
    It acts as the person who allowed it, in ONE clinic. Edits are recorded as "<Name> (KI)",
    as for clinic tokens.
  - **Clinic token:** `Bearer vct_…` — a token a clinic admin creates in
    Admin → Integrations → API (valid 30–90 days, revocable). The token acts as that admin and
    stops working when the admin loses the admin/manager role.
- **Without a valid token** `/mcp` answers 401 with
  `WWW-Authenticate: Bearer resource_metadata="https://use.viali.app/.well-known/oauth-protected-resource/mcp"`,
  so an MCP client finds the OAuth flow by itself.
- **Scopes:** `sops` (guidelines), `checklists`, `inventory` and/or `support`. The server offers
  only the tools of the token's scopes; a tool outside them does not exist for that token. Tokens
  created before checklists existed keep `sops` only. `support` is for the ADMIN role only
  (like the support panel): an admin may create a token with it; a manager or group admin may not,
  and the scope stops working as soon as the token's user is no longer admin there.
- **Claude Code:** `claude mcp add --transport http viali https://use.viali.app/mcp --header "Authorization: Bearer vct_…"`
- **Claude (claude.ai, desktop):** Settings → Connectors → Add custom connector → URL
  `https://use.viali.app/mcp` → Connect. Sign in to Viali, choose the clinic and the areas, Allow.
- **ChatGPT:** Settings → Apps & Connectors → Advanced settings → turn on developer mode → Create
  → MCP server URL `https://use.viali.app/mcp`, authentication OAuth → Create. Sign in to Viali,
  choose the clinic and the areas, Allow.

### OAuth 2.1 for /mcp

Standard discovery; a client needs nothing but the URL above.

- **Protected resource metadata (RFC 9728):** `GET /.well-known/oauth-protected-resource/mcp`
  (also without `/mcp`). `resource` is `https://use.viali.app/mcp`.
- **Authorization server metadata (RFC 8414):** `GET /.well-known/oauth-authorization-server`.
  Issuer `https://use.viali.app`.
- **Dynamic client registration (RFC 7591):** `POST /oauth/register` (JSON). Only known clients
  may register. Every one of the 1–10 `redirect_uris` must be on the allow-list:
  `https://claude.ai/api/mcp/auth_callback`, `https://claude.com/api/mcp/auth_callback`,
  `https://chatgpt.com/connector_platform_oauth_redirect`, `https://chatgpt.com/connector/oauth/…`,
  or `http` on `localhost` / `127.0.0.1` / `[::1]` on any port (Claude Code and other desktop
  clients). Anything else gets 400 `invalid_redirect_uri`; an empty or overlong list gets 400
  `invalid_client_metadata`. To add a client, contact Viali. `token_endpoint_auth_method` `none` (public client)
  or `client_secret_post` (the secret comes back once; only its hash is stored). 20 registrations
  per hour per IP.
- **Authorization:** `GET /oauth/authorize` with `response_type=code`, `client_id`,
  `redirect_uri` (exact match to a registered one), `code_challenge` +
  `code_challenge_method=S256` (required; `plain` is refused), `state`, `scope` (optional,
  space-separated), `resource` (optional, RFC 8707; `https://use.viali.app/mcp` — the default —
  or `https://use.viali.app/mcp/platform` for a platform login; anything else gets
  `invalid_target`). The
  person signs in to Viali and chooses ONE clinic and the scopes on the consent page. The
  redirect carries `code` and `state`, or `error=access_denied`, and always `iss`
  (RFC 9207, `authorization_response_iss_parameter_supported: true`).
- **Token:** `POST /oauth/token` (form-encoded). `grant_type=authorization_code` with `code`,
  `code_verifier`, `redirect_uri`, `client_id`; `grant_type=refresh_token` with
  `refresh_token`, `client_id` and optional narrower `scope`. The code is single-use and lives
  5 minutes; a second use revokes what it issued. Access token `vot_…`: 1 hour. Refresh token
  `vor_…`: 30 days, rotated on every use; reuse of an old refresh token revokes the whole
  connection. Errors: 400 `invalid_grant` / `invalid_scope` / `invalid_target`, 401
  `invalid_client`.
- **Revocation (RFC 7009):** `POST /oauth/revoke` with `token` and `client_id` → 200, also for
  an unknown token. Revoking either token ends the whole connection.
- **What a connection may do follows the person's CURRENT role in that clinic,** checked on every
  request: admin, manager and group admin read and write; doctor, nurse and staff read only (no
  write tool is offered); cleaning reads checklists only; marketing and guest cannot connect;
  `support` only for admins.
  A removed role, a lost login, an archived account or an archived clinic ends access at once.
- **Connections** are listed with revoke in Personal Settings (one's own) and in
  Admin → Integrations → API (the clinic's, for admins).
- **SOP tools (`sops`):** `list_sops`, `get_sop`, `search_sops`, `list_versions`, `get_version`,
  `create_sop`, `update_sop` (needs `expectedVersion`; refused when out of date),
  `set_sop_status`, `restore_version`, `set_sop_tags`, `list_tags`, `rename_tag`. No delete.
- **Checklist tools (`checklists`):** `list_checklists`, `get_checklist`, `create_checklist`,
  `update_checklist` (items and rooms replace the whole list; past completions keep a snapshot
  of what was signed), `set_checklist_active` (deactivate instead of delete),
  `set_checklist_assignments` (units and roles; replaces all), helpers `list_units`,
  `list_roles`, `list_rooms`, and `list_checklist_completions` — read-only history
  (who, when, which period); comment and skip-reason text and signatures are never returned.
  No delete. Checklist writes are recorded in the audit trail under the token's admin.
- **Inventory tools (`inventory`):** reads `list_items`, `list_low_stock`, `get_item`,
  `list_suppliers`, `consumption_report` (consumption documented in the anaesthesia records,
  totals per item, month and case — no patient, record or surgery is named), `forecast_needs`
  (the cases planned in the OP plan, each costed by its procedure type, else its specialty, else
  the clinic average — a level needs at least 3 past cases; duration-dependent items per planned
  hour, the rest per case; counts only, no procedure is named), `order_proposal` (forecast + 2 weeks of average
  consumption − stock − open orders, whole packs, capped at the maximum), `expiring_lots`,
  `list_orders`, `get_order`, and `list_units` when the token has no checklist scope.
  Writes: `create_order_draft`, `add_order_lines`, `update_order_line_qty`,
  `remove_order_line` (DRAFT orders only — a person sends an order in the app),
  `set_thresholds` (min/max with a reason) and `adjust_stock` (a counted correction with a
  reason). Controlled substances (BTM): the agent may do what needs no signature in the app
  (draft order lines, min/max) and never what does: `adjust_stock` refuses a BTM item, because a
  BTM stock change is signed in the app.
- **Support tools (`support`, admins only):** `list_support_tickets` (the clinic's own requests:
  title, status, dates, unread answers), `get_support_ticket` (the thread),
  `create_support_ticket` (a new request in the user's name), `add_support_reply`. Every
  ticket text is anonymised before it is returned (patterns + the OpenMed model; patient names
  and dates become placeholders such as `[FIRSTNAME_1]`); screenshots are never returned, only
  noted; ids in the page address are masked; Viali's internal notes are never shown. When the
  anonymiser does not answer, the tool returns an error and nothing. A text the agent writes
  may not contain a placeholder (refused). Messages written here end with
  "(über den KI-Assistenten gesendet)". Reading through the agent does not mark a ticket read.
- **Writes** are refused while the clinic is read-only for billing.
- **Limits:** 120 requests per minute per token; SOP text max 200,000 characters.
- **Errors:** 401 missing/invalid/expired/revoked token (with the `WWW-Authenticate` header
  above); 429 over the limit; tool errors come back as MCP tool results with `isError: true`
  and a readable message.

### /mcp/platform (platform operators only)

A second MCP endpoint for the Viali platform operator, not for clinics: the national insurer
address book (the address prefilled on new Tiers-Payant invoices), or the support requests of
all clinics.

- **URL:** `https://use.viali.app/mcp/platform` — Streamable HTTP, stateless, `POST` only.
- **Auth, two kinds:**
  - **OAuth 2.1 (Claude and ChatGPT connectors), platform admins only:** add
    `https://use.viali.app/mcp/platform` as a connector; the same OAuth endpoints as for `/mcp`
    with `resource=https://use.viali.app/mcp/platform`. The consent page offers no clinic, only
    the scopes `support` and/or `insurers`. The token (`vot_…`) is bound to this resource: it
    gets 401 on `/mcp`, and a clinic login gets 401 here. Whether the user is STILL a platform
    admin is checked at consent, at the token exchange, at every refresh and on every request.
    Discovery: `GET /.well-known/oauth-protected-resource/mcp/platform`; a 401 here carries
    `WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource/mcp/platform"`.
  - **Platform token:** `Authorization: Bearer vpat_…`, minted by a platform admin at
    /platform → Settings (1–720 hours, revocable). It carries ONE scope: `insurers` → the address
    book, `support` → the support requests. A clinic token (`vct_…`) gets 401; a `vpat_` token
    gets 401 on `/mcp`.
  The server offers only the tools of the granted scopes.
- **Support tools (`support`):** `list_support_tickets` (all clinics; filter by status, words,
  clinic), `get_support_ticket` (the thread with internal notes, plus the opener's first name
  for the greeting), `add_support_reply` (a reply signed "Viali Support" that notifies the
  clinic, or `internal: true` for a note nobody is told about), `set_support_ticket_status`
  (open / running / closed), `search_support_clinics`, `list_clinic_support_admins`,
  `create_support_ticket` (open a request for a clinic; the opener is one of its admins).
  Same anonymisation and the same placeholder rule as on `/mcp`.
- **Insurer tools (`insurers`):** `list_insurers`, `get_insurer`, `list_tnv_insurers` (read-only search in the
  MediData participant directory), `update_insurer`, `create_insurer`. No delete. Every write
  is recorded in the audit trail under the token's creator. Existing invoices keep the address
  they stored; a change applies to invoices created after it.
- **Limits and errors:** as for `/mcp` (120 requests per minute per token; 401, 429).

## Leads Webhook

Forward leads from your website contact form or Meta Lead Ads (via Make/Zapier) into Viali. Each lead becomes a record in the business inbox with full UTM / click ID attribution.

```
POST /api/webhooks/leads/<HOSPITAL_ID>?key=<YOUR_API_KEY>
```

### Required fields

- `source` — `fb`, `ig`, `website`, or any free-form label
- `first_name`, `last_name`
- At least one of `email` or `phone`

### Optional fields (all sources)

- `timeslot` — free-text preferred call-back window from the lead (e.g. `"weekend mornings"`, `"after 18:00"`). Shown to the clinic in the lead inbox. Max 200 characters.
- `language` — ISO 639-1 code for the lead's preferred language. Allowed values: `en`, `de`, `fr`, `it`, `es`. Drives the language of the outbound WhatsApp greeting and the patient-facing booking invitation email. If omitted, defaults to the clinic's UI language.

### Invitation email and conversion tracking

When `email` is present on the lead payload and the clinic has invitation
emails enabled, Viali sends the patient a branded "Book your appointment"
email immediately after the lead is received. The CTA includes a signed
`lid` parameter; when the patient books through that link, the appointment
is automatically attributed to the lead and the lead status flips to
`converted`. If the patient ignores the email and books later via the
public booking page, attribution still applies as long as the booking
email exactly matches the lead email (case-insensitive). Ambiguous matches
(2+ open leads with the same email) are left for the operator to resolve
manually.

The clinic can disable auto-send in **Settings → Integrations → Lead
webhook → Auto-send lead invitation email**.

### Meta (Facebook / Instagram) leads

Also require: `lead_id`, `form_id`, `operation`.

### Website leads

May also include `message`, any UTM params, and any of the ad click IDs listed under [Booking link](#booking-link-book).

### Response

```json
{
  "status": "received",
  "id": "lead-uuid"
}
```

Use the returned `id` to deep-link to the lead in Viali at `/leads/<id>`.

### Error responses

- `401` — missing or invalid `key`
- `403` — webhook is disabled for this hospital
- `400` — validation failed (missing required field)

### Example — website contact form

```bash
curl -X POST "https://<your-viali-host>/api/webhooks/leads/YOUR_HOSPITAL_ID?key=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "website",
    "first_name": "Maria",
    "last_name": "Müller",
    "email": "maria@example.com",
    "phone": "+41791234567",
    "message": "Interested in rhinoplasty",
    "timeslot": "weekend mornings",
    "language": "de",
    "utm_source": "google",
    "utm_medium": "cpc",
    "utm_campaign": "spring-2026",
    "gclid": "abc123"
  }'
```

### Example — Meta Lead Ads (via Make)

```bash
curl -X POST "https://<your-viali-host>/api/webhooks/leads/YOUR_HOSPITAL_ID?key=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "fb",
    "lead_id": "123456789",
    "form_id": "987654321",
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane@example.com",
    "phone": "+41791234567",
    "operation": "Rhinoplasty"
  }'
```

---

## Conversions API

Pull conversion events for ad-platform reporting (Google Ads offline conversions, Meta Conversions API, etc.). Useful as the source for a scheduled Make / Zapier flow that pushes conversions back to your ad platforms.

```
GET /api/webhooks/conversions/<HOSPITAL_ID>?key=<YOUR_API_KEY>
```

### Query parameters

- `key` — your API key *(required)*
- `platform` — `meta_forms`, `meta_ads`, or `google_ads` *(optional — omit to get all platforms)*
- `level` — `kept`, `surgery_planned`, or `paid` *(optional — omit to get all levels)*
- `from` / `to` — ISO date range filter on the **referral/lead date**, e.g. `2026-01-01` *(optional)*. `to` is treated as midnight, so it excludes events on the `to` day itself — pass the next day to include it.

Any **unknown query parameter is rejected** with `400` (e.g. a misspelled `platfom`), so filter typos fail loudly instead of silently returning the full, unfiltered list. An invalid `platform` or `level` value also returns `400`.

### Conversion levels

- `kept` — the patient showed up to their appointment (arrived, in progress, or completed).
- `surgery_planned` — **the appointment converted to a real clinical outcome**: either a surgery was scheduled for this patient, or a treatment (Botox, fillers, other in-clinic procedures) was performed and signed.
- `paid` — the conversion has been settled: the surgery has been paid for, **or** a treatment has been signed (signed treatments count as paid — clinics may bill them via an embedded or external invoicing system, so the signature is the authoritative "done and billable" signal).

The `surgery_planned` level name is retained for backward compatibility; treat it as a general "converted" signal that covers both surgical and treatment outcomes. Clinics that only do one or the other still see consistent data — the unused path simply contributes nothing.

### Event names per platform and level

| Level | Google Ads `conversion_name` | Meta Ads `event_name` | Meta Forms `event_name` |
| --- | --- | --- | --- |
| `kept` | `Lead` | `Lead` | `lead_attended` |
| `surgery_planned` | `Converted` | `Converted` | `lead_converted` |
| `paid` | `Paid` | `Purchase` | `lead_paid` |

Conversion value (`conversion_value` / `value` / `lead_value`) is the surgery price when a surgery is the conversion, or the summed treatment-line total when a treatment is the conversion. Google Ads rows carry it on the `paid` level only (see below).

### Google Ads rows

Google Ads rows are built for a daily offline-conversion import, one conversion action per level:

- `conversion_time` is always `yyyy-MM-dd HH:mm:ss+HH:mm` in the clinic's time zone, with the real summer/winter offset — e.g. `2026-09-21 14:30:00+02:00`. No milliseconds, no date-only values.
- The time is when the event happened: the patient's arrival (`kept`), the moment the surgery was **first booked** (`surgery_planned` — not the planned surgery date, and not a later re-booking), the payment (`paid`; a payment recorded as a date only is sent as 12:00 local time). A signed treatment counts as converted at its performed time and as paid at its signing time.
- For one click, `surgery_planned` is never later than `paid`. If the booking is recorded after the payment date, `surgery_planned` is sent one minute before the payment.
- A row whose time is still in the future is not returned (Google rejects future conversions), and no conversion is dated before its click.
- Levels are **cumulative** for Google Ads: a paid case also returns its `kept` and `surgery_planned` rows, so each conversion action sees its own milestone. `level=` returns the rows of that one level.
- Only `paid` rows carry `conversion_value` (always > 0; a paid case without an amount is left out). `kept` and `surgery_planned` rows leave `conversion_value` empty and keep `conversion_currency`, so one case's revenue is counted once.
- At most one row per click id and level (the earliest).
- Four columns follow the original ones, in this order: `gclid`, `gbraid`, `wbraid` (exactly one is filled — iOS clicks carry a `gbraid` or `wbraid` instead of a `gclid`) and `order_id`, a stable, non-identifying id per case and level that Google uses to avoid double counting on re-import.

```json
{
  "platform": "google_ads",
  "level": "paid",
  "click_id": "Cj0KCQjw...",
  "click_type": "gclid",
  "conversion_name": "Paid",
  "conversion_time": "2026-09-21 12:00:00+02:00",
  "conversion_value": "7200.00",
  "conversion_currency": "CHF",
  "gclid": "Cj0KCQjw...",
  "gbraid": "",
  "wbraid": "",
  "order_id": "3f9c2a7e1b4d8c6059e1a2b3c4d5e6f7"
}
```

### Response

```json
[
  {
    "lead_id": "123456789",
    "event_name": "lead_attended",
    "event_time": 1712700000,
    "lead_value": "5000",
    "currency": "CHF",
    "platform": "meta_forms",
    "level": "kept"
  }
]
```

Each result includes `platform` and `level` so you can filter on your end when pulling everything at once.

### Example — Meta Lead Forms "kept" conversions

```bash
curl "https://<your-viali-host>/api/webhooks/conversions/YOUR_HOSPITAL_ID?key=YOUR_API_KEY&platform=meta_forms&level=kept&from=2026-01-01&to=2026-04-10"
```

---

## Clinic closures

The days a clinic is shut — holidays and closure periods — for a clinic's own
website, so an opening-hours banner stays true without anyone re-typing it.

```
GET /api/public/closures/:code
```

- `code` is the clinic's short code, the same one behind its short links (for example `MUE`). Case-insensitive. A retired code keeps working, so printed and published material stays valid.
- No authentication, no API key. Readable from a browser on any domain (`Access-Control-Allow-Origin: *`).
- Returns the closures that overlap the next 12 months, earliest first.
- Cached for 5 minutes (`Cache-Control: public, max-age=300`). Rate limited to 60 requests per minute per IP; over that returns HTTP 429 with `RATE_LIMITED`.
- Returns HTTP 404 with `CLINIC_NOT_FOUND` for an unknown code.

```json
{
  "closures": [
    { "startDate": "2026-12-24", "endDate": "2027-01-02", "name": "Betriebsferien" }
  ]
}
```

- `startDate` and `endDate` are inclusive calendar dates, `YYYY-MM-DD`. A single-day closure repeats the same date in both.
- Only these three fields are ever returned. Internal notes on a closure stay inside the clinic.

## Cleaning status of common areas

When a clinic's common areas (a toilet, a corridor) were last cleaned, for patients in the
building. The data comes from the clinic's room-linked cleaning checklists. A clinic admin
chooses which rooms are public (Admin → Clinical → Rooms, room type "Common area", switch
"Show cleaning status publicly").

```
/cleaning/:token              → the page: every public room with its last cleaning
/cleaning/:token/:roomId      → one room: its last 10 cleanings (the QR sticker on the door)
/<CODE>/r                     → short link to /cleaning/:token
```

JSON the page reads:

```
GET /api/public/cleaning/:token
GET /api/public/cleaning/:token/rooms/:roomId
```

- `token` is the clinic's cleaning-status token (32 hex characters). No login, no API key.
- An admin can renew the token. The old page links and every printed QR code then answer 404; the short link `/<CODE>/r` follows to the new token.
- Returns HTTP 404 with `NOT_FOUND` for an unknown or renewed token, and for a room that is not public, not a common area, or of another clinic — one answer for all of them.
- Rate limited to 60 requests per minute per IP; over that returns HTTP 429 with `RATE_LIMITED`.
- `X-Robots-Tag: noindex, nofollow` and `Cache-Control: no-store`. Not for search engines, not cached.
- Dates are ISO 8601 instants (UTC). The page formats them in the clinic's timezone, date format and hour format, which the response carries.

```json
{
  "clinic": { "name": "Klinik Muster", "logoUrl": null, "timezone": "Europe/Zurich", "dateFormat": "european", "hourFormat": "24h", "language": "de" },
  "rooms": [
    { "id": "…", "name": "WC Eingang", "lastCleanedAt": "2026-09-29T08:15:00.000Z" },
    { "id": "…", "name": "Korridor OG", "lastCleanedAt": null }
  ]
}
```

The room endpoint returns the same `clinic`, the `room` (`id`, `name`, `lastCleanedAt`) and `cleanings`, latest first, at most 10:

```json
{ "completedAt": "2026-09-29T08:15:00.000Z", "checklistName": "Reinigung WC", "items": ["WC gereinigt", "Seife aufgefüllt"] }
```

- Only COMPLETED checklist runs of checklists linked to that room count. A skipped run is not a cleaning and is never shown.
- Never returned: who cleaned (no name, no initials), the signature, the comment, anything about a patient.

## Unsubscribe link

Marketing emails sent via the Flows feature include a one-click unsubscribe link in their footer:

```
GET /unsubscribe/:token[?channel=sms|email|all]
```

- `token` is an HMAC-signed value binding a patient id and hospital id, generated server-side and embedded in every marketing email footer. Tokens never expire.
- `channel` is optional; `all` (default) turns off both SMS and email marketing for the patient. `sms` or `email` scopes the unsubscribe to one channel.
- Returns HTTP 200 + a small confirmation page on success, written in the sending clinic's own language (German, English, Italian, Spanish or French — from the hospital id inside the token).
- Returns HTTP 400 + an error page for invalid or malformed tokens. That page has no clinic to read a language from, so it follows the browser's `Accept-Language` header instead, falling back to German. No authentication header is ever required.
- Patient records stay intact; only the `sms_marketing_consent`, `email_marketing_consent`, and `marketing_unsubscribed_at` fields change.

This endpoint is intended for end-users (patients) clicking the link from an email — it is not part of the integration API surface.

---

## Resend webhook

Viali receives email engagement events from Resend at:

```
POST /api/webhooks/resend
```

- This endpoint is only invoked by Resend's servers — not part of the integration API surface.
- Requests are authenticated via Svix signature headers (`svix-id`, `svix-timestamp`, `svix-signature`) using HMAC-SHA256 with a shared secret. Requests with invalid or stale (>5 min) signatures return 400.
- Subscribed events: `email.sent`, `email.delivered`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained`. Other event types are acknowledged with 200 and ignored.
- Engagement events are written to the internal `flow_events` log; `email.complained` additionally flips `email_marketing_consent` to `false` on the recipient patient.
- Returns 200 even for unknown email IDs (transactional emails sent through the same Resend account flow through here too).

## Agency worker registration (public)

Token-gated public endpoints that let a staffing worker self-register into an agency's pool. The `{token}` is the agency's rotatable public registration token (distinct from the agency portal token); an agency can disable it at any time, after which all of these endpoints return 404.

`GET /public/agency-join/{token}` — returns `{ agencyName, language }` for an active agency registration link; `404` if the token is unknown or registration is disabled.

`GET /public/agency-join/{token}/positions` — lists the agency's **open** job postings as `{ positions: [{ id, title, role, employmentPct, location }] }` (no description body). Rate-limited.

`GET /public/agency-join/{token}/positions/{id}` — returns one open posting incl. its rich `descriptionHtml`: `{ id, title, role, employmentPct, location, descriptionHtml }`; `404` if the posting is closed or not this agency's.

`POST /public/agency-join/{token}/upload-url` — body `{ filename }` → `{ uploadURL, storageKey }` (presigned upload for a document). Rate-limited.

`POST /public/agency-join/{token}` — body `{ email, firstName, lastName, qualifications[], privacyConsent, note?, documents?[], postingId?, locale? }` registers the worker as a **pending** applicant in the agency's pool (the agency approves them before they can be matched). `privacyConsent` must be `true` (the applicant's consent to the agency storing and processing their data) or the request is rejected with `400`; the consent text, language, and timestamp are recorded. An optional `postingId` ties the application to one of the agency's open postings (`400` if it is closed or not this agency's). Includes a honeypot field and is rate-limited; returns `{ ok: true }`.

## Pricing API (JSON)

Published list prices for the Viali plan ladder. The app is the source of
truth: viali.app reads this endpoint rather than keeping its own copy, so the
marketing site, the in-app billing page and the invoice can never disagree.

    GET /api/public/pricing

No authentication. CORS is open (`Access-Control-Allow-Origin: *`) and the
response is cacheable for 5 minutes (`Cache-Control: public, max-age=300`).

Response:

```json
{
  "currency": "CHF",
  "monthsPerYear": 12,
  "yearlyDiscountPct": 0.1,
  "chainDiscounts": [
    { "minLocations": 16, "discountPct": 0.3 },
    { "minLocations": 6,  "discountPct": 0.2 },
    { "minLocations": 2,  "discountPct": 0.1 }
  ],
  "tiers": [
    {
      "tier": "tardoc",
      "label": "TARDOC",
      "monthlyPriceChf": 99,
      "yearlyPriceChf": 1069.2,
      "includedLogins": 1
    }
  ]
}
```

Fields:

- `tier` — stable machine key (`tardoc`, `praxis`, `clinic`, `clinic_xl`).
  Order is cheapest-first and is part of the contract.
- `label` — product name as published.
- `monthlyPriceChf` — list price for one clinic, one month.
- `yearlyPriceChf` — total for twelve months paid up front, with
  `yearlyDiscountPct` already applied. Precomputed so every consumer rounds
  identically; do not re-derive it.
- `includedLogins` — logins the tier covers. Only people who can log in count.

- `chainDiscounts` — multi-location brackets, highest first. A chain is billed
  per location on ONE invoice, so the bracket applies to the SUM of its
  locations' tier prices; there is no separate "chain plan". Take the first
  bracket whose `minLocations` the group meets.

List prices and published discount rules only. A clinic's individually
negotiated rate (`discount_pct`) is a commercial term and is never exposed
here.

`currency` is the currency of the published list price. Clinics are invoiced
the same amount in the currency set in their regional settings (CHF, EUR or
USD) — there is no exchange-rate conversion.

## Patient feedback

`GET /api/public/feedback/:token`

- A token beginning `preview_` opens the page in PREVIEW: it is built from the clinic's own settings and dimensions, carries no patient and no request, and every write is refused with HTTP 403 `PREVIEW_READ_ONLY`. Clinic staff create one from Settings → Patient feedback; it expires after 7 days.

Returns the feedback form for one token: the clinic name, logo, colour, the
active dimensions, and whether the link is expired or already answered. Carries
no patient data.

`POST /api/public/feedback/:token/submit`

Body: `{ "answers": [{ "dimensionKey": "staff", "value": 3 }], "comment": "…", "contactRequested": false }`.
`value` is `1` sad, `2` neutral, `3` happy. One submission per token.

`POST /api/public/feedback/:token/opt-out`

Stops all further feedback requests for that patient.

Errors: `404` unknown token, `410` expired link, `409` already answered,
`400` invalid answers.
