Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
1273 lines
52 KiB
Markdown
1273 lines
52 KiB
Markdown
# 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 |
|
||
| `POST /api/auth/password` — change your OWN | authed (platform admins too) |
|
||
| `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` · `GET /api/sales` · `GET /api/sales/{id}` · `GET /api/dashboard/summary` | 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** |
|
||
| `GET /api/admin/clients/{id}` · `…/{id}/sites` · `…/sites/{site}` · `…/sites/{site}/cameras` · `…/cameras/{camera}` · `GET /api/admin/monitoring/summary` | **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.
|
||
|
||
`authed` above means any signed-in user **of a company**. A platform admin has
|
||
no company — that absence is what defines one — so a company's own routes
|
||
answer them **403 `not_a_tenant_account`**, naming the `/api/admin/clients/{id}/…`
|
||
route that reads the same data. Their own `/api/auth/*` keeps working: a session
|
||
is not a company's data, and revoking a lost device must not depend on having a
|
||
tenant.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
### `POST /api/auth/password` — any signed-in account
|
||
|
||
Change your own password. Works for **every** account including a platform
|
||
admin, who has no company and therefore cannot be reached by the team routes.
|
||
|
||
```json
|
||
{ "current_password": "...", "new_password": "..." }
|
||
```
|
||
|
||
```json
|
||
{ "changed": true, "sessions_revoked": 3 }
|
||
```
|
||
|
||
- **The current password is required.** An access token lives twelve hours and
|
||
travels on shop-floor PCs and staff phones; without this a stolen one would
|
||
own the account permanently rather than until it expires. Wrong current
|
||
password is **403 `wrong_password`** and changes nothing.
|
||
- **Every other session is revoked; the caller's is kept.** Somebody changing
|
||
their password because they think it is known must not wonder whether the
|
||
device that already had it is still signed in — and must not be signed out of
|
||
the one in their hand while dealing with it.
|
||
- A new password under the floor is **400**, and so is reusing the current one.
|
||
|
||
This is the route to use rather than asking an administrator. `POST
|
||
/api/team/{id}/password` remains what a manager uses on somebody *else*.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### `GET /api/sales` — anyone in the company
|
||
|
||
The purchases behind the conversion report. That report has always summed this
|
||
table; until 28 Sep nothing could read a row of it, so "revenue was 41,000"
|
||
could not be checked against a till.
|
||
|
||
Takes the same window as a report: `from`, `to` (`YYYY-MM-DD`), `site` or
|
||
`site_id` (slug or uuid), plus `customer` (`V-42` or a uuid) and `limit`
|
||
(default 50, max 200). An unknown shop or customer is a **400**, not a silently
|
||
ignored filter.
|
||
|
||
```json
|
||
[{ "id": "8525ef18-…", "occurred_at": "2026-09-19T10:48:44Z",
|
||
"site_id": "93d0565f-…", "site": "TeNext Coimbatore", "site_slug": "chennai",
|
||
"amount": 1000, "currency": "INR",
|
||
"visitor_id": "ba5e5d2e-…", "visitor_ref": "V-1", "visitor_label": "Visitor 1",
|
||
"visit_id": "3bcd53ca-…", "items": ["Headphones"], "source": "manual" }]
|
||
```
|
||
|
||
- **A sale with no `visitor_id` is listed**, not joined away. An unidentified
|
||
walk-in is still revenue, and hiding it would make this disagree with the
|
||
conversion report computed over the same rows.
|
||
- `items` is always an array, never `null`.
|
||
- **No cursor, deliberately.** A keyset cursor needs a monotonic
|
||
server-assigned column and `purchases` has none; ordering by
|
||
`(occurred_at, id)` with a random uuid tie-break is the shape that silently
|
||
dropped visits from the arrivals feed before `visits.seq` existed. Narrow by
|
||
date and `limit` instead.
|
||
|
||
### `GET /api/sales/{id}`
|
||
|
||
One sale, same shape. Another company's sale is **404**.
|
||
|
||
### `GET /api/dashboard/summary` — anyone in the company
|
||
|
||
The home screen in one call, so a client does not combine four.
|
||
|
||
Takes `site`/`site_id` and `tz` (IANA, default the company's). "Today" is cut
|
||
in **that timezone** — a dashboard that says today and means UTC is five and a
|
||
half hours wrong in India.
|
||
|
||
```json
|
||
{ "date": "2026-09-28", "visitors": 0, "visits": 0,
|
||
"sites_total": 4, "sites_online": 0, "cameras_total": 1, "cameras_up": 1,
|
||
"fraction_below_gate": 0.59, "worst_site": "TeNext Coimbatore",
|
||
"timezone": "Asia/Kolkata" }
|
||
```
|
||
|
||
`visitors` is unique people and `visits` is arrivals — **do not add the daily
|
||
bars of a footfall report to get either.** `fraction_below_gate` travels with
|
||
them because it is what says whether the count is a number or a floor.
|
||
|
||
### `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.
|
||
|
||
### The drill-down: `GET /api/admin/clients/{id}` and below
|
||
|
||
A platform admin has **no company**, so the tenant routes cannot serve this —
|
||
they scope by the signed-in account's client, and an admin has none. These take
|
||
the merchant in the path instead. `{site}` accepts a slug or a uuid; `{camera}`
|
||
accepts the engine's camera id or a uuid.
|
||
|
||
| | |
|
||
|---|---|
|
||
| `GET /api/admin/clients/{id}` | the list row plus `owner_email`, `owner_name` |
|
||
| `GET …/{id}/sites` | same shape as a tenant's `GET /api/sites` |
|
||
| `GET …/{id}/sites/{site}` | one of them |
|
||
| `GET …/{id}/sites/{site}/cameras` | **redacted** — see below |
|
||
| `GET …/{id}/sites/{site}/cameras/{camera}` | one of them |
|
||
| `GET /api/admin/monitoring/summary` | `cameras_total`, `cameras_online`, `merchants_active`, `sites_total`, `events_today`, `as_of` |
|
||
|
||
**Camera rows here are a different shape from `GET /api/cameras`** and carry no
|
||
`host`, `port`, `path`, `username` or `has_password`. A company seeing those
|
||
for its own camera is correct; a platform admin browsing somebody else's estate
|
||
is a different question, and an RTSP host with a username beside it is most of
|
||
a live path into that customer's camera.
|
||
|
||
```json
|
||
[{ "id": "3a96a742-…", "site_id": "93d0565f-…", "site": "TeNext Coimbatore",
|
||
"camera_id": "cam2", "label": "Open office", "enabled": true,
|
||
"connected": true, "last_seen_at": "2026-09-24T08:33:10Z",
|
||
"snapshot_at": "2026-09-24T08:33:10Z", "check": { … } }]
|
||
```
|
||
|
||
`connected` is still three states: `null` = no shop PC has reported yet,
|
||
`false` = not connecting, `true` = up.
|
||
|
||
A shop or camera belonging to a **different** merchant is **404**, never an
|
||
empty list — `[]` would say "this shop has no cameras" when the truth is "not
|
||
your shop". A suspended merchant stays readable; that is what an admin opens
|
||
the console to see. Every read below the merchant list is written to
|
||
`audit_log`; the counts-only summary is not.
|
||
|
||
**Not built, and each is a decision rather than a missing handler:**
|
||
|
||
- `…/events` and `…/alerts` — there is no events table and the shop PC
|
||
deliberately does not send diagnostics (`camera.up`, `person.missed`) to the
|
||
server. This needs that decision, a table and a retention policy first; an
|
||
endpoint now would return `[]` forever.
|
||
- `POST /api/admin/assistant` — the assistant's tools take no client id by
|
||
design, which is what makes cross-tenant access impossible rather than merely
|
||
disallowed. An admin one needs a principal scoped to the merchant being
|
||
viewed, which weakens that. Deliberately not done quietly.
|
||
|
||
---
|
||
|
||
## 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. Also `not_a_tenant_account`: a **platform admin** calling a company's own route, who reads that data through `/api/admin/clients/{id}/…` instead |
|
||
| 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.
|