# 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`
- What changed, newest first: "Changelog", at the end

## 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.

### Signing in from the app

These routes are for eKard's own app; agents use the API key. Each
device signs in once and gets its own pair of tokens.

- `GET /sessions/nonce` → `{nonce, expires_at}` (5 minutes, works once):
  pass it to Credential Manager with the Google request (on iPhone, to
  Google's sign-in page). Sign in with Apple's request carries its SHA-256
  as lower-case hex, and the nonce itself goes to `POST /sessions/apple`.
- `POST /sessions/google` `{id_token, nonce, device}`,
  `POST /sessions/apple` `{identity_token, authorization_code, nonce, name?, device}`,
  or `POST /sessions/password` `{email, password, name?, create?, device}` →
  `201 {session, account, created}`. A password sign-in with an email no
  account uses makes the account only with `create: true`; without it the
  answer is `401 no_account`, so the app can ask before creating one. A new
  account's email needs a dot in its domain (`422`, `details.email`).
  `device` is `{name, platform: "android" or "ios", app_version, os_version}`.
- Send `session.access_token` (`eka_…`) as `Authorization: Bearer`. It works
  wherever the API key does, for an hour; then the answer is
  `401 token_expired`. Send `session.refresh_token` (`ekr_…`) to
  `POST /sessions/refresh` `{refresh_token, device?}` for a new pair and
  keep both new tokens (store them before using them). `device` is the
  sign-in's, as the device is now: its `name`, `app_version` and
  `os_version` replace the session's (a blank one keeps it; the platform
  stays the sign-in's), so `GET /sessions` names an updated app by its new
  version. A refresh token lasts 60 days from its last use.
  The one just replaced still works until the new pair is first used, for up
  to 7 days, and answers the same pair again, so an answer that never
  arrived can be asked for again (and two refreshes sent at once get one
  pair); once the new pair is used, sending the old token again (or any
  older one) ends the session. `401 refresh_invalid` means sign in again.
- Google verifies the address. When it signs in to an account that had a
  password but never verified its address (anyone could have made it), the
  Google user takes it over: the password is removed (`has_password`
  false; "Forgot password" sets one), every other device and browser is
  signed out, the AI key stops (make a new one at `/account/ai`), and the
  owner is emailed.
- Sign in with Apple, from the iPhone app: `identity_token` and
  `authorization_code` are the credential's, as text, and `name`
  (`{given_name, family_name}`) comes at the first sign-in only: it names a
  new account, or one with no name yet. The account is the one this Apple
  Account signed in to before; otherwise the one with the email Apple
  verified (a private relay address too), linked to Apple from then on, its
  owner emailed that Sign in with Apple was added (and taken over, as with
  Google, if its address was never verified); otherwise a new one, with no
  password. The server trades the code for
  Apple's refresh token, so deleting the account ends its link with Apple
  too. Offer it only where `features.apple_sign_in` in `GET /app/config` is
  true; elsewhere the answer is `503 apple_sign_in_unavailable`. Refusals:
  `invalid_id_token` (401), `invalid_nonce`, `email_missing` (Apple shared
  no address for an Apple Account new here) and `email_not_verified` (422),
  `email_not_allowed` (403), and `apple_unavailable` (503: Apple's keys
  were out of reach; try again in a minute).
- `DELETE /session` signs this device out and stops its notifications.
  When that had no signal, `POST /sessions/revoke` `{refresh_token}` (no
  key) does it later with the refresh token the app kept: always `204`.
  `GET /sessions` lists the person's signed-in devices (`current` marks this
  one), and with `?include=web` the browsers signed in to the website too
  (`platform: "web"`, named "Chrome on Windows" and the like);
  `DELETE /sessions/{id}` signs one out, a browser too. Both take the app's
  own sign-in: an AI key is `422 not_a_session` (since 1.9.13). A new
  password signs out every device but the one it was set on.
- Sign in with a link by email (the web's "Email me a sign-in link"):
  `POST /sessions/email_link` `{email, device_nonce}` → always `202`
  `{message}`, the same words for every address, with an account or
  without, as the web's Check your email ("We've sent a link to …. It
  works once, for 15 minutes."); nothing is
  made until the link is used, and asking reads no account. An address
  whose mail bounced for good (Amazon SES said so) gets `422
  validation_failed` instead, `details.email` saying when, why and what to
  do; that reads the address's mail, not accounts. An address one slip
  from a big provider's (`kabir@gmial.com`) carries `did_you_mean`
  (`kabir@gmail.com`) in the `202` and in every refusal's `error` (`403`,
  `422`, `429`): read from the typed address alone, never an account. The
  link still goes to the address as typed, as the web sends it when the
  person asks anyway (its sign-in page shows "Did you mean …?" under the
  field as it's left and on a refusal, never on Check your email); offer
  it on Check your email, its press asking again for that address. `device_nonce`
  is a random value the app makes for the ask (32 to 128 letters, digits,
  `-` or `_`) and keeps for 15 minutes. The email's link is
  `https://<host>/sign-in-link/open?token=…&bind=…&app=1`: with `app=1` it
  opens the app where it's installed (App Links, Universal Links), the
  web's page otherwise; `bind` ties it to that `device_nonce` (a keyed
  HMAC of the link's own token and the nonce's digest). Then
  `POST /sessions/email_link/sign_in`
  `{token, bind?, device_nonce?, claim?, confirm?, device}` → `201
  {session, account, created, claimed, message}`. On the device that asked
  for this link (its `device_nonce` and the link's `bind`), a verified
  account or a new address signs in at once;
  an account nobody has verified answers `409 confirm_claim` first (show
  `message`, which says the claim signs out every other device and removes
  the password and AI key, then send again with `claim: true`). Anywhere
  else (no `device_nonce` or `bind`, or another one's): `409 confirm_sign_in` first, with
  the account's `email` (send again with `confirm: true`); it never claims
  an account, and leaves one nobody has verified as it was. A `409` uses
  nothing. Using a link verifies the address, makes the account for a new
  one (`created`), and ends the link: `410 link_gone` for a late, used,
  altered or other-purpose one (its `title` is the web page's heading,
  which `message` begins with). Limits, counted with the web's asks: 5
  links an hour per mailbox from one network, 20 from anywhere, 30
  mailboxes and 40 links in all per network (an IPv4 address, an IPv6
  /64), 60 per IPv4 /24 or IPv6 /48, 300 site-wide (`429 rate_limited`, `Retry-After`
  to the end of that hour); a malformed address is `422`, a blocked domain
  `403 email_not_allowed`.
- A code beside the link, typed on the screen that asked (from 1.9.4): a
  screen with the field asks with `with_code: true`
  (`{email, device_nonce, with_code}`). The email then carries an 8-digit
  code beside the link ("4821 7390"), and the `202` adds `ask_id` (22
  characters) and `code_length` (8), its `message` saying "We've sent a
  link and a code to …. … Open the link on this phone, or type the code
  here." Without `with_code`, the email and the `202` are as before; a
  server before 1.9.4 ignores it (no `ask_id`: show no field). Then
  `POST /sessions/email_link/code` `{ask_id, code, device_nonce, claim?,
  device}` → `201 {session, account, created, claimed, message}`, the
  link's own answer: the code signs this device in as the link would have
  if it had been opened here. The ask is found by `ask_id` and
  `device_nonce` together (another nonce never finds it). One code per
  ask, 15 minutes, 5 wrong tries; a newer ask from the same nonce replaces
  it. The link and the code spend each other: one email, one sign-in, and
  any sign-in to the account since the email ends the code. An account
  nobody has verified answers `409 confirm_claim` first (the link's
  `EmailLinkConfirmation`, "You never confirmed this address, so signing in with it now confirms it";
  send again with `claim: true`); never `confirm_sign_in`. The code is
  read as typed: spaces, dashes and dots dropped, any script's digits
  read as 0 to 9, none of it a try; anything else that isn't 8 digits is
  `422 validation_failed` (`details.code`). `422 code_wrong` with
  `tries_left` (4 to 1), `422 code_earlier` (an earlier email's code from
  this device: no try); `410` with `title` (`message` begins with it):
  `code_expired` (also an unknown `ask_id` or another device's),
  `code_replaced`, `code_tries_used`, `code_used` (the link was used, or
  this code already signed in), `code_ended` (the account signed in
  another way, its sign-ins ended, its address changed). Before a right
  code nothing says whether an account exists. Tries (each comparison of
  an 8-digit code): 10 an hour per device, 10 an hour and 20 a day per
  mailbox, 20 an hour per network, 50 per IPv4 /24 or IPv6 /48, 100 an
  hour site-wide (then codes pause until the hour ends; links don't):
  `429 rate_limited` with `Retry-After`. The door counts with the other
  sign-ins too (30 a minute per network). Keep `ask_id`, the code and the
  nonce out of logs, analytics, navigation arguments and crash reports;
  forget `ask_id` after a `201`.
- Refusals: `invalid_credentials`, `no_account` and `invalid_id_token` (401), `invalid_nonce`
  and `email_not_verified` (422), `email_not_allowed` (403). 30 sign-ins a
  minute per network, and 10 password tries a minute per email, counted with
  the web's sign-in. After 50 wrong passwords in a day for one account (web
  and app together), password sign-in for it pauses for 24 hours, even with
  the right password: `429 password_sign_in_paused` with `Retry-After`; the
  owner is emailed, "Forgot password" lifts it, and Google sign-in works.
- `GET /staff_invitations/{token}` (no key needed) — a calendar team's
  invitation as the app shows it: the calendar, organization, `role`, the
  team member (`provider`), who invited, the masked email, `status`, and
  `refusal` `{code, message}` (what stops joining now; with a key, for that
  account). `POST /staff_invitations/{token}/accept` (the app session of
  the invited email) joins: `{calendar: {id, name, role}, message}`;
  `403 wrong_account` for another email, `403 email_unverified` for an
  account made before the invitation that hasn't verified its address,
  `410 invitation_closed` (cancelled, expired, used) with why. 10 a minute
  per invitation. `email_unverified`'s `message` is words to show as
  written; beside it, `verify_url` (in the error, and in `refusal` when
  reading) is where the app sends the link: POST it with the same session
  (it's `POST /me/email_verification`), then accept again.

  ```json
  {"error": {"code": "email_unverified",
             "message": "This account was made before the invitation and hasn't verified its email address yet. Open the link we email to it, then join.",
             "verify_url": "https://ekard.app/api/v1/me/email_verification"}}
  ```
- `GET /organization_invitations/{token}` (no key needed) and
  `POST /organization_invitations/{token}/accept` — an invitation to an
  organization's team, the same way: the organization (`name`, `slug`),
  `role` (`member` or `admin`), what that role can do (`abilities`), who
  invited, the `kard` that comes with it (`{name}` or `null`), the masked
  email, `status`, and `refusal` `{code, message}` (`membership_ended`:
  they joined with it and have left since). Accepting needs the invited
  email's app session: `{organization: {slug, name, role}, kard, message}`
  (`kard` `{share_link, name}`: theirs now; `null` when none came, or it
  went to someone else meanwhile, which the `message` says); `403
  wrong_account`, `403 email_unverified` (with `verify_url`, as above),
  `410 invitation_closed`.
- `GET /join_links/{token}` (no key needed) and `POST
  /join_links/{token}/join_requests` — a team's join link (the web's
  `/teams/join/{token}`, which an admin shares in a group chat): the
  organization (`name`, `slug`), `status` (`open`, `expired`, `used_up`),
  `expires_at`, what a member can do (`abilities`), and with a key the
  account's own `join_request` (`{id, name, status, created_at,
  decided_at, kard}`), `refusal` `{code, message}` (what stops asking now:
  `sign_in_required`, `on_the_team`, `waiting`, `declined`,
  `membership_ended`, `expired`, `used_up`, `email_unverified`, `invited`,
  `too_many_waiting`, `daily_limit`) and `suggested_name`. Asking takes the person's app
  session and `{"join_request": {"name": "Priya Sharma"}}` (the name on the
  team kard they get: 4 to 100 characters, letters in any script, spaces
  and `. , ' - ( )`, and nothing a mail app makes a link of, as it leads
  the admins' email subject; `422 validation_failed` says which):
  `201 {join_request, message}`.
  Nobody joins by asking: the team's admins approve or decline, and the
  person is emailed either way. One request per person per team: the
  account, and its address (a decline outlives a deleted account, and a
  `+tag` or Gmail's dots make no new address). `403
  email_unverified` (with `verify_url`, as above), `410 expired` or
  `used_up`, `422` with the refusal's code, `422 not_a_session`
  for an API key; a link turned off or replaced is a `404`. 5 requests
  waiting per account at once, 10 a day (the web's and the app's
  together: `429 rate_limited`, `Retry-After` when the first of them is a
  day old); 30 reads a minute per link per network.
- `POST /web_sessions` `{return_to: "/calendar/settings"}` (the app's
  sign-in only) — `{url, expires_at}`: a link for a web-only page, one of
  `web_pages` in `GET /app/config` (any other is `422 page_not_allowed`:
  open it as it is). A fragment may end `return_to`, as the web's link has
  it: `/organizations/glowstudio/edit#logo` lands at the logo (a letter,
  then up to 63 letters, digits, `-` or `_`; any other is `422
  invalid_parameter`). Match the page against `web_pages` without its
  query and fragment. Opened in
  a Custom Tab it lands on `return_to` signed in as this account, once,
  within 2 minutes (a browser not already signed in as this account first
  confirms with "Continue as …"; the link alone never signs anyone in);
  someone else signed in there is asked first. That browser stays signed
  in for its session only, and only while this app session does: signing
  the app out signs it out too. A link from an iPhone app session
  (`platform: ios`) also marks that browser, from its first page, as the
  app's: no page in it shows a plan, a price, an upgrade or a way to pay
  (App Store Review Guidelines 3.1.1 and 3.1.3(f)), and `/pricing` and
  `/subscription` show only the account's plan in words. The mark is the
  browser's, not the sign-in's (the app's browser keeps cookies of its
  own): signing out there, or signing in on the web as anyone, keeps it.
  A link from an Android app session (`platform: android`) marks its
  browser as the Android app's: the plans stay, as Google Play allows. In
  either app's browser the web offers no notifications of its own (the
  desk's "Get a notification on this phone" and Calendar settings' "Turn
  on"), since the app on that phone already notifies; Calendar settings
  says so instead. Setup's last page, instead of going on to the web's
  desk, says to close the page to see the bookings in the app; in the
  Android app's browser it also has a button to the calendar's home
  (`/calendar` or `/organizations/{slug}/calendar`, a full page load), the
  address an Auth Tab closes on to hand back to the app. A mark ends
  when that browser's cookies are cleared, and the other app's link turns
  it into that app's. A page an app opens as it is, without this link (a
  University course such as `/university/custom-domains`, the terms, the
  privacy policy), needs `?in_app=ios` or `?in_app=android` added to its
  address to be marked the same way, from that page on. Any other `in_app`
  value marks nothing.
- `GET /app/config?platform=android&version_code=1203` (no key; the iPhone
  app asks with `platform=ios` and its build number, `CFBundleVersion`) —
  that platform's versions (`min_supported_version_code`: under it the app
  shows a blocking update screen, the kill switch for a broken release;
  `latest_version_code`), `update_required` and `update_available` for the
  version asked about, `update_message`, `store_url` (Google Play or the
  App Store; `null` until the app is in that store), `support_email`,
  `privacy_policy_url` and `terms_url` (the brand's own pages, for the
  sign-in screen and settings),
  `features` (`card_parse`, `push_notifications`, `apple_sign_in`:
  offer Sign in with Apple only when it's true, and `email_link_sign_in`:
  offer "Email me a sign-in link" only when it's true, which a server
  before it leaves out), `web_pages`: the
  web-only pages the app opens signed in, as path patterns (`*` one
  segment, a final `**` anything below), and `route_words`: the first path
  segment of every page and route here, sorted (`account`, `kards`,
  `pricing`, `teams`, `follow-up`…). A link starting with one is never a
  kard's share link or an organization's slug: open it in the browser, not
  as a kard. Hyphenated words are in it too (no share link has a hyphen,
  but `/follow-up/…` is no kard either). `contact_sources`: every
  contact `source`, `{key, label}`, in eKard's words ("Read off their
  paper card", "Added by an app or AI agent"), for a filter by source.
  Cached an hour.
- `POST /password_resets` `{email}` (no key) — "Forgot password": the web's
  reset email, whose link sets a new password on the web. Always `202` with
  the same `message`, whether or not the email has an account (for an Apple
  relay address it adds that the email arrives only while Apple forwards
  it: none goes to an account whose relay Apple stopped forwarding); 5 an
  hour per email and per network. An address whose mail bounced for good
  gets `422 validation_failed` saying when and why, and nothing is sent.

## Conventions

- Request bodies: JSON (`Content-Type: application/json`), except file
  uploads, which are `multipart/form-data` (marked below). The sign-in
  doors that take no key (`/sessions/google`, `/apple`, `/password`,
  `/email_link`, `/email_link/sign_in`, `/email_link/code`, `/refresh`, `/revoke`) take JSON
  alone: anything else is `415 unsupported_media_type`.
- 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.
- `403 ai_needs_priced_plan`: the endpoint runs eKard's AI (reading a
  card's text, reading a price list), which comes with a plan with a price
  (Starter, Professional, Unlimited). No plan, a plan given free, or the
  trial doesn't include it. Nothing was read or counted. `message` says
  what still works, and has no link in it (an app can show it as
  written); `upgrade_url` is the pricing page. Offer what works on every
  plan instead: your own reading of the card, adding services by hand.

  ```json
  {"error": {"code": "ai_needs_priced_plan",
             "message": "Read on your phone. A paid plan reads cards with AI.",
             "upgrade_url": "https://ekard.app/pricing"}}
  ```
- `503 timed_out`: the request took longer than the server gives one (15
  seconds; 28 to read a card's text, import clients and make a contacts
  CSV) and was stopped. Whatever it changed inside a transaction was
  undone, and a create sent again with the same `idempotency_key` is never
  made twice. Send it again in a minute; a write, after reading what it
  changes.
- 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>`.
- Times in words ("Mon–Sat 10:00 AM–7:00 PM", "Blocked Maya today,
  1:00–2:00 PM") follow each calendar's clock: 12- or 24-hour by its
  country. An app sends its phone's setting on every request as the
  `Hour-Cycle` header, `24` or `12` (CLDR's `h23` and `h12`, and `h24` and
  `h11`, mean the same), and every time an answer words reads that way:
  "Mon–Sat 10:00–19:00". Without it, or with another value, the calendar's
  own clock. Messages to clients (emails, WhatsApp texts, a contact's
  booking line) always keep the calendar's own clock. Times as data
  (`starts_at`, `local`, `hours`) never change.
- Words in the account's language (since 1.9.13): an app whose own
  screens speak it sends `Owner-Language: account` (or one of the eight
  codes, `hi`, `ar`…, the language its screens are in). Where the web's
  page for the same thing speaks the account's language (Your Kards, New
  Kard and Edit Kard, Products: `GET /kards`, `POST /kards`, `GET` and
  `PATCH /kards/{share_link}`, the products' routes but `order` and
  `settings`), the answer's refusals (`message`, `details`), notices and
  words are the web's, in that language, and `Content-Language` says
  which. Without the header, or with another value, English, as before:
  an app whose screens are English never shows a sentence in another
  language beside them. The other routes' words stay English whatever is
  asked, until their web pages speak the account's language (Contacts,
  Calendar, teams and the account are next), and so do words for
  developers (`invalid_parameter`'s) and the names you send. `PATCH /me`'s
  notice for a new `language` is in the language chosen either way.

Quick smoke test:

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

## MCP

The same API as Model Context Protocol tools, for Claude, ChatGPT, Cursor
and any other MCP client. One endpoint, the same key, the same permissions:

- Endpoint: `https://ekard.app/api/v1/mcp` (Streamable HTTP). POST one JSON-RPC
  message, get one JSON answer: no event streams, no sessions (`GET` and
  `DELETE` answer `405`).
- Header: `Authorization: Bearer <API key>`, the key above. Rotating it
  disconnects MCP clients too.
