API.md documented 27 routes; the server has 48
A mobile developer builds against this file, so a gap in it is a gap in
the app. Checked route by route against the mux: nineteen routes had no
entry at all, including the ENTIRE platform-admin surface, adding and
checking cameras, issuing shop-PC installation codes, the assistant, and
the face bytes endpoint. Most of what was documented had no response
shape - a client had to guess the field names for shops, cameras, team,
customers, history and both reports.
Every shape here is now taken from the server's own types, and the
uncertain claims were checked against the handlers rather than written
from memory: check requests return 202, history is newest first, the
visitor list is most-recently-seen first and excludes the erased, an
admin slug is derived from the company name when omitted.
Restructured by audience, because "who may call this" was scattered:
- three callers named up front - merchant, platform admin, shop PC -
and what each one signs in with and sees
- the three merchant roles and what each adds, taken from
CanWriteProfiles / CanManageSites rather than paraphrased
- a permission matrix: every route and the least role that may call it
- quick starts for the three clients that will actually be written:
a floor app for staff, a console for owners, and admin
- /api/agent/* listed once as "not for you", so nobody wonders
The prose that explained WHY - refresh rules, the cursor, photos as data
not errors, the report arithmetic - is kept; that is the part a client
developer cannot get from the code.
Also recorded plainly: the admin API is two endpoints. There is no way
to suspend a company, delete one, or reset an owner's password over
HTTP. Written down rather than left for someone to discover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -62,3 +62,6 @@ node_modules/
|
||||
# reproducible Windows build needs. Only the compiled output is ignored.
|
||||
/desktop/frontend/wailsjs/
|
||||
/desktop/frontend/package.json.md5
|
||||
|
||||
# Left behind by `pip install .` of the engine (setuptools metadata), not source.
|
||||
/behavision.egg-info/
|
||||
|
||||
620
API.md
620
API.md
@@ -1,14 +1,112 @@
|
||||
# Behavision API — for the web console and a mobile app
|
||||
# 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -19,14 +117,14 @@ is not something a person can say, type or recognise, so **anywhere a path or a
|
||||
|---|---|---|
|
||||
| customer | `V-<number>` | `V-42` — also accepts bare `42` |
|
||||
| shop | its slug | `chennai` |
|
||||
| camera | the id the engine knows it by | `Office1` |
|
||||
| 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/Office1
|
||||
PATCH /api/cameras/cam1
|
||||
```
|
||||
|
||||
The customer number is **per company**, so `V-42` at one tenant and `V-42` at
|
||||
@@ -35,7 +133,7 @@ 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 `Office1`. That is
|
||||
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.
|
||||
|
||||
@@ -49,9 +147,6 @@ 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.
|
||||
|
||||
The uuid is still returned everywhere and still works. Use it if you want a key
|
||||
you never have to think about; use the reference when a person will read it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Signing in
|
||||
@@ -67,20 +162,19 @@ you never have to think about; use the reference when a person will read it.
|
||||
"access_token": "…",
|
||||
"refresh_token": "…",
|
||||
"expires_at": "2026-09-05T18:00:00Z",
|
||||
"user": { "id": "…", "email": "…", "full_name": "Priya R",
|
||||
"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.
|
||||
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.
|
||||
|
||||
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`. |
|
||||
@@ -132,9 +226,9 @@ and hands it over; the holder chooses their own password.
|
||||
```
|
||||
|
||||
```json
|
||||
{ "id": "…", "email": "arjun@tenext.in", "role": "manager",
|
||||
{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager",
|
||||
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
|
||||
"expires_at": "…", "created_at": "…" }
|
||||
"invited_by": "suriya@tenext.in", "expires_at": "…", "created_at": "…" }
|
||||
```
|
||||
|
||||
**`code` is returned exactly once and is not recoverable.** Only a hash is
|
||||
@@ -178,9 +272,10 @@ wrong code) does **not** spend the invitation.
|
||||
| 404 | `invalid_code` |
|
||||
| 409 | `conflict` — that address already has an account; sign in instead |
|
||||
|
||||
### `GET` / `DELETE /api/team/invitations[/{id}]` — manager or owner
|
||||
### `GET /api/team/invitations` · `DELETE /api/team/invitations/{id}` — manager or owner
|
||||
|
||||
List what is still pending, or withdraw one before it is used.
|
||||
List what is still pending (same shape as above, **without** `code`), or
|
||||
withdraw one before it is used.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,21 +297,34 @@ 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.
|
||||
deactivate them (§4); 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}` |
|
||||
### `GET /api/team` — anyone in the company
|
||||
|
||||
Deactivating signs that person out **immediately** and stops them signing back
|
||||
in. Reactivating restores the account but not their old sessions.
|
||||
```json
|
||||
[{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun",
|
||||
"role": "manager", "active": true,
|
||||
"last_login_at": "…", "created_at": "…" }]
|
||||
```
|
||||
|
||||
409 `last_owner` if the change would leave the company with no active owner.
|
||||
### `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.
|
||||
|
||||
---
|
||||
|
||||
@@ -232,7 +340,7 @@ in. Reactivating restores the account but not their old sessions.
|
||||
"visit_id": "…",
|
||||
"occurred_at": "2026-09-05T06:01:45Z",
|
||||
"site_id": "…", "site": "TeNext Chennai", "site_slug": "chennai",
|
||||
"camera_id": "Office1",
|
||||
"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" },
|
||||
@@ -253,22 +361,20 @@ again without one.
|
||||
|
||||
An empty poll returns your own cursor back, not an empty string.
|
||||
|
||||
**There is no `seq` on the wire.** It existed as a convenience for "have I
|
||||
fallen behind"; `visits.seq` is a plain bigserial, so it counted every visit on
|
||||
the *platform* and put the total footfall of every customer we have on every row
|
||||
of every tenant's feed. The cursor — opaque and version-prefixed — is the
|
||||
supported way to know your position, and the only one you need.
|
||||
|
||||
Of the three ids on an arrival, only one of them is a reference you would type:
|
||||
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 `EventSource` will not do —
|
||||
read the stream with an HTTP client) and resume with `Last-Event-ID` or
|
||||
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.
|
||||
|
||||
@@ -292,7 +398,7 @@ 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 …` |
|
||||
| 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 …' } }}`.
|
||||
@@ -303,6 +409,12 @@ how you tell them apart. Do not infer it from the shape of the URL.
|
||||
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
|
||||
@@ -317,59 +429,400 @@ two rows in *"who looked at my customers"* for one glance at one person.
|
||||
|
||||
## 7. Customers
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/visitors?q=…` | search by name, phone, or customer number (`42`, `V-42`) |
|
||||
| `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 |
|
||||
### `GET /api/visitors?q=…&limit=50` — search
|
||||
|
||||
`{id}` is a uuid **or** `V-42` **or** `42`. Every customer object carries `ref`
|
||||
("V-42") beside `id`, and `label` reads "Visitor 42" until somebody names them.
|
||||
`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.
|
||||
|
||||
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.
|
||||
```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, cameras, reports
|
||||
## 8. Shops and cameras
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `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 |
|
||||
### `GET /api/sites`
|
||||
|
||||
**`site` and `site_id` are both accepted everywhere**, and either may be a slug
|
||||
or a uuid. They used to differ per endpoint, which mattered because an unknown
|
||||
query parameter is silently ignored — so getting it the wrong way round returned
|
||||
the whole estate instead of an error. `site` is the documented spelling.
|
||||
Every shop in the company, with the three facts that tell a quiet week from an
|
||||
unplugged PC.
|
||||
|
||||
Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the
|
||||
7th.
|
||||
```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 }]
|
||||
```
|
||||
|
||||
Two arithmetic traps the API is explicit about, so a client does not reinvent
|
||||
- `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: <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 the total.** A site sending counts
|
||||
without templates records real footfall by an unidentified person, which
|
||||
belongs to neither.
|
||||
- **`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.
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
---
|
||||
|
||||
## 9. Errors
|
||||
## 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." }
|
||||
@@ -381,15 +834,26 @@ rewritten freely.
|
||||
|
||||
| status | meaning |
|
||||
|---|---|
|
||||
| 400 | the request was wrong; `message` says how |
|
||||
| 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 | signed in, but this role may not |
|
||||
| 404 | not found — **also** what another tenant's data returns, always |
|
||||
| 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 | throttled |
|
||||
| 501 | the feature is off for this deployment, not an error |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user