A mobile developer builds against this file, so a gap in it is a gap in
the app. Checked route by route against the mux: nineteen routes had no
entry at all, including the ENTIRE platform-admin surface, adding and
checking cameras, issuing shop-PC installation codes, the assistant, and
the face bytes endpoint. Most of what was documented had no response
shape - a client had to guess the field names for shops, cameras, team,
customers, history and both reports.
Every shape here is now taken from the server's own types, and the
uncertain claims were checked against the handlers rather than written
from memory: check requests return 202, history is newest first, the
visitor list is most-recently-seen first and excludes the erased, an
admin slug is derived from the company name when omitted.
Restructured by audience, because "who may call this" was scattered:
- three callers named up front - merchant, platform admin, shop PC -
and what each one signs in with and sees
- the three merchant roles and what each adds, taken from
CanWriteProfiles / CanManageSites rather than paraphrased
- a permission matrix: every route and the least role that may call it
- quick starts for the three clients that will actually be written:
a floor app for staff, a console for owners, and admin
- /api/agent/* listed once as "not for you", so nobody wonders
The prose that explained WHY - refresh rules, the cursor, photos as data
not errors, the report arithmetic - is kept; that is the part a client
developer cannot get from the code.
Also recorded plainly: the admin API is two endpoints. There is no way
to suspend a company, delete one, or reset an owner's password over
HTTP. Written down rather than left for someone to discover.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
33 KiB
Behavision API — for the web console, a mobile app, and platform administration
Base URL: https://platform.loyaly.ai (locally http://127.0.0.1:8088).
Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says
otherwise. Every shape below is taken from the server's own types, not written
from memory — if the two ever disagree, the server is right and this file has a
bug.
There is one API, not a web one and a mobile one. The web console in this repository uses exactly these calls; anything it can do, an app can do.
Who is calling: three audiences, one API
| audience | who they are | signs in with | what they see |
|---|---|---|---|
| Merchant | a company's owner, managers and shop-floor staff | email + password | their own company's shops, cameras, customers, arrivals |
| Platform admin | Loyaly, running the platform | email + password, an account with no company | the list of companies, and nothing inside any of them |
| Shop PC | the agent running on a till or back-office PC | an installation code, once; its own token thereafter | /api/agent/* only — not for a web or mobile client |
A merchant user has one of three roles. They are strictly nested — each can do everything the one below can:
| role | can additionally |
|---|---|
staff |
see arrivals, search customers, edit a customer's profile, record a purchase |
manager |
manage cameras, invite and remove team members, issue shop-PC installation codes, erase a customer |
owner |
promote somebody to owner |
A platform admin has role: "admin" and an empty client_id — both
together, never the role alone. A tenant-scoped account with the role set to
admin is rejected by every admin endpoint. Admins can call merchant endpoints
too, but with no company of their own they see empty lists; the admin screens
are /api/admin/*.
Permission matrix
Every route, and the least role that may call it. authed means any signed-in
user; the tenant is always taken from the session and never from the request.
| route | least role |
|---|---|
POST /api/auth/login refresh · GET /api/auth/invitation · POST /api/auth/register |
no auth |
POST /api/auth/logout · GET /api/auth/me · /api/auth/sessions* |
authed |
GET /api/visits · GET /api/visits/stream |
authed |
GET /api/visitors · GET /api/visitors/{id}/history · GET /api/visitors/{id}/image · GET /api/faces/{id} |
authed |
PUT /api/visitors/{id}/profile · POST /api/purchases |
staff |
DELETE /api/visitors/{id} — erasure |
manager |
GET /api/sites · GET /api/sites/{site}/check · GET /api/cameras · GET /api/cameras/{id}/snapshot.jpg · GET /api/cameras/{id}/live |
authed |
POST /api/sites/{site}/cameras · PATCH / DELETE /api/cameras/{id} · POST /api/cameras/{id}/check |
manager |
POST /api/sites/{site}/enrolment-code |
manager |
GET /api/team |
authed (tenant users only) |
PATCH /api/team/{id} · /api/team/invitations* |
manager |
GET /api/reports/footfall · GET /api/reports/conversion |
authed |
POST /api/assistant |
authed |
GET / POST /api/admin/clients |
platform admin |
/api/agent/* |
shop PC token — never a user |
A role that may not call something gets 403 forbidden with a message
saying who can. Another tenant's data returns 404, never 403: a tenant user
has no business learning that a resource exists.
Quick starts
A mobile app for shop-floor staff
The one screen this app exists for is who just walked in.
POST /api/auth/login → access_token, refresh_token
GET /api/visits?limit=30 → arrivals[], cursor (first load)
GET /api/visits?cursor=… → arrivals[], cursor (every 3–5 s)
tap a row →
GET /api/visitors/{visitor_ref}/history → their past visits
PUT /api/visitors/{visitor_ref}/profile → give them a name
Store refresh_token securely; read §1 for the three refresh rules before
writing the client, because all three have already been bugs here.
A web console for an owner or manager
POST /api/auth/login
GET /api/sites → every shop: online? cameras up? faces usable?
GET /api/sites/{slug}/check → why a shop is or is not working
GET /api/cameras → every camera with its latest still
POST /api/sites/{slug}/cameras → add one
POST /api/cameras/{id}/check → prove it can see faces
GET /api/visits/stream → live arrivals (SSE)
GET /api/reports/footfall?from=&to= → the numbers, with their confidence
Platform administration
POST /api/auth/login (an account with no company)
GET /api/admin/clients → every company
POST /api/admin/clients → create one, with its owner
That is the whole admin surface today. Everything inside a company is the company's own business and is reached by signing in as one of its users.
0. Identifiers — you do not have to use uuids
Every id in the database is a uuid and every one of them still works. But a uuid
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
{ "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" }
{
"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
{ "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", "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
{ "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 /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 |
[{ "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.
4. The team
GET /api/team — anyone in the company
[{ "id": "…", "email": "arjun@tenext.in", "full_name": "Arjun",
"role": "manager", "active": true,
"last_login_at": "…", "created_at": "…" }]
PATCH /api/team/{id} — manager or owner
{ "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)
{
"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.
"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.
[{ "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
[{ "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
{ "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
{ "visitor_id": "V-42", "site_id": "chennai",
"amount": 1250.00, "currency": "INR",
"items": ["…"], "source": "till", "notes": "" }
Links a sale to a customer so the conversion report can say who bought. One currency per report — see §9.
DELETE /api/visitors/{id} — erasure, manager and above
Destroys the face template and the photo outright. Keeps the visit rows, unlinked (they are the shop's own footfall history). Keeps the consent record, revoked. Keeps the customer row with a deletion mark so the same face is not re-enrolled next week as a brand-new person.
It is irreversible. If the photo cannot be deleted the whole request fails with 502 and nothing is erased — so an error there means the data is still there, and must be reported as a failure, never swallowed.
8. Shops and cameras
GET /api/sites
Every shop in the company, with the three facts that tell a quiet week from an unplugged PC.
[{ "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 }]
onlineis three missed heartbeats, not one. One is a dropped packet.fraction_below_gateis 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.queuedis footfall waiting on the shop PC's disk to be sent;droppedis footfall lost because that queue overflowed. Non-zerodroppedis a report that is wrong in a way the report itself cannot show.
GET /api/sites/{site}/check
Five ordered steps that answer is this shop working, assembled from what head office already knows — so it costs no round trip and works when the PC is off, which is itself one of the answers.
{ "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.
[{ "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.connectedis a pointer:nullmeans "no shop PC has reported on this camera yet",falsemeans "not connecting". A barefalsesays the second when it means the first, and sends an installer to check cabling on a camera nobody has tried to reach.checkis always present. An empty one (stateabsent) means never checked;state: "done", ok: falsemeans checked and failed. Those are different situations and a client must not guess from an absent field.
POST /api/sites/{site}/cameras — manager
{ "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
{ "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 forsecondswhile 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.
{ "label": "till PC", "days": 7 } ← both optional
{ "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.
{ "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:
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 thanvisitors. 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.newmeans 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.
{ "visitors": 183, "purchasers": 41, "conversion": 0.224,
"revenue": 51250.00, "average_basket": 1250.00, "currency": "INR" }
revenueis summed for ONE currency — whichever accounts for most of it, named incurrency. Adding rupees to dollars produces something that looks like money and is not.average_basketis 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.
{ "history": [
{ "role": "user", "text": "Is everything working today?" },
{ "role": "assistant", "text": "…" },
{ "role": "user", "text": "Why is footfall low at Chennai?" }
] }
{ "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
[{ "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.
{ "company_name": "TeNext Retail", "slug": "tenext-retail",
"owner_email": "suriya@tenext.in", "owner_name": "Suriya",
"password": "" }
{ "client_id": "…", "slug": "tenext-retail",
"owner_email": "suriya@tenext.in",
"password": "xK9…" }
- Leave
passwordempty. The server generates one. An operator inventing a password for somebody else invents a weak one and sends it over chat. passwordis returned exactly once and is bcrypt-hashed on the way in. Not recoverable. Show it, or send it, immediately.slugis optional and is derived from the name when omitted. It becomes part of the company's message-broker topic, so/,+and#are stripped, and it can never be changed.
That is the whole admin API. There is no endpoint to delete a company, suspend
one, reset an owner's password, or look inside one — those are done by signing
in as the company's owner, or by behavision-server provision on the server.
12. Errors
{ "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 |
| 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.