- Protocol versions: `2026-07-28` (no handshake: each request's
  `params._meta` carries `io.modelcontextprotocol/protocolVersion` and
  `io.modelcontextprotocol/clientCapabilities`, and the
  `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers must say
  what the body says; `server/discover`), and `2025-11-25`, `2025-06-18`
  and `2025-03-26` (`initialize` first, then the agreed version in
  `MCP-Protocol-Version`).
- Each tool is a call to an endpoint of this reference, made with the key,
  so it answers what that endpoint answers and is refused what it refuses.
  A refusal is a tool result with `isError: true` whose text is the API's
  status, code and message ("403 forbidden: …"); the JSON is in
  `structuredContent` as well as in the text.
- Tools that work in a Kard or a calendar take `share_link` or
  `calendar_id`; leave them out when the account has one Kard or works in
  one calendar, and the tool says which there are when it has several.

| Tool | Endpoint |
|---|---|
| `get_account` | `GET /me` |
| `list_kards`, `get_kard` | `GET /kards`, `GET /kards/{share_link}` |
| `get_analytics`, `get_kard_analytics` | `GET /analytics`, `GET /kards/{share_link}/analytics` |
| `list_events` | `GET /kards/{share_link}/events` |
| `search_contacts` | `GET /contacts` |
| `get_contact`, `add_contact`, `update_contact` | `GET`, `POST`, `PATCH /kards/{share_link}/contacts` (`add_contact` saves a `manual` contact: nobody is emailed unless `send_my_kard`) |
| `list_calendars`, `get_calendar` | `GET /calendars`, `GET /calendars/{id}` |
| `get_schedule` | `GET /calendars/{id}/schedule` |
| `find_free_times` | `GET /calendars/{id}/availability` |
| `list_bookings`, `get_booking` | `GET /calendars/{id}/bookings`, `GET …/bookings/{booking_id}` |
| `search_clients` | `GET /calendars/{id}/clients` |
| `create_booking` | `POST /calendars/{id}/bookings` |
| `reschedule_booking`, `cancel_booking` | `POST …/bookings/{booking_id}/reschedule`, `/cancel` |
| `set_booking_status` | `POST …/bookings/{booking_id}/arrive`, `/complete`, `/no_show`, `/approve`, `/decline`, `/confirm` |
| `read_api_reference` | this reference, one section at a time |
| `api_request` | any other endpoint here (JSON only, no file uploads) |

- Not over MCP: the app's own sign-in (`/sessions…`, `/password_resets`,
  `/web_sessions`), and the endpoints that run eKard's AI
  (`POST /contact_cards/parse`, reading a price list into a menu): the
  connected assistant reads the card or the list itself, then saves it
  with `add_contact` or `POST /calendars/{id}/offerings`.
- Limits: 120 MCP messages a minute per account (`429 rate_limited`,
  `Retry-After`), on top of each endpoint's own.
- A browser page on another site is refused (`403`); MCP clients send no
  `Origin`.
- Signing in from claude.ai's or ChatGPT's connector menus (OAuth) isn't
  available yet. Use a client where a header can be set: Claude Code
  (`claude mcp add --transport http eKard https://ekard.app/api/v1/mcp
  --header "Authorization: Bearer $KEY"`), Cursor or VS Code (a server
  with `url` and `headers`), or the Claude and OpenAI APIs' MCP options.

## Account

### GET /me
Account profile, subscription/plan, counts, docs link. Start here to confirm
the key works. `account` has the `id`, the photo (`image_url`),
`email_verified` (Google vouches for the address, or the person followed the
emailed link) and `has_password`; `organizations` lists every organization
the account belongs to with its `role` (`admin`, or `member`, such as a
front-desk login) and `calendar_only` (a member whose only way in is a
calendar login, who isn't on its team: see `GET /organizations`); `brand`
is `{key, name, domain}` of the host asked.
`account.apple_linked` says an Apple Account signs in to it (Sign in with
Apple), and `account.email_reachable` is false when we know our mail
doesn't reach `email`: Apple stopped forwarding to that private relay
address. Emailed links can't arrive then; Delete my account confirms with
Apple instead. `account.name` is null for an account made without one;
`account.name_from_kard` (since 1.9.13) is true when it's the name on a
kard the account holds (the web's Profile: "Your name: Lakshmi Reddy, from
your Kard");
`account.image_url` is the photo the person chose on the web's Profile ›
Edit, else their Google photo; `account.photo_chosen` is true for a chosen
photo, the one `remove_photo` takes away (offer Remove only then), and
false for a Google photo or none. Read it rather than guessing from the
address. `account.email_bounced` is set once mail to `email` bounced for
good, so we've stopped emailing it, sign-in links included: `{bounced_at,
reason, message, help}`, with the web Profile's line ("Mail to
priya@example.com bounced on 20 Oct 2026 (that mailbox doesn't exist), so
we've stopped emailing it, sign-in links included.") and the one under it
("To sign in with another address, change your email.", which the web
links to Profile's Change email); null while mail goes. The web's Profile
(`/profile`, in the app config's `web_pages`; its Change email form,
`/profile/email/new`, since 1.9.10) has Change email, and
"Mailbox works now? Try it again", which the API has too: `DELETE
/me/email_bounce` (below).

`whatsapp_hello` is the hello WhatsApp opens with beside each contact
(each contact's `whatsapp_url` carries it, filled in for them), as the
web's Profile › Edit shows it: `text` (the words in force, with their
placeholders `{first_name}`, `{event}` and `{link}`), `own` (false while
it's the default), `default_text` (the default in the account's country's
language, English where there's none of its own), `sample` (`{first_name:
"Sam", event: "Expo 2026", link, text}`: the preview, with the link of the
person's first kard; fill in `text` the same way while it's edited),
`defaults` (`[{language, name, dir, text}]`, the eight to start again
from, the web's chips) and `max_length` (500).

`account.country` (ISO 3166, `"US"`) and `account.timezone` (IANA,
`"America/Chicago"`) are the web Profile's "Country and time zone": the
country reads a phone number typed without its country code and decides
what the person's Kards offer first (Text or WhatsApp, the ways to pay) and
the printable card's paper; the zone is the day a follow-up is due, when the
morning email arrives and the times in emails, unless the person's booking
calendar has its own. `account.language` (since 1.9.10) is what the person
reads eKard in (owner languages: the web's screens converted so far and
the emails sent to them), one of the eight in `languages` (`[{code, name,
dir}]`, each named in itself, in Profile › Language's order).

`follow_ups` (since 1.9.12) is "Remind me"'s quick choices as the web's
contact sheet offers them to this account, and their rule: `time_zone`
and `today` (the account's own day, its morning email's), `days_off` (the
weekdays its calendar's hours say it doesn't work: `["sat", "sun"]`;
empty when unknown, and nobody's weekend is assumed), `weekend` (its
country's, when the hours don't say: `["fri", "sat"]` in much of the Gulf),
`choices` (`[{key, label, on, on_weekend, instead_on}]`: Tomorrow, In 3
days and Next week, each with the day it lands on, a day off passed: "In
3 days, Mon"; one landing on the weekend says "(weekend)" and offers
`instead_on`, "Mon 2 Nov instead") and `days_off_note` (the web's line under
the chips: "Sat and Sun are off in your calendar's hours, so In 3 days is
Mon."). The choices are `today`'s: past it, work them out again from
`days_off` and `weekend`, or read `/me` again. Set the day picked as the
contact's `follow_up_on`.

### PATCH /me
Changes the person's name, as the web's Profile › Edit (`/profile/edit`)
does: `{"account": {"name": "Asha Rao"}}` (or just `{"name": "Asha Rao"}`).
Spaces are tidied. A blank name or one over 100 characters is
`422 validation_failed` with `details.name`. `200` with the `account` and a
`message`.

And the photo: send the request as multipart/form-data with the file in
`account[photo]` (JPEG, PNG, GIF or WebP, up to 5 MB), with `account[name]`
or without; the answer's `account.image_url` is the new photo. Another kind
of file (judged by its bytes, whatever its name or type says), or a bigger
one, is `422 validation_failed` with `details.photo`;
`photo` as anything but a file is `422 invalid_parameter`.
`account[remove_photo]=1` (`{"remove_photo": true}` in JSON) removes it, and
the Google photo, else initials, shows again. What isn't sent stays as it
is, so an account without a name can set its photo first.

And the WhatsApp hello: `{"account": {"whatsapp_hello": "Hi {first_name}!
Good to meet you at {event}. My card: {link}"}}`, with Profile's checks:
up to 500 characters, line breaks kept, and `{first_name}`, `{event}` and
`{link}` the only placeholders (`{name}` is `422 validation_failed`,
`details.whatsapp_hello`: "can only fill in {first_name}, {event} and
{link}, not {name}"). For someone not met at an event, the sentence that
holds `{event}` is left out. `""`, or the default written out, goes back to
the default ("Back to the default hello, in English."). The answer carries
`whatsapp_hello` too.

And the country and time zone (since 1.9.10), with Profile › Edit's checks:
`{"account": {"country": "US", "timezone": "America/Chicago"}}`, either or
both. The country is a two-letter code (any case); `null` clears it (the
web's "Choose your country"). The zone is an IANA name, kept in one
spelling ("Asia/Calcutta" is "Asia/Kolkata"). One the server doesn't know
is `422 validation_failed` with `details.country` or `details.timezone`
("isn't one we know: choose it from the list"), an empty or null zone
"can't be empty: choose one from the list", and nothing changes. `message`
says what was saved: "Saved: your country is United States and your time
zone Chicago time (CDT, America/Chicago).", and when the person's booking
calendar has another zone, "Follow-ups and the morning email follow your
booking calendar's, India time (IST): change that one in the calendar's
settings." An account with no name yet may set them.

And the language (since 1.9.10), as Profile › Language: `{"account":
{"language": "hi"}}`, one of `GET /me`'s `languages` by its `code`.
`message` is the web's notice in the language chosen ("अब eKard आपके लिए
हिन्दी में है।"). Since 1.9.13 each kard of the account that follows its
language (`settings.languages_chosen` false) keeps what it says to others, as on the
web: its languages are stored as they were (`languages_chosen` turns true),
so its printed cards, pictures and QR screen don't change, and `message`
says so after the notice ("आपका कार्ड दूसरों के लिए English में ही
रहेगा।", "Your 3 Kards still speak English to others."); its languages
change in `PATCH /kards/{share_link}`. A kard made after follows the new
language. `null` is the phone's own: the first of the eight in the
request's `Accept-Language`, else English. Another code is `422
validation_failed` in the web's words, in the account's language
(`details.language`: "That language isn't one we speak yet. Choose one of
the languages listed."). `country`, `timezone` or `language` that isn't
text (a number, a list) is `422 invalid_parameter`. A server before 1.9.10
takes none of the three: a request with only them is `400`.

### POST /me/email_verification
Emails the link that verifies the account's address (it works for 3 days):
`202` with a `message`, or `200` when it's already verified. Until then a
personal calendar holds the email to clients its owner types in. 3 an hour.
`422 email_undeliverable` when mail to the address bounced for good: nothing
is sent, and the `message` says when, why and what to do: Try it again
(below) for a mailbox that works now, else "To sign in with another
address, change your email.", as `/me`'s `email_bounced.help` says it
(since 1.9.9; before, it said to write to support).
A `403 email_unverified` (joining a team, starting one) carries this
endpoint's address as `verify_url`.

### Change the sign-in email
The web's Profile › Change email, in the app (since 1.9.12): the new
address is proved by a link and a code sent to it, and the current address
is told, with "This wasn't me" (which cancels the change, or puts the old
address back for 7 days, signs everyone out and removes the password). The
app's own sign-in only: an AI key is `422 not_a_session`. Every answer has
`email_change` (where it stands, as Profile shows it: `state`, `title`,
`message` in Profile's words, `old_email`, `new_email`, `expires_at`; `GET
/me` carries it too) and a `message` to show as written.

- `POST /me/email_change` — `{"new_email": "meera@clinic.com"}`. **Proof
  first:** a phone that signed in within the last 15 minutes asks at once;
  any other sends `"password"` (counted with every password door:
  `password_required`, `password_wrong` "That password isn't right. Try
  again, or email a code to meera@personal.com instead.",
  `password_paused`), or `"proof": "code"`: an 8-digit code goes to the
  current address (`state` `proving`), typed at `/check`, and nothing goes
  to the new address before. A proof lasts this phone 15 minutes. `202`:
  "We sent a link and a code to meera@clinic.com. Your sign-in email
  changes when you open the link or type the code." (`state` `pending`).
  The answer is the same for an address with an account and without; the
  email decides. A new ask replaces an open one.
- `POST /me/email_change/check` — `{"code": "1234 5678"}`, the code to
  the current address: then the link and code go to the new one.
- `POST /me/email_change/confirm` — `{"code": "…"}` from the email to
  the new address, or `{"token": "…"}`, its link's token
  (`/email-change/confirm/{token}`), for an app that opens the link
  itself. The link also works on the web, in a browser signed in as the
  account. Then the address changes: this phone stays signed in, with its
  notifications; every other browser and phone is signed out. The answer
  carries `account` as it signs in now. `409 another_account` (a link for
  another account), `409 email_taken` (taken meanwhile): nothing changed.
- `POST /me/email_change/resend` — Send it again: a new link and code;
  the earlier email's stop working.
- `DELETE /me/email_change` — Cancel the change.
- `POST /me/email_change/kards` — after the change, the new address on
  the kards that still show the old one (`kards_showing_old_email`), each
  saved as its editor would.

Codes: 5 wrong tries each (`code_wrong` with `tries_left`,
`code_tries_used`, `code_expired`), 10 wrong codes an hour per account
(`429`). Limits, with the web's: 5 links to new addresses an hour and 10 a
day per account, 5 an hour to one mailbox, 5 proof codes an hour, 30 tries
a minute from a network (`429 rate_limited`, `Retry-After`).

### DELETE /me/email_bounce
"Mailbox works now? Try it again", as on the web's Profile (since 1.9.5):
mail goes to the account's address again, sign-in links included, and
`account.email_bounced` is null. If it bounces again, Amazon SES says so and
`email_bounced` comes back. `200` with a `message`: "We'll email
priya@example.com again. If it bounces again, your Profile will say so.",
or, when nothing had bounced (tried again on another device), "Mail goes to
priya@example.com already: there's nothing to try again." 3 tries a day,
counted with the web's button (each try can bounce, and bounces count
against our sending): then `429 rate_limited` with `Retry-After` and "That's
3 tries today. If mail still doesn't arrive, write to …". Read `GET /me`
again after.

### POST /me/deletion_request
Asks to delete the account, as `https://ekard.app/account/delete` does: `{reason}`
is optional. `202`: a link to confirm went to the account's address (it
works for 3 days, once); once confirmed, the account and everything in it
is deleted within 30 days. Team kards the account holds in an organization
stay with it, without the person's details. Asking again sends the link again; a confirmed
request answers `200` with `deletion_request.deleted_by`. When Apple has
stopped forwarding mail to the account's private relay address
(`email_reachable` false), no link goes: `422 confirm_with_apple`, in
words. 3 an hour. Make sure the owner asked for exactly this.

### POST /me/deletion_request/apple
The same request, confirmed by a fresh Sign in with Apple instead of the
emailed link, for an account that signs in with Apple (`apple_linked`):
the iPhone app's way for a Hide My Email address, which may never get the
link. Get a nonce from `GET /sessions/nonce`, run an Apple request whose
nonce is its SHA-256 (lower-case hex), and send `{identity_token,
authorization_code, nonce, reason?}` (the credential's, as text). The
token must be Apple's, for this app, carry the nonce (once), be no older
than the nonce's 5 minutes, and be for the Apple Account that signs in to
this account. `200`: confirmed (the request waiting for its link, or a new
one), deleted within 30 days as after the link; the code then ends the
account's link with Apple right away, so `apple_linked` turns false once it
has. This request signs no device out, but once the link ends Apple
reports the app's credential revoked on the iPhone. Confirmed already: `200`
with its date, and the link with Apple still ends. Refusals:
`apple_not_linked` and `apple_account_mismatch` (403, with how else to
confirm); `invalid_nonce`, `stale_id_token` and `invalid_id_token` (422,
never 401: run a new Apple request with a new nonce);
`apple_sign_in_unavailable` and `apple_unavailable` (503). 10 tries an
hour.

### 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`.
`active` is whether the plan's features are on; `renews` whether Razorpay
charges again at `current_period_end`. A cancelled Professional plan stays
`active` until `current_period_end`, with `renews: false`.

### DELETE /subscription
Stops the next renewal (same as the web "Cancel" button): nothing more is
charged, and the plan stays active **until the end of the period paid for**
(`current_period_end`). A plan that doesn't renew (paid once, or cancelled
already) is `422 not_renewing`, with what it is in `message`. Razorpay
trouble is a `502`: `razorpay_refused` (nothing changed), or
`razorpay_unavailable` (Razorpay didn't answer: the cancellation is finished
in the background, and `renews` turns false once Razorpay confirms). Confirm
with the owner before calling this.

## Kards

A kard's JSON includes its attributes, social handles, ways to get paid
(the fields below, and `payment_methods`), custom links, settings, public
URLs (`urls.public`, `urls.qr_png`, `urls.vcard`), counts, custom-design
status, and its own domain (`custom_domain`, or null).

`payment_methods` is how the public page offers them, UPI too, what the
owner's country uses first (the kard's phone number's country, else the
account's): `[{method, label, value, url, copy, details}]`. `method` is
`upi`, `paypal`, `venmo`, `cash_app`, `pix`, `iban`, `zelle`, `mpesa` or
`clabe` (since 1.9.13); `label` is the method's own name, M-Pesa's saying
how it's paid ("M-Pesa" to a phone number, "M-Pesa Till", "M-Pesa
Paybill"); `value` is what to show ("@jane-doe", an IBAN in groups of four,
an M-Pesa phone number as its country dials it, "0712 345678", a CLABE in
its parts, "032 180 00011835971 9"); `url` opens the payment app at the
owner (an https link the app claims, so a phone without it opens the web
page; UPI's is `upi://`), `copy` is what a copy button puts on the
clipboard (UPI, Pix, IBAN without spaces, Zelle, M-Pesa in digits, CLABE
without spaces); `details` is an IBAN's `[{label, value}]` account name and
BIC, or an M-Pesa Paybill's account number (`"Account number"`). No money
passes through eKard: visitors pay in their own app or bank.

### GET /kards
All kards the account owns (personal and organization-owned), newest first.
Each kard the person may edit carries `last_30_days` (since 1.9.13), its
last 30 days as the web's Kards list says them under it, on every plan:
`{visits, contacts, left_details, added, booked, words, sentence}`, the
contacts by how they came (left their details on the kard, added by its
people, booked), `words` the phone row's ("41 visits · 2 left their
details · 4 you added", where the list's heading says "in the last 30
days" once; "added by hand" on a kard the reader doesn't hold; "No visits
or contacts" with neither) and `sentence` the computer's, with the period
("… in the last 30 days"). One kard's `GET` has it too; a bookmark's is
`null`.

### 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)
- `licence_number`: a licence or registration number, shown as written
  under the job title and kept in the contact people save (at most 100
  characters; `null` or `""` removes it); the kard's answer has it too
- `languages` (since 1.9.13): its languages, as the web's Kard Settings
  chooses them, the main first and a second or none (`["ar", "en"]`, each
  one of the eight once): what its printed cards, pictures and QR screen
  say; its second is where `translations` belongs. Not a list of one or
  two codes, `422 invalid_parameter`; a code that isn't one of the eight,
  more than two or one twice, `422 validation_failed` with
  `details.languages`. A team kard's are its organization's admins' to
  choose (`settings.languages_choosable`): its holder sending other ones
  gets `403 forbidden` ("Bell Realty's admins choose this Kard's
  language."), the same ones are ignored. Saved, the answer has `message`,
  Settings' notice ("Saved. Your printed cards, pictures and QR screen now
  say العربية + English.", the names held whole between FSI and PDI when
  one runs right to left), and that a second language's name, job title
  and company are kept for its return when it went out of sight.
  `settings.languages_chosen` is false while they follow the holder's
  account language with English second; sending back the languages it
  follows (as GET says them) changes nothing, so it keeps following.
  Visitors still read the kard in their own language.
- `message_first_setting` (since 1.9.13): "Text or WhatsApp first" on its
  page and its contacts' rows, Edit Kard's choice: `text` or `whatsapp`;
  null or `""` follows where the owner is, and so does the one its
  country leads with (`settings.message_first_country`), as the web keeps
  it. `message_first` is the one in force, `settings.message_first_setting`
  the owner's own (null: the country's).
- `job_title` and `company` (each up to 100 chars): under the name on the
  public page, and the contact card's `TITLE` and `ORG`. A team kard's
  `company` is its organization admins' to set (`can_edit_company`); its
  holder sending a different one gets `403 forbidden`.
- `translations`: the name, job title and company in the kard's second
  language, as its owner writes them (Edit Kard's "Name in English" under
  Name; field round 2's Yousef: English visitors of his Arabic shop saved a
  contact they couldn't type to search). By language:
  `{"en": {"name": "Yousef Al-Haddad", "company": "Al-Haddad Oud & Perfumes",
  "job_title": "Owner"}}`. Each field sent replaces its copy (null or `""`
  clears it), one left out stays; a language sent as null clears its copy,
  and `"translations": null` every copy. Only the kard's second language
  (`languages[1]`, below) takes one: another language, or a kard with one,
  is a 422 `validation_failed` whose reason is in `details.base`. Lengths
  are the main fields' (a name 4–100, company and job title up to 100),
  refused under `details.<field>_in_<language>` (`name_in_en`). A team
  kard's company copy is its organization admins' to set, as its company
  is (`403 forbidden` for its holder). What it changes: a visitor reading
  that language sees the copy on the page, and one reading neither of the
  kard's languages sees it when it alone is in Latin letters (an English or
  Hindi visitor of an Arabic shop); the contact card keeps `N` and `FN` the
  kard's name, and carries the copy as its phonetic name only
  (`X-PHONETIC-FIRST-NAME`/`-LAST-NAME`; not `NICKNAME`, which an iPhone
  may show in place of the name), the company's as `X-PHONETIC-ORG`; the page's title and link preview say both names, and
  its JSON-LD gives the copies as `alternateName`.
- 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`. A web address without a scheme, as cards print it
  (`www.example.com`, `example.com/about`), is saved with `https://` in
  front: `website`, the four network links, `google_review_url` and a
  custom link's `url`. One with its own scheme (`http://`) stays as sent;
  what isn't a web address (`mailto:…`, a name) is a 422
  `validation_failed` on its field.
- Ways to get paid, beside `upi_id` (an empty string clears one; a 422
  `validation_failed` says what's wrong with a field):
  `paypal_handle` (the PayPal.Me name after paypal.me/: up to 20 letters
  and digits), `venmo_handle` (5–30 letters, digits, `-` or `_`),
  `cash_app_handle` (the $Cashtag without the `$`: up to 20 letters and
  digits, at least one a letter), `pix_key` (a CPF or CNPJ with valid check
  digits, an email, a phone as +55 and digits, or a random key), `iban`
  (checked for its country's length and check digits; kept in capitals
  without spaces), `iban_bic` and `iban_account_name` (only with an IBAN),
  `zelle_email`, M-Pesa (since 1.9.13: `mpesa_kind`, `phone` for Send
  Money, `till` for Buy Goods or `paybill` for Pay Bill, required with
  `mpesa_number`; `mpesa_number`, a phone number kept as +254… and read in
  the owner's country when written without its code, or a Till or Paybill
  of 5 to 8 digits; `mpesa_account`, up to 20 characters, required with a
  Paybill and kept only with one; an empty `mpesa_number` clears all
  three), `clabe` (since 1.9.13: a Mexican CLABE, 18 digits, kept without
  spaces, its last digit checked against the other 17). A pasted link, `@`
  or `$` comes off a handle (`venmo.com/u/jane-doe` is kept as `jane-doe`).
- The four network links must be on their network when they change:
  `linkedin_url` on linkedin.com or lnkd.in, `youtube_url` on youtube.com
  or youtu.be, `facebook_url` on facebook.com, fb.com or fb.me, and
  `google_business_url` on Google (google.com/maps and Google's country
  domains, maps.app.goo.gl, g.page, g.co, share.google). Otherwise a 422
  `validation_failed` names the field and where the link belongs.
- `google_review_url` (up to 500 chars): the Google review link a Google
  Business Profile's "Ask for reviews" gives: `g.page/r/<id>/review`,
  `g.page/<name>/review` or `search.google.com/local/writereview?placeid=…`.
  A bare `g.page/r/<id>` (which opens the Maps page) is saved with
  `/review` added. The public page shows "Review us on Google" with it,
  and the printable card prints it as a counter card ("Scan to review
  us"). A Maps share link (`maps.app.goo.gl/…`, which can open the Maps
  page instead of the form), a link to the business on Google that isn't
  its review form (a google.com/maps page), or a link elsewhere, gets a
  422 `validation_failed` saying why. Read it back on the kard, and on the
  public kard (`GET /kards/lookup`).
- `google_place_id` (up to 500 chars): the kard's own business's place ID
  on Google Maps (`ChIJ…`: letters, digits, `-` and `_`), what the web's
  "Find your business on Google" picks; only the business the kard is for.
  With it, the public page shows the business's Google rating under
  "Review us on Google", with the business's name as Google gives it, and,
  on a tap, up to five of its Google reviews, fetched from Google per
  visitor and never kept, on any active plan. Anything else is a 422 `validation_failed` on
  `google_place_id`; `null` or `""` removes it.
- `address_place_id` (up to 500 chars): the place of the street address
  picked from Google's suggestions (`POST /address_suggestions/pick`,
  below), never a business's (that's `google_place_id`), sent with the
  parts it filled, so Directions open that exact place when the kard has
  no `google_place_id`. The server clears it on any save that moves the
  address unless the same save sends a new one: the town or city, postal
  code or country changed, line 1 changed other than by growing ("Shop 4,
  12 Market Road" after "12 Market Road" keeps it) or typed where there
  was none, or the address emptied. Anything but a place ID is a 422
  `validation_failed`; `null` or `""` removes it.
- `custom_links_attributes`: array of `{"title": "...", "url": "https://..."}`
  (max 20 links)
- `contact_form`: the kard's own questions on its exchange sheet (free on
  every plan), which visitors answer after their name, email and number:
  `{"questions": [{"question": "Which company are you with?", "kind":
  "short_answer", "required": true}, {"question": "What are you looking
  for?", "kind": "choice", "choices": ["Wholesale", "Retail"]}]}`. At most 2
  questions, each a `short_answer` or a `choice` of 2 to 6 (up to 60 chars
  each), the question up to 120 chars. The list replaces the kard's
  questions: send a question's `key` (read it back on the kard's
  `contact_form`) to keep it, edited or not; a question without one is new;
  one left out is removed; `null` removes them all. Answers land in each
  contact's `custom_fields` under the question's `key`. On a team kard only
  the organization's admins change them (`contact_form.can_edit`); anyone
  else sending a change gets 403. A 422 `validation_failed` says what's
  wrong under `details.contact_form`, by the question's number. Read back,
  each question carries `choice_values` beside `choices` (what an answer
  keeps for each choice: a form made by hand before the editor may keep
  its own, `buyer` for "Buyer"), and `contact_form.other_fields` lists a
  hand-made form's fields the sheet never shows (`{key, question, kind,
  answer_words}`: a checkbox's `1` is Yes, `0` No), read only: both are
  for wording a contact's answers as the web does, and never sent back.
  Since 1.9.10 each question carries `answer_count`, how many contacts
  answered it in these words (Edit Kard says "3 people have answered it in
  these words. Change the words and their answers keep these ones; new
  answers go under the new words." only when there are any), and each of
  `other_fields` its `answer_count` and, for an earlier wording of a
  question, `asked_until` (the last day it was asked, in the account's
  zone; null for a hand-made form's field): "Earlier wordings, kept with
  their answers: “What do you need packed?” (2 answers, asked until Wed 28
  Oct)." A server before 1.9.10 leaves both out: say no rewording line
  then.
- `is_active` (bool; false is switched off for a while, Kard Settings'
  switch on the web; since 1.9.14 its visitors see one "switched off for
  now" page wherever they reach it: its link and a printed QR's, its custom
  domain, its files, its vCard (`/contact`), its exchange, its booking pages
  and Book button's link, its pay links and its custom links' `/out`. That
  page is its name and photo and "Priya has switched this Kard off for
  now." in the visitor's language, `200`, `noindex`, never cached at the
  edge; nothing else of the kard (number, email, address, links, products,
  files, booking, exchange or pay ways). A booking page says the business
  isn't taking bookings online right now; bookings already made stand, and
  their manage links work. Its holder, its organization's admins and site
  admins see it as it is. A team kard switched off leaves its team's "Our
  team" page; an organization's own kard switched off shows "Our team"
  switched off too, its members' kards as they are. Switched back on, every
  link and printed QR opens the live page at once. Visits while it's off
  are `totals.visits_while_off` in analytics, not visits. Before 1.9.14 its
  page still opened, without its custom links or Google rating. Show the
  web's words beside the switch: "Visitors see a “switched off for now”
  page; your link and printed QR codes work again the moment you switch it
  back on."),
  `indexable` (bool; allow search engines), `layout_type` (a new personal
  kard starts on `letterhead` unless one is sent; a team kard sent none
  keeps `null` and wears its organization's, which a new organization
  starts on `letterhead` too)
- Settings: `allow_connect_popup_setting`, `allow_email_notifications_setting`,
  `allow_reviews_setting` (booleans); on a team kard, `show_on_our_team_setting`
  (boolean: on its team's "Our team" list, `settings.show_on_our_team`; its
  holder and the team's admins switch it; it comes back on once the kard
  goes to someone new and no longer shows who held it before: another
  name, and not their photo; unless an admin other than its holder
  switched it off)
- `organization_id` — only if you administer that organization. On PATCH,
  moving a kard out of its organization (`null`, or another organization)
  needs the admin role there too; a plain member edits their team kard
  but can't take it out.
- `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. When file storage doesn't
  take the photo: `502 photo_not_stored`, and nothing was saved (no new
  kard, no change); send it again, or without the photo.
- `photo_logo_setting` — the web's "This is a logo" beside the photo:
  `true` drawn whole, as a logo; `false` a portrait; `null` (or none with a
  new photo) has the photo judged in the background. Sent alone, for the
  photo the kard has, it's saved alone, as `show_on_our_team_setting` is
  (since 1.9.5): the kard's other fields aren't checked again, so a kard an
  older rule would now refuse still takes it; `settings.photo_logo` in the
  answer says how the photo is drawn now.
- `visitor_email_line_setting` — a line of your own at the top of the
  email each visitor gets ("Here's Priya's card"), under the kard's name
  and signed with it (`settings.visitor_email_line` reads it back; null
  without one). The web's rules: one line, at most 280 characters, plain
  text, no web or email address but the kard's own page here
  (`https://ekard.app/priya`); not its website, its own domain or its email
  address, which the email already links. Anything a mail app would make a
  link (`https://…`, `www.…`, an email address, a bare `example.com`) is
  `422 validation_failed` with the web's words and the text to take out
  (`details.visitor_email_line_setting`). Saved with who wrote it, as the
  web saves it: it goes into visitors' emails while that account's address
  is verified (`/me`'s `email_verified`). `""` takes it out. When a line
  isn't going out, `settings.visitor_email_line_held_back` says why, as the
  web's Settings does: `unverified` (whoever saved it last hasn't verified
  their address) or `foreign_link` (an address in it isn't the kard's own
  page any more); null while it goes out.
- `visitor_whatsapp_line_on_setting` (bool) — a visitor's WhatsApp to this
  kard opens with a first line already written, which they can change
  before they send (`settings.visitor_whatsapp_line_on` reads it back; off
  by default). The line is `visitor_whatsapp_line_setting` if there is one,
  else ours in the visitor's language (`settings.visitor_whatsapp_line_default`
  has it in English, for a preview).
