# Behavision API — for the web console and a mobile app 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. 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. --- ## 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": "…", "full_name": "Priya R", "role": "staff", "client_id": "…", "client_name": "TeNext Retail" } } ``` Send `Authorization: Bearer ` on every other call. **`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. Failures: | 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", "role": "manager", "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", "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` / `DELETE /api/team/invitations[/{id}]` — manager or owner List what is still pending, 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 (below); that revokes every session they hold. --- ## 4. The team | | | |---|---| | `GET /api/team` | everybody in this company | | `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` | Deactivating signs that person out **immediately** and stops them signing back in. Reactivating restores the account but not their old sessions. 409 `last_owner` if the change would leave the company with no active owner. --- ## 5. Who just walked in — the screen a mobile app is for ### `GET /api/visits` `?limit=50&cursor=…&site_id=…` ```json { "arrivals": [{ "visit_id": "…", "seq": 412, "occurred_at": "2026-09-05T06:01:45Z", "site_id": "…", "site": "TeNext Chennai", "camera_id": "Office1", "visitor_id": "…", "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. ### `GET /api/visits/stream` — server-sent events The same rows, pushed. Send `Authorization` (so `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 | `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/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=…` | search by name or phone | | `GET /api/visitors/{id}/history` | their past visits | | `PUT /api/visitors/{id}/profile` | name, phone, notes — staff and above | | `DELETE /api/visitors/{id}` | **erasure** — manager and above | | `POST /api/purchases` | link a sale to a visit | Erasure destroys the face template and the photo outright and keeps the visit rows, unlinked. 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, cameras, reports | | | |---|---| | `GET /api/sites` | estate health: online, cameras up, `fraction_below_gate` | | `GET /api/sites/{id}/check` | five-step smoke test for one shop | | `GET /api/cameras` | cameras and their latest still | | `GET /api/cameras/{id}/live` | live view relayed from the shop PC (SSE) | | `GET /api/reports/footfall` | `?from=&to=&site=&tz=&bucket=` | | `GET /api/reports/conversion` | same parameters; revenue and basket size | **The site parameter is spelled differently here.** Reports take `site`; the arrivals feed takes `site_id`. That is a wart, not a rule — but an unknown query parameter is silently ignored, so getting it wrong returns the whole estate rather than an error. Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the 7th. Two 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 the total.** A site sending counts without templates records real footfall by an unidentified person, which belongs to neither. Report buckets are **local wall time with no offset**, labelled by `timezone`. Do not parse them as a `Date` — the viewer's own zone would shift every label. --- ## 9. 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 | | 401 | not signed in, or `token_expired` → refresh once and retry | | 403 | signed in, but this role may not | | 404 | not found — **also** what another tenant's data returns, always | | 409 | a conflict `message` explains (`last_owner`, duplicate address) | | 429 | throttled | | 501 | the feature is off for this deployment, not an error | | 502 | a downstream failure; for erasure it means **nothing was deleted** | Roles, in increasing order: `staff` → `manager` → `owner`. A platform admin has `role: "admin"` **and an empty `client_id`** — the two together, never the role alone.