Files
Behavision/API.md
Suriyakumarvijayanayagam 4c750cb2ac Opening a shop is an API call; the broker learns of it in the same request
The last step of onboarding that needed a shell: provision site printed
a broker password and a person typed it into Mosquitto's passwd file on
the host - mounted read-only in the container, so the first attempt
failed silently and the password was re-rolled. No tenant could open a
second branch without us.

The server now drives Mosquitto's dynamic-security plugin over its own
broker login: POST /api/sites (owner) writes the row and the sealed
password, registers the login and a per-site role with literal topics
(the 2.0 plugin does not substitute %u - measured), and removes the row
again if the broker refuses, so a shop cannot exist in the database and
not on the broker. provision site goes through the same path. The
head-office Shops screen gets 'Open a new shop'.

broker-init converts the existing passwd file into the plugin's store
with every hash intact - PBKDF2-SHA512 both sides - so the cutover
re-claims no shop PC. Rehearsed locally: old logins keep working,
isolation holds, the health probe works, and a PC claiming a shop opened
through the API connects as that shop. run-local.sh now brings the
broker up the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 11:55:26 +05:30

43 KiB
Raw Blame History

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
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 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 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.


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

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.
  • The admin cannot reset a merchant owner's password over HTTP, nor suspend or delete a merchant. Today that is behavision-server provision on the server.

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:

  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

{ "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": "…" }]

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.

{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
  "password": "" }
{ "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

{ }                    or                    { "password": "chosen-one" }
{ "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

{ "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

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 }]
  • 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.

{ "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.
  • 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

{ "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 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.

{ "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:

  • 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.

{ "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.

{ "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 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.


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.