- `visitor_whatsapp_line_setting` — your own words for that line
  (`settings.visitor_whatsapp_line` reads it back; null for ours). One line,
  at most 200 characters, plain text; longer is `422 validation_failed`
  (`details.visitor_whatsapp_line_setting`). `""` goes back to ours. It's
  kept while the line is off.
- `idempotency_key` (create only, up to 100 characters) — your key for
  this kard (a UUID made when the person starts the form). Sent again
  within 24 hours by the same account, it answers `200` with the kard the
  first request made, as it is now, and saves nothing else, even when both
  arrive at once; never a second kard. After 24 hours, or once that kard is
  deleted or handed to someone else, the key makes a new one.

Every kard (in lists too) carries what an app's list and share screen
need: `organization_name`, `photo_url` (the photo's largest copy, a WebP at
most 828 × 1104 pixels; the uploaded file itself isn't served) and
`photo_thumb_url` (a 384-pixel WebP copy), both without the upload's
metadata (its GPS position, its camera), `qr_image_url` (the designed QR image, once generated),
`accepts_bookings` with `booking_url` and `book_label`, `can_edit`,
`can_delete` (whether `DELETE /kards/{share_link}` would let them), `yours`
(the person holds it: the web's "Show me on “Our team”" rather than "Show
this Kard…"), `contacts_count` (for a kard the person may edit; else
null), and `message_first` (since 1.9.10: `text` or `whatsapp`, which way to
message a number leads on the kard's page and its contacts' rows, as the
web decides per kard: the owner's choice in Edit Kard, else where the
kard's phone number is from, else its holder's country; Text first in the
US, Canada and Puerto Rico). Lead with that one wherever an app offers both
for the kard.
`organization_front_door` is true for an organization's own kard (the one
at its slug, standing for the business rather than a person): its contact
card (`urls.vcard`) files it under the business's name, `N` holding the
name as the family name, and a card made offline should do the same.
The contact card's `TITLE` is `job_title`, else the `tagline`; its `ORG` is
`company`, else `organization_name`. A card made offline should do the same.
`languages` (on a kard's detail) is its languages, the main first, at most
two (`["ar", "en"]`): chosen on the web's Kard Settings, else its holder's
account language plus English. `translations` is the name, job title and
company its owner wrote in the second (`{}` with none; a field not written
is null): `{"en": {"name": "Yousef Al-Haddad", "company": null,
"job_title": "Owner"}}`. Only the second language's (`languages[1]`): a
copy written for a language that isn't the second any more (the kard's
languages changed, or its holder's account language) is kept, unlisted
and unseen, and comes back when that language is the second again. An
app showing the kard to someone reading that language shows the copy; a
card made offline can carry the name's copy as its phonetic name, as the
contact card does.

### GET /kards/{share_link}
Full kard detail. Beside what every kard carries:

- `launch_checklist` — "Get your Kard seen: 2 of 6 done", for the kard's
  holder only (null for anyone else, once all six are done, or once they
  hid it): `{"done": 2, "total": 6, "next": "chat", "steps": [{"key":
  "photo", "done": true, "done_on": null}, …]}`. The six steps, in order,
  each ticked from what really happened: `photo` (a photo or logo),
  `chat` (sent on WhatsApp, through the share sheet, by email or as a
  copied link), `signature` (its email signature copied), `in_person` (its
  lock-screen wallpaper or printable card downloaded), `visit` (someone
  opened it) and `contact` (someone sent their details through it; not
  the people its own people typed in, scanned or added here). `done_on`
  is the day, in the account's time zone, where it's known: the first
  visit's and the first contact's. The `in_person` step alone also says
  which were made, `made`: `["lock_screen"]`, `["print"]` or
  `["lock_screen", "print"]` (`[]` while it's to do), for the web's
  "Lock-screen wallpaper made", "Printable cards made" or "Lock-screen
  wallpaper and printable cards made". `next` is the first step not done.
  `DELETE /kards/{share_link}/launch_checklist` hides it for good, on the
  web too (`204`; again: `204`); it's the holder's alone, so anyone else
  gets `404`.
- `search_offer` — "Let people find this Kard on Google", while the web
  asks it: for the kard's holder and its organization's admins, while the
  kard is a business's (a company, an address it shows, a Google Business
  or review link, a way to pay or a Book button), hidden from search
  engines, live, and unanswered; null otherwise. `{"search": "Sweta Studio
  in Anand", "town": "Anand", "preview": {"url", "title",
  "description"}}`: what people would type, the town its title names (null
  without one: the web suggests adding the address), and what Google would
  read. Free on every plan. Answer it with `PATCH
  /kards/{share_link}/search_offer` and `{"answer": "yes"}` ("Show it on
  Google": `indexable` becomes true), `"no"` ("No, keep it hidden") or
  `"not_now"` ("Not now"); each ends it for good, on the web too, and
  answers `{"answer", "indexable", "message"}` with the web's words to
  show. Another answer: `400 invalid_parameter`; anyone the offer doesn't
  ask: `404`; a kard that can't save: `422 validation_failed`, nothing
  kept. The Google panel's "Show it on Google" (below) sends `yes` too.
- `wallpaper_styles` — the lock-screen wallpaper's colours,
  `[{key, label, description}]` in the web's order and words, the default
  (`kard`, "Your Kard's colours") first.
- `picture_occasions` — the greetings a picture to post can carry,
  `[{key, label, greeting, message}]` in the web's order: `none` ("No
  greeting") first, then `wishes` and `thanks`, then each festival's,
  alphabetically. `greeting` and `message` are the words drawn when none of
  your own are sent, in the Kard's main language (an Arabic Kard's `eid`
  greeting is "عيد مبارك"); `label` is English.
- `settings.picture_greeting` — the greeting saved on the kard by the web's
  "Save my greeting", `{occasion, greeting, message}`, which the web's
  picture page opens with; send them as they are to `picture.png` (a null
  `message` is no line: `message=`). Null when none is saved. Save one with
  `PATCH /kards/{share_link}` and `picture_greeting_setting` (below), or on
  the web.
- `settings.photo_logo` — how the photo is drawn now, as the web's "This is
  a logo" beside it is ticked: `true` whole, as a logo, `false` as a
  portrait; the owner's answer (`photo_logo_setting`), else the photo's
  judgement, else its shape. Null without a photo.
- `settings.photo_logo_by` — whose answer that is (since 1.9.9): `owner`
  (`photo_logo_setting` sent `true` or `false`, which the web's form does
  with every save of a kard with a photo) or `judged` (the photo's
  judgement, or its shape until then). While it's `judged`, "Decide from
  the picture" is the photo's choice; send `photo_logo_setting` null,
  alone, to have it judged again. An answer kept before 1.9.9 reads
  `owner`. Null without a photo.
- `settings.live_panel_dismissed` and `urls.preview_image` — "Your Kard is
  live", the panel the web opens Create on: hidden for good by its holder
  (`DELETE /kards/{share_link}/live_panel`, `204`; anyone else `404`), so an
  app shows its own after Create only while this is false; and the link
  preview's picture (1200 × 630 JPEG, `image/jpeg`), what chats show
  beside the kard's link, null until it's drawn a moment after Create (read
  the kard again).
- `settings.from_team` and `settings.from_team_note` (since 1.9.13) — on a
  team kard, the parts that came from the team's own kard when it was made
  and still read as they do there (`address`, `website`, `company`), and
  the note the web's Edit Kard puts under each, for the reader: "From Bell
  Realty's Kard. Change it if yours is different." (its holder), "… if
  Kim's is different." (an admin on Kim's). `[]` and null otherwise.
- `settings.message_first_setting` and `message_first_country`,
  `settings.languages_chosen` and `languages_choosable` (since 1.9.13): see
  `message_first_setting` and `languages` under POST /kards.

### POST /kards/{share_link}/shares
`{"channel": "share_sheet"}` — the kard was sent on: `share_sheet`,
`copy_link`, `whatsapp`, `email` or `sms` (a text message from the
phone's messages app; each ticks `chat`), `email_signature` (ticks
`signature`) or `qr_download`. Any of them ticks a team's `out` step. Send it as the app hands the link to
another app or copies the signature; the sending itself never reaches the
server, which records the same from the web's Share tab. `204`, recorded
for the kard's holder and its organization's admins (a site admin's
records nothing). Another channel: `422 invalid_parameter`. 30 a minute
per account, the web's and the app's together (`429 rate_limited`).

### POST /kards/{share_link}/qr_shows
`{"from": "share"}` (since 1.9.10) — the app put the kard's QR up full
screen for someone in front of the person: `from` is where (`share`:
Share's Show full screen, `widget`, `control`: the Show my Kard control;
left out: elsewhere in the app). Counted with the web's Show my QR in
the growth report, by where; it ticks no step of `launch_checklist`
(showing the code isn't proof anyone scanned it). Send it in the
background as the QR opens, never while offline. `204`, recorded for the
kard's holder and its organization's admins (a site admin's records
nothing). Another `from`: `422 invalid_parameter`. 30 a minute per
account (`429 rate_limited`).

### GET /kards/{share_link}/wallpaper.png?phone=iphone&style=kard
The kard's QR on a phone's lock screen, as the web's wallpaper page makes
it (free on every plan): its QR studio's code on a white plate, with the
name, job title and company over it, the top of the picture left clear for
the clock and the bottom for the lock screen's buttons. `image/png`:
`phone` `iphone` (the default, 1290 × 2796, which every iPhone fits to its
screen) or `android` (1440 × 3200); `style` one of the kard's
`wallpaper_styles` (`kard` when left out); anything else is `422
invalid_parameter`. The kard's people only (its holder, its
organization's admins). Each picture ticks the checklist's `in_person`
step, as the web's download does. Words the kard's fonts can't draw:
`422 undrawable`, with the web's message ("… can't go on a wallpaper yet:
its fonts don't draw Ꮜ Ꮚ. Change those words in Edit Kard to make one.").
Drawn in the request, so out of one drawing budget per account for every
picture the web and the app draw (wallpapers, pictures to post, video
backgrounds, printable cards): about 30 seconds of drawing a minute, some
16 wallpapers (`429 rate_limited`, `Retry-After`). A kard hidden after a
complaint draws nothing (`451 unavailable`). To set it, the web says: on an
iPhone, open it in Photos, tap Share, then Use as Wallpaper, then Add, and
choose Set as Wallpaper Pair; on Android, Use as or Set as wallpaper (the
words differ from phone to phone), then Lock screen. Set it as it comes,
without zooming in: zooming can push the code off the screen.

### GET /kards/{share_link}/picture.png?shape=status&style=kard&occasion=diwali
A picture to post, as the web's Share tab makes it (Status picture and
Festival greeting; free on every plan): the kard's photo (or its team's
logo), name, job title and company over its QR studio's code, in the
colours of `style`, with the brand line at the foot unless a paid plan
hides it. `image/png`, inline, named like the web's download ("Priya
Sharma greeting 1080x1920.png"). `shape` `status` (the default, 1080 ×
1920 for WhatsApp Status and Stories, its top and bottom left clear for
their bars) or `square` (1080 × 1080, for chats); `style` one of the
kard's `wallpaper_styles`; `occasion` one of its `picture_occasions`
(`none` when left out). With an occasion, `greeting` (up to 40 letters; the
occasion's own when left out) and `message` (the line under it, up to 90;
the occasion's own when left out, none when empty): any script the print
fonts carry, emoji left off. A value it doesn't have, words too long, or
words without an occasion: `422 invalid_parameter`; letters the fonts
can't draw: `422 undrawable`, in the web's words, with `details.words`
(`kard`: its own words, changed in Edit Kard; `greeting`: the words sent)
and `details.characters` (the letters, eight at most). `Content-Disposition`
names the file as the web's download does (`inline;
filename="Priya Sharma greeting 1080x1920.png"; filename*=UTF-8''…`): use
`filename*`, the name in any script. `preview=true`: the same
picture 400 pixels wide, to show while the words are chosen. The kard's
people only. Each picture (not a preview) ticks the checklist's `chat`
step. Drawn in the request, so out of the account's one drawing budget,
the wallpaper's above: about 30 seconds of drawing a minute, some 20
pictures or 40 previews (`429 rate_limited`, `Retry-After`). A kard hidden
after a complaint draws nothing (`451 unavailable`). Put the kard's link in
the caption: a viewer can't scan their own screen.

### 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.

The picture's greeting, as the web's "Save my greeting" keeps it:

```json
{"kard": {"picture_greeting_setting": {"occasion": "diwali", "greeting": "शुभ दीपावली", "message": null}}}
```

`occasion` is one of the kard's `picture_occasions` but `none`; `greeting`
left out, null or empty is the occasion's own; `message` left out is the
occasion's line, null or empty no line. The answer's
`settings.picture_greeting` is what was saved, and sending that back saves
the same. `null` forgets it. The web's checks, each `422
validation_failed` with `details.picture_greeting_setting` and nothing in
the request saved: no greeting ("Not saved: pick a greeting first, then save
it."), words too long (40 letters, 90 for the line), or letters the
picture's fonts don't draw ("Not saved: the picture's fonts don't draw Ꮜ.
Change those letters, then save."). Only the greeting is written: the
kard's pages and its `updated_at` don't change.

### DELETE /kards/{share_link}
Deletes the kard and everything under it but its contacts, which stay the
account's people (since 1.9.5): kept for the account, a team kard's for its
team's admins, in `GET /contacts` with `kard` null and on the web's
Contacts as "From a deleted Kard". Organization kards can only be
deleted by organization admins; an organization's default kard can't be
deleted (its contacts go with the organization). The kard's `can_delete`
says beforehand whether this person may. Bookings made from it stay in
the calendar, without the kard: the kard's `counts.upcoming_bookings`
(since 1.9.9) says how many are still to come, as the web's question does
("2 upcoming bookings made from it stay in the calendar."). Irreversible —
confirm with the owner first.


### GET /public_kards/lookup?url=…
The Kard a scanned or tapped link opens: the brand's share links in every
shape (a person's, an organization's, a team member's, their booking and
other public pages), custom domains, and a share link or organization slug
a Kard has moved from. `{kard, viewer}`: what its public page shows (name,
tagline, photo and a 384-pixel copy, links, custom links, the vCard, the
booking page and its label), and `viewer` `{is_mine, bookmarked,
bookmark_id, can_share_back, my_kards}`. A Kard switched off for a while
(`is_active` false) shows what its "switched off for now" page does, its
name and photo: `share_link`, `organization`, `name`, `languages`, its name
in `translations`, `photo_url`, `photo_thumb_url`, `public_url`, and
`is_active` false; every other field null, `[]` or false. Show it as
switched off for now, without Save, Book, the exchange or a bookmark (both
answer `422 switched_off` then). `organization` keeps its team's slug,
name and logo, which the web's page leaves out: on a team Kard they say
whose Kard it is, and they're on the team's own pages anyway. A leaver's
Kard (a team Kard whose holder left, the team's way in) reads switched off
while its team's own Kard is, as on the web: that Kard's name and photo.
Its own people (its holder,
its team's admins, a site admin) get it as it is, with `is_active` false,
as the web shows it them (since 1.9.14). Otherwise a leaver's Kard is
drawn as its page is (since 1.9.14): under its own `share_link` and
`public_url`, the team's own Kard's `name`, its phone, email and website
in `links`, its `address`, and a `vcard_url` that saves the team's contact;
nothing of the person who left (no photo, title, products, booking or
payments). `left_team` `{share_link, organization, public_url}` names the
team's own Kard: show the web's "The person on this card has moved on."
above it and a "See <team>'s card" button. `left_team` is absent on any
other Kard. `viewer.can_share_back` is false for a Kard closed to the
person asking (switched off) and for a leaver's, whose page has no
exchange (since 1.9.14). Not a Kard: `404 not_an_ekard`; a Kard hidden after a
complaint: `451 unavailable`. 60 a minute; no visit is
recorded.
It carries `job_title` and `company` too (a team Kard's `company` is null
unless it has its own: its organization's name stands in), and
`payment_methods` as on a kard (above; empty on an inactive Kard).
`languages` and `translations` are the Kard's languages and its name, job
title and company in its second, as its page shows them to a visitor
reading it (the second language's only; `company` and `job_title` null on
an inactive Kard): show the copy to a viewer who reads that language.
`whatsapp_url` is the Kard's WhatsApp as its page opens it: `wa.me` with
the owner's first line written when they've turned it on
(`settings.visitor_whatsapp_line_on`; ours in English when they wrote
none). Open it as it is; null without a WhatsApp number or on an inactive
Kard.

### Address suggestions
`GET /address_suggestions?input=12+Laxmi&session=<uuid>&country=IN` gives
up to five street addresses Google Maps suggests for what's typed in a
kard's Address line 1 (`suggestions`: `place_id`, `main`, `secondary`,
`street_only` for a street without a number), and
`POST /address_suggestions/pick {place_id, session}` the one picked in the
kard's address parts with its `address_place_id`, and `missing_number`.
Through our server, as the web's editor: no Google key in the app, and
Google gets only the words, the country, the language and the session,
never the person. Ask after 3 characters and a 300 ms pause, one request in
flight, one UUID session an address (new after each pick). Fill line 2
only if it's empty, and send `address_place_id` with the kard's save.
`kard` (a share link, with `organization`) or `organization` (New Kard in a
team) names the account whose plan counts. Show the Google Maps logo at the
list's foot. `off` (`not_set_up`, `paused`) with `notice`: show it once,
then stop asking until the screen opens again. A person may ask 100 times
and pick 10 a day, a session 15 times, and spend a quarter of what owners
may spend on Google Maps in a day (`person_share`); an account without a
plan only within Google's free calls this month (Google's month, in
Pacific time). A pick is a street address only: a business's or a
landmark's place (a kard's `google_place_id`, say) is `422
not_a_street_address`, and nothing of its address comes back, since
Google's terms don't allow copying it. `422` for words outside 3 to 200
characters or a session that isn't a UUID; `404 place_not_found`; `429`
past 60 a minute per network. Answers are never kept (`no-store`).

### Your own domain

A personal kard (and an organization, below) can open at the owner's own
domain, such as `card.priya.com`, on every paid plan. The eKard link keeps
working beside it.

- `GET /kards/{share_link}/custom_domain` — `{custom_domain}`, or `null`.
- `PATCH /kards/{share_link}/custom_domain`
  `{"custom_domain": {"hostname": "card.priya.com"}}` — connects it (`201`),
  or switches to it from the current one (`200`; the same domain again
  changes nothing). `dns_records` is what to add at the owner's DNS
  provider: usually one `CNAME`, whose `name` is the part before `dns_zone`
  (`@` for a bare domain) and whose target is this domain's own
  (`<token>.domains…`). Nothing opens at the domain until that record (or,
  for a bare domain, the `TXT` record) proves it's the owner's; then it goes
  live by itself, usually within minutes.
- `POST /kards/{share_link}/custom_domain/check` — checks now, in the
  background: `202` at once, then `GET` it in a few seconds. Checks also run
  by themselves: every few minutes in the first hour, then less often.
- `DELETE /kards/{share_link}/custom_domain` — removes it. Tell the owner to
  delete the DNS record too (`message`).

`status` says where it stands (`waiting_for_dns`, `points_elsewhere`,
`proxied`, `needs_txt`, `confirming`, `securing`, `live`, `stopped`, …);
`title` and `message` say it for a person, with what to do next: relay them
as they are. Until it's `live`, links and `public_url` keep the eKard
address. `release_at`: when it's let go, if its record doesn't show up
(a week after it was registered) or a domain that stopped working doesn't
come back (two weeks). A subdomain works with every DNS provider; a bare
domain only where the provider can flatten a CNAME (ALIAS, ANAME), and it
also needs the `TXT` record in `dns_records`. Refusals: `403 plan_required`
(with `upgrade_url`), `422 validation_failed` (not a domain, one of
eKard's own, already connected elsewhere, a team kard, too many still
being set up), `503 unavailable` (connecting isn't open on this server right
now). A name another account has claimed but not proven yet is the owner's
to take: the 422 carries `error.challenge`, a `TXT` record; once it's in the
name's DNS, the same `PATCH` connects it here and the other claim ends. 10
connects and checks a minute.

The emails about a domain (it's live, isn't live yet, stopped, was let go,
went to someone who proved it) link to the domain panel of the owner's
settings: `/kards/{share_link}/settings#your-own-domain`, or
`/organizations/{slug}/settings#your-own-domain` for an organization's. An
app can open its own domain screen for those links; to open the web page
instead, a web handoff's `return_to` is the path without the
`#your-own-domain`.

## 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?". It covers every
kard the account holds and every kard of the organizations it administers,
the organization's own kard included (the kards an app lists). Each row
names its kard by `kard` (the share_link) and `organization` (the slug, null
for a personal kard), since a personal kard and a team's can share a
share_link; `organization_name` is for showing. `organization` (a slug) keeps
one organization's kards: all of them for its admins, the account's own kards
there for anyone else.

Each row has `visits` (`last_7_days`, `last_30_days`, `lifetime`),
`contacts` (all of them), `contacts_last_30_days` and `history`. Every plan
gets the last 7 and 30 days. A kard's history comes with its plan (a team
kard's is its organization's), as the web app's Analytics tab does: without
one, `history` is false and `visits.lifetime` is the last 30 days' (still an
integer: call it lifetime only when `history` is true). `totals` adds the
rows up; its `history` is true when every row's is.

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

