# eKard Agent API — v1 Reference

REST JSON API for operating an account on eKard: digital business cards
("kards"), their links, events, documents, QR codes, custom designs,
contacts, analytics, and booking calendars. Built for AI agents acting on the account owner's
behalf — everything the owner can do in the web app, over HTTP.

- Base URL: `https://ekard.app/api/v1`
- This document: `https://ekard.app/api/v1/docs` (plain markdown)
- OpenAPI 3.1 description: `https://ekard.app/api/v1/openapi.json` (every endpoint,
  for tools that import one)
- For people: `https://ekard.app/developers` (what an agent can do, with examples)
- The account owner finds their API key at `https://ekard.app/account/ai`

## Authentication

Send the account's API key on every request:

```
Authorization: Bearer ekard_...
```

One account, one key; it can do everything the signed-in owner can do.
Missing/invalid key → `401`. The owner can rotate the key at any time
(`https://ekard.app/account/ai`); the old key stops working immediately.

## Conventions

- Request bodies: JSON (`Content-Type: application/json`), except file
  uploads, which are `multipart/form-data` (marked below).
- Responses: JSON, wrapped in a resource key (`{"kard": {...}}`,
  `{"contacts": [...]}`).
- Errors: `{"error": {"code": "...", "message": "..."}}` with the right HTTP
  status. Validation failures are `422` and include a `details` object of
  per-field messages.
- `404` means the record doesn't exist **or** doesn't belong to this account;
  `403` means it exists but the account's role can't do that to it.
- Updates are partial: omitted fields keep their value; send `""` to clear a
  field.
- Kards are addressed by their `share_link` (their public URL slug), not by
  numeric id. If the same share_link exists in two organizations you belong
  to, disambiguate with `?organization=<org-slug>`.

Quick smoke test:

```
curl -H "Authorization: Bearer $KEY" https://ekard.app/api/v1/me
```

## Account

### GET /me
Account profile, subscription/plan, counts, docs link. Start here to confirm
the key works.

### GET /subscription
Current plan (`null` when on the free tier) and `upgrade_url`. Buying a plan
happens on the web (Razorpay checkout) — send the owner to `upgrade_url`.

### DELETE /subscription
Cancels the active subscription **at the end of the billing period** (same as
the web "Cancel" button). Confirm with the owner before calling this.

## Kards

A kard's JSON includes its attributes, social handles, custom links,
settings, public URLs (`urls.public`, `urls.qr_png`, `urls.vcard`), counts,
and custom-design status.

### GET /kards
All kards the account owns (personal and organization-owned), newest first.

### POST /kards
Create a kard. Body: `{"kard": {...}}`. Fields:

- `name` (required, 4–100 chars). The public `share_link` (URL slug) is
  generated automatically; read it from the response.
- `tagline`, `nickname`, `description` (plain text or simple HTML)
- Social handles: `phone_number`, `whatsapp_number`, `email`,
  `instagram_handle`, `google_business_url`, `linkedin_url`, `upi_id`,
  `website`, `telegram_handle`, `youtube_url`, `facebook_url`,
  `twitter_handle`
- `custom_links_attributes`: array of `{"title": "...", "url": "https://..."}`
  (max 20 links)
- `is_active` (bool; inactive kards show an "inactive" public page),
  `indexable` (bool; allow search engines), `layout_type`
- Settings: `allow_connect_popup_setting`, `allow_email_notifications_setting`,
  `allow_reviews_setting` (booleans)
- `organization_id` — only if you administer that organization
- `photo` — multipart only; send the whole request as `multipart/form-data`
  with fields named `kard[name]`, `kard[photo]`, etc. (JPEG/PNG/GIF/WebP,
  ≤ 5 MB). `kard[remove_photo]=1` removes it.

### GET /kards/{share_link}
Full kard detail.

### PATCH /kards/{share_link}
Partial update; same fields as create. Custom links use nested semantics:

```json
{"kard": {"custom_links_attributes": [
  {"title": "Portfolio", "url": "https://example.com"},
  {"id": 12, "title": "New title"},
  {"id": 13, "_destroy": true}
]}}
```

No `id` → adds a link; with `id` → edits it; `"_destroy": true` → removes it.

### DELETE /kards/{share_link}
Deletes the kard and everything under it. Organization kards can only be
deleted by organization admins; an organization's default kard can't be
deleted. Irreversible — confirm with the owner first.

## Analytics

### GET /analytics
Account overview: per-kard visit counts (7/30 days, lifetime) and contact
counts, plus totals. Use this for "how are my kards doing?".

### GET /kards/{share_link}/analytics?time_range=7_days|30_days|lifetime
Per-kard performance (default `7_days`):

- `totals`: visits in range, lifetime visits, contacts (total and in range)
- `visits_by_day`: date → count timeseries (long lifetime ranges group by month)
- `device_types`, `browsers`, `countries`, `regions`, `cities` (top 10)
- `referrers`: referring domain → count (`"Direct"` = no referrer, top 10 + `"Other"`)
- `custom_link_clicks`: per-link click counts in range

## Contacts (leads)

People who submitted the kard's contact ("connect") form. Includes any
per-kard custom form fields under `custom_fields`.

- `GET /contacts` — all contacts across the account's kards. Params: `query`
  (searches name/email/message/custom fields), `page`, `per_page` (≤ 100).
- `GET /kards/{share_link}/contacts` — one kard's contacts. Params: `query`,
  `sort` (`name|email|created_at|message`), `direction` (`asc|desc`), `page`,
  `per_page`.
- `GET /kards/{share_link}/contacts.csv` — CSV export (same `query`/`sort`).
- `GET .../contacts/{id}` — one contact.
- `POST /kards/{share_link}/contacts` — add a lead manually. Body:
  `{"contact": {"name": "...", "email": "...", "country_code": "+91",
  "phone_number": "...", "message": "...", "event_id": 1,
  "custom_fields": {...}}}` (`name` required).
- `PATCH .../contacts/{id}` / `DELETE .../contacts/{id}`.

## Events

