Files
Behavision/API.md
Suriyakumarvijayanayagam 177584e812 Record what the live system actually does, measured not assumed
Production had 1,211 events accepted and six recognised customers from
the office cameras - the first time the whole chain has carried a real
person, and the project had never been able to claim it. Repeat
sightings score 0.44-0.72, a distribution the match threshold sits
clearly below, on the head-height camera this file has recommended since
August. fraction_below_gate is still 0.59, so the visit count is a floor
and the report says so beside it.

The face-image chain was exercised on production as a shop PC does it -
upload URL, PUT to object storage, anonymous read refused 403. Every
server link holds; the only reason a customer has no photo is
app.store_faces being false by default, which is a data-protection
decision rather than a gap.

Sixteen mobile-API checks pass as a staff account. Three apparent bugs
were test errors and are written down so nobody re-files them, along
with the one field name a caller could guess wrong (site_token).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-24 13:14:14 +05:30

1133 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Behavision API — for the web console, a mobile app, and platform administration
Base URL: `https://mcp.loyaly.ai` — the API host. (`platform.loyaly.ai` serves the head-office web console, not the API.)
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, create / invite / reset / 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` — open a shop · `DELETE /api/sites/{site}` — remove an empty one | owner |
| `POST /api/sites/{site}/enrolment-code` | manager |
| `GET /api/team` | authed (tenant users only) |
| `POST /api/team/members` · `POST /api/team/{id}/password` · `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` · `PATCH /api/admin/clients/{id}` · `POST /api/admin/clients/{id}/owner-password` · `DELETE /api/admin/clients/{id}` | **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 ┌──────────────────┐ creates ┌──────────────────┐
│ Platform admin │ ───────────▶ │ Merchant owner │ ───────────▶ │ Sales staff │
│ (web) │ company + │ (web) │ login, pw │ (mobile) │
│ │ owner login │ │ shown once │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
POST /api/admin/clients POST /api/team/members POST /api/auth/login
(or /api/team/invitations → (or /api/auth/register
a code they redeem themselves) with the code)
```
### 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
PATCH /api/admin/clients/{id} {"active": false} → suspend (or true: reinstate)
POST /api/admin/clients/{id}/owner-password → new owner password, shown once
DELETE /api/admin/clients/{id} {"confirm": "<slug>"} → delete a SUSPENDED company
```
### Tier 2 — the merchant owner registers sales staff
Signed in as the owner (or any manager). Two ways to do it; use whichever fits
the moment.
**Directly — create the login and hand it over.** For a salesperson being set
up before their first shift, without a phone in hand. Exactly how the admin
created the merchant in Tier 1.
```
POST /api/auth/login
{ "email": "suriya@tenext.in", "password": "xK9…", "device": "Head office" }
POST /api/team/members
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff" }
→ 201 { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
"role": "staff", "active": true, "created_at": "…",
"password": "m4kq…" } ← shown ONCE
```
Leave `password` out and one is generated; give one and it is used (8
characters minimum). Either way it is returned exactly once — write it on the
card now. The salesperson signs in on their phone with that email and
password, and the merchant login is done.
**By invitation — the salesperson chooses their own password.** Better when
they have their phone: the merchant never sees or handles a staff password.
```
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. Single-use, expires. They redeem it in
Tier 3 and pick a password there.
**When they forget it** — which is the everyday case on a shop floor:
```
POST /api/team/{id}/password { } or { "password": "chosen" }
→ 200 { "password": "n7xw…" } ← shown ONCE
```
Resets the password **and signs them out of every device** in one step,
because the other reason a manager resets a password is a lost phone, and a
reset that left that phone signed in would look complete while fixing nothing.
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 opens shops and sets them up from the same login — `POST
/api/sites` to open one, `POST /api/sites/{site}/enrolment-code` for its
shop PC, `POST /api/sites/{site}/cameras` for cameras — see §8.
### Tier 3 — the salesperson gets their mobile login
If the merchant created the login directly, they already have an email and
password: skip straight to `POST /api/auth/login` below. Otherwise they have a
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
- **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.
- **A shop with visit history cannot be deleted**, only its cameras removed.
`DELETE /api/sites/{site}` is for the shop opened by mistake (no visits, no
cameras); taking away footfall and faces is an erasure decision, and there
is no endpoint for it yet.
---
## 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
PATCH /api/admin/clients/{id} → suspend / reinstate
POST /api/admin/clients/{id}/owner-password → reset the owner's password
DELETE /api/admin/clients/{id} → delete, once suspended
```
That is the whole admin surface. 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-<number>` | `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 <access_token>` 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": "…" }]
```
### `POST /api/team/members` — manager or owner
Create a login directly and hand it over. The alternative to an invitation
(§2) for somebody without a phone in hand.
```json
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
"password": "" }
```
```json
{ "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
"role": "staff", "active": true, "last_login_at": "", "created_at": "…",
"password": "m4kq…" }
```
- **`password` is returned once** and is not recoverable. Leave it empty in
the request and one is generated; supply one and it must be 8+ characters.
- `role` is `staff` (default), `manager` or `owner`. Only an owner may create
an owner; `admin` is refused.
- **409 `conflict`** if that email already has an account anywhere.
### `POST /api/team/{id}/password` — manager or owner
```json
{ } or { "password": "chosen-one" }
```
```json
{ "password": "n7xw…" }
```
Sets a new password (generated unless given) and **revokes every session the
member holds**, in one transaction. Returns the new password once. A member of
another company is **404**, never 403.
There is deliberately no self-service reset and no reset-by-email: a shop-floor
account often has no mailbox anyone checks, and the person who can vouch for
the salesperson standing in front of them is their manager.
### `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 `<img>` 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.
### `POST /api/sites` — open a shop (owner)
```
{ "name": "TeNext Bengaluru", "slug": "bengaluru", "timezone": "Asia/Kolkata" }
→ 201 { "site_id": "…", "slug": "bengaluru", "name": "TeNext Bengaluru",
"timezone": "Asia/Kolkata", "broker_username": "tenext-retail.bengaluru" }
```
`slug` and `timezone` are optional: the slug is made from the name (lower-case,
digits and dashes, 3–32 characters) and the timezone defaults to Asia/Kolkata.
The slug is the shop PC's identity and an MQTT topic segment; it **cannot be
changed afterwards**. The server registers the shop's broker login with
Mosquitto in the same request, so the next step is simply
`POST /api/sites/{slug}/enrolment-code` for the PC.
| status | code | meaning |
|---|---|---|
| 403 | `forbidden` | not the owner |
| 409 | `conflict` | a shop with that slug exists |
| 502 | `broker_unavailable` | the broker did not accept the login; **nothing was created** — try again |
| 503 | `broker_unavailable` / `no_encryption_key` | this server cannot create shops; contact support |
### `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: <base64 JPEG>
```
`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.
---
### `PATCH /api/admin/clients/{id}` — suspend or reinstate
```
{ "active": false }
→ 200 { "client": { "id": "…", "slug": "acme", "active": false, "sites": 2, "users": 5, … },
"sessions_revoked": 3 }
```
Suspension is complete the moment it returns: the company's users cannot sign
in, every session they hold is revoked in the same transaction (so a live
access token stops working now, not at expiry), and visits from its shop PCs
are dropped at ingest. `{"active": true}` reinstates; sessions are not
restored — people sign in again.
### `POST /api/admin/clients/{id}/owner-password` — reset the owner's password
```
{ "email": "owner@acme.com" } ← optional when the company has exactly one owner
→ 200 { "email": "owner@acme.com", "password": "n7xw…" } ← shown ONCE
```
For the owner who has locked themselves out with nobody above them. Generated,
never chosen; every session that owner held is revoked. With several owners
and no `email`, 400 listing them.
### `DELETE /api/admin/clients/{id}` — delete a company
```
{ "confirm": "acme" }
→ 200 { "deleted": "acme", "images_deleted": 12 }
```
Irreversible, and the data is biometric, so it is a two-step decision: the
company must already be **suspended** (`409 still_active` otherwise) and the
body must repeat its slug. Stored face images are deleted from object storage
first — a failure there is `502 storage_error` and nothing else is touched —
then the shop PCs' broker logins, then every row (templates, visits, users,
sessions, cameras) by cascade.
## 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.
One field name, because it is the only one in the product that is easy to guess
wrong: `POST /api/agent/enrol` takes **`site_token`** (the installation code),
not `code`, plus an optional `device`. Verified against production.