- `history`: whether the kard's plan includes its history (below)
- `totals`: visits in range, lifetime visits, contacts (total and in range), taps in range,
  and `visits_while_off` (since 1.9.14, on every plan): the range's visits to
  its "switched off for now" page while it was switched off, which `visits`
  leaves out (the web: "While it was switched off · 3 visits to its “switched
  off for now” page")
- `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
- `taps`: what visitors tapped on the kard's page in range, kind → count, most
  first, only kinds with taps: `call`, `whatsapp`, `sms`, `email`,
  `directions`, `google_business`, `google_review`, `website`, `instagram`,
  `facebook`, `linkedin`, `twitter`, `telegram`, `youtube`, `pay_<method>`
  (`pay_upi`, `pay_paypal`, `pay_venmo`, `pay_cash_app`, `pay_pix`,
  `pay_iban`, `pay_zelle`, `pay_mpesa`, `pay_clabe`), `file`, `link`, `save_contact`, `book`,
  `product` (a product's button, each product once a visit, 10 a visit at
  most). Each kind counts once per visit; robots, link previews and the
  kard's holder never count, and only the kind and the time are kept. On
  every plan.
- `products` (with history only; `[]` without, and for a kard without
  products): each product's `id`, `name`, `available`, `taps` in range,
  `taps_previous` (the window before, null for `lifetime`) and `asked` (the
  range's contacts who said they were interested), most tapped first.
- `taps_previous`: the window before the range's, as long as it, counted as
  `taps` is (null for `lifetime` and without history, where the web shows
  no change chip). The web's chip compares the kinds its line shows (all
  but `save_contact`, `book` and `link`) added up: "▲ 25%", "▼ 10%",
  "· same", or "▲ new" when there were none before.
- `taps_counting_since`: the day taps were first counted, on any kard
  (`YYYY-MM-DD`): the web's footnote "Counting since Oct 19." A range
  reaching back past it shows fewer.
- `saves`: the web's "Saved you to their phone", the range's people who
  downloaded the kard's contact (robots, link previews and prefetches never
  count), with `saves_counting_since` (the web's "counting since 19 Oct")
  and `saves_uncounted` (the range's downloads from before that day, which
  the number leaves out: the web's "Not in this number: …" when it's more
  than 0). All three null without history, where the web shows no saves.
  Not `taps.save_contact`, a tap counted once per visit.

- `google`: "Your Kard on Google", what Google Search Console says about
  the kard from eKard's daily read, on every plan and whatever the
  range: Google's own last 28 days, `from` to `to` (about 2 days behind),
  `impressions` (times Google showed it in its results, its tagged copies
  too), `clicks` (times someone opened it from there), `searches` (the top
  five, `{query, impressions, clicks}`, most shown first; Google keeps rare
  ones private, so they can add up to less) and `read_at`. `counted` is
  false, with `own_domain`, for a kard whose address is its own domain or
  its organization's: those aren't in the property, so it has no numbers.
  Null for anyone but the kard's holder and its organization's admins (a
  site admin on someone else's kard), and before the first read. A failed
  read changes nothing. The web's words: "Google showed your Kard 1,496
  times between 22 Sep and 19 Oct, and 11 people opened it."; with
  nothing, "Google hasn't shown your Kard yet." (`never_shown` true) or
  "Google didn't show your Kard between 22 Sep and 19 Oct." (false: an
  earlier read found it) and, while the kard is hidden from search
  engines, "Show it on Google" (`PATCH /kards/{share_link}/search_offer`,
  `yes`). `page_title` is the title Google shows as its result, for the
  web's "People pick a result by its title. Google shows yours as “…”"
  when it was shown and nobody opened it. While a kard's numbers wait for
  its new address, `address_since` and `numbers_from` say since when and
  about until when ("Google's numbers for your Kard start around 18 Nov").

Without a plan (`history: false`), the answer is the last 7 or 30 days'
counts and no history: `lifetime` is answered as `30_days` (`time_range`
says which), `totals.visits` and `totals.contacts_in_range` are the range's,
`totals.contacts` is every contact, `totals.visits_lifetime` is the last 30
days' (still an integer: call it lifetime only when `history` is true), and
`visits_by_day`, the breakdowns and `custom_link_clicks` are empty; `taps`
is the range's, as with a plan, and `google` the same as with one. The rest
comes with a paid plan (`https://ekard.app/pricing`); say so rather than reading
the empty lists as no visitors.

## Contacts (leads)

People who submitted the kard's contact ("connect") form. Their answers to
the kard's own questions (the kard's `contact_form`) are under
`custom_fields`, by each question's `key`, always as text (a number or a
yes/no sent by an integration is kept and read as its digits or
`true`/`false`). `answers` (since 1.9.12) says them as the web's contact
row and sheet do, so an app needn't rebuild them from each kard's
questions: `[{"key", "question", "answer"}]`, the products they ticked
first (`{"key": "products", "question": "Interested in", "answer":
"Mailer box, Kraft pouch"}`), then the kard's questions in its sheet's
order, each in the words it was asked (an earlier wording's own) with the
answer in the owner's words (a choice's words), then any other answer kept
under its key; blank ones left out. The web's row joins the answers with
" · " ("Mailer box, Kraft pouch · Buying · Within 3 months"), each
"question: answer" for a screen reader. The same list as a contact
webhook's `data.answers`.

- `GET /contacts` — the contacts of the kards you hold (a team's with
  `organization`), newest first. Params: `query`
  (see **Search** below), `organization` (a slug: every
  team kard's contacts for its admins), `kard` (a share_link),
  `event_id` (the people met at it: since 1.9.13 a returner is listed on each event they came to, as
  the event's `contacts_count` counts them), `source` and `found_via` (each one or more, comma-separated), `follow_up` (`due`:
  a reminder on the account's today or before, the people the morning
  email lists; `set`: any reminder), `page`, `per_page` (≤ 100). Without `organization` it lists the kards the account holds; an
  organization's own kard (which nobody holds) and teammates' kards are its
  admins' under `organization`, so an app showing a team's contacts sends it.
  It lists the contacts the account's deleted kards left it too (since
  1.9.5), and under `organization` its admins get the team's: `kard`,
  `kard_name` and `kard_organization` null. Show "From a deleted Kard"
  where the kard's name goes, with `deleted_kard_name` when it has one
  (since 1.9.12: "From the deleted Kard Diwali Fair"); there's no kard to open, so no
  `/kards/{share_link}/contacts/{id}` for them: `/contacts/{id}` (below)
  opens, notes, reminds and deletes them. `kard` leaves them out;
  `query` and the other filters read them as any contact.