Named lead-capture buckets (e.g. a conference); contacts can be tagged with
an `event_id`. Deleting an event keeps its contacts (they're detached).

- `GET /kards/{share_link}/events` · `POST` (`{"event": {"name": "...",
  "note": "...", "archived": false}}`, name required) · `GET/PATCH/DELETE
  .../events/{id}`.

## Documents

Files on a kard (menus, brochures, price lists — PDF, Word, images; ≤ 10 MB
each, max 10 per kard). Each gets a stable public URL
(`{kard url}/files/{slug}`) and its own QR code.

- `GET /kards/{share_link}/documents` — list.
- `POST /kards/{share_link}/documents` — **multipart**: `file` (required),
  `title`, `slug` (lowercase-hyphen; generated from the filename if omitted).
- `GET .../documents/{id}` — detail.
- `PATCH .../documents/{id}` — JSON: `slug` and/or `title`.
- `PATCH .../documents/{id}/replace` — **multipart**: `file`. New file, same
  slug/URL/QR.
- `DELETE .../documents/{id}`.

### Document landing page
Instead of opening the raw file, a document's URL can serve a custom HTML
page (draft → publish, sanitized on publish):

- `GET /kards/{share_link}/documents/{id}/design`
- `PATCH .../design` — `{"design": {"draft_html": "<html>..."}}`; add
  `"publish": true` to publish in the same call.
- `POST .../design/publish` · `DELETE .../design/unpublish` (QR opens the
  file directly again; the draft is kept).
- Publish responses include `sanitizer_removals` — anything stripped for
  safety (scripts, external iframes, …). Mention removals to the owner.

## QR code

Every kard has a QR PNG pointing at its public URL. The stored PNG is also
public at `{kard url}/qr`.

- `GET /kards/{share_link}/qr` — design properties + image URL.
- `PATCH /kards/{share_link}/qr` — three modes:
  1. Design: `{"qr": {<keys below>}}`
  2. Logo (**multipart**): `logo` file (PNG/JPG/WebP/GIF ≤ 2 MB), or
     `remove_logo=1`
  3. `reset_default=1` — back to the stock design
- `POST /kards/{share_link}/qr/regenerate` — re-render the stored PNG.

Design keys (sent as strings; booleans as `"1"`/`"0"`): `module_px_size`
(4–20), `qr_shape` (`square|circle|rounded_square`), `quiet_zone` (0–8),
`error_correction` (`l|m|q|h`), `background_color`,
`transparent_background`, `foreground_color`, `canvas_color`,
`canvas_color_transparent`, `qr_corner_radius` (0–64), `show_border`,
`border_color`, `border_width` (0–12), `finder_pattern_style`
(`square|circle|rounded`), `finder_pattern_color`, `dot_style`
(`classic|circle|square|rounded_square|rounded|triangle|diamond|star|hexagon|cube`),
`dot_size` (1–12),
`dot_corner_radius` (0–12), `center_space_enabled`, `center_space_size`
(4–14), `show_center_text`, `center_text` (≤ 30 chars), `center_text_color`,
`center_text_font_size` (8–48), `center_text_line_spacing` (0–60),
`center_text_rotation` (−180–180), `show_logo`, `use_default_logo`,
`logo_size` (4–14), `show_footer`, `footer_text` (≤ 60 chars),
`footer_color`, `footer_font_size` (8–48), `footer_position`. Colors are hex
(`#RRGGBB`). Out-of-range numbers are clamped. Updates merge into the
existing design; only send the keys you're changing.

## Custom design (kard landing page)

Replace the kard's standard layout with fully custom HTML. Draft → publish
(sanitized); visitors only ever see published HTML. History records every
save; checkpoints are named safety points (max 25); built-in templates can
seed the draft.

- `GET /kards/{share_link}/design` (+ `versions_count`)
- `PATCH .../design` — `{"design": {"draft_html": "..."}}` (≤ 150,000 chars);
  add `"publish": true` to publish in one call.
- `POST .../design/publish` · `DELETE .../design/unpublish` (standard layout
  returns; draft kept) · responses include `sanitizer_removals`.
- `GET .../design/versions` — last 50: `{id, source
  (draft_save|publish|checkpoint|restore|ai_edit), label, created_at}`.
- `POST .../design/restore` — `{"version_id": 123}` → that version becomes
  the draft (nothing goes live until publish).
- `POST .../design/checkpoint` — `{"label": "before redesign"}`.
- `GET .../design/templates` — built-ins: `{slug, name, blurb}`.
- `POST .../design/apply_template` — `{"template": "<slug>"}` → template
  becomes the draft; the previous draft stays in history.

## Organizations

Read and configure organizations where the account is an **admin** (creating
or deleting organizations isn't self-serve).

- `GET /organizations` — memberships with `role`.
- `GET /organizations/{slug}` · `PATCH /organizations/{slug}` — `name`,
  `slug`, `layout_type`, `show_logo_on_kards_setting`,
  `allow_dynamic_layouts_setting`; `logo` via multipart, `remove_logo=1`.
- `GET /organizations/{slug}/kards` — all kards in the organization.
- Kard endpoints accept `?organization={slug}` to address an org kard whose
  share_link collides with one of your personal kards.

## Bookmarks

Kards this account saved (any public kard, not just its own).

- `GET /bookmarks` · `POST /bookmarks` (`{"share_link": "..."}`) ·
  `DELETE /bookmarks/{id}`.

## Calendars

Calendar is the booking calendar behind the kards: appointments, meetings,
clients and the front desk, and setting it all up (settings, the menu, the
team and their hours, links, logins, webhooks). A key does in Calendar
exactly what its person can do on the web, on the same roles:

| role | who | can |
|---|---|---|
| `admin` | the owner of a personal calendar; an organization's admins | everything below |
| `front_desk` | a front-desk login an admin invited | every booking (book, move, cancel, statuses, requests), walk-ins, clients, blocked time, marking visits paid and advances received, and their own hours if they're on the team too; not takings, services, settings or the team |
| `staff` | a team member's own login | the schedule and every booking (read); statuses and the team note on their own visits, and their clients' details (phone, email, notes, alert note, guests) there only; their own hours, special days, time off and private links |

- `GET /calendars` lists the calendars this account works in, with its
  `role` on each. Everything inside one is under `/calendars/{id}/…`.
- A calendar the account has no role on answers `404` (like one that
  doesn't exist). An action above the role answers `403 forbidden`, and
  the message says what this login can do and who to ask.
- Words follow the kind of business (`labels`: a salon has "Services",
  "Clients" and "Appointments"; a consultant "Meeting types", "Invitees"
  and "Meetings"). Use them when you talk to the owner.
- Times: `starts_at` and friends are UTC ISO 8601, with `local_starts_at`
  in the calendar's `time_zone`. A `date` is a local date (`YYYY-MM-DD`) in
  that zone. Money is integer minor units (`price_cents: 80000` = ₹800)
  plus `currency`; `price` says it in words.
- Writes take the booking's `lock_version` (from its JSON) when you have
  one: if someone changed the booking since you read it, the answer is
  `409 stale_booking` and nothing is overwritten. Read it again and decide.

### GET /calendars
Each calendar: `id`, `name`, `role`, `role_summary` (what the role can do,
in words), `owner` (`{"type": "user"}` or `{"type": "organization",
"slug"}`), `preset` (the kind of business), `labels`, `time_zone`,
`currency`, `country`, `today` (its local date), `live`
(`accepting_bookings`: online booking is on; `bookable_online`: there's
something clients can book), `web_url`.

### GET /calendars/{id}
The same, plus `features` (which parts the business uses: `front_desk`,
`walk_ins`, `team`, `request_to_book`, `advance` …), `providers` (the
people who take bookings: `id`, `name`, `title`, `active`,
`bookable_online`), `your_provider` (the person this login is, if any)
and, for the front desk and admins, `offerings`: the menu it books from
(`id`, `name`, `category`, `duration_minutes`, `duration_options`,
`price_cents`, `price_mode`, `price`, `advance_cents`, `visibility`
(`internal` = front desk only), `requires_approval`, `provider_ids`).

### The schedule
`GET /calendars/{id}/schedule?date=2030-01-10` — the desk's day (default
today), for every role: "what does my Tuesday look like?".

- `people`: each person with `status` (`working`, `day_off`, `leave`,
  `inactive`), `note` ("On leave until Fri 11 Jan"), `hours`
  (`[["10:00","13:00"],["14:00","19:00"]]`, local), `blocks` (breaks, time
  off, blocked time and closures: `kind`, `label`, `from`, `to`;
  `whole_calendar: true` for a closure) and `bookings` (`id`, `status`,
  `title`, `starts_at`, `ends_at`, `local` ("15:00–16:30"), `client`
  (`id`, `name`, `alert_note`: `null` for staff on someone else's visit),
  `confirmed`, `overlap`).
- `free_now` (today): who is free right now, and `until` when.
- `summary`: `booked`, `requests`, `unconfirmed` (reminded, not confirmed
  yet: the likeliest no-shows), `on_leave`, `off`, `requests_waiting` (on
  any day).
- `closed`: a closure covering the whole day ("Closed: Diwali"), or null.
- `?view=week` — Monday to Sunday around `date`: each day's `status`
  (`open` / `closed`), `note`, `booked`, `blocks` and every visit.
- `&provider_id=7` — one person only.

### Free times
For the front desk and admins.

- `GET /calendars/{id}/availability?offering_ids[]=3&date=2030-01-10` —
  open times for a visit (one or more offerings back to back with one
  person), with anyone who does them, grouped by local date: `{"dates":
  {"2030-01-10": [{"starts_at", "ends_at", "local_starts_at",
  "provider_ids"}]}}` (`provider_ids`: who is free, best first). Params:
  `days` (1–14), `provider_id` (one person), `duration` (an offering's
  duration option), `client_id` (their usual person first), `mode`:
  `front_desk` (the default here: what the business may book, ignoring
  notice and the booking window) or `online` (what clients can book).
- `?booking_id=812&date=…` — where a booking could move: its services and
  timing, its own time not counted, its person unless `provider_id` names
  another.
- `GET /calendars/{id}/availability/explain?starts_at=…&offering_ids[]=3`
  — why a time isn't free, per person who does it: `{"people":
  [{"provider", "free": false, "reasons": ["booked: Meera Iyer,
  Haircut"]}]}`. With `mode=online`, also why clients can't book it
  ("within the 2-hour minimum notice").

### Bookings in a calendar
- `GET /calendars/{id}/bookings` — every role. Params: `status` (comma
  list of `requested`, `booked`, `arrived`, `completed`, `cancelled`,
  `no_show`, `declined`, or `all`; default `requested,booked`), `date` (a
  local day) or `from` / `to` (ISO 8601; `from` defaults to now unless
  `to` or `date` is given), `provider_id`, `client_id`, `source` (`online`,
  `front_desk`, `walk_in`, `api`), `paid` (`true`/`false`), `order`
  (`desc` for newest first), `page`, `per_page` (≤ 100). A request whose
  hold ran out is released time and isn't listed. `status=requested` is
  the list of requests to approve.
- `GET /calendars/{id}/bookings/{booking_id}` — one booking, as the desk's
  booking sheet shows it, with the client's last 3 visits (`history`).

A booking here is the webhook `data.booking` object (see Webhooks) plus:
`lock_version`, `channel` (how the desk took it), `created_by`,
`provider`, `client` (`id`, `name`, `phone`, `email`, `alert_note`,
`bookings_count`, `no_show_count`, `cancelled_count`, `new`),
`internal_notes` (the team's note), `video_url`, `overlap_allowed`,
`confirmed_at` (the client confirmed), `reminder_sent_at`,
`request_expires_at` (a request's hold), `payment` (`paid`, `payable`,
`paid_cents`, `tip_cents`, `payment_method`, `paid_at`,
`amount_due_cents`) and `advance` (`amount_cents`, `received_at`, `due`,
`outcome`: `refunded` or `kept` once a cancelled visit's advance is settled),
`client_mail_held` (see below), and `can`: `{"work": true, "manage":
false}` — `work`: this login may change its status and team note and
sees the client's details; `manage`: it may also book, move, cancel,
answer requests and handle the money.

`client_mail_held: true`: the client and guests of this booking aren't
emailed (its confirmation, changes, cancellation, reminders) because the
calendar is a personal one whose owner hasn't verified their email
address yet; then the answer also carries a `notice` sentence to pass on.
Signing in with Google verifies it, as does the link the owner can send
themselves from the calendar's new-booking sheet ("Send me the link");
after that, the booking's later emails go out. Online bookings (the
visitor's own address) and organizations' calendars are never held.
Staff read every visit, but on someone else's the client's `phone`,
`email`, `alert_note`, `notes`, `answers`, `client_address`,
`guest_emails` and `internal_notes` are `null`, as on the web; `payment`
and `advance` are `null` for staff.

Writes (the answer is the booking as above):

- `POST /calendars/{id}/bookings` — book like the front desk: the same
  body as `POST /kards/{share_link}/bookings` below (`offering_ids`,
  `starts_at`, `client` or `client_id`, `provider_id`, `duration`,
  `channel`, `notes`, `answers`, `guest_emails`, `client_address`,
  `overlap_allowed`, `idempotency_key`), plus `internal_notes` (a note
  only the team sees) and `source`: `"walk_in"` for someone in the shop
  now (it starts now unless `starts_at` says otherwise, is marked arrived,
  and without a client files under the desk's anonymous "Walk-in"; only
  where `features.walk_ins` is on). `201`.
- `PATCH …/bookings/{booking_id}` — `{"internal_notes": "Bring the round
  brush"}` (`""` clears it). Staff: their own visits.
- `POST …/bookings/{booking_id}/arrive`, `/complete`, `/no_show` (only
  once it has started) — staff: their own visits. `/undo_status`
  `{"to": "booked"}` puts an arrived, completed or no-show visit back.
- `POST …/approve` · `POST …/decline` (`{"reason": "…"}`, optional; the
  client reads it) · `POST …/cancel` (`{"reason": "Stylist unwell"}`,
  required: the client and the team both read it).
- `POST …/reschedule` — `{"starts_at": "…", "provider_id": 7,
  "overlap_allowed": false}`; the client's manage link and reminders
  follow, and they're told.
- `POST …/resize` — `{"duration_minutes": 75}`: the visit's whole length
  from its start (the client isn't messaged).
- `POST …/confirm` — "Mark confirmed": the client confirmed some other way
  (a visit still to come that's booked or requested; anything else is
  `422 invalid_transition`).

### Money
For the front desk and admins.

- `PUT /calendars/{id}/bookings/{booking_id}/payment` — mark a visit paid
  (or change what was recorded). All optional, as on the web's sheet:
  `amount_cents` (default: what's due, the price less an advance
  received), `tip_cents` (`null` clears it), `payment_method` (`cash`,
  `upi`, `card`, `other`; default: the last one used here), `paid_at`
  (ISO 8601; default now; not in the future), `lock_version`. Only a
  visit that has started can be marked paid. `DELETE …/payment` — not paid
  after all.
- `PUT /calendars/{id}/bookings/{booking_id}/advance` — the advance the
  client was asked for is received (they're emailed);
  `{"approve": true}` approves a request in the same step. `DELETE
  …/advance` — not received after all.
- `PUT /calendars/{id}/bookings/{booking_id}/advance-outcome` — a
  cancelled (or declined) visit's received advance was `{"outcome":
  "refunded"}` or `"kept"`: cancelling refunds nothing by itself, and the
  client's page shows the answer (a refunded advance leaves takings).
  `DELETE …/advance-outcome` — not said after all.
- `GET /calendars/{id}/takings` — admins only: what was marked paid,
  today by default; `range=week|month`, or `from` and `to` (local dates,
  up to 366 days). `taken_cents`, `tips_cents`, `visits`, `by_day`,
  `by_provider`, `by_method` (advances count as `advance` on the day they
  arrived), and `unpaid`: finished visits nobody marked paid (`count`,
  and the 50 most recent). `GET …/takings.csv` — the paid visits as a
  spreadsheet.

### Clients
For the front desk and admins. Notes and alert notes can hold health
details: keep them to what the owner asked for.

- `GET /calendars/{id}/clients?q=4512` — search by part of a name or
  email, or any 3+ digits of a phone number (best matches first); without
  `q`, `sort=recent` (default), `name` or `no_shows`. Each: `id`, `name`,
  `phone`, `email`, `alert_note`, `blocked`, `visits`, `bookings_count`,
  `no_show_count`, `cancelled_count`, `last_visit_at`, `next_booking`,
  `usual` (`{"offering": "Root touch-up", "provider": "Priya", "times":
  12}`), `possible_duplicate_of`. `page`, `per_page` (≤ 100, default 50).
- `GET /calendars/{id}/clients.csv` — admins: everyone, or the search;
  notes only with `include_notes=1`.
- `GET /calendars/{id}/clients/{client_id}` — one client: `notes`,
  `visits`, `paid_cents`, `upcoming`, `history` (latest 10;
  `?history=all` for all) and `history_count`, `possible_duplicates`.
- `POST /calendars/{id}/clients` — `{"client": {"name": "Dev Patel",
  "phone": "98765 11111", "email": "…", "notes": "…", "alert_note":
  "Nut allergy", "blocked": false}}` (`name` required; a phone number
  another client has is a `422`). `201`.
- `PATCH /calendars/{id}/clients/{client_id}` — any of those. `"blocked":
  true` stops them booking online (the desk still can); `"alert_note"`
  (≤ 200 characters) shows on every booking of theirs (to staff on their
  own visits only).
- `POST /calendars/{id}/clients/{client_id}/merge` — a client marked
  `possible_duplicate_of` another (a public booking whose details clashed
  with an existing client's) is merged onto the older record: bookings,
  notes, alert note, a missing phone or email. The answer is the kept
  client and `merged` (`id`, `name`, `moved`). `DELETE …/merge` — not the
  same person: the link goes, both records stay.

### Setting up a calendar
Every calendar starts as the setup wizard does: the kind of business, then
(for an organization) who takes bookings, opening hours and a starter
menu. `POST /calendars` takes those choices at once and saves all or
nothing; change anything afterwards through the endpoints below.

- `GET /calendars/presets?currency=INR` — the kinds of business (`key`:
  `consulting`, `salon`, `spa`, `clinic`, `tattoo`, `home_services`,
  `tutoring`, `fitness`, `other`), each with its `name` and `description`
  as the wizard shows them, `labels` (its words), `features`, its default
  rules (`defaults`) and `opening_hours`, and `starter_offerings` priced in
  `currency` (default: your country's): `starter` (its index), `name`,
  `category`, `duration_minutes`, `price_cents`, `price_mode`, and
  `needs_price` where a priced starter has no price in that currency.
- `POST /calendars` — `{"calendar": {…}}`:
  - `organization`: an organization's slug, for its calendar (its admins
    only: a member gets `403` saying so, anyone else `404`). Omit it for
    your own calendar, behind your personal Kards.
  - `preset` (required): the kind of business. `time_zone`, `currency` and
    `country` default from you (or the organization's admins). A business
    that asks clients for a phone needs a `country` to read local numbers;
    where several countries share a time zone (Europe/London), say which.
  - `kard`: on your own calendar, the Kard (share_link) its one person
    stands behind (default: your first).
  - `team` (organizations): who takes bookings, e.g. `[{"kard": "priya"},
    {"name": "Rahul Verma", "phone": "98765 43210"}, {"me": true}]`: a team
    Kard nobody stands behind yet (their name, tagline and number come
    from it), a person by name, or yourself. Several people turn on
    choosing who to book with.
  - `opening_hours`: the days to change from the kind of business's, e.g.
    `{"mon": [["11:00", "20:00"]], "sun": []}` (24-hour; `"24:00"` is
    midnight; `[]` is closed). They seed the team's hours; on your own
    calendar they are your hours.
  - `offerings`: `"starters"` for the starter menu `GET /calendars/presets`
    lists, or rows: `{"name": "Haircut", "duration_minutes": 45,
    "price_cents": 50000, "category": "Hair"}`, or a starter with changes,
    `{"starter": 1, "price_cents": 120000}` (`price_mode`: `fixed`, `from`,
    `free`, `hidden`; a row without a price shows none). Everyone on the
    team does each one. Saving the menu turns online booking on, as the
    wizard does, unless `"accepting_bookings": false`.

  `201` with the `calendar` (as `GET /calendars/{id}`), `setup` (`steps`,
  `finished`: the last step done, `next`: where the wizard would resume)
  and a `message`. `409 calendar_exists` (`details.calendar_id`) when there
  is one already; `422 validation_failed` with `details` by step (`kind`,
  `team`, `opening_hours`, `offerings`) and nothing saved; `400` for a
  choice it can't read.

### Settings
Admins only: the web's Settings page.

- `GET /calendars/{id}/settings` — `name`; `preset` and `kind` (the kind of
  business) and its `labels`; `time_zone`, `country`, `currency`,
  `address`; `accepting_bookings` (online booking on, or paused) and `live`
  (`bookable_online`: there's something clients can book; `kards`,
  `kards_showing_book`); the rules `min_notice_minutes`, `horizon_days`,
  `slot_step_minutes`, `max_upcoming_per_client`, `require_phone`,
  `require_email`, `cancellation_cutoff_minutes` (0 = until the start) and
  `policy_text`; reminders `reminder_24h`, `reminder_2h` and
  `reminder_note` (in every reminder and on the client's booking page);
  emails to you: `notify_owner_online_bookings` and the morning summary
  `digest_time` (`"08:00"`, or `null` for none; `digest_minute` is the same
  in minutes); `opening_hours` (an organization's; `null` on your own
  calendar, whose hours are its person's) and `opening_hours_summary`;
  `feed_url`, the whole calendar's private calendar-feed link.
- `PATCH /calendars/{id}/settings` — `{"settings": {…}}` with what changes
  (an unknown key is a `400`). `opening_hours` names only the days that
  change (`{"sat": [["10:00", "14:00"]], "sun": []}`); your own calendar's
  hours change under `/providers/{provider_id}/hours`.
  `"accepting_bookings": false` pauses online booking (Book disappears from
  the Kards; existing bookings stay); `true` turns it back on. The answer
  is the settings and, when something people should hear about changed, a
  `message`: where Book disappeared or shows, the words a new kind of
  business (`preset`) brings, or that prices keep their numbers in a new
  `currency`. The calendar's rules answer `422` with each field's reason:
  `slot_step_minutes` is 5, 10, 15, 20, 30, 45, 60, 90 or 120; ask for at
  least a phone or an email; a kind of business that asks for a phone
  needs a `country`.
- `POST /calendars/{id}/feed_link` — a new private feed link for the whole
  calendar (`feed_url`). The old one stops at once: calendar apps
  subscribed to it stop updating until they subscribe again.

### Sharing the booking page
Admins only: the share kit, and single-use booking links.

- `GET /calendars/{id}/share_kit` — `booking_url` (the front door's booking
  page: an organization's default Kard, or your own Kard), `front_door`,
  `accepting_bookings`, `bookable_online`, `kards_showing_book` (each
  Kard's `booking_url` and `book_label`), `team_links` (on a team of
  several, each person's own "Book with …" page), `whatsapp_url` (a
  message ready to send with the link), `qr` (the `url` it encodes, and a
  `png` data URL to print), `website` (`inline` and `popup` snippets to
  paste into a website) and the latest `booking_links`.
- `GET /calendars/{id}/booking_links` — single-use links, newest first
  (`page`, `per_page`): `url` (the one to send), `title`, `label`,
  `offering` and `provider` (`null`: anything on the menu, with anyone),
  `max_uses`, `uses_count`, `uses_left`, `expires_at`, `usable`, `status`
  ("used 0 of 1 · works through Wed 16 Jan").
- `POST /calendars/{id}/booking_links` — `{"booking_link": {"offering_id":
  3, "provider_id": 7, "label": "For Meera", "max_uses": 1,
  "expires_in_days": 7}}`, all optional (anything on the menu, with anyone,
  once, no end date). Any switched-on offering, front-desk-only ones too:
  a private link is how someone books what isn't listed. It works through
  the end of its last day in the calendar's zone. `201`; `422` when the
  offering or person isn't there or is switched off, `max_uses` isn't
  1–100 or `expires_in_days` isn't 1–365.
- `DELETE /calendars/{id}/booking_links/{link_id}` — it stops booking at
  once.

### The menu
Admins only. The menu is what clients book: Services, Meeting types,
Treatments … (`labels.offerings`). Writes follow the web's form, with its
checks and words.

- `GET /calendars/{id}/offerings` — every one, switched-off ones too, in
  menu order. Each: `id`, `name`, `slug`, `category`, `description`,
  `active`, `position`, `duration_minutes`, `duration_options` (the other
  lengths a client may pick), `duration_choices` (all of them, the default
  first), `buffer_before_minutes`, `buffer_after_minutes` (prep and
  cleanup), `processing_offset_minutes` and `processing_minutes` (a window
  inside the visit when the person is free, like colour developing; `0` =
  none), `price_cents`, `price_mode` (`fixed`, `from`, `free`, `hidden`),
  `price` (as clients see it: "From ₹1,200"), `currency`, `advance_cents`,
  `location_kind` (`at_business`, `at_client`, `phone`, `video`,
  `custom`), `location_details`, `confirmation_note`, `visibility`
  (`public`: on the booking page; `unlisted`: bookable only by its link;
  `internal`: the front desk only), `requires_approval`,
  `min_notice_minutes`, `horizon_days`, `slot_step_minutes`, `daily_cap`
  (`null` = the calendar's rule), `questions` (asked when booking: `key`,
  `label`, `kind`: `text`, `textarea`, `select` or `checkbox`, `required`,
  `options`), `providers` (who performs it: `id`, `name`, `price_cents`
  and `duration_minutes` = their own, or `null` for the offering's, and
  `price`), and `booking_url`: its own page on the business's booking
  link while a client can reach it (switched on, not `internal`, online
  booking live), else `null`. The link ends in a code
  (`…/book/s/beard-trim-k3x9q2ab7c1m`): an `unlisted` offering opens only
  through it, never by its name, and it keeps working if the offering
  moves between `public` and `unlisted`.
- `GET /calendars/{id}/offerings/{offering_id}` — one, plus what the web's
  form shows: `upcoming_bookings`, `recent_bookings` (the last 90 days)
  and `has_bookings` (then it can be switched off but not deleted).
- `POST /calendars/{id}/offerings` — add one. `201`.

  ```json
  {"offering": {"name": "Hair spa", "category": "Hair", "duration_minutes": 60,
    "duration_options": [90], "price_cents": 120000, "price_mode": "from",
    "questions": [{"label": "Any allergies?", "kind": "text", "required": true}],
    "providers": [{"id": 12, "price_cents": 150000, "duration_minutes": 75}, {"id": 14}]}}
  ```

  Any key of the JSON above except `id`, `slug`, `position`, `price`,
  `duration_choices` and `booking_url`; `name` and `duration_minutes`
  (steps of 5, up to 12 hours) are needed, and `fixed` and `from` need
  `price_cents`. Without `providers`, everyone on the team performs it,
  as on the web. `questions` (up to 5) and `providers` replace the whole
  list; a question sent with its `key` keeps it (answers are stored by
  key), and select questions need `options`. `processing_minutes: 0`
  turns processing off; with a window, `processing_offset_minutes` must
  be after the start and the window must end within every person's
  length. Problems: `422 validation_failed`, with `details` per field (a
  provider not on this calendar's team is one).
- `PATCH /calendars/{id}/offerings/{offering_id}` — change any of them;
  keys you leave out keep their value, `null` clears one. `{"active":
  false}` switches it off: it leaves the booking page and its bookings
  stay; `true` switches it back on. A switch answers with a `message` to
  relay ("Blow-dry is off: it's no longer on your booking page.").
- `DELETE /calendars/{id}/offerings/{offering_id}` — delete one that has
  never been booked: `{"deleted": true, "message": "Deleted Fringe
  trim."}`. One with bookings answers `422 has_bookings` (it stays in the
  history; switch it off instead).
- `POST /calendars/{id}/offerings/{offering_id}/move` — `{"direction":
  "up"}` or `"down"`: swaps it with its neighbour in the same category.
  Answers `moved` (`false` when it's already at that end) and the whole
  menu (`offerings`) in its new order. Another direction is `400
  invalid_parameter`.

### Importing a price list
Admins only. Send a printed price list (a photo, a PDF, a link to one,
or its text); it's read in the background into draft rows, which you
check and then confirm. Nothing joins the menu before that. Up to 10
imports a day per calendar (the web's imports count too), and each
account's imports, on all its calendars, may use a tenth of the day's
reading (everyone shares it); the photo,
PDF or text is deleted once the list is added or discarded, or after 24
hours.

- `GET /calendars/{id}/menu_imports` — whether one can start now:
  `daily_limit`, `imports_left_today`, `reading_paused` (the reading
  service is out for today), `can_start`, `refusal` (why not, in words);
  `running` (a read in progress), `waiting_for_review` (a list read and
  not yet added or discarded), and `menu_imports`: the last 10, newest
  first.
- `POST /calendars/{id}/menu_imports` — one of: `{"menu_import":
  {"text": "Haircut 45 min 500\nBlow-dry 30 min 400"}}`, `{"menu_import":
  {"image_url": "https://example.com/price-list.jpg"}}`, or a multipart
  upload with the photo or PDF as `file` (15 MB at most). Answers `202`
  with the import (`status: "queued"`): poll `GET …/menu_imports/{import_id}`
  every few seconds; a text read takes 10–20 seconds, a photo 20–60. An
  `image_url` must be a public `https://` address on the standard port,
  leading straight to a photo or PDF; it's fetched while the list is read
  (up to 3 redirects, 15 seconds, 15 MB), never at once, and a fetch that
  fails leaves the import `failed` with `download_failed` and the reason
  in `error.detail` ("the address answered 404"). Refusals: `409
  import_running` (one is still being read; `details.menu_import_id`),
  `429 rate_limited` (the calendar's imports, or the account's share of
  the day's reading, are used up; `Retry-After`), `503
  reading_paused`, `422 validation_failed` (nothing sent, more than one
  of them, or an address that can't be used).
- `GET /calendars/{id}/menu_imports/{import_id}` — `status`: `queued`,
  `reading`, `ready`, `failed`, `confirmed`, `discarded` or `expired`;
  `source_kind` (`text`, `photo`, `pdf`, or `url` until it's fetched),
  `has_source`, `created_at`, `expires_at`, `stalled` (a read that died:
  start again). `failed` (or stalled): `error` with `code`
  (`unreadable`, `unreadable_file`, `download_failed`, `budget`,
  `provider`, `stalled`), `title` and `message` (what the web says, and
  what to do next) and `detail`. `ready`: `rows`, the review table, each
  with `key`, `included` (the draft's choice), `name`, `category`,
  `duration_minutes`, `duration_guessed` (not on the list: check it),
  `price_cents`, `price_mode`, `price_missing`, `price_text` (as written),
  `attention` and `note` (something to check), `duplicate` (`menu`:
  already on the menu; `list`: twice on the list; left out by default)
  and `duplicate_name`; `summary` (`found`, `included`, `guessed`,
  `attention`, `duplicates`, `no_price`, `categories`); `currency_note`
  (when the list's prices are in another currency than the calendar's:
  amounts are copied as written, never converted). `confirmed`:
  `added_count`.
- `POST /calendars/{id}/menu_imports/{import_id}/confirm` — add the rows
  to the menu (everyone on the team performs them). Without a body, the
  draft is added as read (the rows `included`). With `{"rows": [{"key":
  "1", "duration_minutes": 40, "price_cents": 35000}, {"key": "2",
  "include": true, "name": "Long haircut"}]}`, only the rows sent are
  added (`"include": false` leaves one out), each keeping the draft's
  value for what you leave out (`name`, `category`, `duration_minutes`,
  `price_cents`, `price_mode`). Answers the import, the new `offerings`
  and a `message` to relay. Problems: `422 validation_failed` with
  `details.rows` (`{"0": ["Use minutes in steps of 5, like 40 or
  45"]}`) and `details.base`; nothing is added. Not read yet (or it
  failed): `409 not_ready`; already added, discarded or expired: `409
  already_finished`.
- `DELETE /calendars/{id}/menu_imports/{import_id}` — discard it: nothing
  is added and what was sent is deleted. A confirmed or expired one is
  `409 already_finished`.
- `GET /calendars/{id}/menu_imports/{import_id}/source` — the photo or
  PDF itself, to check the rows against; `404 no_source` for a pasted
  text, or once it's deleted.
- `POST /calendars/{id}/menu_imports/{import_id}/reread` — only after the
  reading service failed (`error.code` `provider` or `budget`): reads the
  same list again as a new import (`202`; the old one is discarded), with
  the same refusals as starting one. Anything else is `409
  not_rereadable`.

### The team
The people who take bookings: Team members, Hosts, Stylists …
(`labels.providers`). Nobody needs an account to be on the team; a Kard
and a login are optional. The team is the admins'; a front-desk or staff
login opens its own record only (`your_provider` in `GET /calendars/{id}`).

- `GET /calendars/{id}/providers` — admins: everyone, switched-off people
  too, in the team's order. Each: `id`, `name`, `first_name`, `title`,
  `phone`, `email`, `color`, `active` (on the team), `bookable_online`
  (the switch) and `takes_online_bookings` (switched on, active, and doing
  something on the booking page), `daily_cap` (most online bookings in a
  day; `null` = no limit), `position`, `yours` (it's the key's own
  person), `kard` (`share_link`, `name`, `url`: the Kard showing "Book with
  Priya", or `null`), `hours_summary` ("Mon–Fri 09:00–18:00"), `offerings`
  (what they do: `id`, `name`, `active`, `price_cents` and
  `duration_minutes` = their own, or `null` for the menu's, and `price`,
  `duration` in words) and `login` (`status`: `joined`, `admin`, `invited`,
  `invite_expired` or `none`; `role`; `email`; an open `invitation` with
  its `email`, `status` and `expires_at`; `null` on a personal calendar).
  Plus `suggested_kards`: the organization's Kards nobody stands behind yet
  (`share_link`, `name`, `title`, `url`), each one tap from joining.
- `GET /calendars/{id}/providers/{provider_id}` — one person, as their
  page shows them: the same, plus `weekly_hours`, upcoming `special_days`
  and `time_off` (shapes below), and `links` (`my_day_url`: their private
  read-only day on a phone; `feed_url`: their bookings for Google, Apple or
  Outlook). Admins, and that person's own login (without `login`). The
  links are private: anyone with them sees this person's bookings.
- `POST /calendars/{id}/providers` — add someone. `201`.
  - By name: `{"provider": {"name": "Sara Khan", "title": "Junior
    stylist", "phone": "98450 22222", "email": "sara@example.com", "color":
    "teal", "kard": "sara", "bookable_online": true, "daily_cap": 6,
    "offering_ids": [3, 4]}}` (only `name` is needed). They start with the
    opening hours, and do everything on the menu unless `offering_ids` says
    what. `color` is one of `clay`, `amber`, `ochre`, `olive`, `sage`,
    `teal`, `sky`, `indigo`, `plum`, `rose` (the next free one if left
    out).
  - From a team Kard, in one tap: `{"provider": {"kard": "priya"}}` with
    no `name` takes the Kard's name, tagline and number, links it, and does
    everything (a `suggested_kards` entry).
  - Yourself: `{"provider": {"me": true}}` puts the key's own person on the
    team, linked to their account (organization members; any other keys in
    the body apply too). Already on it: `200` with your record. A personal
    calendar (its one person is its owner) or someone outside the
    organization: `422 cannot_join`, saying why.
  - A `kard` must be one of the business's Kards that nobody else stands
    behind, and not the business's own Kard (that one books the whole
    team): otherwise `422 validation_failed` with `details.kard` naming
    why. An unknown id in `offering_ids` is `details.offering_ids`.
- `PATCH /calendars/{id}/providers/{provider_id}` — change any of those;
  keys left out keep their value. `"kard": null` unlinks their Kard.
  `offering_ids` replaces what they do (what they keep keeps their own
  price and minutes; set those in `providers` of `PATCH …/offerings/{offering_id}`).
  `"active": false` switches them off: they leave the calendar and the
  booking page, their past bookings stay, and their "My day" and calendar
  links stop working (the `message` says so; switched back on, they need
  the new links). Answers the person, as `GET` does, and a `message`.
- `DELETE /calendars/{id}/providers/{provider_id}` — off the team, with
  their hours and time off: `{"deleted": true, "message": "Sara Khan is
  off the team."}`. Someone with bookings stays in the history: `422
  has_bookings` (switch them off with `"active": false` instead). The only
  person on a personal calendar stays: `422 last_provider` (pause online
  booking in settings instead).
- `POST /calendars/{id}/providers/{provider_id}/move` — `{"direction":
  "up"}` or `"down"`: one place up or down the team (the order of the
  day's columns, and who comes first when a client lets the business
  choose). Answers `moved` (`false` when already first or last, with a
  `message`) and `providers` in the new order. Any other direction: `400
  invalid_parameter`.

### Hours, special days and time off
Under one person (`/calendars/{id}/providers/{provider_id}/…`): admins
for anyone, and a front-desk or staff login for its own person (its
`your_provider`). Clock times are 24-hour `"HH:MM"` in the calendar's
`time_zone`; `"24:00"` is midnight at the end of a day.

- `GET …/hours` — `{"hours": {"weekly_hours": {"mon": [["09:00",
  "13:00"], ["14:00", "18:00"]], …, "sun": []}, "summary": "Mon–Fri
  09:00–18:00", "bookable_online": true, "takes_online_bookings": true,
  "opening_hours": {…}, "opening_hours_summary": "…", "time_zone":
  "Asia/Kolkata"}}`. `[]` is a day off; two ranges are a split shift or a
  lunch break. `opening_hours` are the business's, to copy ("use opening
  hours").
- `PUT` (or `PATCH`) `…/hours` — `{"weekly_hours": {"sat": [["10:00",
  "14:00"]], "sun": []}, "bookable_online": false}`: the days named change,
  the rest keep their hours. Existing bookings stay where they are (the
  `message` says so). Problems are `422 validation_failed` with
  `details.weekly_hours` ("Monday: 10:00–12:00 overlaps 11:00–13:00",
  "\"sab\" isn't a day"); nothing is saved. `bookable_online` (whether
  clients can pick them online) is the admins' switch: from anyone else the
  request is `403 forbidden` and nothing is saved; send the hours alone.
- `GET …/special_days` — upcoming dates with different hours, from the
  calendar's today: `{"special_days": [{"id", "date": "2030-01-10",
  "day_off": false, "hours": [["12:00", "20:00"]], "note": "Late shift",
  "summary": "12:00–20:00"}]}`. They replace the weekly hours that day.
- `POST …/special_days` — `{"special_day": {"date": "2030-01-10",
  "hours": [["12:00", "20:00"]], "note": "Late shift"}}`, or `{"special_day":
  {"date": "2030-01-10", "day_off": true}}`. A date that already has
  special hours is replaced: `201` new, `200` replaced. No `date`, no
  `hours` (without `day_off`), or hours that overlap or end before they
  start: `422 validation_failed` (`details.date`, `details.hours`). A date
  that isn't `YYYY-MM-DD`: `400 invalid_parameter`.
- `DELETE …/special_days/{special_day_id}` — by its `id` or its date
  (`…/special_days/2030-01-10`): that day is back to the usual hours.
- `GET …/time_off` — upcoming time off, breaks and blocked time (ending
  from now on): `{"time_off": [{"id", "kind": "time_off" | "break" |
  "blocked", "starts_at", "ends_at" (UTC), "local_from", "local_to" (in the
  calendar's zone), "all_day", "note", "summary": "Mon 14 Jan – Fri 18 Jan
  (time off)", "bookings_to_move": 2}]}`. `bookings_to_move`: upcoming
  bookings with this person inside it, which need moving (clients can't
  book this time; the front desk can, after a warning).
- `POST …/time_off` — whole days: `{"time_off": {"kind": "time_off",
  "starts_on": "2030-01-14", "ends_on": "2030-01-18", "all_day": true,
  "note": "Family trip"}}`; a few hours: `{"time_off": {"kind": "break",
  "starts_on": "2030-01-15", "start_time": "13:00", "end_time": "14:00"}}`
  (`ends_on` left out = the same day; `all_day` left out = all day unless
  times are given; `kind` left out = `time_off`). `201` with the block and
  a `message` that counts the bookings to move. Problems are `422
  validation_failed` with the form's sentences in `details.base` ("Pick an
  end time.", "The last day can't be before the first day.").
- `DELETE …/time_off/{time_off_id}` — this person's own blocks only: that
  time is bookable again. A closure of the whole calendar is removed with
  `DELETE /calendars/{id}/blocks/{block_id}`.
- `POST …/my_day_link` — a new private "My day" link (their day on a
  phone, no login): `{"my_day_url": "…", "message": "…"}`. The old link
  stops opening at once (a lost phone, someone who left); send them the
  new one. Their calendar feed keeps working.
- `POST …/feed_link` — a new private calendar-feed link: `{"feed_url":
  "…", "message": "…"}`. Calendars subscribed to the old one stop updating
  until they subscribe again with the new link. "My day" keeps working.

### Blocked time and closures
The desk's "Block time": a break, blocked time or time off for one person,
or the whole business closed (a holiday). Clients can't book it online;
the desk can still book over it after a warning; visits already in it
stay booked. A person's own time off is also under
`/providers/{provider_id}/time_off`.

- `GET /calendars/{id}/blocks?from=2030-01-07&to=2030-01-13` — every role:
  the blocks touching those local dates (default: today and the next 30
  days; 92 days at most). Each: `id`, `kind` (`break`, `blocked`,
  `time_off`, `closed`), `whole_calendar`, `provider`, `starts_at`,
  `ends_at`, `local_from`, `local_to`, `all_day`, `note`, `summary` ("Thu
  10 Jan, 3:00 – 4:00 PM"). `provider_id`: that person's and the closures;
  `kind`: a comma list.
- `POST /calendars/{id}/blocks` — the front desk and admins: `{"block":
  {"provider_id": 7, "kind": "break", "date": "2030-01-10", "time": "15:00",
  "duration": 30, "note": "Lunch"}}`: from a local date and time for
  `duration` minutes (5–1440, or `"rest"` for the rest of the day) or
  `until` a time that day (`"18:00"`); or between two instants,
  `starts_at` and `ends_at`. Without `provider_id` it closes the whole
  calendar. What's left out is the desk's default: today, the next quarter
  hour, an hour, `blocked`. `201` with the `block`, `still_booked` (visits
  that keep the person busy in that time, anyone's for a closure; a
  processing window, when the person is free, doesn't count. They stay
  booked: move them if needed) and a `message` saying so.
- `DELETE /calendars/{id}/blocks/{block_id}` — the front desk and admins:
  that time is bookable online again.

### Logins and invitations
Admins of an organization's calendar only. Who signs in to the calendar,
and as what (the roles above). A personal calendar has no logins (only its
owner signs in), so these answer `404` there. Nobody needs a login to be
booked: most of the team never has one.

- `GET /calendars/{id}/logins` — the Logins page: `admins` (the
  organization's admins: `name`, `email`); `front_desk` (`logins`: each
  front-desk login, with `id`, `role`, `user` {`name`, `email`} and
  `provider` (`id`, `name`) or `null`; `invitations`: front-desk
  invitations still open, shaped as below); `providers` (everyone on the
  team: `provider` {`id`, `name`, `title`, `active`} and `login`: `status`
  = `joined` (with the login's `id`, `role` and `user`), `admin` (they sign
  in as an admin), `invited` or `invite_expired` (with the `invitation`),
  or `none`); `roles` (what `front_desk` and `staff` can do, in words); and
  `summary` (the page's count line: "4 people sign in to Glow Studio's
  calendar: 2 admins, 1 front-desk login and 1 stylist. …").
- `DELETE /calendars/{id}/logins/{login_id}` — remove access (a login's
  `id`: `front_desk.logins[].id` or `providers[].login.id`). They can no
  longer open the calendar; a team member's private "My day" and calendar
  feed links get new addresses (the old ones stop working); their
  bookings, hours and time off stay. `{"deleted": true, "message": "Priya
  Sharma's access was removed. Bookings, hours and time off are
  unchanged."}`. Another calendar's login is a `404`.
- `GET /calendars/{id}/invitations` — the open ones: waiting (`status:
  "pending"`) and `expired` (resend to bring the link back). Used and
  cancelled ones aren't listed. Each: `id`, `email`, `role`, `provider`
  (`id`, `name`; `null` for the front desk), `status`, `expires_at`,
  `last_sent_at`, `invited_by` (`name`), `created_at`.
- `POST /calendars/{id}/invitations` — invite someone by email.
  `{"invitation": {"email": "priya@example.com", "provider_id": 7}}`
  invites a team member to their own login (`staff`); `{"invitation":
  {"email": "desk@example.com"}}` without a `provider_id` invites the front
  desk. `role` (`staff` or `front_desk`) is only needed for a team member
  who runs the desk: `{"email": "…", "provider_id": 7, "role":
  "front_desk"}`. The email goes out at once; its link works once, for 14
  days, for the account with that email. `201` with the `invitation` and
  a `message` ("Invitation sent to priya@example.com. The link works until
  Mon 3 Feb."). Refusals: `422 validation_failed` with `details` (an
  address that doesn't look right; someone who already has a login or an
  invitation waiting; an email that already signs in here; `staff` without
  a provider); another calendar's `provider_id` is a `404`. 30 invitations
  an hour per account, counted together with the web's: `429
  rate_limited` with `Retry-After`.
- `POST /calendars/{id}/invitations/{invitation_id}/resend` — the same
  link again, working for 14 days from now (an expired one comes back).
  Answers `invitation`, `sent` and `message`; `sent: false` (nothing went
  out) within a minute of the last send, with a message saying to check
  the spam folder. A resend counts in the same 30 invitations an hour.
- `DELETE /calendars/{id}/invitations/{invitation_id}` — cancel: its link
  stops working. Answers the invitation (`status: "revoked"`) and a
  `message`. Only open invitations are found: a used or cancelled one is a
  `404`.

### Webhook endpoints
Admins only (on a personal calendar, its owner). The endpoints this
calendar sends booking changes to, up to 5; what they receive and how to
check a signature is under "Webhooks" below.

- `GET /calendars/{id}/webhooks` — `webhooks`, `events` (the kinds of
  change there are: `booking.created`, `booking.rescheduled`,
  `booking.cancelled`, `booking.status_changed`) and `max` (5). Each
  endpoint: `id`, `url`, `host`, `events` (what it hears), `secret` (its
  signing secret, `whsec_…`: keep it out of chats and logs), `enabled`,
  `disabled_at` and `disabled_reason` (it pauses itself after 10 failed
  deliveries in a row), `consecutive_failures`, `last_attempted_at`,
  `last_delivered_at`, `last_status_code`, `last_error`, `test` (the last
  test: `state` = `sending`, `stalled`, `delivered`, `failed` or `null`;
  `requested_at`, `tested_at`, `status_code`, `duration_ms`, `error`) and
  `created_at`.
- `POST /calendars/{id}/webhooks` — `{"webhook": {"url":
  "https://hooks.example.com/bookings", "events": ["booking.created",
  "booking.cancelled"]}}` (`events` left out: all of them). `201` with the
  `webhook` (its `secret` included) and a `message`; send a test next.
  `422 validation_failed` with `details`: the `url` must be a full
  `https://` address on the standard port, reachable on the internet (not
  a private or local address), without a username or password; `events`
  need at least one known kind; a sixth endpoint is refused (remove one
  first).
- `PATCH /calendars/{id}/webhooks/{webhook_id}` — `{"webhook": {"events":
  ["booking.created"]}}` changes what it hears; `{"webhook": {"enabled":
  true}}` turns a paused endpoint back on (its failure count starts
  again). Answers the `webhook` and a `message`. An endpoint can't be
  paused by hand (`"enabled": false` is a `422`: remove it, or change its
  events), and its address can't change (`422`: add the new address as
  another endpoint, then remove this one).
- `DELETE /calendars/{id}/webhooks/{webhook_id}` — `{"deleted": true,
  "message": "Removed. hooks.example.com no longer hears about
  appointments."}`.
- `POST /calendars/{id}/webhooks/{webhook_id}/test` — the web's "Send
  test": a sample `booking.created` with `"test": true` and made-up client
  details, sent from our servers a moment later. `202` with the `webhook`
  (`test.state: "sending"`) and a `message`; read `GET …/webhooks` a few
  seconds later for the answer in `test`. A test already on its way: `200`,
  nothing new is sent. 10 tests a minute per account, counted together
  with the web's: `429 rate_limited` with `Retry-After`.
- `POST /calendars/{id}/webhooks/{webhook_id}/rotate_secret` — a new
  signing secret, in the answer's `webhook.secret`. The old one stops
  matching at once: update the receiver (the `message` says so).

### Importing clients
Admins only. Bring clients in from a spreadsheet (CSV) or from the
contacts people left on the business's Kards. Each person is matched like
a booking does it (phone number first, then email): someone already here
only gets what their record is missing, and nobody is messaged. Nothing is
kept between requests: send the file again with each one, a dry run first.

- `GET /calendars/{id}/clients/import` — the sources: `csv` (`max_bytes`
  (2 MB), `max_rows` (5,000), `fields`: what a column can be, `name`,
  `first_name`, `last_name`, `phone`, `email`, `notes` or `skip`, with
  their labels; `default_country`) and `contacts` (`total`, and `kards`:
  each Kard with contacts, `id`, `share_link`, `name`, `contacts`).
- `POST /calendars/{id}/clients/import` with `"dry_run": true` — the
  preview; nothing is written.
  - A CSV: `{"source": "csv", "csv": "Name,Phone\nAsha Rao,98765
    00001\n", "dry_run": true}`, or a multipart upload with the file as
    `file`. `mapping` (optional) says what each column is, in order:
    `["name", "phone", "skip"]`, or by column number `{"0": "name", "1":
    "phone"}`; left out, it's guessed from the headings. `country` (two
    letters; the calendar's by default) is how numbers without a country
    code are read.
  - Contacts: `{"source": "contacts", "kard_ids": [12], "dry_run": true}`
    (`kard_ids` left out: every Kard with contacts).

  Answers `preview`: `source`; for a CSV `headings`, `mapping`,
  `mapping_guessed` and `country`, for contacts `kard_ids`; `counts`
  (`create`, `update`, `skip`); `rows` (the first 200: `label` ("Row 4"),
  `action` = `create`, `update` or `skip`, `reason` (why it's skipped),
  `name`, `phone`, `email`); `rows_total`; `problems` (what still needs
  choosing, in words; empty when it's ready); `too_many_rows`.
- The same `POST` without `dry_run` — the import: `{"result": {"created":
  12, "updated": 3, "skipped": [{"label": "Row 5", "name": null, "reason":
  "no phone number or email"}]}, "message": "12 new clients added, 3
  already here got what was missing. Nobody was messaged."}`. Refusals:
  `422 validation_failed` (`details.mapping`: no column with names, or
  none with phone numbers or emails; `details.kard_ids`: no Kard ticked),
  `422 too_many_rows` (more than 5,000 at once: split the file), `422
  unreadable` (not a CSV, such as an Excel file, an empty file or one over
  2 MB; the message says what to do), `409 changed_meanwhile` (someone was
  added with one of these numbers or emails meanwhile, so nothing was
  imported: preview again), `400 invalid_parameter` (an unknown `source`,
  `mapping` field or `country`), `400 missing_parameter` (neither `csv`
  nor `file`). Another calendar's Kard is a `404`.

### Calendar sync (Google Calendar, Outlook)
The key's own Google Calendar or Outlook, one at a time. It belongs to
the person, not to a calendar, so it's at `/calendar_sync`, not under
`/calendars/{id}`: any key, its own connection only. Busy times in the
calendars it checks block the booking page, and new bookings go into the
one it picks, for every team member linked to this account (their own
booking page, and the teams they work in). **Connecting needs a browser**:
the person approves it on Google's or Microsoft's consent screen, at
`connect_url` (`https://ekard.app/calendar-sync`). Send them there; everything
after that works here. Writes count against the 30 changes a minute per
account.

- `GET /calendar_sync` — `connection` (`null` when nothing is connected),
  `services` (`key` = `google` or `microsoft`, `name`, `available`: this
  server offers it), `providers` (where it counts: each team member linked
  to this account, `id`, `name`, `calendar` {`id`, `name`}) and
  `connect_url`. The connection: `service`, `name`, `account_email`,
  `status` (`connected`, or `needs_reauth`: connect again at
  `connect_url`), `checks_busy_times`, `writes_events` (new bookings go
  into it), `busy_calendar_ids`, `destination_calendar_id`,
  `add_meet_links` (a Meet or Teams link for video meetings that have
  none), `invite_clients` (the client is added to the event as a guest),
  `calendars` (the account's calendars to choose from: `id`, `name`,
  `primary`), `calendars_listed_at`, `calendar_list_failed` (the service
  couldn't list them just now: these are the last ones), `allowed` (what
  the person allowed when connecting: `busy_times`, `add_bookings`,
  `list_calendars`; connect again to change it),
  `upcoming_not_in_calendar` (upcoming bookings not in it yet, when it
  writes events), `last_synced_at`, and `last_error` with `last_error_at`
  while a problem lasts.
- `PATCH /calendar_sync/{service}` (`google` or `microsoft`; `PUT` works
  too) — `{"calendar_sync": {"busy_calendar_ids": ["maya@example.com",
  "family123@group.calendar.google.com"], "destination_calendar_id":
  "family123@group.calendar.google.com", "add_meet_links": true,
  "invite_clients": false}}`, ids from `calendars`. Keys left out keep
  their value; `"busy_calendar_ids": []` checks none (10 at most);
  `"destination_calendar_id": null` stops adding bookings. Answers the
  `connection` and a `message` ("Saved. Busy times in 2 calendars block
  your booking page; new bookings go into “Family”."). A calendar the
  account doesn't have: `422 validation_failed` with `details`. Not
  connected: `409 not_connected` ("Connect Google Calendar first, on the
  web: …").
- `DELETE /calendar_sync/{service}` — disconnect: busy times there stop
  blocking, the stored sign-in is deleted (and the access revoked at
  Google), and bookings already added stay in that calendar. `{"connection":
  null, "message": "…"}`; for Outlook the message says where to remove the
  access at Microsoft too. Nothing connected: `409 not_connected`.
- `POST /calendar_sync/{service}/add_upcoming` — add the upcoming bookings
  that aren't in it yet (`upcoming_not_in_calendar`). `202` with `count`
  and a `message`; they appear there within a minute. A connection that
  doesn't add bookings: `409 not_connected`, saying why (no calendar is
  picked for them, or adding wasn't allowed when connecting: connect
  again).

### Errors and limits
- The engine's errors carry its plain-language message: `409 slot_taken`
  (`details.alternatives`: up to 3 nearby free times), `409
  stale_booking`, `422 invalid_transition` ("This appointment is
  cancelled, so it can't be marked as arrived."), `422 invalid_duration`,
  `422 offering_unavailable`, `422 too_late_to_request`, `422
  invalid_recipient`, `422 validation_failed` (`details` per field),
  `400 invalid_parameter`.
- Writes share the booking limits below, whichever path they take, and
  the web's front desk counts its bookings and changes on the same
  counters: per account, 5 new bookings a minute and 200 a day, and 30
  changes a minute (any calendar write); per calendar, 10 new bookings a
  minute and 300 a day; and a booking can change 5 times an hour in ways
  that email its client (reschedule, cancel, approve, decline, advance
  received). `429 rate_limited` with `Retry-After`.
- Setup writes count in the same 30 changes a minute, and keep the web's
  own limits on the same counters: 30 invitations an hour, 10 webhook
  tests a minute, and 10 price lists read a day per calendar.
- Only in the browser: connecting a Google or Outlook calendar (their
  consent screen; see Calendar sync), accepting an invitation (the invited
  person's own consent, from the email), and the private pages opened by
  their link (the calendar feed, "My day").

## Bookings

Booking from a kard: the calendar behind it, for its admins. Everything
else in Calendar (every role, the schedule, clients, money) is under
Calendars above; these kard-scoped endpoints stay for agents that start
from a kard. As on the web, an organization's bookings here are its
admins' only: owning a team kard isn't enough (`403`).

- `GET /bookings` — upcoming bookings across every calendar the account
  manages, earliest first. Params: `from`, `to` (ISO 8601; `from` defaults
  to now), `status` (comma list of `requested`, `booked`, `arrived`,
  `completed`, `cancelled`, `no_show`, `declined`; default
  `requested,booked`), `page`, `per_page` (≤ 100). Each booking carries its
  `calendar`.
- `GET /kards/{share_link}/bookings` — the same, for the calendar behind one
  kard. `GET /kards/{share_link}/bookings/{id}` — one booking.
- `GET /kards/{share_link}/offerings` — what can be booked from the kard
  (a team kard linked to a person lists what that person does): name,
  `duration_minutes` and `duration_options`, `price_cents` / `price_mode` /
  `price` (formatted), `visibility` (`public`, `unlisted` = link only,
  `internal` = front desk only), `providers`, and a `booking_url` deep link
  for each one visitors can book. The response's `kard` block says whether
  the kard shows a Book button (`accepts_bookings`, `booking_url`,
  `book_label`).
- `GET /kards/{share_link}/availability?offering_ids[]=…&date=YYYY-MM-DD` —
  open times for a visit (one or more offerings, back to back with one
  person), grouped by local date: `{"dates": {"2030-01-10": [{"starts_at",
  "ends_at", "local_starts_at", "provider_ids"}]}}` (`provider_ids`: who is
  free, in assignment order). Params: `days` (1–14, default 1),
  `provider_id` (a team kard linked to a person defaults to them),
  `duration` (one of the offering's duration options), `mode` (`online`,
  the default: what a visitor can book; `front_desk`: what the business
  can, ignoring notice and booking window).
- `POST /kards/{share_link}/bookings` — book like the front desk (`source:
  "api"`). Body: `{"booking": {"offering_ids": [3], "starts_at":
  "2030-01-10T09:30:00Z", "client": {"name": "...", "phone": "...", "email":
  "..."}}}` (or `client_id` of an existing client; front-desk bookings need
  only a name). Optional: `provider_id` (default: anyone free, or the kard's
  linked person), `duration`, `notes`, `answers`, `guest_emails`, `channel`
  (`phone`, `whatsapp`, `instagram`, `in_person`, `other`), `client_address`
  (where to go, for a service done at the client's address; up to 500
  characters),
  `overlap_allowed: true` (book anyway over existing time; needs
  `provider_id`), and `idempotency_key` (send one: a retry of the same
  booking with the same key returns the first booking instead of booking
  twice; keys are per API user, so nobody else's key ever reaches your
  bookings). Answers `201` with
  the booking. Agent bookings don't count as the kard's Book-button
  bookings in its analytics.
- Every booking emails its client and guests, so the addresses must be real
  ones: `client.email` and each of the `guest_emails` must be a single
  mailbox on a public domain (`asha@company.com`; not `root@localhost` or a
  `.test`/`.example` domain), and a booking made here names at most 2
  guests. Otherwise `422 invalid_recipient`, and nothing is booked or sent.
  On a personal calendar whose owner hasn't verified their email address,
  nobody the owner books is emailed yet: the answer's booking says
  `client_mail_held: true`, with a `notice` to pass on (see the
  calendar-scoped bookings above).
- `PATCH /kards/{share_link}/bookings/{id}` — either a new time,
  `{"booking": {"starts_at": "...", "provider_id": 7, "overlap_allowed":
  false}}` (the client's manage link and reminders follow), or a new
  status, `{"booking": {"status": "arrived"}}`: `arrived`, `completed`,
  `no_show` (after the start), `booked` (approve a request), `declined`
  (an optional `reason`) and `cancelled` (a `reason` is required, as on the
  web: the client and the team both read it; cancelling here is the
  business's). Not both at once.
- Errors carry the engine's plain-language message: `409 slot_taken` (the
  time isn't free; `details.alternatives` lists up to 3 nearby open times),
  `409 stale_booking` (it changed meanwhile: read it again),
  `422 invalid_transition` ("This appointment is cancelled, so it can't be
  marked as arrived."), `422 invalid_duration`, `422 offering_unavailable`,
  `422 too_late_to_request`, `422 invalid_recipient`, `400 invalid_parameter`.
- **Limits.** Because each write can email people, booking writes have
  their own limits, counted together with the Calendars endpoints and the
  web's front desk: per
  account, 5 new bookings a minute and 200 a day, and 30 changes a minute;
  per calendar, 10 new bookings a minute and 300 a day; and each booking
  can change 5 times an hour. Past one, the answer is `429 rate_limited`
  with a `Retry-After` header (seconds); wait that long and send it again.
- A booking is the `data.booking` object shown under Webhooks below. Times
  are UTC ISO 8601 plus `local_starts_at` in the calendar's zone; the
  client's private manage link is never included.
- A kard with no booking calendar answers `404` with code `no_calendar`.

## Webhooks

A booking calendar can push every booking change to up to 5 endpoints of
your choosing (Zapier, Make, your own server). Its admins (a personal
calendar's owner) manage them through this API at
`/calendars/{id}/webhooks` (see "Webhook endpoints" under Calendars), or
on the web: `https://ekard.app/calendar/webhooks` for a personal calendar,
`https://ekard.app/organizations/{slug}/calendar/webhooks` for an
organization's. Each endpoint has its own signing secret (`whsec_...`)
and picks which changes it hears.

Every change is one `POST` with a JSON body:

```
{
  "type": "booking.created",
  "timestamp": "2030-01-09T06:30:00Z",
  "data": {
    "booking": {
      "id": 812, "status": "booked", "source": "online",
      "title": "Haircut + Blow-dry",
      "starts_at": "2030-01-10T09:30:00Z", "ends_at": "2030-01-10T10:45:00Z",
      "local_starts_at": "2030-01-10T15:00:00+05:30", "time_zone": "Asia/Kolkata",
      "duration_minutes": 75, "price_cents": 130000, "currency": "INR", "price": "₹1,300",
      "client": { "id": 41, "name": "Ananya Rao", "phone": "+919800000001", "email": null },
      "client_time_zone": "Asia/Kolkata",
      "items": [
        { "offering": { "id": 3, "name": "Haircut", "slug": "haircut" },
          "provider": { "id": 7, "name": "Priya Sharma" },
          "starts_at": "2030-01-10T09:30:00Z", "ends_at": "2030-01-10T10:30:00Z",
          "duration_minutes": 60, "price_cents": 80000 }
      ],
      "kard": { "share_link": "priya", "organization": "glowstudio", "public_url": "..." },
      "notes": null, "client_address": null, "answers": {}, "guest_emails": [],
      "cancelled_at": null, "cancelled_by": null, "cancellation_reason": null,
      "created_at": "2030-01-09T06:30:00Z", "updated_at": "2030-01-09T06:30:00Z"
    },
    "calendar": { "id": 2, "name": "Glow Studio", "preset": "salon", "time_zone": "Asia/Kolkata", "currency": "INR" },
    "source": "online"
  }
}
```

- `type` and the extra `data` keys:
  - `booking.created`: `source` (`online`, `front_desk`, `walk_in`, `api`);
  - `booking.rescheduled`: `previous_starts_at`, `by` (`client`/`business`);
  - `booking.cancelled`: `by` (`client`/`business`);
  - `booking.status_changed`: `from`, `to` (`arrived`, `completed`,
    `no_show`, `booked` when a request is approved, `declined`), and
    `reason: "expired"` when an unanswered request lapsed.
- `data.booking` is a snapshot at the moment of the change. Times are UTC
  ISO 8601; `local_starts_at` is the start in the calendar's zone. Money is
  integer minor units plus `currency`; `price` says it in words ("₹1,300",
  "Free", or null when no price was set). The client's manage link is never
  sent.
- "Send test" on the web sends a `booking.created` with `"test": true` and
  made-up client details; the endpoint's row shows the answer a few
  seconds later.
- Endpoints must be `https://` on port 443, with a host name or a public
  IP address. Each delivery has 15 seconds in all (5 to connect) to get a
  complete answer, and addresses that resolve to private, local or tunnel
  (6to4, Teredo, NAT64) networks are never called.

**Headers** follow the Standard Webhooks spec (standardwebhooks.com), so
its libraries verify them:

```
Content-Type: application/json
webhook-id: msg_6f1c...        (same on every retry of one event: use it to skip duplicates)
webhook-timestamp: 1894170600  (Unix seconds of this attempt)
webhook-signature: v1,<base64>
```

**Check the signature:** base64-decode the part of the secret after
`whsec_` to get the key; compute HMAC-SHA256 with that key over
`{webhook-id}.{webhook-timestamp}.{raw body}`; base64-encode it; compare it,
in constant time, with the value after `v1,`. Reject timestamps more than 5
minutes from now.

```ruby
key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
expected = "v1," + Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}"))
```

**Delivery:** answer with any `2xx` within 10 seconds (do slow work after
answering). Anything else, or no answer, is retried 5 more times over about
15 minutes. After 10 events in a row fail every try, the endpoint pauses
itself and the calendar's owners get an email; updates from while it's
paused aren't sent later. Redirects aren't followed, and addresses must be
public `https` (no private or local networks).

## Not in this API

- Plan purchase (web checkout: `https://ekard.app/pricing`) — cancel via
  `DELETE /subscription`.
- Creating/deleting organizations; organization member management.
- The AI design studio (web-only for now).
- Sign-in/session management — the API key replaces it.

## Being a good agent

- Destructive calls (`DELETE` kard/document/contact, design publish over a
  live design, subscription cancel) are irreversible or visitor-visible —
  make sure the owner asked for exactly that.
- Public pages are edge-cached; the cache is purged automatically on writes,
  so changes go live within seconds.
- Rate limits are generous for normal use (booking writes have their own: see Bookings); batch reads where you can.
- Calendar writes reach real clients: a new time, a cancellation or an
  answer to a request emails them. Say what you're about to do and to whom
  ("Cancel Meera's 3:30 PM haircut on Friday?") before you do it, and
  relay the API's messages as they are: they're written for people.
