Asked of the row the feed actually returns. site_id had a reference all along and the feed was not sending it. A client could read the shop's NAME off an arrival and still had no way to ask for that shop except by uuid - the exact gap the reference scheme exists to close. site_slug now travels with it. visit_id stays a uuid and needs no reference: no route takes it, it is a key a client de-duplicates on because delivery is at-least-once, and nobody says a visit id out loud. The uuid in a face URL must STAY random. visit_faces.id is gen_random_uuid() and a derived or sequential one would let somebody walk a shop's customers by date - the same reason bucket keys are random rather than derived from the event id. A readable identifier is right for a customer and wrong for the thing that points at their photograph. And seq is now json:"-". visits.seq is a plain bigserial, so it counts every visit on the PLATFORM, and shipping it put the total footfall of every customer we have on every row of every tenant's feed - the same German-tank estimate that decided visitors.number had to be per client. It was a convenience for "have I fallen behind", nothing ever read it, and the cursor answers that without disclosing a number. The SSE event id was never the raw value; it has always been the opaque cursor. The one test that broke was reading seq back off the wire to assert the cursor pointed at the last row of a burst. It asserts against the seeded position now: the property is unchanged, and the test can no longer see what a client cannot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
387 lines
14 KiB
Markdown
387 lines
14 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.
|
|
|
|
---
|
|
|
|
## 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.
|