- `GET|PATCH|DELETE /contacts/{id}` — one contact from a deleted kard
  (`kard` null in `GET /contacts`; since 1.9.5), as the web's Contacts ›
  From a deleted Kard: for the account it's kept for, a team's for the
  team's admins (and site admins); anyone else gets `404`, the team's
  members and the kard's last holder included, and so does a contact still
  on a kard (that one is `/kards/{share_link}/contacts/{id}`: pick the path
  by `kard`). `GET` and `PATCH` answer `{contact}`, `DELETE` `{deleted:
  true, id}` (for good, as the web's Delete). `PATCH` changes only `note`
  and `follow_up_on`, by their rules on a kard's contact, each checked
  alone (what the person sent stays as they sent it); any other field in
  `contact` is `422 invalid_parameter` naming it, and nothing changes. The
  reminder is the sender's: the morning email and push list the person
  "From a deleted Kard", for the sender while they may still open the
  contact, then for the account it's kept for, or the team's admins.
- `POST /contacts/{id}/reaches` `{"way": "whatsapp"}` (since 1.9.13) — a
  tap on Call, Text, WhatsApp or Email on a contact from a deleted kard,
  as `POST /kards/{share_link}/contacts/{id}/reaches` is for a kard's
  (see **Reached out**), so `?reached=no` on `GET /contacts` counts it:
  kept for the account it's kept for and the team's admins; a site admin
  outside them is answered with `reach: null`. Who may: as `GET`.
- `GET /kards/{share_link}/contacts` — one kard's contacts. Params: `query`,
  `sort` (`name|email|created_at|message`), `direction` (`asc|desc`),
  `event_id` (one of this kard's events; another kard's is `404`),
  `follow_up` (`due` or `set`, as on `/contacts`), `question` and
  `answer` together (the people who picked one choice of the kard's own
  questions: a `choice` question's `key` and one of its `choices`; one the
  kard doesn't have is `400`), `found_via` (one or more, comma-separated,
  as on `/contacts`), `ids[]` (only these of them: the web's ticked rows,
  still narrowed by the rest), `page`, `per_page`. JSON whatever the
  `Accept` header says (curl's `*/*` included), unless it asks for
  `text/csv` (or `text/vcard`).
- `GET /kards/{share_link}/contacts.csv` — CSV export (same `query`, `sort`,
  `event_id`, `follow_up`, `question` and `answer`, `found_via`, and
  `ids[]`), every matching contact; `Accept: text/csv` on the path above
  is the same. The newest first, unless `sort` says otherwise. Its
  columns are headed in plain words: Name, Email, Phone, Message, Your
  note, How they came (the webhook's `source_label`), Date, Remind me on
  (the reminder's day, `YYYY-MM-DD`, blank without one), Found your Kard
  through (in words, "QR code", blank where it wasn't read), Event, Their
  Kard (a card shared back), then a card's Job title, Company, Website,
  Address, Other phones, Other emails and Notes from their card (since
  1.9.6; before, `name`, `phone_number`, `found_via`, `card_company` and
  so on). The kard's own questions follow, headed in the owner's words (a
  choice's words in each cell; "(their answer)" after one worded as a
  column above), then any other answer kept (a question since removed)
  under its key. Since 1.9.11 the file starts with a UTF-8 byte-order mark
  (EF BB BF), so Excel reads Devanagari or Arabic right, and a column no
  exported row fills is left out, except Name, Email, Phone, How they came
  and Date: read the columns by their headers, never by position. A cell that starts with `=`, `+`, `-`, `@`, a tab or a
  CR gets a leading `'`, so a spreadsheet won't run it, except a phone
  number (a `+`, then digits, spaces, dashes and brackets), written as
  it is.
- `GET /kards/{share_link}/contacts.vcf` — the web's "Save to my phone":
  the same people as the CSV (same parameters, and the same access), as one
  contact file (vCard 3.0, `text/vcard`) that an iPhone ("Add All 12
  Contacts") and Google Contacts import in one step, the newest first
  whatever `sort` says. Each card is the contact's own: name, job title,
  company, every email and phone, the address, a note with their message,
  their card's notes, your note, where you met them when they're tagged
  with an event ("Met at PackEx India 2026 (22–24 Oct).", since 1.9.6) and
  the day they arrived ("Received on
  eKard on 17 October 2026.", then how they found the kard when that's
  known: "Found your Kard through your QR code."), and a shared kard's link. Nobody to save:
  `422 no_contacts`, with the web's words ("No contacts yet, so nothing to
  save to your phone.", or with a search, an event or `follow_up`: "Nobody
  in this list to save. Show everyone, then Save to my phone."). The web
  says beside its button: "Saving the same people twice makes copies. To
  save only some, search or open an event first: the file holds the list
  on screen."
- `GET .../contacts/{id}` — one contact.
- `POST /kards/{share_link}/contacts` — add a lead manually. Body:
  `{"contact": {"name": "...", "email": "...", "country_code": "+91",
  "phone_number": "...", "message": "...", "note": "...", "event_id": 1,
  "custom_fields": {...}, "card_details": {...}}}` (`name` required). The
  answer is `{contact, message}`. Without a `source` it is saved as `api` (a
  lead from elsewhere): the owner is emailed about it, and the person is
  emailed the kard unless `send_my_kard` is `false`, by the rules below for
  `send_my_kard`. Its `message` is never quoted to them as theirs.
  - **Phone numbers**: one that starts with `+` stands as typed. One typed
    without its country code is read with `country_code` (`"+91"`), else
    in `phone_country` (ISO 3166 alpha-2, such as `"IN"`: the app sends the
    phone's region); otherwise it's `422` and `details.phone_number` asks
    for the country code.
  - **People the kard's own people met** (the app): `source` `manual` (typed
    in) or `scan` (read off a paper card) emails nobody, unless
    `send_my_kard: true` asks to email them the kard. That goes only from an
    account whose email is verified, once a day per person and kard, 200 a
    day, within the day's most any typed address gets (since 1.9.11: 10
    to one address from every kard, 1,000 from one kard, 2,000 from one
    account's kards), and never from a kard hidden after a complaint; `message` says when it didn't ("Saved Ravi Mehta. Your Kard wasn't
    sent: …"), and `contact.kard_sent_at` when it did.
  - **Added by an app or AI agent** (the account's key: the MCP server's
    `add_contact`, a Zap, a script): `source` `agent`, which emails nobody
    either. `manual` and `scan` count only from the app's sign-in, the
    person typing or scanning; from the key they're saved as `agent`. What
    an app or agent passes on may be someone else's words, so a contact
    found in a connected app (HubSpot, Google Contacts) by its email or
    phone gets a note there, never its empty fields filled.
  - `card_details`: the paper card's other details: `job_title`, `company`,
    `website`, `address`, `notes`, and lists `phones` and `emails` (10 each).
    A `website` without a scheme (`www.example.com`) is saved with
    `https://`; one that isn't an `http(s)` address (`mailto:`,
    `javascript:`, a word) is left out, and the contact saved without it.
    Anything else read there is kept as sent.
    The owner corrects them later, `job_title` and `company` on the web's
    contact sheet too.
  - `note` (≤ 1,000 characters): the owner's own note on this person ("Met
    at the expo, wants 200 units"); they never see it. For a `manual` or
    `scan` contact it is their `message`, where the apps have kept the
    owner's note (sent with a `message`, `note` wins); for anyone else it
    sits beside what they wrote.
  - **Email addresses** (`email`, `card_details.emails`) need a dot in the
    domain: `kabir@mehtaevents` is `422`, and `details.email` says "is missing
    the end of its domain, like mehtaevents.com". A contact saved before
    keeps its address until it changes.
  - `idempotency_key` (≤ 100 characters): send the same one again and the
    answer is `200` with the first contact, nothing saved or sent twice.
  - **Limit** (since 1.9.11): 500 new contacts an hour per account,
    counted with the web's Add a person (`429 rate_limited`, `Retry-After`
    the seconds left). A repeat with its `idempotency_key`, or one refused
    (`422`), isn't counted.
  - **Events**: without an `event_id`, a contact made while one of the
    kard's events runs is tagged with it (see **Events** below), whatever
    its `source`. `"clear_event": true` leaves it off any event.
    `captured_at` (since 1.9.13, ISO 8601): when the app made it, for one
    sent later from its upload queue; the event is the one running then,
    by its days and hours, not when the request arrives (a scan at an
    18:00–21:00 talk uploaded at 22:30 is the talk's; one made at 09:00 and
    uploaded at 18:00 isn't). Left out, the request's time. A time ahead of
    now is read as now; one more than 31 days back leaves it off any event
    (no event running now is its); one that isn't ISO 8601 is
    `400 invalid_parameter`.
- `PATCH .../contacts/{id}` — the fields above; the ones left out keep
  their value. In `card_details` too: only the keys sent change (`""`
  clears one), so `{"contact": {"card_details": {"company": "Mehta
  Exports"}}}` keeps the job title, the phones and the rest. What a visitor
  typed when they came back (`unchecked_phones`, `unchecked_emails`,
  `unchecked_companies`, `unchecked_answers`) is only cleared, with `""` or
  `[]` (since 1.9.10; before, those keys were dropped and nothing changed);
  the value the contact has already, as `card_details` was read, is
  accepted and left as it is, so a client can send the whole object back
  with one key changed; any other value (text, a number, `true` or
  `false`) is `422 invalid_parameter`. `"note": ""`
  (or `null`) clears the note. `event_id` moves the contact to another of the kard's events
  (another kard's is `422`, in `details.event`). `"event_id": null` takes
  it off its event, and so does `"clear_event": true`, for a client that
  leaves nulls out (the iPhone app's generated one does); sent together,
  `clear_event` wins. The answer is the contact, `event_id` and `event`
  null.
- `DELETE .../contacts/{id}`.

**Several at once** (the web's bulk actions: ticked rows, or "Select all"):
`POST /kards/{share_link}/contacts/bulk` with a `verb`:

- `event`: put them in one of the kard's events (`move_to_event_id`; an
  archived one is refused), or take them off theirs (`clear_event: true`);
- `follow_up`: one reminder for them all on `follow_up_on` (`YYYY-MM-DD`,
  yours: the morning email and push go to you), or clear theirs
  (`clear_follow_up: true`);
- `done`: their follow-ups marked done, the web's Done on To follow up's
  bar: their reminders cleared, so they're off To follow up and the
  morning email, in Done's words (dry run: "Done takes all 3 off To follow
  up and out of the morning email, as Done on each one would. Nothing else
  about them changes. You can undo it for an hour.", `now` "3 of the 5 have
  a reminder, 2 of them from an earlier day"; done: "Marked 3 follow-ups
  done: they're off To follow up and the morning email.", or "Nothing
  changed: none of the 3 had a reminder to mark done."), with the same
  hour's undo (since 1.9.10; before, `done` answered nothing);
- `delete`: delete them, for good.

Pick them by `ids` (up to 1000), or with `all: true` every contact the
list holds on every page, as `query`, `event_id`, `follow_up`, `question`
with `answer`, `found_via` and `product` (one of the kard's products by its
`id`: the people interested in it, the web's "Interested in") narrow it
(the list's own parameters; `ids` are narrowed by them too; a choice or a
product the kard doesn't have is `400` and nothing changes, never the whole
list). Only this
kard's contacts are found, whatever ids come. Who may: whoever may open the
kard's contacts (its holder, a team kard's organization admins); a team
member acts on the team kards they hold, not on another member's.

Ask first with `dry_run: true`: nothing changes, and the answer is the
web's step before the button, `{verb, dry_run, count, changed, unchanged,
who, now, message, undo}`: `who` is who's picked ("Asha Rao, Om Prakash and
10 others.", "All 340 contacts matching “mehta”, on every page of the
list."), `now` where they stand ("9 have no event · 2 are in Trade Fair"),
`message` what the choice does ("11 go in Auto Expo. 1 is there
already."), and `changed`/`unchanged` the exact counts. Then send the same
with `count`, the number you showed: if the list changed in between,
nothing changes and it's `409 count_changed` with the new `count` in the
error ("The list changed since you looked: this is now 341 contacts, not
340. Check who's in it, then confirm again."). Done, `message` says what
happened ("Put 12 contacts in Auto Expo.", "Nothing changed: all 12 were
already in Auto Expo.", "Deleted 12 contacts."). An `event`, `follow_up` or
`done` change that changed someone has `undo: {token, expires_at}`: `POST
/kards/{share_link}/contacts/bulk/undo` `{"token": …}` puts back, once and
within the hour, the contacts still as the change left them (`{restored,
kept, message}`); only the person who made it can. Afterwards: `422
nothing_to_undo`. `delete` has no undo: offer the same people's CSV or
contact file first (`ids[]`). Nobody picked: `422 nothing_selected`.

**Follow-up reminders** ("Remind me"): `follow_up_on` (`YYYY-MM-DD`) on
`POST` or `PATCH` is the day to follow up with the person, and the
reminder becomes the sender's (`follow_up_by` `{id, name}`: whoever set it
last hears of it while they may open the Kard's contacts; once they can't,
handed on, removed or made a member, the Kard's holder does; a contact from
a deleted kard, `PATCH /contacts/{id}`, the same way, its account or the
team's admins standing for the holder). `null` or
`""` clears it, which is "Done". The day is
the person's own today or later, at most 2 years ahead; an earlier or later
one is `422 validation_failed` (`details.follow_up_on`), something that
isn't a date `422 invalid_parameter`. That morning, at about 8 in the
reminded person's time zone (their calendar's, else the one their browser
reported, else UTC), one email lists everyone due that day or overdue,
each with WhatsApp, Call, Email, Done and Again in a week, and the
person's phones with the app get one push (event `follow_ups`, below).
Nothing is sent on a day nobody is due. An overdue reminder is named on
its day and the morning after; after that the email counts it on one line
with the ones from earlier days, and no push names it, so the same names
don't come every morning; `follow_up_on` keeps it on the contact until
it's cleared. The web's contact sheet sets it
with Tomorrow, In 3 days, Next week (7 days) or a date; Again in a week is
7 days from today.

- `POST /contact_cards/parse` `{text, locale_hint}` — a paper business
  card's text (as a phone read it; never its photo, at most 4,000
  characters) read into `card` `{name, job_title, company, phones, emails,
  website, address, notes}` and a `confidence` (`high`, `medium`, `low`), for
  the person to check before saving the contact with `source: scan`. A
  `website` the card printed without `https://` comes with it, so it goes
  into a new kard as it is; one that isn't an `http(s)` address is null. Nothing
  of the text or of what was read is kept. A plan with a price, the
  person's or one of their organizations': else `403 ai_needs_priced_plan`
  (Conventions), before anything is counted. 60 an hour, 500 a day, and
  none once the account's card reading today (the web's Scan cards too) has
  cost a third of the server's day of AI reading: `429 rate_limited`, its
  message saying when it reads again, `Retry-After` the seconds until then;
  `503 parse_unavailable` when it can't be read just now: Claude out of
  reach, or too many cards being read at once (the account's two, or four
  of everyone's, the web's Scan cards and New Kard's paper card with them),
  its message saying which. Either way, keep your own reading.
- `POST /exchanges` — share one of the account's kards back with another
  kard, as the web's exchange sheet does: `{"to": {"url": "…"}}` (or
  `{"share_link", "organization"}`), `"from_kard"` (a share_link of the
  account's; `from_organization` when two share it), `"note"`,
  `"bookmark": true`. It lands in their contacts as a `share_back`, their
  owner is emailed when it's new, and their kard is bookmarked. Again: `200`,
  brought up to date, nobody told. The account's own kard: `422 own_kard`.
  30 an hour. A kard whose people blocked the account answers the same,
  and keeps nothing (below). A kard switched off for a while: `422
  switched_off` ("Priya has switched this Kard off for now: nothing was
  sent. Share yours once it's back on."), nothing sent, no bookmark.

Each contact carries `source` (how they arrived) and `source_label` (it
in words, as a contact webhook's `data.source_label`), `found_via` (how they
found the kard: `qr`, `nfc`, `signature`, `chat`, `email`, `search`,
`social`, `brand`, `website`, `direct` or `unknown`, read from their
browser's visit to the kard's page as the web's exchange, a kard shared back
on the web, or Suggest a time made them; `null` for everyone else and for
contacts made before 1.8.6), `kard_name`,
`kard_organization` (the kard's organization's slug, `null` for a personal
kard: a personal kard and a team kard can share a share_link, so `kard` alone
can't say which), `kard_message_first` (since 1.9.10: the kard's
`message_first`, whether Text or WhatsApp leads on this contact's row, as
the web's rows lead by the contact's kard; `null` from a deleted kard), `deleted_kard_name` (since 1.9.12:
the name a deleted kard had, on the contacts it left; `null` on a live kard's
contact, and on one whose kard was deleted before 1.9.12), `event` `{id, name}`, `shared_kard` `{share_link, name,
public_url}` when they shared their own kard back, `card_details`, `note`,
`kard_sent_at`, `came_back_at` (since 1.9.13: when they last came back and
sent their details again, the recaps' "Came back Sat 24 Oct"; `null` if never),
`follow_up_on`, `follow_up_by`, `email_typo` (since 1.9.13, below), `whatsapp_url` and `updated_at`.
`whatsapp_url` opens their WhatsApp chat with a hello already written, for
the person to send, as the web's WhatsApp buttons do: the account's hello
from Profile › Edit (else the default in its country's language) with their
first name, the event they were met at and the kard's link; `null` when
WhatsApp can't open their number.

**One contact's sheet** (since 1.9.13): `GET` and `PATCH
/kards/{share_link}/contacts/{id}`, `GET /contacts/{id}`, a reach recorded
and a merge answer the contact with what the web's sheet shows beside a
row's fields; a list's rows leave them out.

- `reaches` `{recent, count}`: the sheet's "Reached out", its newest five
  lines (each as `last_reach`) and how many there are. The web lists each
  line with its day and time in the reader's zone and who when it wasn't
  them ("Opened WhatsApp · Fri 23 Oct, 10:42 AM · by Priya Shah"), then
  "And 3 earlier." when `count` is more, then "eKard sees the tap, not
  whether the call or the message went."; with none, "Not reached yet. A
  tap on Call, Text, WhatsApp or Email here puts a dated line here, and so
  does Done on Ravi's reminder."
- `same_person`: "Same person?", the notice atop the web's sheet, or
  `null`. A visitor's send through the kard and someone its people added
  (typed in, Send to a number, scanned) share an email or a number; or
  two of a visitor's own sends from before 1.9.6 are suggested as one
  (`suggested`). `{contact_id, name, source, created_at, shared ("the
  number +91 98201 33457"), suggested, kept_id (the one that stays), names
  (to keep, the added one's first), title ("Same person as Rahul V?"),
  message ("Rahul V was added by hand on 26 Oct, with the number +91 98201
  33457.")}`.
- `POST /kards/{share_link}/contacts/{id}/merge` `{"with": 812, "name":
  "Rahul Verma"}` — Merge them, as the web's: the visitor's send stays as
  they sent it and takes from the other what it lacks (the owner's note
  beside its own, the company, job title and the card's details, the
  event, the reminder, every number and email, bookings and "Reached
  out"); a suggested pair's later message joins the first's as "Came back
  Thu 22 Oct: …". The name is `name` when it's one of `names`, else the
  first. The other contact is deleted; nobody is sent anything. `200
  {contact, merged_id, message}` ("Merged: Rahul Verma is one contact now,
  with what they sent and what you added."); two that aren't such a pair
  are `404`; a merge the kept contact can't save, `422 validation_failed`
  in the web's words.
- `DELETE /kards/{share_link}/contacts/{id}/merge?with=812` — Keep them
  apart, for a suggested pair: it isn't suggested again (`200 {contact,
  message}`). Any other pair is found again by what it shares while it's
  two: `422 not_suggested` (the web's Keep them apart only goes back).

**A mistyped email** (since 1.9.13): `email_typo` on every contact is
`{did_you_mean, title, message}` when their email is one slip from a big
provider's (`rohit.sharma@gmial.com`), by the exchange sheet's own check;
`null` otherwise. The web's sheet shows `title` and `message` ("rohit.sharma@gmial.com
· looks like a typo for gmail.com" / "Email to it may never reach Rohit.
Fixing it here sends Rohit nothing.") with **Fix**: Edit with
`did_you_mean` in the email field, saved with `PATCH
/kards/{share_link}/contacts/{id}`. An email or number put right that
way is the owner's record and sends the person nothing: a return's
not-checked one that becomes theirs leaves `card_details.unchecked_emails`
(or `unchecked_phones`), and the answer has `message` ("Saved Rohit's
email. Nothing was sent to Rohit.").

**Search** (`query`, on both lists, the CSV and the contact file): every word of it, in any
order and any case, in the name, email, phone number, message, `note`,
`card_details` (company, job title, notes, other emails) or the form's
answers: `mehta ravi` finds Ravi Mehta. A query that reads as a number (digits,
spaces and `+ ( ) . - /`, at least 3 digits) also matches any of the
contact's numbers by its digits, punctuation and a leading 0 aside:
`98201 23456` finds `+919820123456`, `020 7946 0018` finds
`+442079460018`.

**Blocking someone** ("Block this sender", what both app stores ask of
apps where people send each other things): whoever may open a kard's
contacts can block the person behind one, so what they send that kard
next is dropped: its exchange form, a card shared back, a time suggested
on its booking page. Nothing of it reaches the kard's people (no contact,
email, notification or webhook), and nobody is told: the blocked person
gets all anyone gets (the answer, a repeat's answer with the same contact
id, their "Here's …'s card" email), and can still see the kard. They're
known by the contact's email, its number (any spacing) and, for a card
shared back, its account; any one matching is enough. A booking on the kard's booking page is refused
too, in the words a client the calendar blocked gets ("We couldn't book
this online. Please contact … directly."), with nothing kept; joining
the waitlist from that page files nothing (the person sees their
waitlist page as anyone does). Blocks are per kard.

- `GET /kards/{share_link}/contacts/{id}/block` — `{block, blockable}`:
  the block the person is under (made from this contact or another of
  theirs), or `null`; `blockable` is false with no email, number or
  shared card to block them by.
- `POST .../contacts/{id}/block` — block them: `201 {block, blockable,
  message}`, `200` when already blocked, `422 nothing_to_block`.
- `DELETE .../contacts/{id}/block` — unblock: every block they're under on
  the kard goes. What they sent meanwhile was never kept.
- `GET /kards/{share_link}/blocks` — everyone blocked on the kard, newest
  first; `DELETE .../blocks/{id}` lifts one, also when its contact is
  deleted (`contact_id` null).

A block is `{id, contact_id, name, email, phone, by_account, known_by
("ravi@mehta.example and +919820123456"), blocked_by {name, email} | null,
created_at}`.

**Reached out** (since 1.9.11): "who haven't I called?" the morning after
a fair. Each tap on Call, Text, WhatsApp or Email on a contact, in the web
or an app, and each follow-up marked done, puts a dated line on it. It's
what eKard knows: the tap opened the dialler or WhatsApp, not that a call
or a message went, so say it as the web does, "Opened WhatsApp · Fri 23
Oct" ("Tapped Call", "Opened a text", "Opened an email", "Followed up"),
never "messaged" or "called".

- `last_reach` on every contact: `{way, words, at, via, by {id, name} |
  null}` or `null` (not reached yet). Read-only.
- `POST /kards/{share_link}/contacts/{id}/reaches` `{"way": "whatsapp"}`
  (`call`, `text`, `whatsapp`, `email`; `at`, ISO 8601, for a tap the
  outbox sends later) — send it as the app opens the dialler, Messages,
  WhatsApp or the mail app: `201 {reach, contact}`. The same way by the
  same person within 10 minutes is the same line: `200` with it. Kept for
  the kard's own people (its holder, its team's admins); a site admin's
  tap answers `200` with `reach: null`. Done (the bulk `done`) records
  `followed_up` by itself.
- `{"way": "marked"}` (since 1.9.14): "I reached them", for a call or a
  text the person made from their own phone, outside eKard; it takes
  the contact off "Not reached yet" as a tap does ("Marked reached · Mon
  26 Oct"). A server before 1.9.14 answers it `422`: offer it only from
  1.9.14.
- `last_reach.way` may also be `booked` (since 1.9.14): a booking the
  desk made from the contact did their follow-up ("Booked · Wed 16 Jan,
  Haircut", "(cancelled)" once the booking is). An online booking filed
  under the contact leaves the follow-up set: it reads "Booked for …"
  while the booking holds. The apps never send it.
- `?reached=no` on `GET /contacts`, `GET /kards/{share_link}/contacts`
  (and its CSV and `.vcf`) and the bulk actions: the web's "Not reached
  yet", everyone without a line; `yes`, the others.

Each new contact can also be pushed, as it arrives, to an address the
kard's people choose (Zapier, Make, a CRM): see "New contacts
(contact.created)" under Webhooks.

## Reports

Anything eKard hosts (a kard, its photo, a booking page, a file) can be
reported to the Grievance Officer, as the web's complaint form
(`/grievance`, and "Report this card" at the foot of every kard) does,
with the same categories, clocks and evidence rules. Any key; for an
app's "Report this Kard" on a kard opened from a scan or a link.

- `GET /grievance?url=…` — what the form needs: the officer (`appointed`
  false while none is named; complaints then reach the team that runs
  eKard, on the same clocks), `categories` `[{key, label, hint,
  officer_label, clock_seconds, clock, needs_order_reference}]` in the
  form's words, the `evidence` limits, the account's name and email
  (`reporter`) and the words to confirm (`declaration`). `form_url` is the
  web form with `url` filled in, for an app that opens it instead.
- `POST /grievance` `{"grievance": {"reported_url", "category", "details",
  "order_reference", "reporter_name", "reporter_email", "reporter_phone"},
  "declaration": true}` — files it: `201 {grievance {reference, category,
  label, received_at, due_at, clock, evidence, evidence_refused, case_url},
  message}`. A file the storage didn't take is left out (`evidence_refused`)
  and `message` asks them to reply to the acknowledgement with it. The
  reporter gets the reference by email at once, and the officer is
  alerted. Categories and their clocks: `impersonation` and `intimate` 2
  hours, `order` (a court or government order; `order_reference` needed)
  3 hours, `unlawful` 36 hours, `other` 7 days. The reporter's name and
  email are the account's unless sent. `case_url` is the reporter's own
  page on the case, no sign-in: where it stands, replies, the decision and
  how to appeal. Something to fix: `422 validation_failed`, each message a
  sentence to show. 5 an hour per account, and 5 an hour per network
  counted together with the web's form (`429 rate_limited`). Send an
  `idempotency_key` (≤ 100 characters, a random one such as a UUID): this
  account sending it again answers `200` with the first case, nothing
  filed or emailed twice. Keys are per account, never per
  `reporter_email`, so another account's key never finds or holds back
  your report.
- **Evidence** (optional): up to 3 photos, screenshots or PDFs, 10 MB
  each, seen only by the officer. Send the request as multipart/form-data
  with the files in `grievance[evidence][]` (the other fields as
  `grievance[category]` and so on, and `declaration=1`). Files only:
  anything else there is `422 invalid_parameter`.

## Events

The fairs, meetups and conferences a kard is shared at ("Auto Expo,
21–23 Oct"); contacts are tagged with an `event_id`. Deleting an event
keeps its contacts (they're detached).

- `GET /kards/{share_link}/events` · `POST` (`{"event": {"name": "Auto
  Expo", "starts_on": "2026-10-21", "ends_on": "2026-10-23", "time_zone":
  "Asia/Kolkata", "note": "...", "archived": false}}`, name required; with
  an `idempotency_key` a repeat answers `200` with the first event) ·
  `GET/PATCH/DELETE .../events/{id}`. At most 30 new events an hour per
  account, counted with the web's New event (`429 rate_limited`,
  `Retry-After`).
- **Days** (event mode): `starts_on` and `ends_on` (`YYYY-MM-DD`, both or
  neither, at most 31 days, the last on or after the first) run whole days,
  midnight to midnight in `time_zone` (an IANA name; left out, the
  caller's own: their calendar's, else their browser's). Something that
  isn't a date is `422 invalid_parameter`; days out of order, one without
  the other, or an unknown zone `422 validation_failed` (`details`). Both
  `null` make it an event without days.
- **While it runs**, every new contact the kard collects is tagged with it,
  unless it came with an `event_id` or `"clear_event": true`: cards shared
  back (the exchange sheet, `POST /exchanges`), contacts typed in or
  scanned (`source` `manual`, `scan`), and leads added here. Bookings
  aren't. An event given hours on the web (since 1.9.13; not in this API
  yet) tags from half an hour before them to an hour after, on each of its
  days, so `tags_new_contacts` is false outside them; a contact's own
  `captured_at` picks the event running when it was made. When two run at
  once side by side, the one within its own hours wins (two open houses
  the same day); one inside the other (a talk inside a fair), the inner
  one, from half an hour before it to an hour after; else the one that
  started last. An
  archived event never tags. The owner isn't emailed about each one while
  it runs (their phones with the app still hear of each): the recap lists
  them all.
- **The recap**: at about 8 the morning after the last day, in its
  `time_zone`, the kard's holder and, on a team kard, its organization's
  admins get one email listing everyone tagged, with WhatsApp, Call and Email; none when
  nobody was. `recap_sent_at` says when it went; changing `ends_on` sends
  it again after the new last day. An event of two days or more also has
  one at about 7 each evening of its days, the last too, listing the people
  tagged since the evening before (none when nobody new). Each recap is
  also a push (event `event_recap`, below), and with the kard's
  `allow_email_notifications_setting` off, the push alone.
- An event: `id`, `name`, `note`, `archived`, `starts_on`, `ends_on`,
  `time_zone` (null without days), `status` (`running`, `upcoming`, `ended`,
  `undated`: from its days alone), `tags_new_contacts` (running and not
  archived), `recap_sent_at`, `contacts_count`, `created_at`.

## Products

What a kard sells: a photo, a name, a price, one line, and one button for
the visitor (order on WhatsApp, pay the owner, ask through the exchange, or
open the product's own page). Money never passes through eKard: every
button hands the visitor to the owner. Since 1.9.7 the shown ones appear
on the kard's page, under the section's heading, and in `GET
/public_kards/lookup`'s `kard.products`. For whoever manages the kard; a
team kard's products are its admins' to change, and its other members get
`403 forbidden` for a change.

- `GET /kards/{share_link}/products`: every product in the kard's order,
  hidden ones too, with `heading`, `on_exchange`, `prices_include_taxes`,
  `can_edit`, `currency` and `limits` (`shown`: 6 without a plan, 200 with
  one, given plans and the trial too, a team kard on its team's; `max`: 200
  on any plan; `count`; `shown_count`). `currency` (since 1.9.9) is the one
  a product added now starts in when `currency` is left out: the kard's
  country's (its phone number's, else its holder's), USD when neither says
  one, so Add can show it before the first product; `currency_name` (since
  1.9.13) names it as the web's form titles it ("Indian rupee").
- `POST /kards/{share_link}/products` (`{"product": {"name": "Mailer box",
  "price": "1,800", "price_mode": "fixed", "currency": "INR", "action":
  "whatsapp", "line": "Kraft, 200 x 150 mm"}}`, name required, and a price
  unless `price_mode` is `ask`; with an `idempotency_key` a repeat answers
  `200` with the first) · `GET/PATCH/DELETE .../products/{id}`. The photo
  goes as multipart/form-data in `product[photo]` (JPEG, PNG, GIF or WebP,
  up to 5 MB); `remove_photo: true` takes it away. Delete has no undo: ask
  first, as the web does, "Delete Mailer box? It leaves your Kard and this
  list for good, with its photo. Contacts who said they were interested
  keep its name."
- **Price**: `price` as typed ("1,800", "40.50") or `price_minor` in minor
  units; `price_mode` `fixed`, `from` ("From ₹499") or `ask` ("Ask for
  price", no figure); `currency` (ISO 4217), the kard's country's when left
  out. No price with `fixed` or `from` is `422`, `details.price_cents` "is
  needed: type the price, or choose Ask for price" (the key older apps
  read), and `message` "Price is needed: …" (since 1.9.10; it said "Price
  cents"). One typed that isn't an amount is `details.price` alone.
- **The button** (`action`): `whatsapp` (the default when the kard has a
  WhatsApp number, else `ask`), `pay`, `ask` or `link` with `url`;
  `paid_link: true` ("I earn from this link") shows "Paid link" on a link.
  One that can't work is refused in words that say how to fix it (`422
  validation_failed`, `details.action` or `details.url`, and `message` the
  same sentence: "Order on WhatsApp needs your WhatsApp number on your
  Kard."): no WhatsApp number on the kard, no way to get paid on it or no
  fixed price, a link without its address. One that stops working later (the number removed)
  is said in the product's `problem`.
- **Sold out and hidden**: `available: false` keeps it on the kard with its
  button greyed; `hidden: true` takes it off and keeps it. One of them sent
  alone in a `PATCH` answers with the web's words for that switch in
  `message` (since 1.9.10): "Mailer box is marked Sold out: it stays on your
  Kard with its button greyed.", "… is back in stock.", "… is hidden: it's
  kept here, off your Kard.", "… shows again."; any other change "… is
  saved.".
- **Limits**: a kard shows 6 products without a plan and up to 200 with
  one, and holds 200 at most. Past either, `422 validation_failed` with
  `details.products` ("Your plan shows 6 products. A paid plan shows up to
  200."). Showing a hidden one past the count says "Your Kard shows 6
  products on your plan. Hide another to show this one, or keep them all
  with a paid plan."; to the apps, which offer no plan, it ends at "Hide
  another to show this one." (since 1.9.10). When a plan ends nothing is deleted: the first 6 in the kard's
  order stay shown, the rest are kept (`shown: false`) until the owner
  hides some, reorders, or a plan shows them again.
- `PUT /kards/{share_link}/products/order` (`{"ids": [42, 17, 23]}`): the
  whole order, every product's id once; answers the list.
- `PATCH /kards/{share_link}/products/settings` (`{"heading": "Our range",
  "on_exchange": true, "prices_include_taxes": true}`): the section's
  heading (30 characters at most; null for ours, "Products"), whether the
  exchange asks visitors which products interest them, and "Prices include
  taxes". `null` puts a setting's default back (since 1.9.10 for
  `on_exchange`, on, and `prices_include_taxes`, the kard's country's; before,
  null stored false). A heading too long is `422 validation_failed` with
  `details.heading` the fragment ("is too long: keep it to 30 letters") and
  `message` a whole sentence ("Heading is too long: keep it to 30
  letters."). Answers the list.
- A product: `id`, `name`, `line`, `price` (`amount_minor`, `currency`,
  `mode`, `text`), `action` (`kind`, `label`, `url`), `paid_link`,
  `available`, `hidden`, `shown`, `position`, `kind` (`goods`, `service`),
  `origin_country`, `condition`, `sku`, `photo` (`tile_url`, `detail_url`,
  or null), `problem`, `created_at`, `updated_at`.
- Taps on a product's button count as `taps.product` in
  `GET /kards/{share_link}/analytics`, each product once a visit; with a
  plan, `products` there lists each product's taps.

## 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.

## Email signature

The kard as an email signature, free on every plan: HTML that pastes into
Gmail, Outlook (web and Windows) and Apple Mail with its layout, links and
images (tables and inline styles only), and a plain-text twin. Its images
live at addresses that always show the kard's current photo and QR
(`{kard url}/signature_photo`, a JPEG, and `{kard url}/signature_qr`, a PNG),
so signatures already sent follow later changes; the text doesn't, so after
editing the kard, copy the signature again. What it says (Save my contact,
Scan for my card, the credit, "My card:") is in the kard's main language,
right to left when that is (`<table dir="rtl">`); `layouts`' labels are
English.

- `GET /kards/{share_link}/signature?layout=qr|button|text&file=1` — `{signature:
  {layout, layouts: [{key, label}], html, text, kard_url, photo_url, qr_url,
  file, with_file}}`.
  `qr` (the default): photo, details and the kard's QR. `button`: photo,
  details and a "Save my contact" button. `text`: three lines of text.
  `photo_url` is null when the kard has no photo (the layouts then show none).
  An unknown layout is a 422 `invalid_layout`. `file` is the file the web
  offers as a line of the signature, the kard's first shown one (`{name,
  url}`, the url its page under the kard's address), null when it shows
  none; `file=1` adds that line ("Résumé →", in the text twin "Résumé:
  <url>"), and `with_file` says whether `html` and `text` carry it.

To offer it in an app, put `html` on the clipboard as HTML with `text` as
the plain-text alternative, then tell the person where their email app keeps
its signature (Gmail: Settings → See all settings → General → Signature;
Outlook: Settings → Account → Signatures; Apple Mail: Settings →
Signatures, with "Always match my default message font" unticked).

## 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

An organization is what the app calls a team: the same thing, under the API's
name. Start a team, read the organizations the account is in, configure
those where it's an **admin**, and run their teams (deleting an organization isn't self-serve:
it's done on request).

- `POST /organizations` — start a team (free for up to 25 people), as the web's Start a
  team: `{"organization": {"name": "Glow Studio", "slug": "glow_studio",
  "kard_name": "Priya Sharma"}}` (`logo` via multipart). The account is its
  first admin. `slug` is its address (https://ekard.app/glow_studio): letters,
  numbers and underscores, 3 to 50, not taken, not reserved, and not an old
  address that still leads somewhere. Its own kard, the front door at
  /glow_studio, is made either way; `kard_name` (optional) also makes the
  account a team kard with that name. Someone who has a kard of their own
  brings it in instead with `kard` (optional, its share link; wins over
  `kard_name`): it moves into the team with everything on it, its contacts
  too, at the team's link for its name (/glow_studio/priya), and becomes
  the team's: any admin of the team can read its contacts, hand it to
  someone else or delete it, and it stays in the team if they leave; its
  old link and every QR code printed with it lead there. Say so before the
  move, as the web does. A kard they can bring in is a
  personal one of theirs that names a person, not a business, without a
  domain of its own; another is `422 validation_failed` under `kard`.
  `GET /me`'s `kards_to_bring` lists them (`[{share_link, name}]`, newest
  first, since 1.9.10): offer "Bring in Marcus Bell" for each, and "Make me
  one" only when it's empty.
  `201` with `organization`, `kard` (the one made or brought in; null
  with neither) and `message`; the account is emailed what to
  do next. Refusals: `403 email_unverified` (verify the address first:
  POST its `verify_url`, `POST /me/email_verification`), `403 team_limit` (an account runs up to 3
  teams), `429 rate_limited` (3 new teams a day), `422 validation_failed`.
  A team is free for up to 25 people for now (`places`, below); a team
  that needs more writes to us, and its `places.limit` says what it has.
- `GET /organizations/address?slug=glow_studio` — is this address free?
  `{slug, available, message}`, as saving would answer, for the field's live
  check. Add `&organization={slug}` when renaming one you administer.
- `GET /organizations` — memberships with `role`, and the account's place
  in each as its own page says it: `calendar_only` (true for a member whose
  only way in is a calendar login: a front-desk or staff login, not a place
  on the team, as the admins' members list says), `calendar_login`
  (`{role, role_name}`, `role_name` as the web words it: "front desk", or
  the calendar's word for its staff; `null` without one) and
  `membership_summary` ("You sign in to Glow Studio's calendar as front
  desk. You're not on Glow Studio's team, so you hold none of its Kards.",
  "You're a member of Glow Studio.", "You're an admin of Glow Studio."),
  and `admin_names`, who to ask as the web names them ("Rhea Kapoor or
  Sam Lee": "To get a Kard here, ask Rhea Kapoor or Sam Lee.").
- `GET /organizations/{slug}` — one of them. Its admins read all of it; a
  member (a calendar login included) reads what the web's page shows them,
  as `GET /organizations` lists it: their place in it and `admin_names`, the
  admins to ask ("To join the team and get a Kard here, ask Rhea Kapoor to
  invite you."), with `custom_domain` and `launch_checklist` null. Anyone
  else gets `403`.
- `PATCH /organizations/{slug}` (admins) — `name`,
  `slug`, `layout_type`, `show_logo_on_kards_setting`,
  `allow_dynamic_layouts_setting`, `our_team_public_setting` (the team's
  "Our team" page at `/<slug>/our-team` in public, `settings.our_team_public`;
  off for a new team); `logo` via multipart, `remove_logo=1`. A
  new `slug` follows the rules above. The old address keeps working: it, the
  team kards' old links and every QR code already printed redirect to the
  new one, and the organization's own kard stays its front door at the new
  address.
- `GET /organizations/{slug}/kards` — all kards in the organization.
- `GET /organizations/{slug}/analytics` — every team kard's visits and
  contacts in one call, the shape of `GET /analytics`, for its admins; a
  member (a calendar login included) gets the team kards they hold and no
  others, totals included, as `GET /analytics?organization={slug}` answers
  them. Anyone outside the team: `403`.
- `our_team_offer` on `GET /organizations/{slug}` — "Show your team on one
  page", while the web offers it to the team's admins (null for anyone
  else, once "Our team" is public, once hidden, or while fewer than two kards
  would be listed): `{listed_count, url}`, how many people the page would
  list now and where it would be public. Turn on is `PATCH
  /organizations/{slug}` with `our_team_public_setting: true`; `DELETE
  /organizations/{slug}/our_team_offer` is "No thanks", hidden for every
  admin, on the web too (`204`; a member `403`, anyone else `404`).
- `our_team_listed` on `GET /organizations/{slug}` — the team's Settings'
  count, for its admins (null for anyone else): `{listed_count,
  team_kards_count}`, how many of the team's kards "Our team" lists now
  (active, not taken off, not hidden after a complaint), of every team
  kard (not the main one). The web says "9 of 10 team Kards listed. The
  other one is off it: taken off in its Settings, switched off (inactive)
  or hidden after a complaint.", "All 10 team Kards are listed." or "No
  team Kards yet: each one you add is listed unless its holder or an
  admin takes it off."
- `our_team_url` on an organization — the "Our team" page's address while
  it's public (`settings.our_team_public`), for everyone it answers; null
  while it's for the team's members only.
- `GET`, `PATCH`, `DELETE /organizations/{slug}/custom_domain` and
  `POST /organizations/{slug}/custom_domain/check` — the organization's own
  domain, as for a kard (Your own domain, above): its root opens the
  organization's own kard, and each team kard opens at the domain followed
  by its share_link. A team kard has no domain of its own. Every paid plan:
  the organization's, or an admin's.
- Kard endpoints accept `?organization={slug}` to address an org kard whose
  share_link collides with one of your personal kards.

### The team

An organization's admins (and site admins) manage its people, as on the
web's Members page. Two roles: an **admin** manages every kard, the members
and the settings; a **member** edits the team kards they hold. A team kard's
holder can edit it; one nobody holds is kept by the admins. The organization
always keeps an admin. Anyone else gets `403` here, and so does an admin
demoted or removed while their change was on its way: the change checks
again as it's made, and nothing changes.

- `GET /organizations/{slug}/members` — `members` (each: `id`, `user`
  {`name`, `email`}, `role`, `you`, `calendar_only`, `can_hold_kards`,
  `calendar_login` (`{role, role_name}` or `null`), `kards` they hold,
  `joined_at`), `invitations` (the
  open ones: waiting, or expired and resendable), `kards` (every team kard
  but the organization's main one, with `holder` {`member_id`, `name`} or
  `null`), and `summary` ("4 people in Glow Studio: 2 admins and 2
  members."). `calendar_only` is true, and `can_hold_kards` false, for
  someone whose only way in is a calendar login (front desk or staff):
  invite them to the team first.
  `summary` counts those as calendar logins, not members ("3 people in Glow
  Studio: 1 admin and 2 calendar logins."). `places` is
  `{limit, taken, left}`: each person on the team (not a calendar login)
  and each open invitation takes a place. `limit` is the team's own number:
  25 unless eKard gave the team more.
  Each invitation's `invited_by` is {`id`, `member_id`, `name`}, or `null`
  once that account is gone: `id` is the sender's account (`GET /me`'s
  `account.id`: yours when you sent it), `member_id` their member `id` here
  (`null` for a site admin outside the organization), so an app can say
  "Invited by you" and count the invitations an admin's demotion or
  removal cancels. An invitation still unanswered 3 days after its last
  send gets one reminder email, with the same link, while the link works:
  `reminded_at` is when it went (`null` until then), `reminder_due_at` when
  it will if they haven't joined by then (`null` once it has gone, when the
  link stops working first, or for an address Apple stopped forwarding to,
  which isn't emailed). Resending it starts again: `reminded_at` is `null`,
  and it gets one reminder of its own. `accept_url` is the link the email
  carries, to send another way (the web's "Copy the invitation link", for
  an address Apple won't forward to): it works only for the invited
  account. Only the invite and resend answers carry it (an expired one:
  resend first); it's `null` in this list and everywhere else.
- `POST /organizations/{slug}/invitations` — `{"invitation": {"email":
  "priya@example.com", "role": "member", "kard": "priya"}}` (`role`
  `member` or `admin`; member if left out; `kard`, optional, a team kard's
  `share_link` handed to them when they join: one nobody holds, or one you
  hold, that no other open invitation carries). The email goes out at once; its link works once,
  for 14 days, for the account with that email (they sign in, or create
  the account). `201` with the `invitation` and a `message`. Refusals:
  `422 validation_failed` with `details` (an address that doesn't look
  right, someone already in the organization, an invitation already
  waiting for that email), or `422 team_full` when the team's places (`places.limit`) are
  all taken (its message says how to free one, or to write to us for more;
  a site admin's invitation isn't held to it). 30 invitations an hour per
  account, counted with the web's and the calendar's: `429 rate_limited`
  with `Retry-After`.
  And one address gets at most 3 invitation emails a day, whoever sends
  them (organizations' and calendars' together): past that, `422
  validation_failed`, and a resend answers `sent: false`.
- `POST /organizations/{slug}/invitations/{id}/resend` — the same link for
  another 14 days (`sent: false` within a minute of the last send).
  `DELETE /organizations/{slug}/invitations/{id}` — cancel: the link stops
  working. Only open invitations are found.
- `PATCH /organizations/{slug}/members/{id}` — `{"member": {"role":
  "admin"}}` or `"member"`. Taking the last admin's role is `422
  last_admin` ("You're Glow Studio's only admin. Make someone else an admin
  first."); you may step down yourself while another admin remains. An
  admin made a member loses the invitations they sent: the open ones are
  cancelled, and the `message` names them. The person is emailed their new
  role (not when you change your own).
- `DELETE /organizations/{slug}/members/{id}?kards_to={member id}&clear_details=true`
  — take someone out. They lose the organization's pages and kards, and their
  calendar login ends. A calendar team member linked to them keeps its
  bookings, but their own calendar stops getting them and its private "My
  day" and feed links change. Their team kards stay the organization's, with
  their links, QR codes and contacts: given to `kards_to` (someone else who
  `can_hold_kards`), or, without it, kept with the admins. They keep showing
  the person's name, photo and contact details until someone edits them;
  `clear_details=true` takes those off first, as leaving does (below). The
  invitations they sent and that are still open are cancelled. `{deleted,
  kards, message}`. They're emailed what changed, who removed them and
  whether their details were taken off. The last admin can't be removed
  (`422 last_admin`). Your own `id` is leaving: an admin leaving can choose
  both `kards_to` and `clear_details` here.
- `DELETE /organizations/{slug}/membership` — leave (any member): your team
  kards there stay with its admins, and the calendar unlinks you as above.
  They keep showing your name, photo and contact details until an admin
  edits them; `?clear_details=true` takes those off as you go (the name
  becomes the organization's; job title, photo, phone, email, social and
  custom links, and any custom design go; the link, QR code and contacts stay). The last admin can't (`422
  last_admin`); not in it is a `404`.
- `PATCH /organizations/{slug}/kards/{share_link}/holder` — `{"holder":
  {"member_id": 12}}` gives a team kard to that member (they're emailed
  its link); `{"holder": {"member_id": null}}` keeps it with the admins.
  `{kard, changed, message}`. The organization's main kard isn't given to
  anyone (`404`). A kard nobody holds can't leave its organization (`PATCH
  /kards/{share_link}` with `organization_id` answers `422`): give it to
  someone first.

#### The join link

One link an admin shares in a group chat instead of an email per person.
Whoever opens it signs in (Google, or email with a verified address) and
asks to join; nobody joins until an admin approves them.

- `GET /organizations/{slug}/join_link` — `{join_link}` (`null` while it's
  off): `url` (what to share), `token`, `status` (`open`, `expired` past its
  14 days, `used_up` once it took `requests_limit` (50) requests),
  `expires_at`, `requests_made`, `created_by` `{name}`, `created_at`, and
  `share_text` (what the web's Share button sends with it).
- `POST /organizations/{slug}/join_link` — turn it on, or replace it: a new
  link for 14 days and 50 requests; the old one stops working at once, and
  requests made through it stay to be answered. `201 {join_link, message}`.
  `DELETE /organizations/{slug}/join_link` turns it off: `{join_link: null,
  message}`.
- `GET /organizations/{slug}/join_requests` — the requests waiting, oldest
  first: each `{id, name, email, status, created_at, decided_at,
  decided_by, kard, needs_a_place}`, plus `places` (as on members) and
  `full_message`. A request takes one of the team's places only once
  it's approved: `needs_a_place` is true while none is free, and
  `full_message` says how to free one (`null` otherwise).
- `POST /organizations/{slug}/join_requests/{id}/approve` — they join as a
  member, with a team kard in the name they gave, held by them, to fill in
  (someone who joined meanwhile keeps their role and kards): `{join_request,
  member, message}`. `422 team_full` with no place free. `POST
  /organizations/{slug}/join_requests/{id}/decline` — `{join_request,
  message}`; the link can't bring them in again (an invitation can). Either
  way the person is emailed and their phones notified; one another admin
  answered already is `422 already_decided`. Each new request emails every
  admin once and notifies their phones (`event` `join_request`).

#### Set up your team (a team's first week)

`launch_checklist` on an organization (`GET /organizations` and `GET
/organizations/{slug}`) is "Set up your team: 2 of 5 done", for its admins
only, as the web shows it on the team's page and on Members: null for a
member, a site admin outside the team, once all five are done, or once an
admin hid it. `{done, total, next, steps: [{key, done, done_on}],
own_kard, people, printed, sent, first_contact_kard}`. The five steps, in
order, each ticked from what really happened: `logo` (the team's logo),
`own_kard` (the admin asking holds a team kard with more than a name and
email: a job title, a phone or WhatsApp number, or a photo; each admin sees
their own), `people` (someone else on the team who joined; since 1.9.13
an invitation or a request to join still waiting no longer ticks it, as
"people added" read as done with nobody in yet), `out` (any team kard printed or sent: any recorded share, its
wallpaper, a picture) and `contact` (the first contact sent through one of
the team's kards; `done_on` is its day, `first_contact_kard` the kard).
Beside them, what the web's lines say: `own_kard` (`{share_link, name,
filled_in, has_email, from_team, has_words}`, null when the admin holds
none: offer "Make my Kard"; not filled in, the web says "Priya Sharma has
a name and email, and Harbor Realty's address and website so far", from
`has_words`, since 1.9.11: a new team kard starts with the team's address,
website and company, listed in `from_team` while they're the team's),
`people` (`{on_team, invited,
requests_waiting, invitations_expired, places}`: `requests_waiting` the
requests to join no admin has answered, "2 people invited, 1 asked to join,
nobody has joined yet"; an expired invitation is worth sending again;
since 1.9.13 that line is the not-done step's: draw it from the counts,
`invited` or `requests_waiting` above 0 with nobody else on the team
(`on_team` 1), never from `done`, with "Answer the request" while
`requests_waiting` is above 0, else the invitations, as the web does),
`printed` and `sent`. `kards_to_bring` (since 1.9.10): while the
`own_kard` step isn't done, the admin's own kards they may bring into the
team, newest first (empty once it's done); the web offers the first three as
"Bring in Marcus Bell". Each has `team_link` (since 1.9.13), the address it
would take in the team brought in now (the domain, the team's address and
a link from its name, else its own: "…/bellrealty/marcus"), null when it
would get a random one.
`DELETE /organizations/{slug}/launch_checklist` hides it for good, for
every admin, on the web too (`204`; again: `204`); a member gets `403`,
anyone else `404`.

`POST /organizations/{slug}/own_kard` `{"kard": "x7kp2"}` (since 1.9.10) is
the web's "Bring in Marcus Bell": one of the account's own kards
(`kards_to_bring`) moves into the team with everything on it, its contacts
too, at the team's link for its name (/bell_realty/marcus), and becomes
the team's: every admin of the team can read its contacts, hand it to
someone else or delete it, and it stays in the team if its person leaves;
its old link and every QR code printed with it lead there, whoever holds
it. Say so in the app's confirm, as the web's Set up your team does
("…moves into Bell Realty with everything on it, its contacts too, and
becomes the team's: its admins can see its contacts, hand it to someone
else or delete it, and it stays in the team if you leave."). An account
with a paid plan of its own: the plan covers its personal kards, and in
the team the kard has the team's plan. `200` with `kard` (where
it is now), `organization` and the web's `message` ("Marcus Bell is in Bell
Realty now, at …/bell_realty/marcus, with everything on it. Its old link
and every QR code printed with it lead there.", and, when a team kard of
theirs there has only a name and email, "Your other Kard here, … : delete
it from its Settings if you don't need it."). The team's admins only,
bringing their own: a member gets `403`, anyone else `404`. A kard they
can't bring, or one the move refuses ("Marcus Bell didn't come into Bell
Realty: …. Fix it in its editor, then bring it in again."), is `422
validation_failed` under `kard`, and nothing moves.

## Bookmarks

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

- `GET /bookmarks` · `POST /bookmarks` (`{"share_link": "..."}`) ·
  `DELETE /bookmarks/{id}`.
- A bookmarked kard switched off for a while (since 1.9.14) comes as its
  "switched off for now" page shows it: `switched_off: true`, its
  `share_link`, `name`, `photo_url`, `photo_thumb_url` and `public_url`,
  `is_active` false, and the rest null or false. Switched back on, it's
  listed as before, without `switched_off`. Bookmarking one while it's off
  is `422 switched_off`.

## 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, insights, 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. Times in words (summaries, messages) read 12- or 24-hour by the
  calendar's country, or as the `Hour-Cycle` header asks (Conventions). 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", "name"}` or `{"type": "organization",
"slug", "name"}`; a person's `name` is never null: the calendar's name when
they haven't given one), `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`, `first_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`,
`buffer_before_minutes`, `buffer_after_minutes`, `price_cents`,
`price_mode`, `price`, `advance_cents`, `visibility` (`internal` = front
desk only), `requires_approval`, `provider_ids`).

`first_name` (since 1.9.9) is what the web calls a person in a sentence
("Book with Priya", "with Dr. Anjali"): a title stays, and a business
keeps its whole name ("Lakshmi's Beauty Lounge", never "Lakshmi's"). Every
person the calendar's answers name carries it beside `id` and `name`:
`providers`, `your_provider`, the schedule's `people`, `free_now` and the
week's person, each visit's `provider` and its `items`' (on the
schedule, a booking, a series, a client's visits). Say it as written; a
server before 1.9.9 leaves it out, so fall back to `name`.

Clients carry `first_name` too (since 1.9.12), what the web's desk calls
them in a sentence ("Mark Ananya as arrived", "Book Ananya for 3:15 PM"):
the first word of their name, a title kept ("Dr. Ananya"), "Walk-in" for
the walk-ins' shared record (say "the walk-in" by `nameless_walk_ins`).
On the schedule's and week's rows, a booking's `client`, the clients list
and a client's page. A server before 1.9.12 leaves it out: use your own
rule on `name`. Since 1.9.13 also on the small `client` (`id`, `name`,
`first_name`) beside a waitlist entry, a series, a new block's
`still_booked` visits and Takings' `unpaid` visits; a server before 1.9.13
leaves it out there.

Prep and cleanup: `buffer_before_minutes` and `buffer_after_minutes` are
the time an offering's person stays busy before and after the client's
time, so the next booking with them starts after the cleanup. `labels`
says what this kind of business calls them: `buffer_before` ("Prep") and
`buffer_after` ("Cleanup", or "Travel" for home services). A booking
summary can say it: "11:00–12:00 · 1 h + 10 min cleanup". In a visit of
several offerings back to back, only the first one's prep and the last
one's cleanup count, none in between. Once booked, its `busy` (The
schedule) is the time it holds.

### 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`,
  `lock_version` (send it with a row's action), `status`, `title`,
  `starts_at`, `ends_at`, `local` ("15:00–16:30"), `client` (`id`, `name`,
  `first_name` (since 1.9.12, see GET /calendars/{id}),
  `alert_note` and `phone`: `null` for staff on someone else's visit;
  `nameless_walk_ins`, since 1.9.12: the one record every walk-in who gave
  no name is filed under, named "Walk-in", so a row's actions say "the
  walk-in": "Mark the walk-in as arrived", never "Mark Walk-in as arrived"),
  `client_time_zone` (when they booked from another zone) and
  `client_zone_label` (that zone as the web names it: "London", "India"),
  `confirmed`,
  `reminder_sent_at` (booked, not confirmed and not started after it: they
  didn't confirm after the reminder), `paid` and `payable` (the front desk
  and admins only), `overlap`, `busy`, `request_expires_at` (when a
  request's hold runs out, as on the booking; `null` otherwise): a day
  read earlier can let its time go then without reading the day again),
  and `declined_at` (since 1.9.9: for a request nobody answered, declined
  "Not confirmed in time" and kept on the day, the end of its hold, as
  the web's row says "Declined at 18:00, when its hold ended"; `null`
  otherwise: a request the desk declines leaves the day, and says when on
  the booking and the client's history, since 1.9.12).
- `busy` (every visit, on the day, the week and in the bookings below):
  the time it keeps its person from anyone else, which is what free times
  book around, as `[{"starts_at", "ends_at"}]` (UTC). Prep and cleanup are
  in: an 11:00–12:00 Hair spa with 10 minutes' cleanup holds its person
  until 12:10, so the gap after it starts at 12:10, not 12:00. A
  processing window (colour developing) is out: two spans, and the person
  is free in between. Empty once the visit holds no time (cancelled,
  declined, a request whose hold ran out).
- `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).
  Today's times start now: a visit that would be over already is left
  out, and of those under way only the latest is there (`starts_at`
  before now: someone already at the desk). Show it as started.
- `?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", "ends_at", "free": false, "reasons": ["booked: Meera Iyer,
  Haircut"]}]}`. `ends_at` (UTC) is when the visit would end with that
  person, free or not: their own length for the services (a haircut can
  take one person 60 minutes and another 45), the `duration` picked, or a
  `booking_id`'s own timing with its person. 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: without `date`, `from` or `to`, a
  request whose advance was received stays in it even once its time has
  passed, since it waits for an answer.
- `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`,
`provenance` (how and when it was booked, as the web's sheet says it:
"Booked at the desk by Rhea on 9 Oct · by phone"),
`provider`, `client` (`id`, `name`, `first_name` (since 1.9.12), `phone`, `email`, `alert_note`,
`visits` (times they turned up: arrived or completed, the same count as
the client's page and the clients list), `bookings_count` (every booking,
any status), `no_show_count`, `cancelled_count`, `new` (their first visit:
no visit before this one, and no earlier booking of theirs still to come,
so only a new client's earliest visit), `nameless_walk_ins`: the walk-ins' shared record,
whose counts are many people's and whose `history` is always empty),
`busy` (the time it holds its person, prep and cleanup in: see The
schedule), `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: worked out when it was made, 4
hours of the calendar's opening hours and its person's, never later than
30 minutes before it starts; `null` once its advance is received, when
it's held until someone answers, and once it's declined),
`declined_at` (when a request was declined: the moment the desk
declined it, since 1.9.12, `null` for one declined before; for a request
nobody answered in time, the end of its hold, since 1.9.9; `null` for
anything but a declined request), `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:
  `{"booking": {...}}` with the same fields 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
  when it starts now (one for a later time is `booked` until it's
  arrived), and without a client files under the desk's anonymous
  "Walk-in"; only
  where `features.walk_ins` is on). `201`. A `client` whose `phone` or
  `email` a client has books that client, as the desk does. A client is
  kept only with a name: a new number or email without `name` is `422
  validation_failed` (`details.name`), in the web sheet's words since
  1.9.12 ("Add their name to keep 98490 98490, so you find them next time.
  Or start without their details: send no client, and the visit is filed
  under "Walk-in"."), and nothing is booked or kept. Booked from eKard's app it
  is the desk's own (`source: front_desk`: "Booked at the desk by …", and
  the business isn't told it as news), as a series or a waitlist entry
  made there is; with an API key it is `api`.
- `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` — "They confirmed they're coming" (the web sheet's button; not "Mark confirmed"): the client confirmed some other way
  (a visit still to come that's booked or requested; anything else is
  `422 invalid_transition`).
- `POST /calendars/{id}/bookings/{booking_id}/confirmation` — the web's
  "Send the confirmation again", after a client's mistyped email was put
  right (on the client, or the booking): the client's confirmation, to the
  email the booking has now, with its private link as it is now. Nothing
  sends it by itself. The front desk and admins. The answer is the
  booking, its `notice` "Sent Ananya the confirmation again, to
  ananya@gmail.com.". Refused: a visit that isn't booked or arrived, a
  walk-in, one that has ended, or one with no email (`422
  invalid_transition`); one sent in the last 10 minutes, from the API or
  the web (`429 rate_limited`, `Retry-After` the seconds left, its
  `message` when it went: "It went to ananya@gmail.com at 3:05 PM. Give it
  a few minutes, and ask Ananya to look in their spam folder."); a
  booking's fourth in a day, from either (`429 rate_limited`,
  `Retry-After` the seconds until the day's window ends: "Not sent: it has
  gone to ananya@gmail.com 3 times since 3:05 PM, the most in a day. Call
  or message Ananya instead: …"); a booking's 5 changes an hour, counted with its email changes on the web
  (`429 rate_limited`); a new address past the day's booking emails (`429
  rate_limited`, "Not sent. Ananya wasn't emailed at …"); a personal
  calendar whose owner hasn't verified their email address (`403
  email_unverified`, with `verify_url` for the owner).
- `PATCH /calendars/{id}/bookings/{booking_id}/email` — `{"email":
  "ananya@gmail.com", "lock_version": 4}` (since 1.9.12): the web sheet's
  "Email for this appointment: … Change", for a booking typed with its own
  address that fixing the client's record doesn't reach. The front desk
  and admins, on a visit still to come that's requested, booked or arrived
  and isn't a walk-in (else `422 invalid_transition`). The booking gets a
  new private link (the one sent before stops working); the client's
  record follows when it held the same address, with their other bookings
  to come and waitlist places. Nothing is emailed: the answer is the
  booking with the web's `notice` ("Saved: Ananya's reminders go to
  ananya@gmail.com now, with a new private link to the appointment. The
  link sent before, to ananya@gmial.com or on WhatsApp, stops working.
  Send the confirmation again so they have the new link." for a booked
  one). The same address is a `200` saying "Nothing changed: …"; not an
  address `422 validation_failed`; an owner not verified `403
  email_unverified`. It counts on the booking's 5 changes an hour.

### Repeating bookings (series)
For the front desk and admins. A series is one client's regular visit
("every week, 6 times"), booked at the desk's new-booking sheet (Repeat)
or here. Each visit is a booking of its own (it's in `GET …/bookings`,
with its reminders, status, payment and manage link), linked by the
series; the client hears once per series change, in one email listing
every date with one calendar file holding them all, never one email per
visit, and the business's phones hear of a series booked here once
(`/calendar_notifications`), all but this key's own account's. Series aren't booked online: clients ask the
business.

- `GET /calendars/{id}/series` — the latest first; `client_id`,
  `status` (`active`, the default: a visit still to come; or `all`),
  `page`, `per_page`.
- `GET /calendars/{id}/series/{series_id}` — one, with every visit.
- `POST /calendars/{id}/series` — `{"series": {"offering_ids": [3],
  "starts_at": "2030-01-15T11:00:00Z", "every_weeks": 1, "count": 6,
  "provider_id": 7, "client_id": 12}}`. `every_weeks` is 1, 2 or 4;
  `count` 2–52, or `until` (a local date within a year of the first). A
  series runs a year at most either way: `count` up to 52 every week, 27
  every 2 weeks and 14 every 4 weeks, and an `until` that makes more than
  52 visits is refused (`422 invalid_series`, saying the last date to pick);
  every date steps on the calendar's wall clock, so a 4:30 PM visit stays
  4:30 PM across a daylight-saving change. The desk's rules (no notice,
  booking window or daily caps); one person for every date
  (`provider_id`, else whoever is first free at the first date). Also
  `client` instead of `client_id`, `duration`, `notes`,
  `internal_notes`, `channel`, `client_address`, `guest_emails`,
  `answers`, `idempotency_key` (a retry returns the same series and
  books any date the first attempt didn't reach).
  - `"dry_run": true` books nothing: `dates` lists each with its `status`
    (`free` and who takes it; `taken` with its `reasons` and up to 3
    `alternatives`, one per person, the same day first, else within 3
    days; `skipped`).
  - Taken dates are skipped (listed in `skipped`) unless `picks` gives
    them another time, `[{"index": 2, "starts_at": "…", "provider_id": 8}]`,
    or leaves one out (`{"index": 4, "skip": true}`). `"if_taken":
    "refuse"` books nothing when any date is taken: `409 series_taken`
    with `details.dates`. A date taken in the moment between the check
    and its booking is in `failed`; the rest are booked. `201`, with the
    series. `422 invalid_series` explains a cadence, count or end it
    can't take.
- `POST /calendars/{id}/series/{series_id}/reschedule` — `{"booking_id":
  41, "starts_at": "…"}`: `"scope": "one"` moves that visit alone (as
  `…/bookings/{id}/reschedule`, with `overlap_allowed`); `"following"`
  (the default) moves it and every later visit still to come by the same
  amount on the calendar's clock (Tuesday 4:30 PM to Wednesday 5:00 PM:
  each later one to the Wednesday after its own Tuesday, 5:00 PM), all to
  `provider_id` when given. `"dry_run": true` returns each move (`from`,
  `to`, `status` `free`, `taken` with `alternatives`, or `kept`). A visit
  whose new time is taken stays where it is unless `picks` gives it
  another (`{"booking_id": 44, "starts_at": "…", "provider_id": 8}`) or
  keeps it (`{"booking_id": 44, "keep": true}`); `"if_taken": "refuse"`
  moves nothing then. The answer lists `moved`, `kept` and `failed`.
- `POST /calendars/{id}/series/{series_id}/cancel` — `{"booking_id": 41,
  "reason": "Moving away"}` (the reason is required): `"scope": "one"`
  cancels that visit alone, `"following"` (the default) it and every
  later one still to come. The answer lists `cancelled` and `failed`.

Limits, shared with the web's front desk: booking a series counts once
on the per-minute limits (5 per account, 10 per calendar) and once per
date on the per-day ones (200 per account, 300 per calendar); a dry run
counts as one of the 30 changes a minute; a series changes at most 5
times an hour (a change can email its client).

### 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, or the visit's end for a visit on a day gone
  by, since 1.9.14; 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); a request's time is
  then held until someone approves or declines it. `{"approve": true}`
  approves a request in the same step. `DELETE …/advance` — not received
  after all; a request is held again from now, as if just made.
- `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.

### Insights
Admins only, as on the web (Calendar → Insights).

- `GET /calendars/{id}/insights` — the owner's numbers for a range of
  local dates, this month by default; `range=week|month` (around `date`,
  today by default), or `from` and `to` (up to 366 days). Visits count on
  their local date; money on the day it was taken (the takings' own
  figures). `booked_time`: each person's booked share of working time,
  split at now into `so_far` and `ahead` (minutes; prep and cleanup count,
  processing time is free, time off, breaks and closures aren't working
  time); `busy_hours`: the share booked per weekday and hour; `no_shows`
  (over visits that have started) and `cancellations`, overall and
  `by_person`; `clients`: new (no visit before these dates) and returning;
  `sources`: online by `via` (`kard`, `link`, `embed`), the desk,
  walk-ins and the API, `by_kard` (each Kard, then one row with `kard_id`
  `null` for Kards deleted since: the rows add up) and `by_channel`;
  `booked_ahead`: local
  days from made to start (a repeating booking once, at its first visit;
  `repeat_visits` counts the later ones left out); `services` and `money`. Every panel has a
  `sentence` in the calendar's own words: read it to the owner as it is.
  `GET …/insights.csv` — every panel as a table (no client names).
- `GET /calendars/{id}/insights/bookings` — the bookings behind a number,
  for the same dates, earliest first, with a `title` ("No-shows · Priya
  Sharma"); `page`, `per_page` (up to 100). Narrow with `status` (`kept`,
  the default: everything but cancellations and declined requests;
  `no_show`; `cancelled`), `provider_id`, `offering_id`, `source`, `via`,
  `kard_id` (or `none`), `channel` (or `none`), `clients` (`new`, `returning`) and
  `ahead` (a `booked_ahead` bucket key). A value that isn't one is `400`.

### Clients
For the front desk and admins. Notes and alert notes can hold health
details: keep them to what the owner asked for. Walk-ins who give no name
share one record (`"nameless_walk_ins": true`, named "Walk-in"): it isn't a
client, so the list, its search and the export leave it out, its counts
are many people's, and it takes no changes.

- `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`,
  `first_name` (since 1.9.12), `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`, `nameless_walk_ins`. `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`.
  Each visit there (and the list's `next_booking`, a booking's
  `history`, takings' unpaid visits) has `client_confirmed_at` and `reminder_24h_sent_at` (`null`
  until then): a `booked` one is Confirmed with the first, Not confirmed
  with only the second (reminded, no answer), else Booked, as the web's
  customer page says; `request_expires_at` (a request's hold, as on
  the booking) and `declined_at` (as on the booking, since 1.9.9). `next_booking` and `upcoming` leave out a request whose
  hold ran out, as the schedule does; until the sweep declines it
  (within minutes) it's in `history`, still `requested`. `unanswered` is
  true for such a request before the sweep and after it (then
  `declined`): the web's History says "Ran out" for both,
  and "Declined" only for one the desk declined.
- `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`. Send an `idempotency_key` (≤ 100
  characters): a repeat answers `200` with the first client.
- `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). The walk-ins' shared record answers `422`
  (`nameless_walk_ins`).
- `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.

### The waitlist
For the front desk and admins, while the calendar's waitlist is on
(`waitlist` in the settings: on by default for salons, spas and clinics;
otherwise a `422` `waitlist_off`). When a day is full, the booking page
offers "Join the waitlist": the visitor picks the days (the fortnight they
were looking at) and parts of the day (`morning` before 12:00, `afternoon`
12:00–17:00, `evening` after 17:00) that suit, whether only the person
they chose will do, and leaves a name and a phone or an email. When time
frees up (a cancellation, a move, removed blocked time or leave, longer
hours), the calendar offers it by itself, in the order they joined: by
email to the first five an email reaches, with a link that opens that time
ready to confirm; and to up to five more who gave only a phone number as
offers for the business to send on WhatsApp (`state` `to_send`; the
business's phones set to everything hear about them). Nobody hears twice
about one time. Whoever confirms first books it; the others are told why
it went, and stay on the waitlist. A booking of the client that covers
what they wait for on one of their days, walk-ins included, ends the
wait. These are the desk's own moves.

- `GET /calendars/{id}/waitlist` — live entries (`status=all`: the ones
  that ended too), in the order they joined; `date=2030-01-10` for one
  day's, `client_id` for one client's. Each: `id`, `status` (`waiting`,
  `booked`, `removed`, `expired`), `source` (`online`, `front_desk`,
  `api`), `client` (`id`, `name`, `first_name`), `reach` (`name`, `phone`, `email`: where
  this entry hears, which for an online entry is what its visitor typed;
  `email_held`), `reached_by` (how an offer reaches them: `email`, sent by
  itself; `whatsapp`, sent by the business from its phone; `null`, nothing
  reaches them), `offerings`, `provider` (`null`: anyone who does it),
  `duration_minutes`, `dates`, `day_parts` (`[]` and `any_time`: any time),
  `time_zone` (the visitor's for a phone or video visit), `wish` ("Thu 10
  or Fri 11 Jan, mornings"), `note`, `joined_at`, `closed_at`, `closed_by`
  (`client` or `business`), `booking_id` and its `offers`, each with its
  `state`, as the desk shows it: `sent` (emailed or sent on WhatsApp),
  `to_send` (nothing sent and no email reaches them, or its email was
  held and they have a number: send it on WhatsApp), `held` (its email
  wasn't sent: an address the entry hadn't emailed, past the day's booking
  emails), `unsent` (made with `send: "none"`), `booked` (booked through it, or by
  its client another way), `taken` (someone else booked the time),
  `blocked` (blocked time, a break, time off or a closure covers it now)
  or `passed`. `offers_to_send`: how many offers on `date` (every date
  without one) the business still has to send on WhatsApp, the desk's
  "2 offers to send on WhatsApp". `page`, `per_page` (≤ 100, default 50).
- `GET /calendars/{id}/waitlist?date=2030-01-10&time=16:00&provider_id=7`
  — who a free time suits ("Offer this time" on a free gap): live entries
  on that day for that person or anyone that want that time and could
  book it online then, in the order they joined.
- `GET /calendars/{id}/waitlist/{entry_id}` — one entry and `free_times`:
  for each of its next days (or `date`), the times it could book online in
  its parts of the day (`starts_at`, `ends_at`, `local_starts_at`,
  `provider`).
- `POST /calendars/{id}/waitlist` — `{"entry": {"client_id": 12,
  "offering_ids": [3], "provider_id": 7, "dates": ["2030-01-10",
  "2030-01-11"], "day_parts": ["morning"], "note": "Short notice is
  fine"}}`, or `"client": {"name": "…", "phone": "…", "email": "…"}`
  instead of `client_id` (matched by phone, then email, like the desk's
  bookings). Without `provider_id`, anyone; without `day_parts`, any time;
  up to 14 `dates`. It reaches the client record's phone and email. `201`.
  The walk-ins' shared record (`nameless_walk_ins`) can't join: `422`
  `validation_failed`.
- `DELETE /calendars/{id}/waitlist/{entry_id}` — takes them off it: no
  more offers.
- `POST /calendars/{id}/waitlist/{entry_id}/offers` — `{"offer":
  {"starts_at": "2030-01-10T10:30:00Z", "provider_id": 7, "send":
  "email"}}`: a time they could book online (else `422` `time_not_open`,
  with the reason); `provider_id` is needed when they wait for anyone.
  `send: "email"` (the default) emails them the link; `"whatsapp"` is the
  desk's WhatsApp button: open the answer's `whatsapp_url` on your phone
  and press send, and the offer counts as sent (`400` without a mobile
  number); `"none"` only makes the offer. An offer to send (`to_send`) is
  sent the same way, with its own `starts_at` and `provider_id`. `201` with
  the `offer` (`state`, `starts_at`, `provider`, `emailed_at`,
  `whatsapp_at`, `opened_at`, `booking_id`, `url`: the link that opens the
  time ready to confirm, `whatsapp_url`: a wa.me link with it typed, for
  the desk's own phone), `emailed` and a `message`. The same time offered
  again is the same offer. The time isn't held. Limit: 10 offer emails a
  minute and 200 a day per account, with the front desk's.

### 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`, `real_estate`, `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); `waitlist` ("Join the waitlist" when a day is full, and
  freed time offered to the people waiting; on by default where there's
  a front desk); on your own calendar, `kard_books_owner_only` ("Clients
  book only me from my Kard"; off by default) and `owner_kard_offers_choice`
  (your own Kard offers "Anyone free" and each person, as a team's front
  door does: two or more people take online bookings and the switch is
  off); `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. Reading a
list comes with a plan with a price, the person's or the calendar's
organization's: else starting or rereading one is `403
ai_needs_priced_plan` (Conventions), and `GET …/menu_imports` says
`can_start: false` with the `refusal` in words. 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: `403
  ai_needs_priced_plan` (`upgrade_url`), `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, in the reader's role: the
  desk is told to move or cancel them; a staff login, who can't, is told to
  ask the front desk or an admin (by name). 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. With an
  `idempotency_key` (≤ 100 characters) a repeat answers `200` with the
  first block.
- `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; they're emailed that their access
  was removed, and by whom. `{"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` (`id`, the sender's account as `GET /me`'s
  `account.id`, and `name`; `null` once that account is gone),
  `accept_url` (the link the email carries, to send another way, as the
  web's "Copy the invitation link": it works only for the invited
  account; only in the invite and resend answers, `null` elsewhere),
  `created_at`. The same
  shape everywhere an invitation is answered (the Logins page, a team
  member's `login.invitation`, invite, resend, cancel). An invitation
  still unanswered 3 days after its last send gets one reminder email,
  with the same link, while the link works, as a team invitation does:
  `reminded_at` is when it went (`null` until then), `reminder_due_at`
  when it will if they haven't joined by then (`null` once it has gone,
  when the link stops working first, or for an address Apple stopped
  forwarding to, which isn't emailed).
- `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`. One address gets at most 3 invitation
  emails a day, organizations' included: past that, `422
  validation_failed`.
- `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 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, `adds_bookings`: connecting asks to add bookings to the
  calendar too; `false` for Google until Google finishes reviewing
  eKard's access to add events, when a connection checks busy times
  only), `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), `add_bookings` (whether
  new bookings go into it: `on`; `no_calendar_chosen`, the person picked
  none: set `destination_calendar_id`; `not_allowed`, adding wasn't granted
  (or the connection predates asking for it): in the browser at
  `connect_url`, "Also add bookings to your Google Calendar" asks for it on
  top of what's granted; `awaiting_review`, Google hasn't approved asking
  for it yet: nothing to do until then), `add_bookings_note` (the same in
  words, `null` when `on`),
  `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 and what to do
  (its `add_bookings_note`).
- `POST /calendar_sync/{service}/handoff` (`{"add_events": true}` to ask
  for adding bookings too) — `{url, expires_at}`: a link that connects the
  service in a browser (the app opens it in a Custom Tab). It shows who is
  connecting and continues to the consent screen, within 10 minutes and
  once, without signing the browser in. Not offered here yet:
  `503 sync_unavailable`.

### Notifications on phones and browsers
The devices that show this person's Calendar notifications (Web Push). They
belong to the person, not to a calendar, so they're at
`/calendar_notifications`: any key, its own devices only. Each device hears
about every calendar the person runs or works in, as their role there
allows: an admin or the front desk about every booking, a team member's
login only about its own. It hears about a booking made online or through
the API (a series once, for all its visits; not on the devices of the
account whose key made it), a request waiting for approval, and a client's own cancellation or move, and shows the client's
first name, the service and the time; tapping it opens the booking. Set
to everything, an admin's or the front desk's device also hears of the
waitlist's offers to send on WhatsApp; tapping it opens that day's
waitlist.
**Turning a device on needs the device**: its browser asks the person and
makes the subscription itself, at `turn_on_url`
(`https://ekard.app/calendar-notifications`; on an iPhone or iPad, from the Home
Screen app). Everything after that works here. Writes count against the 30
changes a minute per account.

- `POST /calendar_notifications` — for eKard's own app, with the access
  token of the session signed in on the phone (an API key answers
  `422 not_a_session`): `{"device": {"kind": "fcm", "endpoint": "<Firebase
  installation ID>", "notify": "everything", "notify_leads": true, "label":
  "eKard app on Pixel 7a"}}`. The iPhone app sends `{"device": {"kind":
  "apns", "endpoint": "<the device token, lower-case hex>", "environment":
  "production", "label": "eKard app on iPhone 15"}}`: `environment` is
  `sandbox` for a build run from Xcode, `production` for TestFlight and the
  App Store. Each app sends its own platform's kind (`422
  invalid_parameter` otherwise). Add `"hour_cycle": "h23"` (or `h12`, or
  any value the `Hour-Cycle` header takes) and that phone's notifications
  write their times as it does ("16:30", "4:30 PM"): a notification is
  worded when no request is in flight, so the header can't reach it.
  Without it, the calendar's own clock. Send it again when the phone's
  setting changes. `201` with the `device`; `200` when the ID
  or token was on file (one another account registered moves to this one).
  A session keeps one phone (a new ID replaces its last), and signing out
  removes it.
  Messages to the Android app are data only: `title`, `body`, `path`, `tag`,
  `user_id` (the account the message is for: drop one for another account
  than the one signed in), and `event` (`new_booking`, `request`,
  `client_cancelled`, `client_rescheduled`, `waitlist`, `new_contact`,
  `follow_ups`, `event_recap`, `test`) with `calendar_id`, `booking_id`, `client` (the client's first
  name, as the title says it) and `status` (the booking's now); a team
  member's login hears a request as "Sana asked for a time with you", since
  it can't approve; for `waitlist` (offers to send on
  WhatsApp, as browsers hear them), `calendar_id` and the offers' `date`
  (`GET /calendars/{id}/waitlist?date=`); or `contact_id`, `kard` and
  `organization`. A new contact
  (a visitor's details, or a card shared back; never one the kard's people
  made) reaches the phones of the kard's owner, or of its organization's
  admins, whose device has `notify_leads`. The morning's follow-ups
  (`follow_ups`, one a day, whatever `notify_leads` says: the person asked
  for it) carry `date` (the person's day, `YYYY-MM-DD`) and `count`;
  several people: "3 people to follow up with today" (since 1.9.14 the
  title says their day as the email's rows do: "3 people to follow up
  with, due yesterday", "3 people to follow up with: 1 due today, 2 from
  yesterday"), the names in `body`, `path` `/account/contacts`; one
  person: "Follow up with Ravi Mehta today" (or "…, due 2 days ago"),
  "Was due 2 days ago · " and the owner's note in `body`, the
  contact's `path`, and its `contact_id`, `kard` and `organization`, as a
  new contact's, for Call and WhatsApp (a contact from a deleted kard, since
  1.9.5: `contact_id` without `kard`, `path` the web's
  `/account/contacts/deleted-kards` searched for them; read it with `GET
  /contacts/{id}`; since 1.9.9 one kept for a team carries the team's
  `organization`, as its `path` does). `tag` `follow-ups-<date>`. An
  event's recap (`event_recap`, each evening of an event of two days or
  more and the morning after its last day; to the phones of the people its
  email goes to whose device has `notify_leads`) has the same shape, with
  `event_id`, `recap` (`evening` or `final`), `date` (the evening's day, or
  the last day) and `count`; several people: "Auto Expo, day 2: 9 new
  people" in the evening, "Auto Expo: 34 people to follow up" after the
  last day, the names in `body`, `path` the kard's contacts with
  `?event_id=`, and `kard` and `organization`; one person: "Follow up with
  Ravi Mehta from Auto Expo", how they came and the owner's note in `body`,
  the contact's `path` and `contact_id`. `tag` `event-recap-<event id>-<date>`
  in the evening, `event-recap-<event id>` after the last day. On iPhone, iOS draws each
  notification itself from `aps`: `alert` (`title`, `body`), `sound`
  `default`, `thread-id` (`calendar-<id>`, or `contacts` for `new_contact`,
  `follow_ups` and `event_recap`), `category`
  (`BOOKING_REQUEST` for `request`, with Approve and Open; `NEW_CONTACT`
  for `new_contact`, and `FOLLOW_UP` for one person's `follow_ups` or
  `event_recap` (with a `contact_id`), with Call and WhatsApp; `BOOKING`
  for every other event, several people's `follow_ups` and `event_recap`
  among them, with Open), `interruption-level` (`time-sensitive` for a request
  and the waitlist's offers, else `active`) and `mutable-content` 1, so
  the app can still change it before it shows: as on Android, Approve is
  for logins that answer requests (an admin, the front desk), and a team
  member's login can't approve. Beside
  `aps` come the same strings Android's data carries but `title` and
  `body` (`path`, `tag`, `event`, the ids, `user_id`), and a booking's
  newer news replaces its last (the `tag` is the collapse id). Not set up
  on the server yet for that platform's phones: `503 push_unavailable`.
- `GET /calendar_notifications` — `devices` (`id`, `kind`: `web_push`, or
  `fcm` and `apns` (the app on Android and iPhone), `name` such as "Chrome on Android", `label`, `notify`,
  `notify_leads` (new contacts on the person's kards), `hour_cycle` (`h12`,
  `h23`, or null: how its notifications write a time), `current` (the
  asking app's phone), `created_at`, and `last_used_at`: when a notification
  last reached it, `null` until the first), `notify_choices` (`key`, `name`,
  `description`, `events`),
  `calendars` (each one the person runs or works in: `id`, `name`, `role`,
  and `hears`: `every_booking`, or `own_bookings` with the team member as
  `provider`) and `turn_on_url`.
- `PATCH /calendar_notifications/{id}` (`PUT` works too) — `{"device":
  {"notify": "requests_and_cancellations"}}`: what the device shows,
  `everything` (new bookings, requests to approve, cancellations and moves,
  waitlist offers to send on WhatsApp) or `requests_and_cancellations`;
  `notify_leads` (`true` or `false`); and `hour_cycle`, for when the
  phone's 12- or 24-hour setting changes. Send any of them. Answers the
  `device` and a `message`.
  Another value: `422 validation_failed`. Another person's device: `404`.
- `DELETE /calendar_notifications/{id}` — remove a device (a lost phone, an
  old browser): it gets no more notifications until it's turned on there
  again. `{"device": null, "message": "…"}`.
- `POST /calendar_notifications/{id}/test` — send "Notifications are on" to
  the device: `202` with a `message`. 5 a minute per account, counted with
  the web's "Send a test".

### Errors and limits
- The engine's errors carry its plain-language message: `409 slot_taken`
  (`details.alternatives`: up to 3 nearby free times; today's from now,
  the latest under way at most), `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; today's times start now, the
  latest one under way at most, as for a calendar).
- `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", "client_zone_label": "India",
      "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",
  "From ₹4,000", "Free", or null when no price was set), always in English
  with English digit grouping, whatever language the client booked in: the
  same as the API's (since 1.9.11; a booking page in German had sent
  "From ₹4.000"). `client_time_zone` is the zone the
  client booked from on the booking page (null for any other booking), and
  `client_zone_label` names it as the web's pages do: the country when the
  zone is its country's only one, else the city ("India", "London", "New
  York"), for "10:00 AM in London". 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}"))
```

**A Google Apps Script web app** (`https://script.google.com/macros/s/…/exec`)
can't read request headers, so a delivery to one also carries the three
`webhook-*` values in its query string, under the same names
(`e.parameter["webhook-signature"]`), beside any query of its own. The
University course "Every new contact in a Google Sheet" has a script that
checks them.

**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). Since 1.9.11 every
webhook and REST hook of one account (a team's, for a team's calendar and
kards) shares a pace: up to 60 deliveries a minute together, after a first
60 at once; more wait their turn, never dropped.

### New contacts (contact.created)

A kard can send every new contact to one address of its people's choosing,
free on every plan: a Zapier "Catch Hook", a Make "Custom webhook", a CRM,
or your own server. Whoever may see the kard's contacts sets it up on the
web (its holder; a team kard's organization admins), at
`https://ekard.app/kards/{share_link}/webhook` for a personal kard,
`https://ekard.app/organizations/{slug}/kards/{share_link}/webhook` for a team
kard's. The page shows the signing secret (`whsec_...`) once, sends tests,
and lists the latest deliveries with the receiver's answer.

Every new contact, however it came (the kard's form, a kard shared back,
a booking, typed in or scanned in the app, the API), is one `POST`, with
the same headers, signature and retries as the booking events above. A
contact that's edited sends nothing.

```
{
  "id": "msg_contact_created_812",
  "type": "contact.created",
  "timestamp": "2030-01-09T06:30:00Z",
  "data": {
    "contact": {
      "id": 812, "kard": "priya", "kard_name": "Priya Sharma", "kard_organization": null, "source": "scan", "found_via": null,
      "name": "Ravi Mehta", "email": "ravi@mehtaexports.example", "country_code": null,
      "phone_number": "+919820012345", "message": null, "note": null,
      "custom_fields": {},
      "card_details": { "company": "Mehta Exports", "job_title": "Head of Purchasing", "notes": "Wants samples by Friday" },
      "event_id": 4, "event": { "id": 4, "name": "Auto Expo" },
      "shared_kard": null, "kard_sent_at": null, "follow_up_on": null, "follow_up_by": null, "last_reach": null,
      "whatsapp_url": "https://wa.me/919820012345?text=Hi%20Ravi%21%20Good%20to%20meet%20you%20at%20Auto%20Expo.%20...",
      "created_at": "2030-01-09T06:30:00Z", "updated_at": "2030-01-09T06:30:00Z"
    },
    "kard": { "share_link": "priya", "organization": null, "name": "Priya Sharma", "public_url": "https://..." },
    "source_label": "Read off their paper card",
    "found_via_label": null,
    "answers": [],
    "products": []
  }
}
```

- `id` is the `webhook-id` header: `msg_contact_created_{contact id}`, the
  same on every retry and never reused, so a receiver skips a contact it
  already has by it (or by `data.contact.id`).
- `data.contact` is exactly what `GET .../contacts/{id}` answers (see
  Contacts): the company, job title and the paper card's notes are in
  `card_details`, answers to the kard's form in `custom_fields`. A field
  the contacts API gains, the event gains too.
- `data.kard` is the kard it came to (`organization`: a team kard's slug,
  else null). `data.source_label` is `data.contact.source` in words, for a
  spreadsheet column: `exchange` "Sent their details from your Kard",
  `share_back` "Shared their own Kard back", `booking` "Booked a time from
  your Kard", `suggestion` "Suggested a time from your Kard", `manual`
  "Typed in by hand", `scan` "Read off their paper card", `api` "Added
  through the API", `agent` "Added by an app or AI agent".
  `data.found_via_label` is `data.contact.found_via` in
  words, as the web's Contacts filter says it ("QR code", "Google or another
  search engine", "WhatsApp or another chat", ...), `null` where it is.
- `data.answers` are their answers to the kard's own questions, each
  `{"key", "question", "answer"}` with the question and answer in the
  owner's words (a choice's words), in the exchange sheet's order: one
  spreadsheet column per question. Empty when they answered none. Since
  1.9.7 the products they ticked lead them: `{"key": "products",
  "question": "Interested in", "answer": "Mailer box, Kraft pouch"}`.
- `data.products` are the kard's products they ticked on the exchange
  sheet ("Which of these interest you?", or Ask about this), `[{"id",
  "name"}]` in that order; empty when none (since 1.9.7).
- "Send test" sends this shape with `"test": true`, a `msg_test_...` id
  and made-up details ("Sample contact", answering the kard's questions:
  "Sample answer", a choice's first, interested in the kard's first
  product, else "Sample product" with id 0); real events carry no `test`.
- **Pace:** up to 60 a minute per kard, after a first 30 at once; more
  wait their turn instead of being dropped. One that would wait more than
  an hour isn't sent (the page's deliveries say so; the contact is saved
  as always). The account's pace is on top (since 1.9.11, above): 60 a
  minute for all its webhooks and REST hooks together, after a first 60;
  past it they wait, never dropped.
- **Failures:** retried 5 more times over about 15 minutes, as above.
  After 10 contacts in a row fail every try, the address pauses itself and
  the kard's holder (and a team kard's admins) get an email; contacts from
  while it's paused aren't sent later. "Turn back on" on the page restarts
  it.

### REST hooks: new contacts to an address a tool subscribes

An automation tool (Zapier, Make, n8n, your own code) can subscribe an
address of its own to hear about new contacts, and unsubscribe it when it
stops: the REST-hook pattern. Each new contact is `POST`ed to it as the
`contact.created` above, with the same body and headers, signed with the
hook's own secret: `POST /hooks` gives it once (`secret`), so keep it and
check the signature as for a webhook. No web page is needed, and nothing
has to be set on the kard.

- `GET /hooks` — this account's hooks: `{"hooks": [Hook], "events":
  ["contact.created"], "max": 50}`.
- `POST /hooks` — subscribe. Body `{"hook": {"event": "contact.created",
  "target_url": "https://…", "kard": "priya" | null, "organization":
  "acme" | null}}`. Answers `201 {"hook": Hook, "secret": "whsec_…"}`:
  the secret only here.
  - `kard`: a share link of a kard whose contacts this account sees (one
    it holds, or a team kard of a team it's an admin of), with
    `organization` when a personal kard and a team kard share the link.
    Any other kard is `404`.
  - No `kard`: every kard whose contacts the account sees, its own and
    the team kards of teams it's an admin of; with `organization`, only
    that team's (all of them for an admin, the ones the account holds for
    anyone else).
  - `event` is `contact.created`, the default. `target_url` must be a
    public `https://` address on the standard port, as a webhook's must;
    another is `422 validation_failed`, keyed `target_url`.
  - **Only with the account's API key**, not an app's sign-in. Generating
    a new key on `https://ekard.app/account/ai` **deletes every hook**: the
    tool must subscribe again with the new key (in Zapier, reconnect the
    account and turn the Zap off and on).
  - **Only from an account whose email address is verified:** another
    is `403 email_unverified`, with `verify_url` (`POST
    /me/email_verification` emails the link).
  - At most 50 per account: the 51st is `429 rate_limited`.
- `DELETE /hooks/{id}` — unsubscribe: `204`, and `204` again for one
  already gone.

`Hook`: `{"id", "event", "kard", "organization", "target_url", "enabled",
"paused_reason", "last_delivered_at", "last_error", "created_at"}`.

- **Who hears what:** a contact goes only if the account still sees it
  when it's sent. A team kard handed to someone else deletes its
  holder's hooks on it, and stops their every-kard hooks hearing it; an
  admin who stops being one stops hearing the team's kards.
- **Ending:** an answer of `410 Gone` (Zapier's for a Zap turned off)
  deletes the hook at once. So does deleting the kard, the team or the
  account, and so does the owner of the account's address taking it
  back (signing in with Google, Apple or an emailed link, or resetting
  the password, of an account whose address was never verified). After 10 contacts in a row fail every try, it pauses
  (`enabled: false`, `paused_reason` says why) and the account gets one
  email; subscribing again (in Zapier, turning the Zap off and on)
  starts afresh.
- **Pace and retries:** as the kard's own webhook: up to 60 a minute per
  hook after a first 30, and the account's 60 a minute for all its
  webhooks and hooks together; retried 5 more times over about 15 minutes.
- **Samples:** a tool that wants recent contacts to show (Zapier's
  "Test trigger") reads `GET /contacts?per_page=3`: `data.contact` above
  is that shape.

## Not in this API

- Plan purchase (web checkout: `https://ekard.app/pricing`) — cancel via
  `DELETE /subscription`.
- Creating/deleting organizations.
- Setting up a kard's new-contact webhook (web only, see "New contacts
  (contact.created)" under Webhooks; its events are described there).
- The AI design studio (web-only for now).
- Signing in — the API key replaces it (the sessions above are for eKard's
  own app).

## Being a good agent

- Destructive calls (`DELETE` kard/document/contact/custom domain, 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.

## Changelog

Changes to answers that already existed, newest first. New endpoints and
fields that only add are described in their sections.

- **2026-10-26 (1.9.13 or 1.9.14, `rails_small_216`): a realtor's kind of
  business, and the apps' small asks.** `GET /calendars/presets` lists a
  tenth kind, `real_estate` ("Book a viewing": viewings at the home the
  client names, a valuation at their address, a call; agents; Travel after
  each visit), and `CalendarPreset` has it: a client that refuses an
  unknown value should take it. A viewing's place is the client's answer
  to its "Which property…?" question (its key `property`), so a
  booking's `.ics` location says it. `unanswered`'s description says the
  web's "Ran out", as the web has since 1.9.13. New: the kard's
  `licence_number` (and `KardInput.licence_number`); the signature's
  `file=1`, `file` and `with_file`; `/kards/*/edit` and
  `/organizations/*/kards/*/edit` in `web_pages`, so an app opens Edit
  Kard at `#google_review` ("Find your business on Google").

- **2026-10-26 (1.9.13, Android wave 13's and iOS wave 9's asks): a
  contact's email or number put right says so; Products' and the picture
  greeting's words are the web's.** `PATCH
  /kards/{share_link}/contacts/{id}` with a new `email` or `phone_number`
  takes a return's not-checked one that became theirs off
  `card_details.unchecked_emails` (or `unchecked_phones`), as the web's
  Edit does, and its answer has `message` ("Saved Rohit's email. Nothing
  was sent to Rohit."). The products' notices and the picture greeting's
  "pick a greeting first" and "fonts don't draw" refusals come from the
  web's words, the same English; with `Owner-Language`, in the language
  asked for. New: `Owner-Language`; one contact's `reaches` and
  `same_person`, every contact's `email_typo` and `came_back_at`; `POST`
  and `DELETE /kards/{share_link}/contacts/{id}/merge`; `POST
  /contacts/{id}/reaches`; an earlier wording's `choices` and
  `choice_values`; `KardInput.languages` and `message_first_setting`, the
  kard's `settings.message_first_setting`, `message_first_country`,
  `languages_chosen`, `languages_choosable`, `from_team` and
  `from_team_note`, and `last_30_days`; `account.name_from_kard`;
  `ProductList.currency_name`; the team checklist's
  `kards_to_bring[].team_link`.
- **2026-10-25 (1.9.13): every client the calendar names carries
  `first_name`.** The small `client` (`id`, `name`) beside a waitlist
  entry, a series, a new block's `still_booked` visits and Takings'
  `unpaid` visits now has `first_name` too, as the desk's rows have since
  1.9.12 (`NamedClient`; "Walk-in" for the walk-ins' shared record).
  Additive.

- **2026-10-25 (1.9.12): a request the desk declined says when.** A
  booking's `declined_at` (on the booking, a client's `history`) is the
  moment the desk declined the request, through the web or `POST
  /calendars/{id}/bookings/{booking_id}/decline`; it was `null` for such a
  request (one declined before 1.9.12 still is). A request nobody answered
  keeps its hold's end, as before. `cancelled_at` stays `null` for a
  declined request, and webhook payloads are unchanged.

- **2026-10-25 (1.9.12, the field round's desk and the apps' small asks):
  a walk-in's new number asks for a name in the desk's words; WhatsApp's
  name.** `POST /calendars/{id}/bookings` with a `client` whose new
  `phone` or `email` comes with no `name` still answers `422
  validation_failed` with `details.name`, and its `message` is now the web
  sheet's ("Add their name to keep 98490 98490, so you find them next
  time. Or start without their details: send no client, and the visit is
  filed under "Walk-in"."), where it said "Name can't be blank". A Kard's
  refusal names "WhatsApp number" (it said "Whatsapp number"). New: a
  client's `first_name` on the day's and week's rows, a booking's
  `client`, the clients list and a client's page.

- **2026-10-25 (1.9.12, iOS wave 8's and Android wave 12's asks): one
  contact's bulk step in the web's words.** `POST
  /kards/{share_link}/contacts/bulk`'s dry run `now` for one contact names
  its reminder's day, as the web's step does ("It has a reminder for Sun 18
  Oct, an earlier day" for Done); it said "1 of the 1 has a reminder". New:
  a contact's `answers`; `GET /me`'s `follow_ups` (the days off "In 3
  days" moves past) and `email_change`; `POST /me/email_change` with
  `/check`, `/confirm`, `/resend`, `/kards` and `DELETE`; a booking's
  `price_from` and its items' `offering.price_mode` (in booking webhooks
  too); `price` and `price_from` on a client's visits and Takings' unpaid
  visits; `client.nameless_walk_ins` on the day's and week's rows; `PATCH
  /calendars/{id}/bookings/{booking_id}/email`. A contact webhook's
  `data.contact` carries `answers` too, the same list as `data.answers`.

- **2026-10-24 (1.9.11): the sign-in link's words, as the web's; a
  budget of new events.** `POST
  /sessions/email_link`'s `202` `message` says what the web's Check your
  email says since 1.9.10: "We've sent a link to meera@example.com. It
  works once, for 15 minutes. Open it on this phone to sign in." (with a
  code: "We've sent a link and a code to …. They work once, …"), where it
  said "If you can read email at …, a link is on its way". It is still the
  same for every address, with an account or without. A claim's `201`
  `message` is the web's too: "Signed in as …. Your address is confirmed,
  and the account is yours alone. Every other browser and phone was signed
  out, and the old password no longer works: set a new one any time with
  “Forgot password?”." `POST /kards/{share_link}/events` answers `429
  rate_limited` (with `Retry-After`) past 30 new events an hour per
  account, counted with the web's New event. A member's role change to
  member, and a removal, add to their `message` what cancelling the
  invitations that person sent did to the Kards made with them ("The Kard
  made for them, Lee Park, is deleted too: nobody ever held it."), as a
  cancel's own message does.

- **2026-10-24 (1.9.10, iOS wave 7's asks): bulk Done, a return's details
  cleared, one product Delete question.** `POST
  /kards/{share_link}/contacts/bulk` with `verb: "done"` marks their
  follow-ups done (their reminders cleared) in the web's words, with the
  hour's undo; it answered nothing before. Its `follow_up` dry run says
  "It has no reminder" for one contact without one (it said "It has a
  reminder"). `PATCH .../contacts/{id}` clears `card_details.unchecked_*`
  with `""` or `[]`, as their description said (the keys were dropped and
  nothing changed), keeps the value it has when that's what's sent (a
  `card_details` sent back as read), and refuses any other value (`422
  invalid_parameter`). Deleting a product is asked one way everywhere:
  "Delete Mailer box? It leaves your Kard and this list for good, with its
  photo. Contacts who said they were interested keep its name." New: `GET
  /me`'s `account.language`, `languages` and `kards_to_bring`; `PATCH /me`'s
  `country`, `timezone` and `language`; `POST
  /organizations/{slug}/own_kard`; the team checklist's `kards_to_bring`;
  `POST /kards/{share_link}/qr_shows`; `answer_count` on
  `contact_form.questions` and `other_fields`, with `asked_until`;
  `/profile/email/new` in `web_pages`.

- **2026-10-24 (field fixes 2h): a booking's price keeps its "From", a
  phone visit says who calls.** A booking's `price` is "From ₹4,000"
  when a service in it has a starting price (`price_mode` `from`), as the
  booking page and the client's emails say it; it was the amount alone
  ("₹4,000"). A phone visit's `.ics` location says who calls whom, with the
  number ("You call Aarav Sharma on +91 98765 43210"), where it said
  "Phone call", and the business's invite writes the client's number
  spaced. New: `POST /calendars/{id}/bookings/{booking_id}/confirmation`.

- **2026-10-24 (address suggestions): Directions to a picked address.**
  `address.directions_url` carries `destination_place_id` for an address
  picked from Google's suggestions when the kard has no `google_place_id`.
  New: `GET /address_suggestions`, `POST /address_suggestions/pick`, and a
  kard's `address_place_id`, which the server clears when the address
  moves. The pick answers only a street address: a business's or a
  landmark's place is `422 not_a_street_address`.

- **2026-10-24 (1.9.9): two refusals' words, and a kept contact's push.**
  A product's `422 validation_failed` `message` is the sentence
  `details` has ("Order on WhatsApp needs your WhatsApp number on your
  Kard."), where it began with the field's name ("Action Order on
  WhatsApp…"). `POST /me/email_verification`'s `422
  email_undeliverable` ends "To sign in with another address, change
  your email.", as `/me`'s `email_bounced.help`, where it said to write to
  support. A follow-up push about one contact kept from a deleted team
  kard carries the team's `organization`. New: `ProductList.currency`;
  `first_name` on the calendar's people (`CalendarProvider`,
  `NamedPerson`); `declined_at` on a request nobody answered in time.

- **2026-10-24 (1.9.9): a bounced address's help.** `GET /me`'s
  `account.email_bounced.help` says what the web's Profile says since
  Change email (1.9.7): "To sign in with another address, change your
  email.", where it said "To use another address, write to" the brand's
  support address. New: a kard's `settings.photo_logo_by` and
  `counts.upcoming_bookings`. `urls.preview_image` is described as the
  JPEG it always was.

- **2026-10-22 (products, part one): a new tap kind.** `taps` and
  `taps_previous` in `GET /kards/{share_link}/analytics` may carry
  `product` (a product's button, each product once a visit), so read them
  as maps whose keys can grow. New: `products` there (each product's taps,
  with a plan), and `GET|POST /kards/{share_link}/products`,
  `GET|PATCH|DELETE .../products/{id}`, `PUT .../products/order`, `PATCH
  .../products/settings`.

- **2026-10-22 (1.9.5): a deleted kard's reminders, and the logo switch
  alone.** A reminder on a contact whose kard was deleted comes in the
  morning email and push again (it stopped with the kard), to the person
  who set it while they may still open the contact, else the account it's
  kept for or the team's admins; a push about one such person carries
  `contact_id` without `kard` or `organization`. `PATCH
  /kards/{share_link}` with `photo_logo_setting` alone saves it without
  checking the kard's other fields again, where a kard an older rule now
  refuses was `422`. New: `GET|PATCH|DELETE /contacts/{id}`, `DELETE
  /me/email_bounce`.

- **2026-10-22 (1.9.5): a deleted kard's contacts, and the card reader's
  slots.** `DELETE /kards/{share_link}` keeps the kard's contacts for the
  account (a team kard's for its team), and `GET /contacts` lists them
  with `kard`, `kard_name` and `kard_organization` null, where they were
  left out; under `organization`, its admins get the team's. Their
  `whatsapp_url` opens the chat with nothing written. `POST
  /contact_cards/parse` answers `503 parse_unavailable` too while the
  account's two cards, or four of everyone's, are being read.

- **2026-10-21 (1.9.4): a code beside the sign-in link.** `POST
  /sessions/email_link` takes `with_code`; only with it, the `202`'s
  `message` says a link and a code are on their way, and the `202` adds
  `ask_id` and `code_length` (`SignInLinkSent`, still a `Message` for the
  builds before). Without it, the `202` and the email are word for word
  as before. New: `POST /sessions/email_link/code`; `Error.tries_left`;
  `Error.title` on the code's `410`s too. `EmailLinkConfirmation` also
  answers the code's `409 confirm_claim`.

- **2026-10-21 (1.9.2): a member's own numbers.** `GET
  /organizations/{slug}/analytics` answers the organization's members (a
  calendar login included) with the team kards they hold, in the same
  shape, where it was `403`; its admins still get every team kard. Anyone
  outside it still gets `403`.

- **2026-10-21: a member's organization, and a handoff's fragment.** `GET
  /organizations/{slug}` answers the organization's members (a calendar
  login included) with what `GET /organizations` already lists for them,
  where it was `403`: a calendar login's page can name the admins to ask.
  Anyone outside it still gets `403`; `PATCH` and the rest stay its
  admins'. `POST /web_sessions` takes a `return_to` ending in a fragment
  (`#logo`) and lands there, where it was `422 page_not_allowed`.

- **2026-10-20: a request whose hold ran out, and answers as text.** A
  client's `next_booking` (the clients list) and `upcoming` (one client)
  leave out a request whose hold ran out, as the schedule, the bookings
  lists and free times already did; until the sweep declines it, it's in
  that client's `history`, still `requested`. A contact's `custom_fields`
  are always text: an answer an integration sent as a number or a yes/no
  is saved, and read back, as its digits or `true`/`false` (and one saved
  before as a list, as its items joined with ", "). The types in the
  description were already strings.
- **2026-10-19: analytics without a plan.** A kard without a plan (a team
  kard: its organization's) gets the last 7 and 30 days and no history, as
  the web app shows it. `GET /kards/{share_link}/analytics` answers
  `history: false` with the range's visits and contacts, `visits_lifetime`
  the last 30 days', and empty `visits_by_day`, breakdowns and
  `custom_link_clicks`; `lifetime` is answered as `30_days`. In `GET
  /analytics` (and `GET /organizations/{slug}/analytics`) such a kard's
  `visits.lifetime` is its last 30 days'. Every field keeps its type: the
  lifetimes are still integers, so apps built before stay readable. Added
  on every plan: `history` on both (and on `GET /analytics`'s totals:
  every row's), and `contacts_last_30_days` on `GET
  /analytics`'s rows and totals. Kards with a plan answer as before.
