Getting started

Five minutes from nothing to a working integration. Everything here runs against stage — swap the host for production once it works.

text
Stage        https://api.stage.theattco.net
Production   https://api.theattco.net

1. Get a token

Every request carries a bearer token. As a person, log in:

bash
TOKEN=$(curl -s https://api.stage.theattco.net/v2/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"…"}' | jq -r .access_token)

As a machine, exchange an API key for one instead — see Authentication for how the two halves of a key work:

bash
TOKEN=$(curl -s https://api.stage.theattco.net/v2/auth/api-token \
  -H 'X-Access-Key-Id: tac_live_7Qk2RfNp4xZ1mVdB' \
  -H 'X-Access-Key-Secret: …' | jq -r .access_token)

2. Make a call

bash
curl -s https://api.stage.theattco.net/v2/locations \
  -H "Authorization: Bearer $TOKEN"
json
{
  "entries": [
    {
      "id": "loc_018f5a01-1c2d-7e3f-8a4b-5c6d7e8f9012",
      "name": "Kungsgatan",
      "timezone": "Europe/Stockholm"
    }
  ],
  "next_cursor": null
}

That is the whole shape of the API: a JSON object, snake_case throughout, with list endpoints returning entries and a next_cursor.

3. The token already knows who you are

Almost no endpoint takes an account id. The token identifies both the person and the account they are working in, and everything you can reach is scoped to that account automatically.

If you find yourself looking for somewhere to pass an account id, you are probably calling an internal endpoint that was not meant for you.

4. Walk a list

next_cursor is how you get the rest. Feed it back as cursor, and stop when it comes back null — not when a page looks short:

bash
cursor=""
while :; do
  page=$(curl -s "https://api.stage.theattco.net/v2/locations?limit=100${cursor:+&cursor=$cursor}" \
    -H "Authorization: Bearer $TOKEN")
  echo "$page" | jq -c '.entries[]'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done

Pagination covers why it is a cursor and not a page number.

5. Handle a failure

Every failure, from every part of the API, comes back in the same shape:

json
{
  "type": "https://api.theattco.com/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "timezone must be an IANA zone name, got 'CET+1'"
}

Switch on type, show detail, log all four. Errors lists every type the API can return and which ones are worth retrying.

Where to go next

If you want toRead
Understand tokens, API keys and rolesAuthentication
Handle failures properlyErrors
Page through large resultsPagination
Know what ids, dates and names look likeConventions
Find a specific endpointThe API reference

A note on ids

Ids carry a type prefix — loc_, cmp_, usr_. It makes an id self-describing in a log or a support ticket, and makes passing the wrong one obvious immediately. Treat them as opaque strings; never build one.


TAC API

The Attribution Company platform is one HTTPS API. It is assembled from several services, each owning a part of the domain, but they sit behind a single gateway and share one base URL, one authentication scheme, one error format and one set of naming rules. You should rarely need to care which service answers a call.

text
Production   https://api.theattco.net
Stage        https://api.stage.theattco.net

Everything is JSON over HTTPS. Every field, query parameter and path parameter is snake_case. Every call carries a bearer token. Every failure comes back in the same shape.

What each part owns

AreaWhat lives there
AccountSign-up and login, users, invitations, roles, API keys, plans, token ledger
OrganizationThe physical estate — groups, locations, zones, screens, installations
DeviceDevice provisioning, enrollment and the device-facing endpoints
CampaignCampaigns, ads, ad and media libraries, scoring and publishing
EventsVisit analytics, campaign performance, loyalty, blacklist
BillingPayments, refunds, prices, checkout sessions
UtilitiesShared lookups — geocoding, coverage areas, weather, context

How to read this reference

If you have not called the API before, start with Getting started — it goes from no token to a working request in five minutes.

Endpoints are grouped by area in the sidebar. Each one lists its parameters, its response shape and the failures it can return. Before you start, three pages are worth five minutes each: Authentication for how tokens and API keys work, Errors for the one failure shape, and Conventions for naming, ids and timestamps.

Endpoints marked Internal are service-to-service and are not available to ordinary callers, even with a valid token.


Authentication

Every request carries a bearer token:

text
Authorization: Bearer <token>

Tokens are HS256 JWTs. There are two ways to get one — as a person, or as a machine — and they produce tokens with different powers.

As a person

bash
curl -s https://api.theattco.net/v2/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"…"}'

You get a short-lived access token and a refresh token. When the access token expires, exchange the refresh token at POST /v2/auth/refresh rather than logging in again. POST /v2/auth/logout revokes the refresh token.

As a machine

API key credentials come in two halves, issued together:

HalfLooks likeCare
access_key_idtac_live_7Qk2RfNp4xZ1mVdBPublic. Safe to display, log and put in a config file.
access_key_secret40 random charactersPrivate. Shown once, at issue or rotation. Never recoverable.

They are split so the two halves can be handled with different levels of care — the id identifies a key in the UI and in logs without revealing anything.

Exchange them for a token, and use that token everywhere else:

bash
curl -s https://api.theattco.net/v2/auth/api-token \
  -H 'X-Access-Key-Id: tac_live_7Qk2RfNp4xZ1mVdB' \
  -H 'X-Access-Key-Secret: …'

Those two headers are accepted by this one endpoint and nowhere else. Never send the secret to any other path, never in a query string, and never log it.

What a key token cannot do

A key token is deliberately weaker than a person's:

  • If the key is read-only, it may use GET and HEAD only, anywhere in the API.
  • /v2/users, /v2/invitations, /v2/api-keys and account switching refuse

every key token, reads included — a key that could invite a user, mint another key or switch accounts would escape its own scope.

What is inside the token

json
{
  "sub": "usr_018f4ea1-3b6c-7d24-8e90-1f2a3b4c5d6e",
  "account": "acc_018f4e9a-7c2d-7e3b-9f1a-2b3c4d5e6f70",
  "role": "admin",
  "account_type": "neuro",
  "exp": 1780000000
}
ClaimMeaning
subThe user id.
accountThe single account this session is scoped to. A user in several accounts holds one token per account and switches explicitly.
roleTheir role in that account: owner, admin, member, tac_admin, tac_support.
account_typeThe account's product tier.
expExpiry, seconds since the epoch.

Because the account rides on the token, endpoints do not take an account id. Anything you can reach, you can reach for that account only.

Group scope

Two optional claims narrow a caller to part of the estate:

json
{ "group_scope": "selected", "groups": { "018f5a01-…-9012": "a" } }

group_scope is all or selected. When selected, groups maps a group id to a (group admin) or m (group member), and that map is the only estate access the caller has.

Both claims are absent for an account-wide caller — including every token minted before the feature existed. Absent therefore means account-wide, which is why unrestricted tokens do not grow over time. Unknown role letters are ignored rather than treated as access.

Scope rides on the token, so a change to someone's grants lands on their next mint. Changing grants revokes their refresh tokens, which bounds the window to one access-token lifetime.

Devices

Devices do not use bearer tokens. They present a client TLS certificate issued during bootstrap by the device CA. First contact, before any certificate exists, is authenticated by signing the request body with the device's factory-issued private key and sending the signature in X-Device-Signature; the server verifies it against the public key recorded for that device, and replays are rejected.

Device endpoints are documented for the firmware that calls them. They are not reachable with a user or key token.


Errors

Every failure, from every service, comes back in the same shape: RFC 9457 problem details, sent as application/problem+json.

json
{
  "type": "https://api.theattco.com/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "timezone must be an IANA zone name, got 'CET+1'"
}
FieldUse it for
typeBranching in code. A stable URI identifying the kind of failure.
titleA short, human label for the kind. Stable, but written for people.
statusThe HTTP status, repeated so a logged body is self-contained.
detailWhat went wrong this time. Written for a human — never parse it.

The rule of thumb: switch on type, show detail, log all four.

Kinds

type ends withStatusMeans
validation-error400The request was malformed or a value was unacceptable.
unauthorized401No token, an expired token, or one that does not verify.
forbidden403A valid token without the right to do this.
not-found404No such resource — or not one this account can see.
conflict409The request fights the current state: a duplicate, or a stale update.
unprocessable422Syntactically fine, but not a thing the domain permits.
insufficient-balance402The account has no tokens left for this operation.
rate-limited429Too many requests. Back off and retry.
upstream-error502A dependency failed. The request may succeed if repeated.

404 deserves a note: the API does not distinguish "does not exist" from "exists but is not yours". Both are not-found, deliberately, so that ids cannot be discovered by probing.

Status codes in use

CodeMeaning
200OK — the response carries the result.
201Created — a new resource; its id is in the body.
202Accepted — queued, not finished. Poll for the outcome.
204No content — it worked and there is nothing to return.
400, 401, 402, 403, 404, 409, 410, 422, 429Your request. Do not retry unchanged, except 429.
502, 503Ours. Safe to retry with backoff.

410 Gone means a one-time thing has been spent: an email verification code that expired, was already used, or was attempted too many times, or an invitation past its expiry. Unlike 404, it is a definite "this existed and is finished" — issue a new one rather than retrying.

Retrying

Retry 429, 502 and 503. Do not retry 4xx otherwise — the request will fail the same way. Use exponential backoff with jitter, and treat 429 as a signal to slow down generally, not just to repeat the one call.

Rate limits apply per API key.

Validation failures

A validation-error names the offending value in detail. It reports the first problem found rather than every problem at once, so fix and resubmit rather than expecting a complete list.


Versioning

The version is in the path:

text
https://api.theattco.net/v2/locations

There is one current version, v2, across every service. There is no version header and no per-account pinning — everyone is on the same version at the same time. v1 has been retired and its endpoints no longer answer.

What we may change without a new version

These are additive, and your client must tolerate them:

  • A new endpoint.
  • A new optional request parameter or body field.
  • A new field in a response.
  • A new value in an enum, where the field already documents that it may grow.
  • A new type in an error, alongside the existing status code.

The practical consequence: ignore fields you do not recognise, and do not validate responses so strictly that an added field is an error. A client that rejects unknown fields will break on a routine release.

What we will not do inside v2

  • Remove or rename a field, parameter or endpoint.
  • Change a field's type, or make an optional request field required.
  • Change what an existing value means.
  • Change a success status code.

Anything in that list needs a new version.

Retiring things

When something is going away it is marked deprecated in this reference first, with what to use instead. Deprecation is announced before it happens, and retirement follows only once the replacement has been available long enough to move to.

Not yet settled: what a retired endpoint should answer. 404 is what it
would do today by default, which reads like a typo rather than a removal.
Worth deciding before the first v2 retirement.

Naming is settled

Every field, query parameter and path parameter is snake_case, across every service. Where you find camelCase, treat it as a bug worth reporting rather than a convention to copy — the last of it was removed in August 2026, and nothing new should carry it.


Conventions

The rules below hold everywhere. If an endpoint appears to break one, it is a bug worth reporting.

Naming

Every field, query parameter and path parameter is snake_case — account_id, avg_dwell_seconds, from, to, next_cursor. Never camelCase, in either direction, in request or response.

Identifiers

Ids are a short type prefix, an underscore, and a UUIDv7:

text
loc_018f5a01-1c2d-7e3f-8a4b-5c6d7e8f9012

The prefix makes an id self-describing in a log, a URL or a support ticket, and makes it obvious when the wrong one has been passed. Match on the whole string — prefixes are stable, but treat the id as opaque and never build one yourself.

Because UUIDv7 leads with a timestamp, ids from the same type sort roughly by creation time. Convenient, but not a substitute for a created_at field.

PrefixResourcePrefixResource
acc_Accountad_Ad
usr_Userapf_Ad profile
inv_Invitationsco_Score
key_API keyasl_Ad library
pln_Planmlb_Media library
led_Ledger entrymda_Media asset
hld_Token holdanl_Analysis
job_Jobcmp_Campaign
rt_Refresh tokendev_Device
grp_Groupcam_Camera
loc_Locationbl_Blacklist entry
zon_Zonepay_Payment
scr_Screenref_Refund
ins_Installationcs_Checkout session

Two are easy to confuse: `cmp_` is a campaign, `cam_` is a camera.

Dates and times

KindFormatExample
TimestampRFC 3339, UTC, Z suffix2026-06-30T14:20:00Z
Calendar dateYYYY-MM-DD2026-06-30

Timestamps are always UTC on the wire. Anything a person sees in local time is converted by the client, or by an analytics rollup that knows the location's timezone — which is why a location carries an IANA zone name.

Date ranges use from and to, and both are inclusive. Omit them for "all time" where an endpoint allows it.

Absent values

A field that is genuinely unknown or not set is null. A field that does not apply is omitted. Both arrive in JavaScript as a falsy value, so check for presence rather than truth when zero is a legitimate answer — several analytics fields can honestly be 0.

Comma-separated filters

Where a filter accepts several values it takes one comma-separated parameter, not a repeated one:

text
?campaign_ids=cmp_018f6a01-…,cmp_018f6a02-…

Money and counts

Counts are integers. Rates and percentages are numbers, expressed as percentages rather than fractions — an attention rate of 36.9 means 36.9%.


API reference

Auth

Auth

Create user + account (signer = owner) + free-tier grant; emails a verification code

POST/v2/auth/signup

Creates the account with email_verified=false and emails a 6-digit code. No session is returned — the user verifies the email (POST /auth/verify-email) to get the first session.

Request body

emailstringrequired
passwordstringrequired
full_namestringrequired

Responses

202Accepted — verification code emailed
user_idstring
account_idstring
verificationobject

What the verification email contains, so the UI renders the right input (digit boxes vs text).

400Invalid input
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring
429Rate limited
typestring
titlestring
statusinteger
detailstring

Verify email with the 6-digit code → first session or MFA setup

POST/v2/auth/verify-email

200 has two shapes, mirroring POST /v2/auth/login. When the target account (or the target user individually) has force_mfa=true, the response is an mfa_setup_required envelope with a short-lived setup_token; the client must complete POST /v2/mfa/enrollment + POST /v2/mfa/enrollment/confirm with that token before a session exists. Otherwise the response is the full Session.

Request body

emailstringrequired
codestringrequired

Responses

200OK
access_tokenstring
refresh_tokenstring
userobject
accountobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
403User is disabled

Body not documented.

410Code expired, already used, or too many attempts

Body not documented.

429Rate limited
typestring
titlestring
statusinteger
detailstring

Resend the email verification code

POST/v2/auth/verify-email/resend

No description yet.

Request body

emailstringrequired

Responses

202Accepted — a fresh code is emailed if the address is still unverified

Body not documented.

429Rate limited
typestring
titlestring
statusinteger
detailstring

Authenticate → full session, an MFA challenge, or a setup requirement

POST/v2/auth/login

Response shape depends on the account's MFA state. (a) User has MFA enrolled → mfa_challenge envelope; exchange via POST /v2/mfa/challenge/verify. (b) Account has force_mfa=true and the user is not enrolled → mfa_setup_required envelope with a short-lived setup_token that only unlocks POST /v2/mfa/enrollment and POST /v2/mfa/enrollment/confirm. (c) Otherwise → full session. Which account the session lands on: the open account the user OWNS (the earliest joined, if several), else their earliest open membership of any role. Closed accounts are never chosen; 403 "Account is closed" is answered only when the user has no open account left at all. Switch afterwards with POST /v2/accounts/{id}/session.

Request body

emailstringrequired
passwordstringrequired

Responses

200OK
access_tokenstring
refresh_tokenstring
userobject
accountobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Email not verified — verify first (POST /auth/verify-email/resend for a fresh code)

Body not documented.

429Rate limited
typestring
titlestring
statusinteger
detailstring

Exchange refresh token for a new access token

POST/v2/auth/refresh

No description yet.

Request body

refresh_tokenstringrequired

Responses

200OK
access_tokenstring
refresh_tokenstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Revoke the current session

POST/v2/auth/logout

No description yet.

Responses

204Revoked

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring

Start password recovery — emails a 6-digit code (always 202, no enumeration)

POST/v2/auth/forgot-password

No description yet.

Request body

emailstringrequired

Responses

202Accepted — always returned, to avoid email enumeration

Body not documented.

400Invalid input
typestring
titlestring
statusinteger
detailstring
429Rate limited
typestring
titlestring
statusinteger
detailstring

Reset password with the emailed code (email + code + new password, one step)

POST/v2/auth/reset-password

No description yet.

Request body

emailstringrequired
codestringrequired
new_passwordstringrequired

Responses

204Password reset — existing sessions are invalidated

No body — 204 returns nothing.

400Invalid input
typestring
titlestring
statusinteger
detailstring
410Code expired, already used, or too many attempts

Body not documented.

429Rate limited
typestring
titlestring
statusinteger
detailstring

Exchange an API key for a short-lived access token (machine callers)

POST/v2/auth/api-token

Send X-Access-Key-Id and X-Access-Key-Secret; get back a short-lived access token carrying the key's account, role and group scope. Every other v2 service then accepts that token as a normal bearer token — no per-request key lookup anywhere. Clients should cache the token until it expires rather than exchanging per request; the endpoint is rate-limited per key. The server finds the key by its access key id, then compares a sha256 hash of the supplied secret against the stored hash in constant time; clients never send a hash. Both halves are shape-checked before the lookup, and a mismatched pair — one key's id with another key's secret — is a plain 401. There is no refresh token: re-exchange the key. Because the token is short-lived, revoking or rotating a key takes effect immediately for new tokens, but a token ALREADY issued keeps working until it expires — 15 minutes (ACCESS_TOKEN_TTL). That window is the gap between revoking a leaked secret and it actually becoming useless.

Responses

200OK
access_tokenstring
token_typestring
expires_ininteger

seconds until the access token expires

401Key unknown, secret wrong, revoked, expired, or issued for a different environment (a tac_stage_ key cannot authenticate against prod). detail distinguishes the cases so customers can self-diagnose.
typestring
titlestring
statusinteger
detailstring
403The key's account is suspended or closed

Body not documented.

429Rate limited
typestring
titlestring
statusinteger
detailstring

API reference

Account

MFA

Whether the current user has an active TOTP enrollment

GET/v2/mfa/enrollment

Read-only status for the caller's own MFA enrolment. Backs the Settings MFA card so it can render the enroll flow vs the disable flow accurately from the first paint, without racing a POST /v2/mfa/enrollment (which would generate a fresh pending secret as a side effect).

Responses

200OK
mfa_enrolledbooleanrequired
mfa_enrolled_atstringrequired
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring

Start a TOTP enrollment — returns otpauth URL + secret for the authenticator app

POST/v2/mfa/enrollment

Idempotent — calling again replaces any pending unconfirmed secret. Not persisted as active until POST /v2/mfa/enrollment/confirm completes.

Responses

200OK
otpauth_urlstringrequired
secretstringrequired
issuerstringrequired
account_namestringrequired
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring

Disable MFA — POST alias for DELETE /v2/mfa/enrollment (same body, same semantics)

POST/v2/mfa/enrollment/disable

Exists because some HTTP clients (including the frontend's shared HTTPClient port) cannot send a body on DELETE. Behaviour is identical to the DELETE endpoint above: refused (403) when the user's account has force_mfa=true; requires the current password.

Request body

passwordstringrequired

Responses

204Disabled

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring

Confirm enrollment with a TOTP code — persists the secret and marks the user enrolled

POST/v2/mfa/enrollment/confirm

No description yet.

Request body

codestringrequired

Responses

200OK
access_tokenstring
refresh_tokenstring
userobject
accountobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring

Exchange an MFA challenge token + TOTP code for a full session

POST/v2/mfa/challenge/verify

No description yet.

Request body

challenge_tokenstringrequired
codestringrequired

Responses

200OK
access_tokenstring
refresh_tokenstring
userobject
accountobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Accounts

The caller's own account memberships

GET/v2/accounts

The accounts the caller belongs to — their memberships, and nothing else. This is the account switcher's list: it returns each account the caller has been invited to, with the role they actually hold there.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring

Read an account

GET/v2/accounts/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

200OK
idstring
namestring
statusstring
balanceinteger

derived pool balance

expiring_sooninteger

subscription tokens above the rollover cap

metadataobject
account_typestring

catalog key from /v2/account-types; drives frontend feature gating

force_mfaboolean

when true every member must have MFA enrolled — login flows return mfa_setup_required and account-scoped routes refuse tokens without the mfa claim

subscriptionobject
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update an account (owner or admin). Use status=suspended for a reversible pause.

PATCH/v2/accounts/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Request body

namestring
statusstring
metadataobject

Responses

200OK
idstring
namestring
statusstring
balanceinteger

derived pool balance

expiring_sooninteger

subscription tokens above the rollover cap

metadataobject
account_typestring

catalog key from /v2/account-types; drives frontend feature gating

force_mfaboolean

when true every member must have MFA enrolled — login flows return mfa_setup_required and account-scoped routes refuse tokens without the mfa claim

subscriptionobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Close an account — status → closed. Never deleted. Owner only.

DELETE/v2/accounts/{id}

An account is never removed: this sets status to closed and revokes the refresh tokens, and every row stays — the token ledger, the memberships, the API keys. Financial traceability depends on it, and the database enforces the same rule independently: ledger_entries and holds reference accounts(id) with no delete rule, so Postgres refuses to delete an account that has ever transacted, which every account has from signup onward (the free-tier grant is written in the same transaction as the account). CAVEAT — this does NOT cancel the Stripe subscription. Nothing here calls billing, and billing exposes no cancel operation; its subscription state only changes when Stripe sends a webhook. Closing an account therefore stops access but keeps the card being charged until somebody cancels on the provider side. Cancel in Stripe as a separate step until that is wired up.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

204Closed

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Switch active account → token scoped to it

POST/v2/accounts/{id}/session

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

200OK
access_tokenstring
refresh_tokenstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Toggle whether every member of the account must have MFA enrolled

PATCH/v2/accounts/{id}/force-mfa

Owner or admin only. Turning this on forces unenrolled members to complete /v2/mfa/enrollment on their next login via an mfa_setup_required response; existing sessions without the mfa: true claim are refused on all account-scoped routes.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Request body

force_mfabooleanrequired

Responses

200OK
idstring
namestring
statusstring
balanceinteger

derived pool balance

expiring_sooninteger

subscription tokens above the rollover cap

metadataobject
account_typestring

catalog key from /v2/account-types; drives frontend feature gating

force_mfaboolean

when true every member must have MFA enrolled — login flows return mfa_setup_required and account-scoped routes refuse tokens without the mfa claim

subscriptionobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Token ledger for the account (cursor-paginated)

GET/v2/accounts/{id}/ledger

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

query parameters

cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Users

The caller's resolved role + group scope + what they may do

GET/v2/users/me/permissions

One call the frontend makes after login (and after an account switch) to drive the nav, the group picker and the invite dialog, with the rules already applied so the UI never re-implements them. Read from the DB, not from the caller's token, so a scope that changed mid-session is reflected immediately.

Responses

200OK
rolestring
group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

The caller's own grants. Empty when group_scope=all.

servicesobject

Per-service access, already expanded across every service so the client never has to know the wildcard rule in the svc token claim. A service the caller cannot touch at all is absent from the map. "r" is read, "w" is read and write.

canobject

Pre-resolved answers so the UI never re-implements the rules.

401Not authenticated
typestring
titlestring
statusinteger
detailstring

Members of the active account

GET/v2/users

No description yet.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring

Read a member of the active account

GET/v2/users/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

200OK
idstring
emailstring
full_namestring
rolestring
readonlyboolean

may read but never write; a separate axis from role

statusstring
metadataobject
mfa_enrolledboolean

true when this user has completed MFA enrolment

force_mfaboolean

true when this user is individually pinned to MFA; login gates on this OR the account-level force_mfa

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Group grants. Always empty when group_scope=all — that member already sees every group.

allowanceobject,null
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a user (field authz per caller; only self may change own full_name)

PATCH/v2/users/{id}

The body updates active-account membership fields (role, group scope, status, metadata, allowance, full_name). Promoting to owner or admin forces group_scope=all, and restricting a member to groups requires role=member. Grant changes revoke the user's refresh tokens so the new scope lands on their next token.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Request body

rolestring
statusstring
metadataobject
allowanceobject
full_namestring
group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Responses

200OK
idstring
emailstring
full_namestring
rolestring
readonlyboolean

may read but never write; a separate axis from role

statusstring
metadataobject
mfa_enrolledboolean

true when this user has completed MFA enrolment

force_mfaboolean

true when this user is individually pinned to MFA; login gates on this OR the account-level force_mfa

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Group grants. Always empty when group_scope=all — that member already sees every group.

allowanceobject,null
400Invalid input — e.g. group_scope=selected on an owner/admin, or an empty groups array with group_scope=selected on a role that requires one
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Cannot demote the only owner of the account

Body not documented.

422Well-formed but unusable — e.g. a group id that does not belong to this account
typestring
titlestring
statusinteger
detailstring

Remove a member

DELETE/v2/users/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

204Removed

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Pin (or unpin) a specific user to MFA

PATCH/v2/users/{id}/force-mfa

Owner or admin only. Sets users.force_mfa. Login gates on accounts.force_mfa OR users.force_mfa, so pinning a user requires them to complete enrolment on next sign-in regardless of the account-level toggle.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Request body

force_mfabooleanrequired

Responses

204Updated

No body — 204 returns nothing.

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Reset another user's MFA enrollment (admin unlock, e.g. lost device)

POST/v2/users/{id}/reset-mfa

Clears the target's mfa_enrolled, ciphertext, and last-step tracking so they can enrol again on next login. Allowed for an owner or admin of the target's account. Never self-serves — a user who has lost their authenticator must contact an admin.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

204Reset

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Change your own password (verifies the current password)

PUT/v2/users/{id}/password

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Request body

current_passwordstringrequired

the caller's current password

new_passwordstringrequired

Responses

204Updated. Every refresh token is revoked AND every access token issued before this moment is refused from here on, so the session that made the change has to sign in again too.

No body — 204 returns nothing.

400Invalid input
typestring
titlestring
statusinteger
detailstring
401current_password did not match

Body not documented.

403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Start an email change — verifies the current password, emails a 6-digit code to the new address, and warns the old address.

POST/v2/users/me/email/request-change

No description yet.

Request body

new_emailstringrequired
current_passwordstringrequired

Responses

202Accepted — code sent
expires_ininteger
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring
429Rate limited
typestring
titlestring
statusinteger
detailstring

Swap the login email in place, using the code from /request-change. Revokes all refresh tokens on success.

POST/v2/users/me/email/confirm-change

No description yet.

Request body

codestringrequired

Responses

204Updated

No body — 204 returns nothing.

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring
410Code expired, already used, or too many attempts

Body not documented.


Invitations

Pending invitations

GET/v2/invitations

No description yet.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Invite by email plus role (existing users are added as a new membership)

POST/v2/invitations

Owner or admin only. Group grants chosen here are stored on the invitation and copied to the membership on accept, so the invitee lands with the right scope on their first login. An invite for role owner or admin must be group_scope=all. You can never grant a role above your own; an admin cannot invite an owner.

Request body

emailstringrequired
rolestring
readonlyboolean

Invite someone who may read but never write. A flag beside the role rather than a fourth role, because role is how much authority you hold while readonly is whether you may change anything — so "an admin who cannot write" is expressible. Refused for role=owner: an owner who cannot write cannot administer the account, including cannot undo the flag, so the account would be stuck with no route back through the API itself.

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Required (non-empty) when group_scope=selected, rejected otherwise. Each entry names a group in this account and the level the invitee gets in it. Only `name` is ignored here.

Responses

201Created
idstring
accountobject

which account you are invited to

emailstring
rolestring
readonlyboolean

accepting this makes a read-only member

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Group grants applied to the membership on accept. Empty when group_scope=all.

expires_atstring
accepted_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring
422Well-formed but unusable — e.g. a group id that does not belong to this account
typestring
titlestring
statusinteger
detailstring

Revoke an invitation

DELETE/v2/invitations/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

204Revoked

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Public preview of an invitation (by token)

GET/v2/invitations/accept/{token}

Everything the accept screen needs before anyone is signed in, including invitee_has_account — whether the invited address already has an account. The screen sends an existing person to sign in and a new one to sign up; without this it can only guess. Not an enumeration vector: reaching this requires the invitation's own 32-character secret, which already reveals the address. It is deliberately absent from the authenticated invitation list, where it would be one.

path parameters

tokenstringrequired

Responses

200OK
idstring
accountobject

which account you are invited to

emailstring
rolestring
readonlyboolean

accepting this makes a read-only member

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Group grants applied to the membership on accept. Empty when group_scope=all.

expires_atstring
accepted_atstring
invitee_has_accountbooleanrequired

Whether the invited email address already has an account. Drives whether the accept screen offers signing in or signing up. Matched case-insensitively, the same way accepting compares the two addresses.

404Not found
typestring
titlestring
statusinteger
detailstring
410Invitation expired

Body not documented.

Accept → join account (creates membership incl. the invitation's group grants)

POST/v2/invitations/accept/{token}

The membership is created with the invitation's role, group_scope and group grants in one transaction.

path parameters

tokenstringrequired

Responses

200OK
accountobject
rolestring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring
410Invitation expired

Body not documented.

Accept an invitation by creating the account user (no session required)

POST/v2/invitations/accept/{token}/signup

The path for an invitee who has no login yet: the token identifies both the invitation and the email, so it stands in for authentication — hence no bearer token. Creates the user, verifies the email by virtue of the token having reached it, creates the membership with the invitation's role and group grants, and returns a session, all in one transaction. An invitee who already has a login uses POST /v2/invitations/accept/{token} instead, with their token.

path parameters

tokenstringrequired

Request body

full_namestringrequired
passwordstringrequired

Responses

201Created — user
access_tokenstring
refresh_tokenstring
userobject
accountobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409A user with that email already exists — accept with a session instead

Body not documented.

410Invitation expired

Body not documented.


ApiKeys

Keys issued for the active account (never the secrets)

GET/v2/api-keys

Includes rotated/expired/revoked keys so the audit trail stays visible. Refused for a token with typ=api_key — a key can never enumerate or manage keys.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring

Issue a key — the secret is returned here and never again

POST/v2/api-keys

owner/admin only. The key gets its own role (viewer, member or admin, never owner), a readonly flag that defaults to true, and its own group scope, which must be a subset of the creator's writeable groups. An expiry is OPTIONAL with no default — send neither expires_in nor expires_at and the key does not expire; 90 days remains the maximum for one that does. Store the secret immediately — only its hash is kept.

Request body

namestringrequired

what this key is for — shown in the list and in the audit trail

rolestring
typestring

What the key is for. Leave it out for an ordinary key. `sim` and `production` are reserved for internal machine credentials and cannot be requested through this API by an ordinary caller — asking for either is refused with 403. Neither type can be changed afterwards — not by PATCH, and not by rotating.

readonlyboolean
group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Required (non-empty) when group_scope=selected. Must be a subset of the creator's own writeable groups.

expires_instring

Validity from now — "7d", "30d", "90d" or any duration string up to 90d, which is the maximum. OPTIONAL and with no default: send neither expires_in nor expires_at and the key does not expire. Mutually exclusive with expires_at.

expires_atstring

Exact expiry, for lining a key up with a contract end date. Must be in the future and no more than 90 days out. Optional — see expires_in.

Responses

201Created — secret included exactly once
idstring
namestring
access_key_idstring

public part of the key — safe to display and log; identifies it in the UI

rolestring

the account role the key acts as; owner is impossible. viewer = read everything on the account, change nothing

typestring

what the key is for. api = the ordinary customer credential; the other values are reserved for internal machine credentials and cannot be requested through this API by an ordinary caller. Fixed for the key's lifetime — a rotation keeps it.

readonlyboolean

true = the key may only read. A viewer key is always read-only.

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

null = the key does not expire. When set it is never more than 90 days after the key was issued.

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

set on the OLD key when rotated; it then dies at expires_at

revoked_atstring,null
created_atstring
access_key_secretstring

The private half of the credential — 40 characters, SHOWN EXACTLY ONCE, here. Only its sha256 hash is stored, so a lost secret cannot be recovered; rotate instead. It does NOT contain the access key id: the two halves travel as separate headers.

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not owner/admin, a support session, an API-key token, requesting a role above your own, requesting groups you do not administer, or requesting an internal key type this caller may not create

Body not documented.

409type=sim or type=production and the account already holds a live key of that type — rotate or revoke it first

Body not documented.

422Well-formed but unusable — e.g. a group id that does not belong to this account
typestring
titlestring
statusinteger
detailstring

One key (never its secret)

GET/v2/api-keys/{id}

404 for a key belonging to another account, so an id cannot be used to probe which keys exist elsewhere.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

200OK
idstring
namestring
access_key_idstring

public part of the key — safe to display and log; identifies it in the UI

rolestring

the account role the key acts as; owner is impossible. viewer = read everything on the account, change nothing

typestring

what the key is for. api = the ordinary customer credential; the other values are reserved for internal machine credentials and cannot be requested through this API by an ordinary caller. Fixed for the key's lifetime — a rotation keeps it.

readonlyboolean

true = the key may only read. A viewer key is always read-only.

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

null = the key does not expire. When set it is never more than 90 days after the key was issued.

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

set on the OLD key when rotated; it then dies at expires_at

revoked_atstring,null
created_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a key's name / scope / readonly / expiry (never its secret)

PATCH/v2/api-keys/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Request body

namestring
readonlyboolean
group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray

Replaces the key's whole grant set.

expires_atstring,null

movable, but never beyond 90 days after the key was ISSUED — measured from creation, so repeated updates cannot keep a key alive indefinitely. Send null to remove the expiry entirely. Omit to leave it alone. `type` is deliberately not patchable.

Responses

200OK
idstring
namestring
access_key_idstring

public part of the key — safe to display and log; identifies it in the UI

rolestring

the account role the key acts as; owner is impossible. viewer = read everything on the account, change nothing

typestring

what the key is for. api = the ordinary customer credential; the other values are reserved for internal machine credentials and cannot be requested through this API by an ordinary caller. Fixed for the key's lifetime — a rotation keeps it.

readonlyboolean

true = the key may only read. A viewer key is always read-only.

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

null = the key does not expire. When set it is never more than 90 days after the key was issued.

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

set on the OLD key when rotated; it then dies at expires_at

revoked_atstring,null
created_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409The key is revoked — issue a new one instead

Body not documented.

422Well-formed but unusable — e.g. a group id that does not belong to this account
typestring
titlestring
statusinteger
detailstring

Revoke a key

DELETE/v2/api-keys/{id}

Soft delete — the row stays for the audit trail and the access_key_id is never reused. Token issuance stops at once; tokens already minted die within 15 minutes.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Responses

204Revoked

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Issue a replacement secret, with an overlap window

POST/v2/api-keys/{id}/rotate

Returns a NEW key (new id and prefix) carrying the same name, role and scope, linked to the old one, and given the same validity window it had (not reset to the 90-day default). The old secret dies at once unless grace asks for an overlap window, in which case it keeps working for that long so an integration can be updated with no downtime. Tokens already exchanged from the old secret live out their 15 minutes regardless.

path parameters

idstringrequired

Resource id (prefixed, e.g. acc_… / usr_… / inv_…)

Request body

gracestring

How long the old secret keeps working. Default "0": it stops the moment the new one exists — the right answer when the secret leaked. Send a duration ("48h", "1h") for a planned rotation with overlap. Never extends the old key past its own expiry, so rotating a key with an hour left gives at most an hour of overlap — the 90-day ceiling is absolute.

Responses

201Created — new secret included exactly once
idstring
namestring
access_key_idstring

public part of the key — safe to display and log; identifies it in the UI

rolestring

the account role the key acts as; owner is impossible. viewer = read everything on the account, change nothing

typestring

what the key is for. api = the ordinary customer credential; the other values are reserved for internal machine credentials and cannot be requested through this API by an ordinary caller. Fixed for the key's lifetime — a rotation keeps it.

readonlyboolean

true = the key may only read. A viewer key is always read-only.

group_scopestring

all = every group in the account, present and future (mandatory for owner/admin). selected = only the groups listed alongside it. An empty selected set is legal — a parked user who can sign in but sees no estate.

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

null = the key does not expire. When set it is never more than 90 days after the key was issued.

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

set on the OLD key when rotated; it then dies at expires_at

revoked_atstring,null
created_atstring
access_key_secretstring

The private half of the credential — 40 characters, SHOWN EXACTLY ONCE, here. Only its sha256 hash is stored, so a lost secret cannot be recovered; rotate instead. It does NOT contain the access key id: the two halves travel as separate headers.

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Already rotated or revoked

Body not documented.


Plans

Catalog of plans (entitlements)

GET/v2/plans

No description yet.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

AccountTypes

Catalog of active account types

GET/v2/account-types

Returns every active product line (Neuro, Vision Lite, Vision Pro). Used by the frontend to render an admin picker; the account's current type also arrives on the access token as the account_type claim, so day-to-day gating does not need this endpoint.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

API reference

Billing

Payments

Create a Stripe checkout session (buy tokens or subscribe) — owner/admin

POST/v2/checkout-sessions

No description yet.

Request body

modestringrequired
plan_keystring

price to charge (token pack or subscription plan)

return_urlstring

Responses

201Created
urlstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Checkout session status

GET/v2/checkout-sessions/{id}

No description yet.

path parameters

idstringrequired

Responses

200OK
idstring
statusstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Purchase/charge history for the active account

GET/v2/payments

No description yet.

query parameters

cursorstring
limitinteger

Responses

200OK
entriesarrayrequired
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

A payment + receipt link

GET/v2/payments/{id}

No description yet.

path parameters

idstringrequired

Responses

200OK
idstring
account_idstring
kindstring
amount_centsinteger
currencystring
token_amountinteger
statusstring
receipt_urlstring
created_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Refunds

Refund history for the active account

GET/v2/refunds

No description yet.

query parameters

cursorstring
limitinteger

Responses

200OK
entriesarrayrequired
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

A refund

GET/v2/refunds/{id}

No description yet.

path parameters

idstringrequired

Responses

200OK
idstring
payment_idstring
amount_centsinteger
token_amountinteger
destinationstring
reasonstring
statusstring
created_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Customer

Billing summary — Stripe customer + dollar store-credit balance

GET/v2/billing

No description yet.

Responses

200OK
account_idstringrequired
provider_customer_idstring,null
credit_balance_centsintegerrequired
currencystringrequired
active_subscriptionnullrequired

The account's current subscription, or null if never subscribed. Sourced from account.subscriptions; billing populates it on Stripe webhooks.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

Cancel the caller's active subscription at the end of the current period. Idempotent — returns a canceled snapshot if there is no live subscription. Stripe fires customer.subscription.updated (billing propagates cancel_at_period_end to account) and later customer.subscription.deleted at period end.

POST/v2/billing/subscription/cancel

No description yet.

Responses

200OK
statusstringrequired
cancel_at_period_endbooleanrequired
current_period_endstring,nullrequired
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Undo an unsubscribe during the grace window (status=active, cancel_at_period_end=true). Flips cancel_at_period_end back to false on every cancelling sub for the caller's customer. No-op returning the current snapshot when nothing is cancelling. 404 when the sub is already terminal (past current_period_end).

POST/v2/billing/subscription/resume

No description yet.

Responses

200OK
statusstringrequired
cancel_at_period_endbooleanrequired
current_period_endstring,nullrequired
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Switch the caller's active subscription to a different recurring plan. Upgrade (new price greater) is immediate with a prorated invoice for the delta. Downgrade (new price smaller) is immediate but non-prorated so the current period keeps the already-granted higher-tier credits; the next renewal invoices at the new rate.

POST/v2/billing/subscription/change-plan

No description yet.

Request body

plan_keystringrequired

Target recurring plan_key. Must exist in plan_prices with active=true and a non-null interval.

Responses

200OK
plan_keystringrequired
change_typestringrequired

Upgrade = new price > current, invoice generated for prorated delta. Downgrade = new price < current, no proration invoice; current period keeps its already-granted higher-tier credits.

statusstringrequired
cancel_at_period_endbooleanrequired
current_period_endstring,nullrequired
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Money ledger (cents)

GET/v2/billing/ledger

No description yet.

query parameters

cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

Saved payment methods

GET/v2/payment-methods

No description yet.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

Start adding a payment method (returns a Stripe SetupIntent client secret)

POST/v2/payment-methods

No description yet.

Responses

201Created
client_secretstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

Remove a payment method

DELETE/v2/payment-methods/{id}

No description yet.

path parameters

idstringrequired

Responses

204Removed

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Prices

Plan → price mappings

GET/v2/plan-prices

No description yet.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

Per-action credit cost catalog

GET/v2/usage-prices

No description yet.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed, RFC 9457 with status 403: either the credential grants no access to billing at all (a member or viewer, whose token carries `bil: n`), or it grants read only and this is a write.
typestring
titlestring
statusinteger
detailstring

API reference

Campaign

Campaigns

List campaigns for the active account

GET/v2/campaigns

No description yet.

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

statusstring

Responses

200A page of campaigns
dataarray
next_cursorstring,null
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a campaign (draft)

POST/v2/campaigns

No description yet.

Request body

namestringrequired
descriptionstring,null
briefobject

Campaign-level brief (versioned blob). The same shape feeds each campaign ad's ad_profile at score time.

start_datestring,null
end_datestring,null
priorityinteger
metadataobject

Responses

201Campaign created (status=draft)
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

{ version, product, objective, outcome, … } — free text, which is why the list sends only `product`

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

UI settings and wizard position only — never data any logic reads

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

Fingerprint of what was last scored, so a rescore that would ask the same question can be refused instead of charged

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Get a campaign (aggregate — brief, target, campaign ads, latest validation)

GET/v2/campaigns/{id}

No description yet.

path parameters

idstringrequired

campaign id

Responses

200The campaign
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

{ version, product, objective, outcome, … } — free text, which is why the list sends only `product`

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

UI settings and wizard position only — never data any logic reads

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

Fingerprint of what was last scored, so a rescore that would ask the same question can be refused instead of charged

targetobject
adsarray
latest_validationobject

Stage-7 preview (spec §5). Two signals per campaign ad — technical QA (errors gate publish, warnings inform) and an informational effectiveness score from the pinned/latest complete score — rolled up campaign-wide. Non-mutating; publish re-runs and freezes this onto the version.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update campaign brief fields (draft only)

PATCH/v2/campaigns/{id}

No description yet.

path parameters

idstringrequired

campaign id

Request body

namestring
descriptionstring,null
briefobject

Campaign-level brief (versioned blob). The same shape feeds each campaign ad's ad_profile at score time.

start_datestring,null
end_datestring,null
priorityinteger
metadataobject

Responses

200Updated
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

{ version, product, objective, outcome, … } — free text, which is why the list sends only `product`

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

UI settings and wizard position only — never data any logic reads

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

Fingerprint of what was last scored, so a rescore that would ask the same question can be refused instead of charged

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflicts with the current state
typestring
titlestring
statusinteger
detailstring

Delete a draft campaign

DELETE/v2/campaigns/{id}

No description yet.

path parameters

idstringrequired

campaign id

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflicts with the current state
typestring
titlestring
statusinteger
detailstring

Set the campaign target (group, zones, locations)

PUT/v2/campaigns/{id}/target

The group defines the candidate locations; zones apply campaign-wide; location_ids is the chosen INCLUDE set. Note the include set is frozen at selection — adding a location to the group later does NOT auto-include it. Location ids come from the organization service (GET /v2/locations?group_id=…).

path parameters

idstringrequired

campaign id

Request body

group_idstringrequired
zone_idsarray
location_idsarray

Responses

200Target set
group_idstring
zone_idsarray
location_idsarray

chosen include set (frozen at selection)

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflicts with the current state
typestring
titlestring
statusinteger
detailstring

Copy a campaign's configuration into a new draft

POST/v2/campaigns/{id}/copy

Duplicates the brief, targeting and every placement (creative, screen, order, duration and triggers) into a NEW draft campaign, named "Copy of <name>" — or "Copy of <name> copy1", copy2 … if that is taken.

Rows are copied as they stand rather than replayed through the create endpoints, so a campaign holding a value that today's validation would refuse keeps it instead of silently losing the placement that carried it. This is the only way to reuse an archived campaign, which cannot be activated, deactivated or edited.

Not copied: the lifecycle (a copy is always a draft), the published version, the scoring fingerprint, the wizard position held in metadata, and each placement's pinned scored version — a score belongs to the campaign it was measured in, so a copy is scored fresh. The run window is not copied either; the copy starts on today→+7, as a new campaign does. The underlying ad rows ARE shared, which keeps a creative's score history in one place.

path parameters

idstringrequired

campaign id

Responses

201The new draft campaign
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

{ version, product, objective, outcome, … } — free text, which is why the list sends only `product`

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

UI settings and wizard position only — never data any logic reads

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

Fingerprint of what was last scored, so a rescore that would ask the same question can be refused instead of charged

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Campaign Ads

List campaign ads (playlists) for the campaign

GET/v2/campaigns/{id}/ads

No description yet.

path parameters

idstringrequired

campaign id

Responses

200Campaign ads, grouped playback order per screen tag
dataarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Place an ad on a screen tag

POST/v2/campaigns/{id}/ads

References an existing ad (the shared creative). Its media, and any existing score, surface via the ad.

path parameters

idstringrequired

campaign id

Request body

ad_idstringrequired

the shared creative to place (prefixed ad_…)

ad_profile_idstring,null

the selected scored version to run (an ad_profile of this ad); optional — pick later via PATCH

screen_idstringrequired
sort_orderinteger
play_duration_sinteger
triggersobject

When a creative plays. Evaluated on the DEVICE against what its camera reports about the viewer. Distinct from an ad_profile, which records the context the creative was SCORED for. One key per attribute, all of which must hold (AND). Each value is an array of alternatives, any of which may hold (OR). A numeric span is the string "x-y", inclusive at both ends — the only numeric form a device parses. A comma-joined "10-15,70-85" is NOT valid and matches nothing; use two array entries. An empty object means no conditions: the default creative, which plays when nothing else matches. Attributes outside the list below are rejected. They are not things a screen can report about a viewer, so a creative carrying one could never play and nothing would say why. Legacy shapes ({rules:[…]} and keywords as an array of single-key objects) are still accepted on write and converted, but should not be used by new callers.

Responses

201Campaign ad created
idstring
campaign_idstring
ad_idstring

the shared creative (prefixed ad_…)

ad_profile_idstring,null

the selected scored version (ad_profile), or null if none pinned

screen_idstring
sort_orderinteger
play_duration_sinteger
triggersobject

When a creative plays. Evaluated on the DEVICE against what its camera reports about the viewer. Distinct from an ad_profile, which records the context the creative was SCORED for. One key per attribute, all of which must hold (AND). Each value is an array of alternatives, any of which may hold (OR). A numeric span is the string "x-y", inclusive at both ends — the only numeric form a device parses. A comma-joined "10-15,70-85" is NOT valid and matches nothing; use two array entries. An empty object means no conditions: the default creative, which plays when nothing else matches. Attributes outside the list below are rejected. They are not things a screen can report about a viewer, so a creative carrying one could never play and nothing would say why. Legacy shapes ({rules:[…]} and keywords as an array of single-key objects) are still accepted on write and converted, but should not be used by new callers.

adobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a campaign ad (reorder, duration, screen tag)

PATCH/v2/campaigns/{id}/ads/{campaign_ad_id}

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

the ad's placement within the campaign, not the ad itself

Request body

sort_orderinteger
play_duration_sinteger
screen_idstring
ad_profile_idstring,null

pin a scored version (ad_profile of this ad), or null to clear the pin

Responses

200Updated
idstring
campaign_idstring
ad_idstring

the shared creative (prefixed ad_…)

ad_profile_idstring,null

the selected scored version (ad_profile), or null if none pinned

screen_idstring
sort_orderinteger
play_duration_sinteger
triggersobject

When a creative plays. Evaluated on the DEVICE against what its camera reports about the viewer. Distinct from an ad_profile, which records the context the creative was SCORED for. One key per attribute, all of which must hold (AND). Each value is an array of alternatives, any of which may hold (OR). A numeric span is the string "x-y", inclusive at both ends — the only numeric form a device parses. A comma-joined "10-15,70-85" is NOT valid and matches nothing; use two array entries. An empty object means no conditions: the default creative, which plays when nothing else matches. Attributes outside the list below are rejected. They are not things a screen can report about a viewer, so a creative carrying one could never play and nothing would say why. Legacy shapes ({rules:[…]} and keywords as an array of single-key objects) are still accepted on write and converted, but should not be used by new callers.

adobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Remove a campaign ad from the campaign

DELETE/v2/campaigns/{id}/ads/{campaign_ad_id}

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

the ad's placement within the campaign, not the ad itself

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Get a campaign ad's playback triggers

GET/v2/campaigns/{id}/ads/{campaign_ad_id}/triggers

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

the ad's placement within the campaign, not the ad itself

Responses

200Triggers
keywordsobject
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Replace the campaign ad's triggers

PUT/v2/campaigns/{id}/ads/{campaign_ad_id}/triggers

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

the ad's placement within the campaign, not the ad itself

Request body

keywordsobject

Responses

200Triggers replaced
keywordsobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Scoring & Publishing

Validate + score the campaign (per campaign ad)

POST/v2/campaigns/{id}/validate

Runs QA validation (resolution, duration, file size, format, duplicate media, empty playlist, trigger conflicts, missing metadata, reach) plus the effectiveness (MIA) score per campaign ad — each ad resolves into an ad_profile (type=campaign) and runs the shared scoring engine — and rolls up to a campaign score. Errors block publishing; warnings do not. Non-mutating.

path parameters

idstringrequired

campaign id

Responses

200Validation + scoring result
campaign_idstring
okboolean

true only when there are zero errors — the publish gate

errorsarray
warningsarray
campaign_scorenumber,null

average of scored ads (0..100); null if none scored

scored_ad_countinteger
total_ad_countinteger
screen_countinteger
estimated_locationsinteger

reach proxy = target include-set size

validated_atstring
adsarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Activate the campaign (freeze an immutable version and send it to devices)

POST/v2/campaigns/{id}/activate

Re-validates (aborts on any error), freezes a numbered version with the resolved locations/zones, affected CSIDs and frozen ad scores, generates the deployment package, and publishes a lightweight retained MQTT notification per CSID.

path parameters

idstringrequired

campaign id

Responses

201Published version
idstring
campaign_idstring
version_numberinteger
manifestobject

The frozen playlist for one published version, stored inline on the version. `storage_key` is the stable media handle; the DEVICE-facing signed url is derived at pull time (never frozen — presigned urls expire).

resolved_location_idsarray
resolved_zone_idsarray
affected_csidsarray
validation_summaryobject

Stage-7 preview (spec §5). Two signals per campaign ad — technical QA (errors gate publish, warnings inform) and an informational effectiveness score from the pinned/latest complete score — rolled up campaign-wide. Non-mutating; publish re-runs and freezes this onto the version.

published_atstring
notifyobject

MQTT fan-out result (present only on the publish response)

401Not authenticated
typestring
titlestring
statusinteger
detailstring
402The account has no tokens left for this operation
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Validation errors present — cannot publish (RFC 9457, includes the blocking errors)
typestring
titlestring
statusinteger
detailstring

Deactivate (published → draft) so the campaign can be edited again

POST/v2/campaigns/{id}/deactivate

Flips a live campaign back to draft so it can be re-scored and re-published (which freezes a NEW version — frozen versions are kept as history). The campaign leaves the default listing's "published" set and devices drop it on their next pull. Only valid on a published campaign.

path parameters

idstringrequired

campaign id

Responses

200The campaign
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

{ version, product, objective, outcome, … } — free text, which is why the list sends only `product`

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

UI settings and wizard position only — never data any logic reads

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

Fingerprint of what was last scored, so a rescore that would ask the same question can be refused instead of charged

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Campaign is not published
typestring
titlestring
statusinteger
detailstring

Archive (soft-delete) a campaign

POST/v2/campaigns/{id}/archive

Soft-deletes a campaign by flipping it to 'archived'. Use this for a published campaign, which cannot be hard-deleted (DELETE is draft-only — frozen version rows reference it). Archived campaigns are excluded from the default listing and taken off-air on the devices' next pull. Idempotent.

path parameters

idstringrequired

campaign id

Responses

204Archived

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Versions

List published versions (newest first)

GET/v2/campaigns/{id}/versions

No description yet.

path parameters

idstringrequired

campaign id

Responses

200Versions
dataarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Get one published version (frozen manifest)

GET/v2/campaigns/{id}/versions/{version}

No description yet.

path parameters

idstringrequired

campaign id

versionintegerrequired

Responses

200The version
idstring
campaign_idstring
version_numberinteger
manifestobject

The frozen playlist for one published version, stored inline on the version. `storage_key` is the stable media handle; the DEVICE-facing signed url is derived at pull time (never frozen — presigned urls expire).

resolved_location_idsarray
resolved_zone_idsarray
affected_csidsarray
validation_summaryobject

Stage-7 preview (spec §5). Two signals per campaign ad — technical QA (errors gate publish, warnings inform) and an informational effectiveness score from the pinned/latest complete score — rolled up campaign-wide. Non-mutating; publish re-runs and freezes this onto the version.

published_atstring
notifyobject

MQTT fan-out result (present only on the publish response)

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Ad Libraries

List ad libraries for the active account

GET/v2/ad-libraries

No description yet.

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

200A page of libraries
dataarray
next_cursorstring,null
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create an ad library

POST/v2/ad-libraries

No description yet.

Request body

namestringrequired
descriptionstring,null
metadataobject

Responses

201Library created
idstring
account_idstring
group_idstring,null

org group (access filtering)

namestring
descriptionstring,null
metadataobject
statusstring
ad_countinteger

folded-in summary — ads in this library

created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Get one ad library (with ad count + embedded ads)

GET/v2/ad-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Responses

200The library, with its ads folded in (single call for the detail view)
idstring
account_idstring
group_idstring,null

org group (access filtering)

namestring
descriptionstring,null
metadataobject
statusstring
ad_countinteger

folded-in summary — ads in this library

created_atstring
updated_atstring
adsarray

the ads in this library (bounded page, up to 200)

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Rename or update an ad library

PATCH/v2/ad-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Request body

namestring
descriptionstring,null
metadataobject
statusstring

Responses

200Updated library
idstring
account_idstring
group_idstring,null

org group (access filtering)

namestring
descriptionstring,null
metadataobject
statusstring
ad_countinteger

folded-in summary — ads in this library

created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete an ad library

DELETE/v2/ad-libraries/{id}

Drops the library and its membership rows. Ads themselves are NOT deleted — they survive and can still belong to other libraries. The default library cannot be deleted.

path parameters

idstringrequired

ad library or media library id

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflicts with the current state
typestring
titlestring
statusinteger
detailstring

Add an ad to a library (idempotent)

PUT/v2/ad-libraries/{id}/ads/{ad_id}

No description yet.

path parameters

idstringrequired

ad library or media library id

ad_idstringrequired

ad id, within the library named by the path

Responses

204Ad is now a member (already-a-member is a no-op)

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Remove an ad from a library

DELETE/v2/ad-libraries/{id}/ads/{ad_id}

Removes the membership link only. The ad itself is not deleted.

path parameters

idstringrequired

ad library or media library id

ad_idstringrequired

ad id, within the library named by the path

Responses

204Membership removed

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Ads

List ads

GET/v2/ads

Scoped to the active account. Pass library_id to list one library; omit it to list all ads in the account.

query parameters

library_idstring

Filter to one library (prefixed asl_…)

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

200A page of ads
dataarray
next_cursorstring,null
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Create an ad from a media-library asset

POST/v2/ads

Wraps one media asset as an ad and links it to library_id — or to the account's auto-provisioned Default ad library when library_id is omitted. No scoring runs yet; a profile + score are separate calls.

Request body

media_asset_idstringrequired

the asset to wrap (prefixed mda_…)

library_idstring

target library; defaults to the account Default

namestring
metadataobject

Responses

201Ad created
idstring
account_idstring
group_idstring,null

org group (access filtering)

media_asset_idstring

the media-library asset this ad wraps

namestring,null

defaults to the media asset's name when omitted on create

metadataobject
library_idsarray

libraries this ad belongs to

score_countinteger

folded-in summary — scoring runs so far

latest_scoreobject

One scoring run against an ad_profile. references the profile it scored.

created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Get one ad

GET/v2/ads/{id}

No description yet.

path parameters

idstringrequired

ad id

Responses

200The ad
idstring
account_idstring
group_idstring,null

org group (access filtering)

media_asset_idstring

the media-library asset this ad wraps

namestring,null

defaults to the media asset's name when omitted on create

metadataobject
library_idsarray

libraries this ad belongs to

score_countinteger

folded-in summary — scoring runs so far

latest_scoreobject

One scoring run against an ad_profile. references the profile it scored.

created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update an ad's editable fields

PATCH/v2/ads/{id}

No description yet.

path parameters

idstringrequired

ad id

Request body

namestring
metadataobject

Responses

200Updated ad
idstring
account_idstring
group_idstring,null

org group (access filtering)

media_asset_idstring

the media-library asset this ad wraps

namestring,null

defaults to the media asset's name when omitted on create

metadataobject
library_idsarray

libraries this ad belongs to

score_countinteger

folded-in summary — scoring runs so far

latest_scoreobject

One scoring run against an ad_profile. references the profile it scored.

created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete an ad

DELETE/v2/ads/{id}

Deletes the ad, its profiles and all their scores, and removes it from every library. The underlying media asset is untouched. Blocked (409) if the ad is used by any campaign.

path parameters

idstringrequired

ad id

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflicts with the current state
typestring
titlestring
statusinteger
detailstring

Ad Profiles

List an ad's saved profiles (newest first)

GET/v2/ads/{id}/profiles

The saved scoring inputs for this ad. The latest is the one "Score again" pre-fills — no client-side draft store needed.

path parameters

idstringrequired

ad id

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

200A page of profiles
dataarray
next_cursorstring,null
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Scores

List the scores of an ad

GET/v2/ads/{id}/scores

The score history across all of the ad's profiles — one row per run.

path parameters

idstringrequired

ad id

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

200A page of scores
dataarray
next_cursorstring,null
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Score the ad — with new inputs, or re-run a saved profile

POST/v2/ads/{id}/scores

Reserves one token via the Account hold API and enqueues a scoring run, returning 202 with status queued. Provide EITHER profile (new scoring inputs — creates a new immutable ad_profile) OR ad_profile_id (re-run an existing profile, e.g. after a prompt/weights change). Send X-Idempotency-Key to make a replay resolve to the in-flight score instead of charging twice.

path parameters

idstringrequired

ad id

header parameters

X-Idempotency-Keystring

Request body

any

Responses

202Scoring run queued
idstring
account_idstring
group_idstring,null
ad_profile_idstring

the profile that was scored

ad_profileobject

One immutable, versioned snapshot of the scoring input for an ad. type=scoring for the ad-scoring tool; type=campaign when derived from a campaign placement.

statusstring
resultobject,null

scoring output — overall + dimensions, pillars, diagnosis tags, recommendations

comment_analysisobject,null
divergence_scorenumber,null

|ai_overall − audience_score|; null until source comments are analysed

prompt_versionobject,null

prompt versions used (calibration provenance)

error_messagestring,null
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
402The account has no tokens left for this operation
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Get one score

GET/v2/scores/{id}

No description yet.

path parameters

idstringrequired

score id

Responses

200The score
idstring
account_idstring
group_idstring,null
ad_profile_idstring

the profile that was scored

ad_profileobject

One immutable, versioned snapshot of the scoring input for an ad. type=scoring for the ad-scoring tool; type=campaign when derived from a campaign placement.

statusstring
resultobject,null

scoring output — overall + dimensions, pillars, diagnosis tags, recommendations

comment_analysisobject,null
divergence_scorenumber,null

|ai_overall − audience_score|; null until source comments are analysed

prompt_versionobject,null

prompt versions used (calibration provenance)

error_messagestring,null
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Live progress for one scoring run (server-sent events)

GET/v2/scores/{id}/events

Streams scoring progress so a client does not have to poll GET /v2/scores/{id}. The first event is always the CURRENT state, so a client that connects mid-run (or reconnects after a reload) is never left with an empty bar. The stream ends with a "done" event and the server closes the connection; a run that had already finished before the client connected gets its snapshot and "done" immediately. Clients should read this with fetch + ReadableStream rather than EventSource, so the bearer token travels in the Authorization header instead of the query string. Progress is best-effort and is not persisted: if the stream drops, fall back to polling GET /v2/scores/{id}.

path parameters

idstringrequired

score id

Responses

200An event stream. Each frame is an SSE event whose `data` is one JSON object of the shape below — `event: progress` while the run is going, then a single `event: done`, after which the server closes the connection. The example is the JSON payload only. SSE framing is not shown because a YAML example cannot carry the literal blank line that terminates a frame without a generator folding it back into one line.

Returns text/event-stream.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Cancel a queued or running score

POST/v2/scores/{id}/cancel

Releases the token hold and marks the run cancelled. Only valid while queued or running.

path parameters

idstringrequired

score id

Responses

200Cancelled
idstring
account_idstring
group_idstring,null
ad_profile_idstring

the profile that was scored

ad_profileobject

One immutable, versioned snapshot of the scoring input for an ad. type=scoring for the ad-scoring tool; type=campaign when derived from a campaign placement.

statusstring
resultobject,null

scoring output — overall + dimensions, pillars, diagnosis tags, recommendations

comment_analysisobject,null
divergence_scorenumber,null

|ai_overall − audience_score|; null until source comments are analysed

prompt_versionobject,null

prompt versions used (calibration provenance)

error_messagestring,null
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflicts with the current state
typestring
titlestring
statusinteger
detailstring

Media Libraries

List media libraries for the active account

GET/v2/media-libraries

No description yet.

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

200A page of libraries
dataarray
next_cursorstring,null
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a media library

POST/v2/media-libraries

No description yet.

Request body

namestringrequired
descriptionstring,null
metadataobject

Responses

201Library created
idstring
account_idstring
namestring
descriptionstring,null
metadataobject

arbitrary extra info

asset_countinteger

folded-in summary — assets in this library

created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Get one media library (with asset count + embedded assets)

GET/v2/media-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Responses

200The library, with its assets folded in (single call for the detail view)
idstring
account_idstring
namestring
descriptionstring,null
metadataobject

arbitrary extra info

asset_countinteger

folded-in summary — assets in this library

created_atstring
updated_atstring
assetsarray

the assets in this library (bounded page, up to 200)

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Rename or update a media library

PATCH/v2/media-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Request body

namestring
descriptionstring,null
metadataobject

Responses

200Updated library
idstring
account_idstring
namestring
descriptionstring,null
metadataobject

arbitrary extra info

asset_countinteger

folded-in summary — assets in this library

created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete a media library

DELETE/v2/media-libraries/{id}

Drops the library and its membership rows. Assets themselves are NOT deleted — they survive and can still belong to other libraries. Any library can be deleted.

path parameters

idstringrequired

ad library or media library id

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Add an asset to a library (idempotent)

PUT/v2/media-libraries/{id}/assets/{asset_id}

No description yet.

path parameters

idstringrequired

ad library or media library id

asset_idstringrequired

media asset id, within the library named by the path

Responses

204Asset is now a member of the library (already-a-member is a no-op)

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Remove an asset from a library

DELETE/v2/media-libraries/{id}/assets/{asset_id}

Removes the membership link only. The asset itself is not deleted.

path parameters

idstringrequired

ad library or media library id

asset_idstringrequired

media asset id, within the library named by the path

Responses

204Membership removed

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Media Assets

List media assets

GET/v2/media-assets

Scoped to the active account. Pass library_id to list one library; omit it to list all assets in the account. asset_role narrows to a purpose — the studio asks for a brand's logos and guidelines this way.

query parameters

library_idstring

Filter to one library (prefixed mlb_…)

asset_rolestring

Filter by what the asset is FOR: ad | general | logo | guideline. Repeat the parameter or pass a comma-separated list to ask for several at once. An unknown value is a 400, never a silently unfiltered page.

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

200A page of assets
dataarray
next_cursorstring,null
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Reserve a presigned S3 upload (direct-to-S3)

POST/v2/media-assets/upload-url

Returns a storage_key + presigned S3 POST policy so the browser uploads the file DIRECTLY to S3 — bytes never transit the API pod. No DB write here; call POST /v2/media-assets/confirm once the S3 upload succeeds.

Request body

filenamestringrequired
media_typestringrequired
asset_rolestring

What the asset is FOR, orthogonal to media_type. Optional — defaults to `general`.

brand_namestring

Free-text brand until Brand exists as an entity. Not an identifier.

content_typestringrequired
library_idstringrequired

Target library (required; validated on confirm)

Responses

200Presigned upload reserved
storage_keystring
uploadobject

presigned S3 POST — the browser posts the file to `url` with `fields` (prepended) + the file part

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Confirm a presigned direct-to-S3 upload

POST/v2/media-assets/confirm

Called after the browser's direct upload to S3 succeeds. Verifies the object landed (HEAD) and creates the media_assets row. The passthrough POST /v2/media-assets (multipart) remains as a fallback.

Request body

storage_keystringrequired

from POST /v2/media-assets/upload-url

filenamestringrequired
media_typestringrequired
asset_rolestring

What the asset is FOR, orthogonal to media_type. Optional — defaults to `general`.

brand_namestring

Free-text brand until Brand exists as an entity. Not an identifier.

library_idstringrequired

Target library (required)

namestring

Responses

201Asset created
idstring
account_idstring
namestring
media_typestring
asset_rolestring

What the asset is FOR, orthogonal to media_type — a logo and an advert are both `image`. Defaults to `general`.

brand_namestring,null

Free-text brand, matching the product catalogue, until Brand exists as an entity. Not an identifier.

source_kindstring
source_urlstring,null

set when source_kind = url

filenamestring,null
duration_snumber,null

video/audio only; null for image/pdf

urlstring,null

derived browsable URL (presigned); not stored

metadataobject

probe output + extras (content_type, size_bytes, width, height, …)

statusstring
library_idsarray

libraries this asset belongs to

created_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Library not found, or the uploaded object is missing in S3
typestring
titlestring
statusinteger
detailstring

Get one media asset

GET/v2/media-assets/{id}

No description yet.

path parameters

idstringrequired

media asset id

Responses

200The asset
idstring
account_idstring
namestring
media_typestring
asset_rolestring

What the asset is FOR, orthogonal to media_type — a logo and an advert are both `image`. Defaults to `general`.

brand_namestring,null

Free-text brand, matching the product catalogue, until Brand exists as an entity. Not an identifier.

source_kindstring
source_urlstring,null

set when source_kind = url

filenamestring,null
duration_snumber,null

video/audio only; null for image/pdf

urlstring,null

derived browsable URL (presigned); not stored

metadataobject

probe output + extras (content_type, size_bytes, width, height, …)

statusstring
library_idsarray

libraries this asset belongs to

created_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a media asset's editable fields

PATCH/v2/media-assets/{id}

No description yet.

path parameters

idstringrequired

media asset id

Request body

namestring
asset_rolestring

Retype an asset — an upload filed as `general` becomes a `logo`.

brand_namestring,null

Free-text brand until Brand exists as an entity. `null` clears it.

metadataobject

Responses

200Updated asset
idstring
account_idstring
namestring
media_typestring
asset_rolestring

What the asset is FOR, orthogonal to media_type — a logo and an advert are both `image`. Defaults to `general`.

brand_namestring,null

Free-text brand, matching the product catalogue, until Brand exists as an entity. Not an identifier.

source_kindstring
source_urlstring,null

set when source_kind = url

filenamestring,null
duration_snumber,null

video/audio only; null for image/pdf

urlstring,null

derived browsable URL (presigned); not stored

metadataobject

probe output + extras (content_type, size_bytes, width, height, …)

statusstring
library_idsarray

libraries this asset belongs to

created_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete a media asset

DELETE/v2/media-assets/{id}

Deletes the asset and removes it from every library it belonged to.

path parameters

idstringrequired

media asset id

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflicts with the current state
typestring
titlestring
statusinteger
detailstring

API reference

Device

Devices

The device as an asset — claiming one from inventory, listing and editing the estate, and taking a unit out of service or back into it. These are the endpoints a person uses from the dashboard.

List devices

GET/v2/devices

device:read. Scoped to the caller's account (staff see all).

Responses

200Device list.array

Claim a device from inventory

POST/v2/devices

device:write. Verifies claim_code against the inventory row, creates the device with account_id = caller's token account, marks the inventory row claimed.

Request body

serial_nostringrequired
claim_codestringrequired
configobject

Responses

201Claimed.
idstring

Device UUID — internal id (== mTLS CN).

serial_nostring
macstring,null
imeistring,null
hardware_versionstring,null
firmware_versionstring,null
hostnamestring,null
ipstring,null
is_activeboolean
onlineboolean

Derived from `heartbeat_lastseen.last_seen_at` vs configured timeout.

account_idstring

The account this device belongs to. Null for a device that exists in inventory but has not been claimed. Always returned so the response has one shape regardless of who is asking.

created_atstring
updated_atstring
403Invalid claim code / lacks device:write / non-vision account.

Body not documented.

404Unknown serial.

Body not documented.

409Already claimed or not yet provisioned.

Body not documented.

One device

GET/v2/devices/{id}

device:read. 404 if outside the caller's account.

path parameters

idstringrequired

Responses

200The device.
idstring

Device UUID — internal id (== mTLS CN).

serial_nostring
macstring,null
imeistring,null
hardware_versionstring,null
firmware_versionstring,null
hostnamestring,null
ipstring,null
is_activeboolean
onlineboolean

Derived from `heartbeat_lastseen.last_seen_at` vs configured timeout.

account_idstring

The account this device belongs to. Null for a device that exists in inventory but has not been claimed. Always returned so the response has one shape regardless of who is asking.

created_atstring
updated_atstring
404Not found / not in account.

Body not documented.

Edit a device

PATCH/v2/devices/{id}

device:write. Cannot change id or account_id.

path parameters

idstringrequired

Responses

200Updated.

Body not documented.

Metrics summary for every device

GET/v2/devices/metrics

device:read. One row per device: average and peak CPU, memory, disk and GPU temperature over the window, plus how reachable the device was. The table view; /v2/devices/{id}/metrics is the detail view for one device.

Averages are weighted by how many heartbeats each hour actually held, so an hour with six beats does not count as much as one with sixty. Peaks are exact.

Read from the hourly figures only. A device that has never reported still appears, with nulls rather than zeroes — a silent device is exactly the one an operator is looking for, so dropping it would hide the problem.

query parameters

fromstring

ISO-8601 start, inclusive. Defaults to 7 days before `to`.

tostring

ISO-8601 end, exclusive. Defaults to now.

Responses

200One summary row per device the caller can see.
fromstring
tostring
resolutionstring

Always hourly. Per-minute detail does not improve a summary.

devicesarray
400Bad window.

Body not documented.

Device metrics over time

GET/v2/devices/{id}/metrics

device:read. EVERY parameter a heartbeat carries, as a time series, for one device: CPU total and per-core, memory as percent and MB, disk, uptime, GPU utilisation and memory, all six thermal zones, three power rails, face-database size, and pipeline health. GET /v2/devices already answers whether a device is up right now; this answers how it has been behaving, which a last-seen timestamp cannot express — a device that has been flapping all week looks identical to a healthy one on that flag.

The response is COLUMNAR: timestamps once, then one array per parameter aligned to it by index. Every array is exactly points long, with null where a device did not report that parameter. Read a chart series straight off series; nothing needs pivoting.

Resolution is chosen for you unless you force it, because two limits override any preference: minute-resolution rows are kept for 7 days only, and a multi-day window at that resolution returns more points than a chart can use. Windows of 48 hours or less are served from the minute table, longer ones from the hourly rollup, and anything starting before the retention boundary is served hourly whatever you asked for. The response always states which table answered.

At hour each parameter becomes {avg, max} rather than a bare array, and samples / expected appear alongside — how many heartbeats arrived against how many a full hour should hold. That is the reachability record: 43 of 60 means the device was reachable 72% of that hour, and a gap is a real gap rather than something to interpolate through.

The device block is not a series: hostname, OS and the CURRENT per-camera pipeline state. Per-camera history is deliberately not kept — the series collapses cameras to a worst-state plus a stalled count, and device.cameras says which one is affected right now.

path parameters

idstringrequired

query parameters

fromstring

ISO-8601 start, inclusive. Defaults to 7 days before `to`.

tostring

ISO-8601 end, exclusive. Defaults to now.

resolutionstring

`auto` picks minute or hour from the window. `minute` is downgraded to `hour` when the window starts before the 7-day raw retention boundary, since those rows no longer exist.

Responses

200The series, plus the same online flag the device reads carry.
device_idstring
namestring,null

`hostname` falling back to `serial_no` — the same rule the internal device list uses, so one device does not read as two different things depending on which endpoint you asked.

serial_nostring,null
account_idstring,null

Returned rather than merely enforced. Callers only ever see their own account, which the scope filter guarantees; the field ships so the response has one shape regardless of who is asking.

fromstring

Start of the window

tostring

End of the window

resolutionstring

Which table answered. Stated so granularity never changes silently under a chart.

last_seen_atstring,null
onlineboolean

Same threshold as `GET /v2/devices` (`HEARTBEAT_TIMEOUT_MS`, 5 minutes by default). Repeated here rather than redefined, so a device can never read online on one endpoint and offline on another.

truncatedboolean

True when the window held more points than the cap and the series was cut.

pointsinteger

Length of `timestamps` and of every array in `series`.

timestampsarray

The shared index. Minute-truncated UTC at `minute` resolution and hour-truncated at `hour`.

seriesobject

Minute resolution — one value per minute per parameter. FLAT: every key is the database column name, so there is nothing to map in either direction and no nesting to walk. `cpu_core_pct` is the only nested value, unavoidably — a device has more than one core.

deviceobject

Not a series — identity and current pipeline state off the latest heartbeat. A hostname does not need 2000 copies of itself. `cameras` is the one thing the collapsed series cannot answer: which camera is stalled, right now. Per-camera HISTORY is deliberately not stored; if it ever changes a decision, that is a new table rather than a change to this shape.

400Bad window or resolution.

Body not documented.

404Not found / not in account.

Body not documented.

Take a device out of service

PATCH/v2/devices/{id}/decommission

device:write. Marks the device inactive.

path parameters

idstringrequired

Responses

200Marked inactive.

Body not documented.

Return a device to service

PATCH/v2/devices/{id}/reactivate

device:write. Marks the device active.

path parameters

idstringrequired

Responses

200Reactivated.

Body not documented.


API reference

Organization

Groups

Groups in the active account (cursor-paginated)

GET/v2/groups

No description yet.

query parameters

cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a group

POST/v2/groups

No description yet.

Request body

namestringrequired
descriptionstring
metadataobject

Responses

201Created
idstring
account_idstring
namestring
descriptionstring,null
location_countinteger

folded-in summary — locations in the group

can_writeboolean

may the CALLER write inside this group — derived from their token scope, not stored. owner/admin are always true; a member is true only where their grant is group_admin. Lets a client offer the right placement choices without decoding the compact groups claim.

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Read a group (incl. folded-in location count)

GET/v2/groups/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
idstring
account_idstring
namestring
descriptionstring,null
location_countinteger

folded-in summary — locations in the group

can_writeboolean

may the CALLER write inside this group — derived from their token scope, not stored. owner/admin are always true; a member is true only where their grant is group_admin. Lets a client offer the right placement choices without decoding the compact groups claim.

metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Rename / update a group

PATCH/v2/groups/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

namestring
descriptionstring
metadataobject

Responses

200OK
idstring
account_idstring
namestring
descriptionstring,null
location_countinteger

folded-in summary — locations in the group

can_writeboolean

may the CALLER write inside this group — derived from their token scope, not stored. owner/admin are always true; a member is true only where their grant is group_admin. Lets a client offer the right placement choices without decoding the compact groups claim.

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete a group (409 if it still has locations — ON DELETE RESTRICT)

DELETE/v2/groups/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Locations

List locations in the active account (optionally one group)

GET/v2/locations

No description yet.

query parameters

group_idstring
cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a location in a group

POST/v2/locations

No description yet.

Request body

group_idstringrequired
namestringrequired
timezonestringrequired

IANA timezone; required — a location without one produces no analytics (rollups bucket by its local day/hour).

gps_latnumber
gps_longnumber
addressobject

Canonical postal address; keys shared with the frontend location editor.

opening_hoursarray

Opening intervals. Split hours are two entries for the same weekday; a closed day has none. An empty array means the hours are UNKNOWN, not that the location is closed.

metadataobject

Responses

201Created
idstring
account_idstring
group_idstring
namestring
timezonestring,null
gps_latnumber,null
gps_longnumber,null
addressobject

Canonical postal address; keys shared with the frontend location editor.

opening_hoursarray

Opening intervals. Split hours are two entries for the same weekday; a closed day has none. An empty array means the hours are UNKNOWN, not that the location is closed.

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Read a location

GET/v2/locations/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
idstring
account_idstring
group_idstring
namestring
timezonestring,null
gps_latnumber,null
gps_longnumber,null
addressobject

Canonical postal address; keys shared with the frontend location editor.

opening_hoursarray

Opening intervals. Split hours are two entries for the same weekday; a closed day has none. An empty array means the hours are UNKNOWN, not that the location is closed.

metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a location

PATCH/v2/locations/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

namestring
timezonestring
gps_latnumber
gps_longnumber
addressobject

Canonical postal address; keys shared with the frontend location editor.

opening_hoursarray

Replaces the whole week. An empty array clears the hours, which means unknown rather than closed.

metadataobject

Responses

200OK
idstring
account_idstring
group_idstring
namestring
timezonestring,null
gps_latnumber,null
gps_longnumber,null
addressobject

Canonical postal address; keys shared with the frontend location editor.

opening_hoursarray

Opening intervals. Split hours are two entries for the same weekday; a closed day has none. An empty array means the hours are UNKNOWN, not that the location is closed.

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete a location (409 if it has ever held an installation — ON DELETE RESTRICT)

DELETE/v2/locations/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Zones attached to this location (via location_zone)

GET/v2/locations/{id}/zones

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
entriesarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Attach a zone to this location (same group; idempotent)

PUT/v2/locations/{id}/zones

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

zone_idstringrequired

Responses

204Attached

No body — 204 returns nothing.

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Detach a zone from this location

DELETE/v2/locations/{id}/zones/{zone_id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

zone_idstringrequired

Responses

204Detached

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Zones

List zones (filter by group_id or zone_code)

GET/v2/zones

No description yet.

query parameters

group_idstring
zone_codestring
cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a zone in a group (409 if (group_id, zone_code) exists)

POST/v2/zones

No description yet.

Request body

group_idstringrequired
zone_codestringrequired
namestringrequired
metadataobject

Responses

201Created
idstring
account_idstring
group_idstring
zone_codestring

stable shared identity, campaign-facing

namestring
metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Read a zone

GET/v2/zones/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
idstring
account_idstring
group_idstring
zone_codestring

stable shared identity, campaign-facing

namestring
metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a zone

PATCH/v2/zones/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

namestring
metadataobject

Responses

200OK
idstring
account_idstring
group_idstring
zone_codestring

stable shared identity, campaign-facing

namestring
metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete a zone (409 if it has ever held an installation — ON DELETE RESTRICT)

DELETE/v2/zones/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Screens

List screens (filter by zone_id or screen_key)

GET/v2/screens

No description yet.

query parameters

zone_idstring
screen_keystring
cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a screen in a zone (409 if (zone_id, screen_key) exists)

POST/v2/screens

No description yet.

Request body

zone_idstringrequired
screen_keystringrequired
screen_labelstringrequired
screen_specobject
metadataobject

Responses

201Created
idstring
account_idstring
group_idstring
zone_idstring
screen_keystring

stable screen identity, campaign-facing

screen_labelstring
screen_specobject

flexible screen spec for campaign scoring/QA

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Read a screen

GET/v2/screens/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
idstring
account_idstring
group_idstring
zone_idstring
screen_keystring

stable screen identity, campaign-facing

screen_labelstring
screen_specobject

flexible screen spec for campaign scoring/QA

metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a screen

PATCH/v2/screens/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

screen_labelstring
screen_specobject
metadataobject

Responses

200OK
idstring
account_idstring
group_idstring
zone_idstring
screen_keystring

stable screen identity, campaign-facing

screen_labelstring
screen_specobject

flexible screen spec for campaign scoring/QA

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete a screen (409 if driven by an installation — ON DELETE RESTRICT)

DELETE/v2/screens/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Installations

List installations (filter by location_id / device_id / active)

GET/v2/installations

No description yet.

query parameters

location_idstring
device_idstring
activeboolean

true = ended_at IS NULL

cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Start an installation — bind a device to a location(+zone). Recomputes the CSID.

POST/v2/installations

device_id is validated against the device service; 409 if the device already has an open installation. Screens driven and camera→screen wiring can be set here or via updates.

Request body

location_idstringrequired
zone_idstring
device_idstringrequired
namestring
location_descriptionstring
camerasarray
screen_idsarray

screens to drive (must belong to zone_id)

screen_camera_maparray
metadataobject

Responses

201Created
idstring
account_idstring
location_idstring
location_namestring,null

the location name, resolved at read time; null only if the location was deleted

group_idstring,null

the group the location belongs to

group_namestring,null

resolved through the location; a group name is not reachable from an installation in any single other call

zone_idstring,null
device_idstring
namestring,null
location_descriptionstring,null
started_atstring
ended_atstring,null

null = active

camerasarray
screen_idsarray

screens this installation drives

screen_camera_maparray
csidstring,null
metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Devices claimed to this account that are free to install

GET/v2/installations/available-devices

The account's claimed devices minus any already bound to an open installation — the picker for the assign step. Organization never reads the device database: it asks the device service and forwards the caller's JWT. Returns an empty list when DEVICE_INTERNAL_URL is unset (local and test runs).

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Assign a device to a pending installation (commissioning). Recomputes the CSID.

POST/v2/installations/{id}/assign

The commissioning step for an installation created without a device. Builds the cameras and the CSID. 409 if the installation has ended, already has a device (unassign or end it first), or the device already has an open installation; 400 if the device is not claimed to this account.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

device_idstringrequired
camerasarray
screen_camera_maparray

Responses

200Assigned
idstring
account_idstring
location_idstring
location_namestring,null

the location name, resolved at read time; null only if the location was deleted

group_idstring,null

the group the location belongs to

group_namestring,null

resolved through the location; a group name is not reachable from an installation in any single other call

zone_idstring,null
device_idstring
namestring,null
location_descriptionstring,null
started_atstring
ended_atstring,null

null = active

camerasarray
screen_idsarray

screens this installation drives

screen_camera_maparray
csidstring,null
metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Read an installation (with screens + camera wiring)

GET/v2/installations/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
idstring
account_idstring
location_idstring
location_namestring,null

the location name, resolved at read time; null only if the location was deleted

group_idstring,null

the group the location belongs to

group_namestring,null

resolved through the location; a group name is not reachable from an installation in any single other call

zone_idstring,null
device_idstring
namestring,null
location_descriptionstring,null
started_atstring
ended_atstring,null

null = active

camerasarray
screen_idsarray

screens this installation drives

screen_camera_maparray
csidstring,null
metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update an installation (screens, camera wiring, name). Recomputes the CSID.

PATCH/v2/installations/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

namestring,null
location_descriptionstring,null

free description of where in the location the device sits

zone_idstring,null
start_datestring,null
end_datestring,null
camerasarray
screen_idsarray
screen_camera_maparray
metadataobject

Responses

200OK
idstring
account_idstring
location_idstring
location_namestring,null

the location name, resolved at read time; null only if the location was deleted

group_idstring,null

the group the location belongs to

group_namestring,null

resolved through the location; a group name is not reachable from an installation in any single other call

zone_idstring,null
device_idstring
namestring,null
location_descriptionstring,null
started_atstring
ended_atstring,null

null = active

camerasarray
screen_idsarray

screens this installation drives

screen_camera_maparray
csidstring,null
metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Hard-delete an installation and its graph (for slots created in error; distinct from end, which retires and keeps history).

DELETE/v2/installations/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Decommission the device from an installation — clears the device + cameras + CSID, keeping the installation as a pending slot (screens retained) ready to be reassigned.

POST/v2/installations/{id}/unassign

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200Unassigned
idstring
account_idstring
location_idstring
location_namestring,null

the location name, resolved at read time; null only if the location was deleted

group_idstring,null

the group the location belongs to

group_namestring,null

resolved through the location; a group name is not reachable from an installation in any single other call

zone_idstring,null
device_idstring
namestring,null
location_descriptionstring,null
started_atstring
ended_atstring,null

null = active

camerasarray
screen_idsarray

screens this installation drives

screen_camera_maparray
csidstring,null
metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

End (decommission) an installation — sets ended_at. Never hard-deleted.

POST/v2/installations/{id}/end

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200Ended
idstring
account_idstring
location_idstring
location_namestring,null

the location name, resolved at read time; null only if the location was deleted

group_idstring,null

the group the location belongs to

group_namestring,null

resolved through the location; a group name is not reachable from an installation in any single other call

zone_idstring,null
device_idstring
namestring,null
location_descriptionstring,null
started_atstring
ended_atstring,null

null = active

camerasarray
screen_idsarray

screens this installation drives

screen_camera_maparray
csidstring,null
metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Brands

List brands you can see (cursor-paginated)

GET/v2/brands

Returns the account's shared brands plus those in groups your token grants. A group_id filter is INCLUSIVE - the group's own brands plus the shared ones - because that is the set usable in that group.

query parameters

group_idstring
cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a brand

POST/v2/brands

No description yet.

Request body

namestringrequired
descriptionstring
group_idstring,null

omit or null = shared with the whole account (requires owner/admin). A caller limited to specific groups MUST name one they administer — omitting it is a 400, never a silent default to shared.

media_asset_idstring,null

brand thumbnail

media_library_idstring,null
metadataobject

Responses

201Created
idstring
account_idstring
group_idstring,null

null = shared with the whole account; otherwise the group that owns it

namestring
descriptionstring,null
media_asset_idstring,null

brand thumbnail (usually a logo) as a media-library asset in the campaign service. As with a product image no URL is stored - media urls are presigned and expire, so resolve them at render time via GET /v2/media-assets/{id}.

media_library_idstring,null

the library that thumbnail lives in

product_countinteger

folded-in summary — products under this brand

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Read a brand (incl. folded-in product count)

GET/v2/brands/{id}

A brand in a group you were not granted reports 404 rather than 403 - whether it exists is itself information you do not get.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
idstring
account_idstring
group_idstring,null

null = shared with the whole account; otherwise the group that owns it

namestring
descriptionstring,null
media_asset_idstring,null

brand thumbnail (usually a logo) as a media-library asset in the campaign service. As with a product image no URL is stored - media urls are presigned and expire, so resolve them at render time via GET /v2/media-assets/{id}.

media_library_idstring,null

the library that thumbnail lives in

product_countinteger

folded-in summary — products under this brand

metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Rename / update a brand

PATCH/v2/brands/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

namestring
descriptionstring,null
media_asset_idstring,null

null clears the thumbnail

media_library_idstring,null
metadataobject

Responses

200OK
idstring
account_idstring
group_idstring,null

null = shared with the whole account; otherwise the group that owns it

namestring
descriptionstring,null
media_asset_idstring,null

brand thumbnail (usually a logo) as a media-library asset in the campaign service. As with a product image no URL is stored - media urls are presigned and expire, so resolve them at render time via GET /v2/media-assets/{id}.

media_library_idstring,null

the library that thumbnail lives in

product_countinteger

folded-in summary — products under this brand

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Delete a brand (409 while it still has products)

DELETE/v2/brands/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Products

List products you can see (cursor-paginated)

GET/v2/products

No description yet.

query parameters

brand_idstring
group_idstring
cursorstring
limitinteger

Responses

200OK
entriesarray
next_cursorstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Create a product

POST/v2/products

409 on a SKU already used in this account. 400 when the brand sits in a different group than the product - the product would otherwise render a brand name its own viewers cannot read.

Request body

brand_idstringrequired

must be a brand you can see, and either shared or in the same group as this product

skustringrequired
namestringrequired
descriptionstring
categorystring
sizestring
group_idstring,null

omit or null = shared with the whole account (requires owner/admin)

media_asset_idstring,null
media_library_idstring,null
metadataobject

Responses

201Created
idstring
account_idstring
group_idstring,null

null = shared with the whole account

brand_idstring
brand_namestring

folded in so a product list needs no second call to label its brand

skustring

unique per ACCOUNT — one SKU means one product; placement governs who may use it

namestring
descriptionstring,null
categorystring,null
sizestring,null
media_asset_idstring,null

the product image, as a media-library asset in the campaign service. No URL is stored: poster/source urls are presigned and expire, so resolve them at render time via GET /v2/media-assets/{id}.

media_library_idstring,null

the library that asset lives in. Carried so a product grid can load the few libraries its products use and build one id-to-poster map, instead of one asset call per product.

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Read a product

GET/v2/products/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

200OK
idstring
account_idstring
group_idstring,null

null = shared with the whole account

brand_idstring
brand_namestring

folded in so a product list needs no second call to label its brand

skustring

unique per ACCOUNT — one SKU means one product; placement governs who may use it

namestring
descriptionstring,null
categorystring,null
sizestring,null
media_asset_idstring,null

the product image, as a media-library asset in the campaign service. No URL is stored: poster/source urls are presigned and expire, so resolve them at render time via GET /v2/media-assets/{id}.

media_library_idstring,null

the library that asset lives in. Carried so a product grid can load the few libraries its products use and build one id-to-poster map, instead of one asset call per product.

metadataobject
created_atstring
updated_atstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Update a product (incl. setting or clearing its image)

PATCH/v2/products/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Request body

brand_idstring
skustring
namestring
descriptionstring,null
categorystring,null
sizestring,null
media_asset_idstring,null

null clears the image

media_library_idstring,null
metadataobject

Responses

200OK
idstring
account_idstring
group_idstring,null

null = shared with the whole account

brand_idstring
brand_namestring

folded in so a product list needs no second call to label its brand

skustring

unique per ACCOUNT — one SKU means one product; placement governs who may use it

namestring
descriptionstring,null
categorystring,null
sizestring,null
media_asset_idstring,null

the product image, as a media-library asset in the campaign service. No URL is stored: poster/source urls are presigned and expire, so resolve them at render time via GET /v2/media-assets/{id}.

media_library_idstring,null

the library that asset lives in. Carried so a product grid can load the few libraries its products use and build one id-to-poster map, instead of one asset call per product.

metadataobject
created_atstring
updated_atstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
409Conflict or duplicate
typestring
titlestring
statusinteger
detailstring

Delete a product

DELETE/v2/products/{id}

No description yet.

path parameters

idstringrequired

Resource id (prefixed, e.g. grp_… / loc_… / zon_… / scr_… / ins_… / brd_… / prd_…)

Responses

204Deleted

No body — 204 returns nothing.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
403Not allowed
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

API reference

Utilities

Geo

Places and their reference data — states, metros, ZIPs, audience profiles, and typeahead. What exists, and where.

All US states

GET/v2/geo/states

No description yet.

Responses

200States
statesarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

One state by code

GET/v2/geo/states/{code}

No description yet.

path parameters

codestringrequired

Two-letter US state code

Responses

200State
stateobject

Common shape for a state, metro area, or ZIP code.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Metro areas (optionally filtered)

GET/v2/geo/metros

No description yet.

query parameters

state_codestring
searchstring

Responses

200Metros
metrosarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

One metro area by id

GET/v2/geo/metros/{id}

No description yet.

path parameters

idstringrequired

Metro id

Responses

200Metro
metroobject

Common shape for a state, metro area, or ZIP code.

401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Curated audience profiles (optionally filtered)

GET/v2/geo/audience-profiles

No description yet.

query parameters

qstring

Responses

200Profiles
audience_profilesarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

One audience profile by id

GET/v2/geo/audience-profiles/{id}

No description yet.

path parameters

idstringrequired

Location-profile id

Responses

200Profile
audience_profileobject
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

ZIP-code-area geography (curated, else state-derived estimate)

GET/v2/geo/zip-codes/{zip}

No description yet.

path parameters

zipstringrequired

5-digit US ZIP

Responses

200ZIP-code result; kind is one of ok / not-found / invalid
kindstring
geographyobject

Common shape for a state, metro area, or ZIP code.

401Not authenticated
typestring
titlestring
statusinteger
detailstring

Resolve a city name to the states it exists in

GET/v2/geo/cities/lookup

No description yet.

query parameters

namestringrequired

Responses

200City states
namestring
statesarray
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Typeahead across states, cities and metros for the campaign Market step

GET/v2/geo/geographies

Ranked states, cities and metros for a query. Each suggestion carries the { type, value } to send to POST /v2/coverage-areas/locations. A 5-digit query returns one ZIP.

query parameters

qstringrequired
limitinteger

Responses

200Ranked location suggestions
querystring
suggestionsarray
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Bulk reference snapshot (states + metros + audience profiles + lookups) in one call

GET/v2/geo/reference-data

No description yet.

Responses

200Bundle
statesarray
metrosarray
audience_profilesarray
city_to_metroobject
aliasesobject
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Coverage Areas

Census demographics for an area — geocode an address, or roll up population, households and income for a ZIP, a radius, or a list of places. Who lives there.

Geocode a US address to a point + census tract via the US Census geocoder

GET/v2/coverage-areas/geocode

No description yet.

query parameters

addressstringrequired
citystring
state_codestring
zipstring

Responses

200Geocoded
okboolean
matched_addressstring
latitudenumber
longitudenumber
state_fipsstring
county_fipsstring
tract_codestring
tract_geoidstring
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
502An upstream data provider failed
typestring
titlestring
statusinteger
detailstring

Demographic snapshot for a single US ZIP code (Census ACS 2023)

GET/v2/coverage-areas/zip-codes/{zip}

No description yet.

path parameters

zipstringrequired

Responses

200ZIP snapshot
okboolean
demographicsobject

ZIP-code-level demographic snapshot from Census ACS 2023.

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring

Aggregate demographics for every ZIP inside a radius around a US point

GET/v2/coverage-areas/radius

Give an address or a lat/lon. Aggregates every ZIP whose centroid falls within the radius (Census ACS 2023).

query parameters

radius_metersintegerrequired
latitudenumber
longitudenumber
addressstring
citystring
state_codestring
zipstring
radius_labelstring

Responses

200Catchment aggregate
okboolean
centerobject
radius_metersinteger
radius_labelstring
zipsarray
totalsobject
truncatedboolean

true if the ZIP result was capped at the per-catchment maximum

400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
404Not found
typestring
titlestring
statusinteger
detailstring
502An upstream data provider failed
typestring
titlestring
statusinteger
detailstring

Aggregate demographics for a list of named places (state / metro / city / ZIP)

POST/v2/coverage-areas/locations

Resolves each { type, value } to its ZIPs and returns one combined aggregate. Use type "auto" to let the service infer the kind from the value.

Request body

locationsarrayrequired

Responses

200Coverage aggregate
okboolean
resolved_locationsarray
totalsobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Context

Environmental signals for a point — 7-day weather, monthly climate normals, and a heuristic traffic estimate. Nothing is stored.

7-day weather forecast for a point (Open-Meteo)

GET/v2/context/weather

No description yet.

query parameters

latitudenumberrequired
longitudenumberrequired

Responses

200Forecast
okboolean
daysarray
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
502An upstream data provider failed
typestring
titlestring
statusinteger
detailstring

Monthly climate normals + receptivity for a point (Open-Meteo archive)

GET/v2/context/climatology

No description yet.

query parameters

latitudenumberrequired
longitudenumberrequired

Responses

200Monthly normals
okboolean
monthsarray
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
502An upstream data provider failed
typestring
titlestring
statusinteger
detailstring

Heuristic 7-day ambient-traffic estimate by business type and zone

GET/v2/context/traffic

No description yet.

query parameters

business_typestring
campaign_zonestring

Responses

200Traffic estimate
okboolean
daysarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

Weather

Cached current conditions for a coordinate (Open-Meteo).

Current weather at a coordinate (cached, self-populating)

GET/v2/weather/current

Most recent snapshot for the 0.05° grid cell at (lat, lon). Served from cache when under 30 minutes old, otherwise refetched and stored.

query parameters

latnumberrequired
lonnumberrequired

Responses

200Current weather snapshot
okboolean
snapshotobject
400Invalid input
typestring
titlestring
statusinteger
detailstring
401Not authenticated
typestring
titlestring
statusinteger
detailstring
502An upstream data provider failed
typestring
titlestring
statusinteger
detailstring

API reference

Events

Analytics

Aggregate visit behaviour across the estate - headline numbers, per location, and the demographic breakdown. Every figure is derived from visits already bucketed into the location's own local day, which is why a location without a timezone never appears.

Executive metrics — KPIs (w/ previous), gender counts, footfall, top locations, location map

GET/v2/analytics/executive

No description yet.

query parameters

fromstring

Inclusive start local date. Accepts `YYYY-MM-DD` or a full ISO 8601 date-time (`2026-06-01T00:00:00Z`); the time part is ignored. The day is read as written and never converted to UTC first, because these bound a store-local day.

tostring

Inclusive end local date. Same formats as `from` — `2026-06-30T23:59:59.999Z` means the 30th, inclusive.

locationsstring

comma-separated location ids

groupsstring

comma-separated group ids — resolved server-side to the groups' member locations and intersected with `locations` when both are given

Responses

200executive metrics
visitsobject

a value plus its previous-period value; the UI derives deltaPct

repeat_pctobject

a value plus its previous-period value; the UI derives deltaPct

avg_dwell_secondsobject

a value plus its previous-period value; the UI derives deltaPct

location_countobject

a value plus its previous-period value; the UI derives deltaPct

genderobject
footfallobject

parallel arrays; this vs last comparable period

top_locationsarray
by_locationarray

per-location visit counts for the map; the UI resolves names + coordinates from organization

Location metrics — overview (w/ previous), visits by weekday/hour, recent visits

GET/v2/analytics/location

No description yet.

query parameters

fromstring

Inclusive start local date. Accepts `YYYY-MM-DD` or a full ISO 8601 date-time (`2026-06-01T00:00:00Z`); the time part is ignored. The day is read as written and never converted to UTC first, because these bound a store-local day.

tostring

Inclusive end local date. Same formats as `from` — `2026-06-30T23:59:59.999Z` means the 30th, inclusive.

locationsstring

comma-separated location ids

groupsstring

comma-separated group ids — resolved server-side to the groups' member locations and intersected with `locations` when both are given

Responses

200location metrics
overviewobject
visits_by_weekdayarray
visits_by_hourarray
recent_visitsarray

most-recently-active locations; the UI resolves name + group from organization

Demographics — gender/age counts, and counts by hour + by day (UI buckets to dayparts)

GET/v2/analytics/demographics

No description yet.

query parameters

fromstring

Inclusive start local date. Accepts `YYYY-MM-DD` or a full ISO 8601 date-time (`2026-06-01T00:00:00Z`); the time part is ignored. The day is read as written and never converted to UTC first, because these bound a store-local day.

tostring

Inclusive end local date. Same formats as `from` — `2026-06-30T23:59:59.999Z` means the 30th, inclusive.

locationsstring

comma-separated location ids

groupsstring

comma-separated group ids — resolved server-side to the groups' member locations and intersected with `locations` when both are given

Responses

200demographics metrics
genderobject
age_splitarray
gender_by_hourarray
age_by_hourarray
gender_by_dayarray
age_by_dayarray

Loyalty

How often people come back, split into recency and frequency bins. The bin thresholds and their labels are per account and editable, so two accounts can mean different things by "Loyal".

Loyalty — frequency + recency + new-vs-repeat, each over 30/60/90-day lookbacks (agg_visitor_daily/recency)

GET/v2/analytics/loyalty

No description yet.

query parameters

locationsstring

comma-separated location ids

groupsstring

comma-separated group ids — resolved server-side to the groups' member locations and intersected with `locations` when both are given

Responses

200loyalty metrics
new_repeatobject

split of new vs repeat visitors in the window (counts)

frequencyarray
recencyarray
new_vs_repeatarray

Save frequency bin thresholds (writes rf_bins) and return the recomputed frequency

PUT/v2/analytics/loyalty/frequency

No description yet.

Request body

thresholdsarrayrequired

cut points in days

Responses

200recomputed frequency across the 30/60/90 lookbacksarray

Save recency bin thresholds (writes rf_bins) and return the recomputed recency

PUT/v2/analytics/loyalty/recency

No description yet.

Request body

thresholdsarrayrequired

cut points in days

Responses

200recomputed recency across the 30/60/90 lookbacksarray

Campaign performance

What a campaign actually did once it was on screen: reach, plays, dwell and attention, for the portfolio, for one campaign, and broken down by day, creative, location or demographic.

Campaign overview — footfall + plays (and more) across all campaigns

GET/v2/analytics/campaigns

No description yet.

query parameters

fromstring

Inclusive start local date. Accepts `YYYY-MM-DD` or a full ISO 8601 date-time (`2026-06-01T00:00:00Z`); the time part is ignored. The day is read as written and never converted to UTC first, because these bound a store-local day.

tostring

Inclusive end local date. Same formats as `from` — `2026-06-30T23:59:59.999Z` means the 30th, inclusive.

campaign_idsstring

comma-separated campaign ids to scope the result to

group_idsstring

comma-separated location-group ids to scope the result to

Responses

200per-campaign summary rows
campaignsarray

Portfolio summary across all campaigns — KPIs with period-over-period deltas

GET/v2/analytics/campaigns/summary

No description yet.

query parameters

fromstring

Inclusive start local date. Accepts `YYYY-MM-DD` or a full ISO 8601 date-time (`2026-06-01T00:00:00Z`); the time part is ignored. The day is read as written and never converted to UTC first, because these bound a store-local day.

tostring

Inclusive end local date. Same formats as `from` — `2026-06-30T23:59:59.999Z` means the 30th, inclusive.

campaign_idsstring

comma-separated campaign ids to scope the result to

group_idsstring

comma-separated location-group ids to scope the result to

Responses

200portfolio-level KPI cards
total_reachobject

one KPI card — the value for the filtered period, and its change on the period before it

total_ad_playsobject

one KPI card — the value for the filtered period, and its change on the period before it

total_ad_viewsobject

one KPI card — the value for the filtered period, and its change on the period before it

average_attention_rateobject

one KPI card — the value for the filtered period, and its change on the period before it

average_dwell_timeobject

one KPI card — the value for the filtered period, and its change on the period before it

screensobject

one KPI card — the value for the filtered period, and its change on the period before it

total_purchase_intentobject

one KPI card — the value for the filtered period, and its change on the period before it

Campaign detail — totals + breakdown by zone, screen, and ad (footfall, plays, dwell)

GET/v2/analytics/campaigns/{campaign_id}

No description yet.

path parameters

campaign_idstringrequired

campaign id

query parameters

fromstring

Inclusive start local date. Accepts `YYYY-MM-DD` or a full ISO 8601 date-time (`2026-06-01T00:00:00Z`); the time part is ignored. The day is read as written and never converted to UTC first, because these bound a store-local day.

tostring

Inclusive end local date. Same formats as `from` — `2026-06-30T23:59:59.999Z` means the 30th, inclusive.

Responses

200campaign detail with per-zone / per-screen / per-ad breakdowns
campaign_idstring
reachinteger

distinct people exposed

playsinteger
dwell_secondsnumber
locationsarray
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Campaign metrics over time — one point per day in the filtered range

GET/v2/analytics/campaigns/{campaign_id}/timeseries

No description yet.

path parameters

campaign_idstringrequired

campaign id

query parameters

fromstring

Inclusive start local date. Accepts `YYYY-MM-DD` or a full ISO 8601 date-time (`2026-06-01T00:00:00Z`); the time part is ignored. The day is read as written and never converted to UTC first, because these bound a store-local day.

tostring

Inclusive end local date. Same formats as `from` — `2026-06-30T23:59:59.999Z` means the 30th, inclusive.

Responses

200a series of daily pointsobject
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Per-creative performance within one campaign

GET/v2/analytics/campaigns/{campaign_id}/creatives

No description yet.

path parameters

campaign_idstringrequired

campaign id

Responses

200one row per creativeobject
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Audience split for one campaign — age and gender

GET/v2/analytics/campaigns/{campaign_id}/demographics

No description yet.

path parameters

campaign_idstringrequired

campaign id

Responses

200demographic breakdown for the campaignobject
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Per-location cards for one campaign

GET/v2/analytics/campaigns/{campaign_id}/locations

No description yet.

path parameters

campaign_idstringrequired

campaign id

Responses

200one card per location the campaign ran inobject
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Live

A stream, not a snapshot. The connection stays open as text/event-stream: the server sends a ready event, then forwards visits as they arrive. It does not replay what happened before you connected, so what you can count from it is "since this connection opened" - never "today so far". For a total, ask Analytics.

Live visit stream for one or more installations (server-sent events)

GET/v2/events/live

The preferred way in. Streams visits for a SET of installations over a single connection.

One connection rather than one per installation: a browser allows only about six connections to the same host, and past that the page cannot make any request at all — which reads as the application hanging rather than as a limit being reached.

installation_ids is required and capped. A set is not a path resource, and because "everything" cannot be asked for, this is not a feed of the whole estate.

Watching a location or a group is done by resolving it to its installations and passing those — this service has no concept of either.

Sends no history and no starting totals, on purpose: the figures mean "since this stream was opened", so seeding them would leave two people watching the same installations disagreeing and both being right. The first frame is a ready event echoing the ids subscribed to; every frame after it is a visits event carrying a batch.

Nothing is delivered twice. The broker delivers at least once and devices re-upload when they miss a receipt, so ids already sent are dropped.

Read it with fetch + ReadableStream rather than EventSource, so the bearer token travels in the Authorization header instead of the query string. Visits from another account are filtered out before they reach the stream: an installation id is not permission by itself.

query parameters

installation_idsstringrequired

Comma-separated installation ids, at most 20. Clients are expected to offer fewer than this; the cap exists so the interface is not relying on a client to enforce it.

Responses

200An event stream. `ready` once, then `visits` frames until the client disconnects. Keep-alive comments are sent about every 20 seconds.

Returns text/event-stream.

400installation_ids missing, over the cap, or not all ins_<uuid> ids

Body not documented.

401missing or invalid bearer token

Body not documented.

503the service is already serving its maximum number of live views

Body not documented.

Live visit stream for one installation (kept for compatibility)

GET/v2/events/installations/{installation_id}/live

Streams visits as they are written, so a view can count them from zero rather than polling.

Sends NO history and NO starting totals, deliberately. The numbers mean "since this stream was opened", so seeding them would make two people watching the same installation disagree with each other. The first frame is a ready event carrying no figures; every frame after it is a visits event holding a batch.

Nothing is delivered twice. The broker delivers at least once and devices re-upload when they miss a receipt, so the service drops event ids it has already sent.

Read it with fetch + ReadableStream rather than EventSource, so the bearer token travels in the Authorization header instead of the query string. Visits from another account are filtered out before they reach the stream: the installation id alone is not permission.

path parameters

installation_idstringrequired

The installation to watch. In the path rather than a query parameter so an unscoped call is not a request that can be made.

Responses

200An event stream. `ready` once, then `visits` frames until the client disconnects. Keep-alive comments are sent about every 20 seconds.

Returns text/event-stream.

400installation_id is not a uuid

Body not documented.

401missing or invalid bearer token

Body not documented.

503the service is already serving its maximum number of live views

Body not documented.


Blacklist

People and locations excluded from analytics - typically staff, who would otherwise look like the most loyal visitors in the estate. The entry is written immediately, but figures already computed are not rewritten on the spot: a rollup applies the filter the next time it recomputes, and the hourly job only rebuilds its trailing window. Older periods keep counting that visitor until something rebuilds them.

List blacklisted people (staff / excluded), paginated; optionally filtered by location

GET/v2/events/blacklist

No description yet.

query parameters

location_idstring

filter the blacklist to one location

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

max entries per page

Responses

200blacklist entries (auto-detected + manual)
entriesarray
next_cursorstring

pass as cursor to fetch the next page; absent on the last page

Add a manual blacklist entry for a location

POST/v2/events/blacklist

No description yet.

Request body

location_idstringrequired
person_idstringrequired
reasonstring

Responses

201created
idstring
location_idstring
person_idstring
auto_detectedboolean

true = staff-scoring cron, false = manual

staff_scoreinteger

set on auto-detected rows

reasonstring
created_atstring
updated_atstring

Get one blacklist entry

GET/v2/events/blacklist/{blacklist_id}

No description yet.

path parameters

blacklist_idstringrequired

blacklist entry id

Responses

200blacklist entry
idstring
location_idstring
person_idstring
auto_detectedboolean

true = staff-scoring cron, false = manual

staff_scoreinteger

set on auto-detected rows

reasonstring
created_atstring
updated_atstring
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Update a manual blacklist entry (reason)

PATCH/v2/events/blacklist/{blacklist_id}

No description yet.

path parameters

blacklist_idstringrequired

blacklist entry id

Request body

reasonstringrequired

Responses

200updated
idstring
location_idstring
person_idstring
auto_detectedboolean

true = staff-scoring cron, false = manual

staff_scoreinteger

set on auto-detected rows

reasonstring
created_atstring
updated_atstring
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Remove a blacklist entry

DELETE/v2/events/blacklist/{blacklist_id}

No description yet.

path parameters

blacklist_idstringrequired

blacklist entry id

Responses

204removed

No body — 204 returns nothing.

404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Exports

Bulk extraction of visit rows. Estimate first - the estimate exists so a caller can find out that a range is enormous before asking for it.

How big a visit CSV export would be, and whether it can be downloaded directly

GET/v2/events/exports/visits/estimate

Counts the raw visits rows in the window, not the aggregates - those are rebuilt by the rollup and lag behind the table the export reads. rows is how many land in the file; window_rows is how many are SCANNED, which is what decides mode, because a group or location filter shrinks the file without shrinking the work.

query parameters

fromstringrequired

inclusive start of the window, RFC3339. Required - it is what bounds how much of the monthly-partitioned visits table is read.

tostring

exclusive end of the window, RFC3339; defaults to now

groupstring

one group id; mutually exclusive with `location`

locationstring

one location id; mutually exclusive with `group`

Responses

200size of the export
rowsinteger

rows that will appear in the file

window_rowsinteger

rows scanned to produce it - this is what sets mode

bytes_estimateinteger

rough uncompressed CSV size in bytes

modestring

download - small enough to stream straight to the caller. job - too large for a direct download, so the CSV endpoint returns 400 for this range. The background export that would serve it is not built yet.

400RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Download raw visits for a time window as CSV

GET/v2/events/exports/visits

Streams one CSV row per visit in started_at order, with the installation, location and group names resolved from organization. Timestamps appear twice, UTC and the location's local time, with the timezone in its own column. Dwell and view time are milliseconds, falling back to the older per-second columns for rows written before that precision existed. Returns 400 when the range is larger than a direct download serves - narrow the window; a background export to S3 is planned.

query parameters

fromstringrequired

inclusive start of the window, RFC3339. Required - it is what bounds how much of the monthly-partitioned visits table is read.

tostring

exclusive end of the window, RFC3339; defaults to now

groupstring

one group id; mutually exclusive with `location`

locationstring

one location id; mutually exclusive with `group`

Responses

200CSV file

Returns text/csv.

400RFC 9457 error
typestring
titlestring
statusinteger
detailstring