# Behavision API — for the web console, a mobile app, and platform administration Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`). Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says otherwise. Every shape below is taken from the server's own types, not written from memory — if the two ever disagree, the server is right and this file has a bug. There is **one API**, not a web one and a mobile one. The web console in this repository uses exactly these calls; anything it can do, an app can do. --- ## Who is calling: three audiences, one API | audience | who they are | signs in with | what they see | |---|---|---|---| | **Merchant** | a company's owner, managers and shop-floor staff | email + password | their own company's shops, cameras, customers, arrivals | | **Platform admin** | Loyaly, running the platform | email + password, an account with **no company** | the list of companies, and nothing inside any of them | | **Shop PC** | the agent running on a till or back-office PC | an installation code, once; its own token thereafter | `/api/agent/*` only — **not for a web or mobile client** | A merchant user has one of three roles. They are strictly nested — each can do everything the one below can: | role | can additionally | |---|---| | `staff` | see arrivals, search customers, edit a customer's profile, record a purchase | | `manager` | manage cameras, invite and remove team members, issue shop-PC installation codes, erase a customer | | `owner` | promote somebody to owner | A platform admin has `role: "admin"` **and an empty `client_id`** — both together, never the role alone. A tenant-scoped account with the role set to `admin` is rejected by every admin endpoint. Admins can call merchant endpoints too, but with no company of their own they see empty lists; the admin screens are `/api/admin/*`. ### Permission matrix Every route, and the least role that may call it. `authed` means any signed-in user; the tenant is always taken from the session and never from the request. | route | least role | |---|---| | `POST /api/auth/login` `refresh` · `GET /api/auth/invitation` · `POST /api/auth/register` | **no auth** | | `POST /api/auth/logout` · `GET /api/auth/me` · `/api/auth/sessions*` | authed | | `GET /api/visits` · `GET /api/visits/stream` | authed | | `GET /api/visitors` · `GET /api/visitors/{id}/history` · `GET /api/visitors/{id}/image` · `GET /api/faces/{id}` | authed | | `PUT /api/visitors/{id}/profile` · `POST /api/purchases` | staff | | `DELETE /api/visitors/{id}` — erasure | manager | | `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed | | `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager | | `POST /api/sites/{site}/enrolment-code` | manager | | `GET /api/team` | authed (tenant users only) | | `PATCH /api/team/{id}` · `/api/team/invitations*` | manager | | `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed | | `POST /api/assistant` | authed | | `GET` / `POST /api/admin/clients` | **platform admin** | | `/api/agent/*` | **shop PC token** — never a user | A role that may not call something gets **403 `forbidden`** with a message saying who can. Another tenant's data returns **404**, never 403: a tenant user has no business learning that a resource exists. --- ## The onboarding chain — who creates whom Three tiers. Each one creates the login for the next, and nobody ever creates their own from nothing. ``` ┌──────────────────┐ creates ┌──────────────────┐ invites ┌──────────────────┐ │ Platform admin │ ───────────▶ │ Merchant owner │ ───────────▶ │ Sales staff │ │ (web) │ company + │ (web) │ code, │ (mobile) │ │ │ owner login │ │ shown once │ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ POST /api/admin/clients POST /api/team/invitations POST /api/auth/register ``` ### Tier 1 — the platform admin registers a merchant Signed in as an account with `role: "admin"` and no company. ``` POST /api/auth/login { "email": "admin@loyaly.ai", "password": "…", "device": "Admin console" } POST /api/admin/clients { "company_name": "TeNext Retail", "owner_email": "suriya@tenext.in", "owner_name": "Suriya" } → 201 { "client_id": "…", "slug": "tenext-retail", "owner_email": "suriya@tenext.in", "password": "xK9…" } ``` `password` is generated and shown **once**. The admin hands the owner their email and that password — that is the merchant login. Leave `password` out of the request; a password an operator invents for someone else is weak and travels over chat. ``` GET /api/admin/clients → every merchant, with site and user counts ``` ### Tier 2 — the merchant owner registers sales staff Signed in as the owner (or any manager). ``` POST /api/auth/login { "email": "suriya@tenext.in", "password": "xK9…", "device": "Head office" } POST /api/team/invitations { "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff", "expires_in_days": 7 } → 201 { "id": "…", "email": "priya@tenext.in", "role": "staff", "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "expires_at": "…" } ``` `code` is shown **once** and is what the merchant gives the salesperson — read aloud, WhatsApp, printed on a card. It is single-use and expires. The salesperson chooses their own password when they redeem it (Tier 3), so the merchant never knows or handles a staff password. Managing the team afterwards: ``` GET /api/team → everyone: role, active, last login GET /api/team/invitations → codes still unredeemed (without the code) DELETE /api/team/invitations/{id} → withdraw one before it is used PATCH /api/team/{id} { "role": "manager" } → promote PATCH /api/team/{id} { "active": false } → they have left; signs them out now ``` The owner also sets the shop up from the same login — `POST /api/sites/{site}/enrolment-code` for the shop PC, `POST /api/sites/{site}/cameras` for cameras — see §8. ### Tier 3 — the salesperson gets their mobile login Not signed in yet. They have the code. ``` GET /api/auth/invitation?code=LQOUHR-AYYTPE-7Q756N-PGAAN6 (no auth) → { "client_name": "TeNext Retail", "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff" } ``` Show *"Join TeNext Retail as Priya R"* and ask for a password. Then: ``` POST /api/auth/register (no auth) { "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "full_name": "Priya R", "password": "…", "device": "Pixel 8" } → 201 { "access_token": "…", "refresh_token": "…", "expires_at": "…", "user": { …, "role": "staff", "client_name": "TeNext Retail" } } ``` **They are signed in.** Do not send them to a login form. From the next day: ``` POST /api/auth/login { "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" } POST /api/auth/refresh { "refresh_token": "…", "device": "Pixel 8" } ``` And the screens a salesperson uses — all `staff` may call: ``` GET /api/visits?limit=30 then ?cursor=… who just walked in GET /api/visits/stream the same, pushed GET /api/visitors?q=priya find a customer GET /api/visitors/V-42/history their past visits PUT /api/visitors/V-42/profile give them a name POST /api/purchases record a sale GET /api/visitors/V-42/image their photo, if any ``` Do **not** send `email` or `role` on register — they come from the code, and a body naming either is refused. That is what stops a forwarded code becoming somebody else's account. ### What does not exist, stated plainly - **The merchant cannot create a staff login directly** with a password of their choosing. The only path is an invitation code the staff member redeems themselves. This is deliberate — it keeps staff passwords out of the merchant's hands and off chat — but it means a salesperson without a phone in hand cannot be set up *for* them. If that friction is real, a `POST /api/team/members` that mirrors `POST /api/admin/clients` (generated password, shown once) is a small addition. - **There is no mobile app in this repository.** Tier 3 is a complete API with no client yet. Everything above is what that app will call. - **The admin cannot reset a merchant owner's password over HTTP**, nor suspend or delete a merchant. Today that is `behavision-server provision` on the server. --- ## Quick starts ### A mobile app for shop-floor staff The one screen this app exists for is *who just walked in*. ``` POST /api/auth/login → access_token, refresh_token GET /api/visits?limit=30 → arrivals[], cursor (first load) GET /api/visits?cursor=… → arrivals[], cursor (every 3–5 s) tap a row → GET /api/visitors/{visitor_ref}/history → their past visits PUT /api/visitors/{visitor_ref}/profile → give them a name ``` Store `refresh_token` securely; read §1 for the three refresh rules before writing the client, because all three have already been bugs here. ### A web console for an owner or manager ``` POST /api/auth/login GET /api/sites → every shop: online? cameras up? faces usable? GET /api/sites/{slug}/check → why a shop is or is not working GET /api/cameras → every camera with its latest still POST /api/sites/{slug}/cameras → add one POST /api/cameras/{id}/check → prove it can see faces GET /api/visits/stream → live arrivals (SSE) GET /api/reports/footfall?from=&to= → the numbers, with their confidence ``` ### Platform administration ``` POST /api/auth/login (an account with no company) GET /api/admin/clients → every company POST /api/admin/clients → create one, with its owner ``` That is the whole admin surface today. Everything inside a company is the company's own business and is reached by signing in as one of its users. --- ## 0. Identifiers — you do not have to use uuids Every id in the database is a uuid and every one of them still works. But a uuid is not something a person can say, type or recognise, so **anywhere a path or a `site` parameter takes an id, it also takes the name people actually use**: | thing | reference | example | |---|---|---| | customer | `V-` | `V-42` — also accepts bare `42` | | shop | its slug | `chennai` | | camera | the id the engine knows it by | `cam1` | | person | their email address | `priya@tenext.in` | ``` GET /api/visitors/3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c/history GET /api/visitors/V-42/history ← the same customer GET /api/visits?site=chennai PATCH /api/cameras/cam1 ``` The customer number is **per company**, so `V-42` at one tenant and `V-42` at another are different people, and a reference never resolves outside the tenant the session belongs to. It is also what the label says: a customer nobody has named is called `Visitor 42`, and `ref` on every customer object carries `V-42` for display. Two shops in one company may each have a camera called `cam1`. That is ambiguous, so it resolves to **nothing** rather than to a guess — use the uuid, or scope by site. An unknown reference in a **path** is `404`; an unknown one in a **query filter** is `400`, because the collection itself was fine and it was the filter that was wrong. **A reference never changes.** A shop's slug, a camera's id and a customer's number are immutable in the database, so it is safe to store one — in a saved URL, a config file or a scheduled report. The **display name** beside it (`"TeNext Chennai"`, `"Front door"`) is free to change and should be; do not key on it. --- ## 1. Signing in ### `POST /api/auth/login` ```json { "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" } ``` ```json { "access_token": "…", "refresh_token": "…", "expires_at": "2026-09-05T18:00:00Z", "user": { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff", "client_id": "…", "client_name": "TeNext Retail" } } ``` Send `Authorization: Bearer ` on every other call. A platform admin's `user` has `"role": "admin"` and `"client_id": ""`. **`device` is worth sending.** It is the only thing that lets somebody look at their list of signed-in devices and tell which one to sign out. Keep it coarse and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a fingerprint here is a tracking signal nobody asked for. | status | `error` | what it means | |---|---|---| | 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. | | 429 | `too_many_attempts` | 10 failures per account / 60 per IP in 15 minutes. Cleared by a success. | ### `POST /api/auth/refresh` ```json { "refresh_token": "…", "device": "Pixel 8" } ``` Returns the same shape. **Both tokens rotate** — the old refresh token stops working the instant the new one is issued, so a copy taken off a resold device cannot keep working alongside the real one. Three rules a client must follow, and all three have already been the cause of a bug in this codebase: 1. **An expired access token returns 401 with `"error": "token_expired"`**, distinct from a real 401. Refresh once and retry, silently — otherwise staff are thrown back to a login form twice a day. 2. **Serialise refresh behind one lock.** The refresh token is single use, so four screens polling at once would each spend it and three would lose, logging the user out at random. 3. **Persist the rotated tokens before doing anything else.** A client that refreshes and is then killed comes back holding a token the server has already invalidated — indistinguishable from a normal expiry, at the worst possible moment. Marshal the request body **before** the first attempt: a retry has to send it again, and a stream is spent after the first read. ### `POST /api/auth/logout` · `GET /api/auth/me` Logout revokes the calling session. `me` returns the `user` object above. --- ## 2. Joining — how somebody gets an account There is **no open registration**, by design. A manager or owner mints a code and hands it over; the holder chooses their own password. ### `POST /api/team/invitations` — manager or owner ```json { "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager", "expires_in_days": 7 } ``` ```json { "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager", "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "invited_by": "suriya@tenext.in", "expires_at": "…", "created_at": "…" } ``` **`code` is returned exactly once and is not recoverable.** Only a hash is stored. Show it immediately; do not expect to read it back. `role` is `staff`, `manager` or `owner`. Only an owner may mint an owner. `admin` is not accepted at all. ### `GET /api/auth/invitation?code=…` — **no auth** ```json { "client_name": "TeNext Retail", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager" } ``` Call this before asking anyone to choose a password, so the screen can say what they are joining and a mistyped code is caught early. Unknown, expired, spent and withdrawn all return **404 `invalid_code`** with one message. ### `POST /api/auth/register` — **no auth** ```json { "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "full_name": "Arjun", "password": "…", "device": "Pixel 8" } ``` Returns **201** and a full session — the same shape as login. Sign the person straight in; do not send them to a login form. **Do not send `email` or `role`.** They come from the invitation, and the request is rejected outright if it names either. That is what stops a forwarded code becoming somebody else's account, or a staff invitation being redeemed as an owner. Dashes and case in the code are ignored. A rejected attempt (short password, wrong code) does **not** spend the invitation. | status | `error` | |---|---| | 400 | password under 8 characters, or a body naming `email`/`role` | | 404 | `invalid_code` | | 409 | `conflict` — that address already has an account; sign in instead | ### `GET /api/team/invitations` · `DELETE /api/team/invitations/{id}` — manager or owner List what is still pending (same shape as above, **without** `code`), or withdraw one before it is used. --- ## 3. Devices | | | |---|---| | `GET /api/auth/sessions` | this account's signed-in devices | | `DELETE /api/auth/sessions/{id}` | sign one out, immediately | | `POST /api/auth/sessions/revoke-others` | sign out everywhere else | ```json [{ "id": "…", "device": "Pixel 8", "created_at": "…", "last_used_at": "…", "expires_at": "…", "current": true }] ``` `current` marks the session making the request — label it, and warn before somebody signs out the device in their hand. `revoke-others` deliberately keeps the caller's own session. A person can revoke only their own sessions. To remove a colleague's access, deactivate them (§4); that revokes every session they hold. --- ## 4. The team ### `GET /api/team` — anyone in the company ```json [{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager", "active": true, "last_login_at": "…", "created_at": "…" }] ``` ### `PATCH /api/team/{id}` — manager or owner ```json { "role": "manager" } or { "active": false } or both ``` Both fields optional; an omitted field is left alone. Deactivating signs that person out **immediately** and stops them signing back in. Reactivating restores the account but not their old sessions. A manager cannot promote anyone to owner. **409 `last_owner`** if the change would leave the company with no active owner. There is no way back from that except a shell on the server, which is exactly what this endpoint exists to stop needing. --- ## 5. Who just walked in — the screen a mobile app is for ### `GET /api/visits` `?limit=50&cursor=…&site=…` (`site_id` also accepted; a slug or a uuid) ```json { "arrivals": [{ "visit_id": "…", "occurred_at": "2026-09-05T06:01:45Z", "site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai", "camera_id": "cam1", "visitor_id": "…", "visitor_ref": "V-42", "label": "Priya", "is_new_visitor": false, "similarity": 0.71, "quality": 0.66, "attributes": { "gender": "Male", "age": 32, "emotion": "neutral" }, "image": { "available": true, "url": "/api/faces/8e7d3d7a-….jpg", "auth": true } }], "cursor": "djE6NDEy", "polled_at": "…" } ``` **Echo `cursor` back on every poll.** It is opaque and it is the only thing that makes the feed lossless: a burst larger than `limit` leaves rows behind, and polling by timestamp alone would skip them permanently. Rows are **ascending**, so the last row's position is your new cursor — which the response already gives you. A cursor that fails to parse means the format changed; drop it and poll again without one. An empty poll returns your own cursor back, not an empty string. Of the three ids on an arrival, only one is a reference you would type: `site_slug`. `visit_id` addresses no route — it is a key for de-duplicating rows, since delivery is at-least-once. And the uuid inside an image URL is **deliberately random**: a derived or sequential one would let somebody enumerate a shop's customers by date. A visit may have **no `visitor_id`** (a shop counting footfall without identifying people) or a blank `label` (a customer who was erased). Both are real people who walked in; render them, do not drop them. ### `GET /api/visits/stream` — server-sent events The same rows, pushed. Send `Authorization` (so a browser `EventSource` will not do — read the stream with an HTTP client) and resume with `Last-Event-ID` or `?cursor=`. Falls back to polling cleanly; the failure mode is latency, never silence. --- ## 6. Photos **A missing photo is data, not an error.** Images are off by default across the whole product, so on most deployments every arrival legitimately has none. Show initials or a placeholder — a screen of red for a system working as configured is a screen whose real errors get ignored. ```json "image": { "available": false, "reason": "This system is not storing customer photos." } ``` When a photo **is** available there are two kinds of URL, and the `auth` flag is how you tell them apart. Do not infer it from the shape of the URL. | | `auth` | how to load it | |---|---|---| | presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds | | served by this API (`/api/faces/{id}`) | `true` | send `Authorization: Bearer …` | - **Mobile**: an image view can attach the header — `Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`. - **Web**: an `` cannot. Fetch it and use an object URL (`URL.createObjectURL`), and **revoke it** on unmount — a screen left open all afternoon otherwise holds hundreds of copies of the same photograph. Prefix a relative URL with the base URL. Treat any relative URL as needing auth whether or not the flag is set: there is no public one. ### `GET /api/faces/{id}` The bytes behind an `auth: true` URL. `image/jpeg`, session required, **404 to any other tenant**. You will not construct this URL yourself — it arrives inside an `image` object. ### `GET /api/visitors/{id}/image` The same `image` object for one customer's latest photo. 404 `no_image` (nothing captured) or 404 `images_disabled` (this deployment stores none) — two different absences, because a shop can act on one and not the other. Every hand-out of a photo link is written to the audit log. **Fetch it once per screen**, not once per component: two components asking for the same face put two rows in *"who looked at my customers"* for one glance at one person. --- ## 7. Customers ### `GET /api/visitors?q=…&limit=50` — search `q` matches name, phone, email, or customer number (`42` or `V-42`). Omit `q` for the most recently seen customers. `limit` defaults to 50, capped at 500. Erased customers never appear. ```json [{ "id": "…", "ref": "V-42", "label": "Priya", "full_name": "Priya R", "phone": "+91 …", "email": "", "visit_count": 7, "first_seen_at": "…", "last_seen_at": "…", "has_profile": true, "has_consent": false }] ``` `label` reads `"Visitor 42"` until somebody names them, then whatever they were named. `ref` is what to show beside it. ### `GET /api/visitors/{id}/history` ```json [{ "id": "…", "occurred_at": "…", "site": "TeNext Chennai", "camera_id": "cam1", "is_new_visitor": false, "similarity": 0.71, "quality": 0.66, "attributes": { "gender": "Male", "age": 32, "emotion": "neutral" } }] ``` Newest first. `is_new_visitor` is true on exactly one row — the visit that enrolled them. ### `PUT /api/visitors/{id}/profile` — staff and above ```json { "full_name": "Priya R", "phone": "+91 …", "email": "", "gender": "", "date_of_birth": "", "notes": "Prefers the window table", "consent": true } ``` Whole-object replace. `consent` records that the customer agreed to be recognised; it is kept — revoked, not deleted — through erasure, because the record of what you were permitted to do is what an auditor asks for. ### `POST /api/purchases` — staff and above ```json { "visitor_id": "V-42", "site_id": "chennai", "amount": 1250.00, "currency": "INR", "items": ["…"], "source": "till", "notes": "" } ``` Links a sale to a customer so the conversion report can say who bought. **One currency per report** — see §9. ### `DELETE /api/visitors/{id}` — **erasure**, manager and above Destroys the face template and the photo outright. Keeps the visit rows, unlinked (they are the shop's own footfall history). Keeps the consent record, revoked. Keeps the customer row with a deletion mark so the same face is not re-enrolled next week as a brand-new person. It is irreversible. If the photo cannot be deleted the whole request fails with **502** and *nothing* is erased — so an error there means the data is still there, and must be reported as a failure, never swallowed. --- ## 8. Shops and cameras ### `GET /api/sites` Every shop in the company, with the three facts that tell a quiet week from an unplugged PC. ```json [{ "site_id": "…", "slug": "chennai", "name": "TeNext Chennai", "timezone": "Asia/Kolkata", "online": true, "last_heartbeat_at": "…", "last_event_at": "…", "recognition_model": "w600k_r50", "agent_version": "0.3.0", "cameras_up": 2, "cameras_total": 2, "fraction_below_gate": 0.47, "queued": 0, "dropped": 0 }] ``` - `online` is **three missed heartbeats**, not one. One is a dropped packet. - `fraction_below_gate` is the share of faces the cameras saw that were too poor to use. It is the number that decides whether the footfall figure means anything: under 0.2 is good, under 0.5 is marginal, above is a camera that needs moving. It reports the **worst** camera, not the average. - `queued` is footfall waiting on the shop PC's disk to be sent; `dropped` is footfall lost because that queue overflowed. Non-zero `dropped` is a report that is wrong in a way the report itself cannot show. ### `GET /api/sites/{site}/check` Five ordered steps that answer *is this shop working*, assembled from what head office already knows — so it costs no round trip and works when the PC is off, which is itself one of the answers. ```json { "site_id": "…", "site": "TeNext Chennai", "ok": false, "steps": [ { "name": "The shop's PC is online", "status": "pass", "detail": "…" }, { "name": "Recognition is running", "status": "pass", "detail": "…" }, { "name": "Cameras are connected", "status": "pass", "detail": "2 of 2" }, { "name": "Cameras can recognise faces", "status": "fail", "detail": "47% of faces too poor to enrol", "advice": "…" }, { "name": "Visits are reaching head office","status": "unknown", "detail": "…" } ] } ``` `status` is `pass | warn | fail | unknown`. **Checking stops at the first failure**; later steps report `unknown`, which is its own state — asking whether cameras see faces on a PC that is switched off produces an answer that means nothing. `ok` is true only when every step passed. ### `GET /api/cameras` Every camera in the company — how it is configured **and** whether it is working, in one object, because those are the two halves of the only question anyone asks. ```json [{ "id": "…", "site_id": "…", "site": "TeNext Chennai", "camera_id": "cam1", "label": "Front door", "host": "192.168.1.122", "port": 554, "path": "/ch0_0.264", "username": "admin", "has_password": true, "max_width": 1280, "tuning": {}, "enabled": true, "revision": 6, "connected": true, "last_seen_at": "…", "snapshot": { "available": true, "url": "/api/cameras/…/snapshot.jpg", "auth": true }, "snapshot_at": "…", "check": { "kind": "placement", "state": "done", "ok": false, "verdict": "marginal", "headline": "Half the faces this camera sees are too poor to enrol", "advice": ["Lower the camera to head height", "…"], "detail": { "faces": 31, "fraction_below_gate": 0.47 }, "image": { "available": true, "url": "…", "auth": true } } }] ``` Three things a client must render correctly: - **`has_password`, never the password.** A camera credential is a live path into the camera. The API structurally cannot return it to a user. - **`connected` is a pointer**: `null` means *"no shop PC has reported on this camera yet"*, `false` means *"not connecting"*. A bare `false` says the second when it means the first, and sends an installer to check cabling on a camera nobody has tried to reach. - **`check` is always present.** An empty one (`state` absent) means *never checked*; `state: "done", ok: false` means *checked and failed*. Those are different situations and a client must not guess from an absent field. ### `POST /api/sites/{site}/cameras` — manager ```json { "camera_id": "cam1", "label": "Front door", "host": "192.168.1.122", "port": 554, "path": "/ch0_0.264", "username": "admin", "password": "…", "max_width": 1280 } ``` Returns the `Camera` object. The shop PC picks it up on its next sync (under a minute) and only then can it be tested — until then `connected` is `null`. `camera_id` is what the engine will know it by and what lands on every visit. **It cannot be changed later.** Two shops may each have a `cam1`; one shop cannot. `path` is the field nobody can look up — it is model-specific. The web console fills it in from a make picker (`shared/cameraMakes.js`); an app should offer the same list rather than expecting a shop owner to know `/ch0_0.264`. ### `PATCH /api/cameras/{id}` — manager Same fields, all optional. **An omitted field is left alone; do not send blank strings to mean "unchanged".** In particular, omit `password` unless the user typed a new one — the API never returns the old one, so a form that round-trips an empty field would wipe it on every save. `camera_id` is refused. Every edit bumps `revision`, and the shop PC restarts that camera's connection when it applies it. ### `DELETE /api/cameras/{id}` — manager A tombstone, not a hard delete: the shop PC is told the camera was removed, rather than not told about it — otherwise its next sync would offer the camera back up and it would reappear. ### `POST /api/cameras/{id}/check` — manager ```json { "kind": "connection" } or { "kind": "placement", "seconds": 25 } ``` Returns **202** and the `check` object in `state: "requested"`. Poll `GET /api/cameras` until `state: "done"`. Two different questions, deliberately: - **`connection`** — can the shop PC open the stream. Answers in seconds. - **`placement`** — does somebody walking past produce a view worth enrolling. Runs for `seconds` while a person walks through the frame. This is the one that matters: a camera can pass the first and fail the second, and did, for weeks, at the pilot site. **Only `verdict: "good"` is a pass.** `marginal` means half the visitors are silently discarded, which is not a working camera. The engine's `headline` and `advice` are written for the person standing next to the camera — show them verbatim. A check is a job the shop PC claims. If the PC is off, `state` stays `requested`; after five minutes the server releases it so the button works again. A camera added seconds ago reports that the PC has not set it up yet, rather than pretending it is broken. ### `GET /api/cameras/{id}/snapshot.jpg` The bytes behind a snapshot `url` with `auth: true`. Refreshed by the shop PC about once a minute. Session required. ### `GET /api/cameras/{id}/live` — server-sent events Live view relayed through the shop PC's outbound connection, roughly 13 frames a second of 640-px JPEG. ``` event: waiting data: {} event: frame data: ``` `waiting` arrives immediately and means the request has reached head office and the shop PC has been asked; the first `frame` follows when it answers. **Nothing is uploaded when nobody is watching** — closing the connection stops the shop PC's upload within seconds, and one view is capped at five minutes (reconnect to continue). Open one camera at a time; a grid of live tiles puts an estate's worth of video on the wire because somebody opened a page. ### `POST /api/sites/{site}/enrolment-code` — manager How a new shop PC gets linked to a shop. The installer types this code once. ```json { "label": "till PC", "days": 7 } ← both optional ``` ```json { "code": "SG26HN-WJFMUP-FRFTHW-MQJB33", "site_id": "…", "site_name": "TeNext Chennai", "label": "till PC", "expires_at": "…" } ``` **Single use, shown once, capped at 30 days.** It is read aloud and pasted into chat on its way to a shop, so it is a credential, not a convenience — which is why staff may not mint one. Minting writes an audit row naming who asked. --- ## 9. Reports ### `GET /api/reports/footfall` `?from=2026-09-01&to=2026-09-07&site=chennai&tz=Asia/Kolkata&bucket=day` `bucket` is `hour | day | week | month` (default `day`). `site` optional — omit for the whole company. Dates are `YYYY-MM-DD`; **`to` is inclusive**. ```json { "from": "2026-09-01", "to": "2026-09-07", "bucket": "day", "timezone": "Asia/Kolkata", "points": [{ "bucket": "2026-09-01T00:00:00", "visitors": 41, "new": 12, "returning": 26 }], "total": 183, "visits": 247, "fraction_below_gate": 0.47, "worst_site": "TeNext Chennai" } ``` Three arithmetic traps the API is explicit about, so a client does not reinvent them wrongly: - **`total` is unique people over the window; the chart does not sum to it.** Somebody who came Monday and Thursday is one person and two bucket-visitors. Show the server's `total`, with `visits` underneath. - **`new + returning` can be less than `visitors`.** A shop sending counts without templates records real footfall by an unidentified person, who belongs to neither. Do not force the two to add up. - **`new` means first-ever, not first-in-window.** Otherwise every report re-labels regulars as new customers the day after the window starts. `fraction_below_gate` travels with the numbers because a footfall figure from a badly placed camera is wrong in a way the figure itself cannot show. Surface it next to the total, not in a footnote. **Bucket labels are local wall time with no offset**, in `timezone`. Do not parse them as a `Date` — the viewer's own zone would shift every label by hours. ### `GET /api/reports/conversion` Same parameters. ```json { "visitors": 183, "purchasers": 41, "conversion": 0.224, "revenue": 51250.00, "average_basket": 1250.00, "currency": "INR" } ``` - **`revenue` is summed for ONE currency** — whichever accounts for most of it, named in `currency`. Adding rupees to dollars produces something that looks like money and is not. - **`average_basket` is per basket, not per purchaser.** Someone who bought twice had two baskets. --- ## 10. The assistant ### `POST /api/assistant` A question about the company's shops, answered in plain language from the same data as the reports — never from raw SQL, so it cannot invent the arithmetic above. ```json { "history": [ { "role": "user", "text": "Is everything working today?" }, { "role": "assistant", "text": "…" }, { "role": "user", "text": "Why is footfall low at Chennai?" } ] } ``` ```json { "text": "Chennai is online with both cameras connected, but 47% of the faces …", "used": ["sites", "footfall", "camera_check"] } ``` **The client holds the history and resends it.** The server keeps no transcript — there is no per-user chat log in a database nobody agreed to. Cap it at 24 turns and 2,000 characters per question; the server refuses more. `used` names the tools it consulted. Show them: an assistant that silently ran a camera check is alarming, and naming what it looked at makes a wrong answer traceable rather than mysterious. It acts as the signed-in user — a staff member asking for a placement check is told a manager can. It cannot see another company's shops, structurally. | status | `error` | meaning | |---|---|---| | 501 | `assistant_off` | not configured on this deployment — a normal state, show it as such | | 503 | `assistant_misconfigured` | configured incorrectly; the message names what | --- ## 11. Platform administration — `/api/admin/*` Only an account with `role: "admin"` **and no company**. Everything else gets **404**, not 403 — a tenant user has no business learning this surface exists. ### `GET /api/admin/clients` ```json [{ "id": "…", "slug": "tenext-retail", "name": "TeNext Retail", "sites": 1, "users": 4, "created_at": "…" }] ``` ### `POST /api/admin/clients` Creates a company **and its owner, in one transaction.** A company with no owner is a tenant nobody can sign into, and it looks normal in every list — the operator finds out weeks later when the customer says their login does not work. ```json { "company_name": "TeNext Retail", "slug": "tenext-retail", "owner_email": "suriya@tenext.in", "owner_name": "Suriya", "password": "" } ``` ```json { "client_id": "…", "slug": "tenext-retail", "owner_email": "suriya@tenext.in", "password": "xK9…" } ``` - **Leave `password` empty.** The server generates one. An operator inventing a password for somebody else invents a weak one and sends it over chat. - **`password` is returned exactly once** and is bcrypt-hashed on the way in. Not recoverable. Show it, or send it, immediately. - `slug` is optional and is derived from the name when omitted. It becomes part of the company's message-broker topic, so `/`, `+` and `#` are stripped, and **it can never be changed**. That is the whole admin API. There is no endpoint to delete a company, suspend one, reset an owner's password, or look inside one — those are done by signing in as the company's owner, or by `behavision-server provision` on the server. --- ## 12. Errors ```json { "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." } ``` `message` is written to be shown to a person; prefer it over inventing your own. `error` is the stable code to branch on — **never match on the prose**, which is rewritten freely. | status | meaning | |---|---| | 400 | the request was wrong; `message` says how — including an unknown reference in a query filter | | 401 | not signed in, or `token_expired` → refresh once and retry | | 403 | `forbidden` — signed in, but this role may not; `message` says who can | | 404 | not found — **also** what another tenant's data returns, always; and what admin routes return to non-admins | | 409 | a conflict `message` explains (`last_owner`, duplicate address) | | 429 | `too_many_attempts` | | 501 | the feature is off for this deployment (`assistant_off`, `images_disabled`) — a state, not an error | | 502 | a downstream failure; for erasure it means **nothing was deleted** | | 503 | misconfigured (`assistant_misconfigured`); the message names what | --- ## Not for you: `/api/agent/*` Ten routes under `/api/agent/` are how a **shop PC** talks to head office: enrolling with an installation code, pulling its camera list (with passwords — it is the thing that has to connect), reporting camera state, uploading snapshots and face images, claiming and answering check jobs, and relaying live video. They authenticate with the PC's own token, issued once at enrolment, and a user session is refused. A web or mobile client never calls them. They are listed here so nobody wonders what they are.