A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.
Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.
Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.
Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.
Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.
Found by running it, not by tests:
* UPDATE ... RETURNING gives the value AFTER the update, so the prune
read back empty keys, deleted nothing, and the table grew with
footfall exactly as if it were not there. The fake agreed with either
version; only the live Postgres test caught it.
* Trusting only the auth flag broke every shop card, because Sites.jsx
rebuilt a partial snapshot object and dropped it. A relative URL is
now sufficient on its own.
* ago() renders a future time as "just now", so a code valid for a week
read "expires just now".
Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
12 KiB
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.
1. Signing in
POST /api/auth/login
{ "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" }
{
"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
{ "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:
- 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. - 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.
- 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
{ "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager",
"expires_in_days": 7 }
{ "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
{ "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
{ "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 |
[{ "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_id=…
{
"arrivals": [{
"visit_id": "…", "seq": 412,
"occurred_at": "2026-09-05T06:01:45Z",
"site_id": "…", "site": "TeNext Chennai", "camera_id": "Office1",
"visitor_id": "…", "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.
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.
"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 or phone |
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 |
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 |
The site parameter is spelled differently here. Reports take site; the
arrivals feed takes site_id. That is a wart, not a rule — but an unknown query
parameter is silently ignored, so getting it wrong returns the whole estate
rather than an error.
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:
totalis 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'stotal, withvisitsunderneath.new + returningcan 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
{ "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.