Files
Behavision/API.md
Suriyakumarvijayanayagam ce0223006b References are immutable, because clients now store them
012 turned three descriptive columns into identifiers other systems
keep: in agent.json on a shop counter, in a saved URL, in a scheduled
report. All three were already treated as stable and none of it was
enforced.

- clients.slug is an MQTT topic segment the broker ACL is written
  against. Rename one and that tenant's whole estate is silently refused
  by the broker, with no way to tell the agents.
- sites.slug is what a shop PC calls itself - agent.json holds
  "site_id": "chennai", never the uuid. A rename orphans the PC from the
  shop it is standing in.
- site_cameras.camera_id lands in visits.camera_id, which is text and
  not a foreign key. A rename orphans every visit already attributed to
  the old name: the footfall is still there and no longer joins to a
  camera. This was half-enforced in handleUpdateCamera and nowhere else,
  which is the shape of a rule that holds until somebody adds a second
  write path.
- visitors.number is assigned once from the tenant's counter and read
  back as V-42.

A trigger, not a CHECK: a CHECK cannot see the old row and the rule is
about the transition. The DISPLAY name is deliberately not frozen -
"TeNext Chennai", "Front door" - it is what a person reads, nothing keys
on it, and a system that cannot fix a typo in a shop's name has confused
the two.

Also records why the uuid stays where a slug would do. The length was
never the problem; needing it was, and that is fixed. Replacing it would
touch eight foreign keys on a live database to shorten a field clients
are already told not to use, and a sequential id would make any future
tenancy hole walkable by counting. It is NOT because ids must be minted
offline - sites, visitors and visits are all created server-side with a
database in hand, and claiming otherwise would defend the status quo
rather than explain it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-07 12:17:49 +05:30

396 lines
15 KiB
Markdown

# Behavision API — for the web console and a mobile app
Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`).
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
otherwise.
There is **one API**, not a web one and a mobile one. The web console in this
repository uses exactly these calls; anything it can do, an app can do.
---
## 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 | `Office1` |
| 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
```
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 `Office1`. 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.
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
### `POST /api/auth/login`
```json
{ "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" }
```
```json
{
"access_token": "…",
"refresh_token": "…",
"expires_at": "2026-09-05T18:00:00Z",
"user": { "id": "…", "email": "…", "full_name": "Priya R",
"role": "staff", "client_id": "…", "client_name": "TeNext Retail" }
}
```
Send `Authorization: Bearer <access_token>` on every other call.
**`device` is worth sending.** It is the only thing that lets somebody look at
their list of signed-in devices and tell which one to sign out. Keep it coarse
and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a
fingerprint here is a tracking signal nobody asked for.
Failures:
| status | `error` | what it means |
|---|---|---|
| 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. |
| 429 | `too_many_attempts` | 10 failures per account / 60 per IP in 15 minutes. Cleared by a success. |
### `POST /api/auth/refresh`
```json
{ "refresh_token": "…", "device": "Pixel 8" }
```
Returns the same shape. **Both tokens rotate** — the old refresh token stops
working the instant the new one is issued, so a copy taken off a resold device
cannot keep working alongside the real one.
Three rules a client must follow, and all three have already been the cause of a
bug in this codebase:
1. **An expired access token returns 401 with `"error": "token_expired"`**,
distinct from a real 401. Refresh once and retry, silently — otherwise staff
are thrown back to a login form twice a day.
2. **Serialise refresh behind one lock.** The refresh token is single use, so
four screens polling at once would each spend it and three would lose,
logging the user out at random.
3. **Persist the rotated tokens before doing anything else.** A client that
refreshes and is then killed comes back holding a token the server has
already invalidated — indistinguishable from a normal expiry, at the worst
possible moment.
Marshal the request body **before** the first attempt: a retry has to send it
again, and a stream is spent after the first read.
### `POST /api/auth/logout` · `GET /api/auth/me`
Logout revokes the calling session. `me` returns the `user` object above.
---
## 2. Joining — how somebody gets an account
There is **no open registration**, by design. A manager or owner mints a code
and hands it over; the holder chooses their own password.
### `POST /api/team/invitations` — manager or owner
```json
{ "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager",
"expires_in_days": 7 }
```
```json
{ "id": "…", "email": "arjun@tenext.in", "role": "manager",
"code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
"expires_at": "…", "created_at": "…" }
```
**`code` is returned exactly once and is not recoverable.** Only a hash is
stored. Show it immediately; do not expect to read it back.
`role` is `staff`, `manager` or `owner`. Only an owner may mint an owner.
`admin` is not accepted at all.
### `GET /api/auth/invitation?code=…` — **no auth**
```json
{ "client_name": "TeNext Retail", "email": "arjun@tenext.in",
"full_name": "Arjun", "role": "manager" }
```
Call this before asking anyone to choose a password, so the screen can say what
they are joining and a mistyped code is caught early. Unknown, expired, spent
and withdrawn all return **404 `invalid_code`** with one message.
### `POST /api/auth/register` — **no auth**
```json
{ "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6",
"full_name": "Arjun", "password": "…", "device": "Pixel 8" }
```
Returns **201** and a full session — the same shape as login. Sign the person
straight in; do not send them to a login form.
**Do not send `email` or `role`.** They come from the invitation, and the
request is rejected outright if it names either. That is what stops a forwarded
code becoming somebody else's account, or a staff invitation being redeemed as
an owner.
Dashes and case in the code are ignored. A rejected attempt (short password,
wrong code) does **not** spend the invitation.
| status | `error` |
|---|---|
| 400 | password under 8 characters, or a body naming `email`/`role` |
| 404 | `invalid_code` |
| 409 | `conflict` — that address already has an account; sign in instead |
### `GET` / `DELETE /api/team/invitations[/{id}]` — manager or owner
List what is still pending, or withdraw one before it is used.
---
## 3. Devices
| | |
|---|---|
| `GET /api/auth/sessions` | this account's signed-in devices |
| `DELETE /api/auth/sessions/{id}` | sign one out, immediately |
| `POST /api/auth/sessions/revoke-others` | sign out everywhere else |
```json
[{ "id": "…", "device": "Pixel 8", "created_at": "…",
"last_used_at": "…", "expires_at": "…", "current": true }]
```
`current` marks the session making the request — label it, and warn before
somebody signs out the device in their hand. `revoke-others` deliberately keeps
the caller's own session.
A person can revoke only their own sessions. To remove a colleague's access,
deactivate them (below); that revokes every session they hold.
---
## 4. The team
| | |
|---|---|
| `GET /api/team` | everybody in this company |
| `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` |
Deactivating signs that person out **immediately** and stops them signing back
in. Reactivating restores the account but not their old sessions.
409 `last_owner` if the change would leave the company with no active owner.
---
## 5. Who just walked in — the screen a mobile app is for
### `GET /api/visits`
`?limit=50&cursor=…&site=…` (`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": "Office1",
"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.
**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:
`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.
### `GET /api/visits/stream` — server-sent events
The same rows, pushed. Send `Authorization` (so `EventSource` will not do —
read the stream with an HTTP client) and resume with `Last-Event-ID` or
`?cursor=`. Falls back to polling cleanly; the failure mode is latency, never
silence.
---
## 6. Photos
**A missing photo is data, not an error.** Images are off by default across the
whole product, so on most deployments every arrival legitimately has none. Show
initials or a placeholder — a screen of red for a system working as configured
is a screen whose real errors get ignored.
```json
"image": { "available": false,
"reason": "This system is not storing customer photos." }
```
When a photo **is** available there are two kinds of URL, and the `auth` flag is
how you tell them apart. Do not infer it from the shape of the URL.
| | `auth` | how to load it |
|---|---|---|
| presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds |
| served by this API | `true` | send `Authorization: Bearer …` |
- **Mobile**: an image view can attach the header —
`Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`.
- **Web**: an `<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/visitors/{id}/image`
The same `image` object for one customer's latest photo. 404 `no_image` (nothing
captured) or 404 `images_disabled` (this deployment stores none) — two different
absences, because a shop can act on one and not the other.
Every hand-out of a photo link is written to the audit log. **Fetch it once per
screen**, not once per component: two components asking for the same face put
two rows in *"who looked at my customers"* for one glance at one person.
---
## 7. Customers
| | |
|---|---|
| `GET /api/visitors?q=…` | search by name, 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 |
`{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.
Erasure destroys the face template and the photo outright and keeps the visit
rows, unlinked. It is irreversible. If the photo cannot be deleted the whole
request fails with **502** and *nothing* is erased — so an error there means the
data is still there, and must be reported as a failure, never swallowed.
---
## 8. Shops, cameras, reports
| | |
|---|---|
| `GET /api/sites` | estate health: online, cameras up, `fraction_below_gate` |
| `GET /api/sites/{id}/check` | five-step smoke test for one shop |
| `GET /api/cameras` | cameras and their latest still |
| `GET /api/cameras/{id}/live` | live view relayed from the shop PC (SSE) |
| `GET /api/reports/footfall` | `?from=&to=&site=&tz=&bucket=` |
| `GET /api/reports/conversion` | same parameters; revenue and basket size |
**`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.
Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the
7th.
Two arithmetic traps the API is explicit about, so a client does not reinvent
them wrongly:
- **`total` is unique people over the window; the chart does not sum to it.**
Somebody who came Monday and Thursday is one person and two bucket-visitors.
Show the server's `total`, with `visits` underneath.
- **`new + returning` can be less than the total.** A site sending counts
without templates records real footfall by an unidentified person, which
belongs to neither.
Report buckets are **local wall time with no offset**, labelled by `timezone`.
Do not parse them as a `Date` — the viewer's own zone would shift every label.
---
## 9. Errors
```json
{ "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." }
```
`message` is written to be shown to a person; prefer it over inventing your own.
`error` is the stable code to branch on — **never match on the prose**, which is
rewritten freely.
| status | meaning |
|---|---|
| 400 | the request was wrong; `message` says how |
| 401 | not signed in, or `token_expired` → refresh once and retry |
| 403 | signed in, but this role may not |
| 404 | not found — **also** what another tenant's data returns, always |
| 409 | a conflict `message` explains (`last_owner`, duplicate address) |
| 429 | throttled |
| 501 | the feature is off for this deployment, not an error |
| 502 | a downstream failure; for erasure it means **nothing was deleted** |
Roles, in increasing order: `staff` → `manager` → `owner`. A platform admin has
`role: "admin"` **and an empty `client_id`** — the two together, never the role
alone.