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
Area
What lives there
Account
Sign-up and login, users, invitations, roles, API keys, plans, token ledger
Organization
The physical estate — groups, locations, zones, screens, installations
Device
Device provisioning, enrollment and the device-facing endpoints
Campaign
Campaigns, ads, ad and media libraries, scoring and publishing
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.
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:
Only name was required. Everything else — description, start_date, priority — is optional, and what you leave out comes back as its default rather than being absent, so you can see what the server decided.
The id is yours to keep: it is how you reach the campaign again, and it is prefixed cmp_ so a glance at a log says what kind of thing it is.
4. 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.
5. 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:
Pagination covers why it is a cursor and not a page number.
6. 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.
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.
Concepts
Most of the API makes sense once you know how the platform models a retail estate and what a campaign is made of. This page is the mental model; the reference is the detail.
The estate
text
group ──▶ location ──▶ zone ──▶ screen
▲
device ───────── installation ───────┘
Read it downwards: a group holds locations, a location holds zones, a zone holds screens. A device is separate — it is a physical thing — and an installation is what puts a device into a location, wired to the screens it drives.
Object
Prefix
What it is
Group
grp_
A set of locations sharing one zone catalogue — a chain, a brand, a region.
Location
loc_
An individual site. Usually a store. Carries the IANA timezone everything local is measured in.
Zone
zon_
A named area within a location — entrance, aisle, checkout.
Screen
scr_
A display in a zone.
Device
dev_
The physical unit. Claimed from inventory, then installed.
Installation
ins_
A device placed in a location, driving screens.
Setting an estate up is done in the product, not through this API. What matters when you are integrating is that this is the shape your analytics come back in: a visit happened at a location, in a zone, and a location's timezone is what makes a day a day.
Campaigns
text
media asset ──▶ ad ──▶ ad library
│
└──▶ campaign ad ──▶ campaign
A media asset is an uploaded file. An ad is that asset made playable. Ads are gathered into an ad library, and a campaign ad is one ad placed into a campaign against a screen tag — which is how an ad ends up somewhere rather than merely existing.
Object
Prefix
What it is
Media library
mlb_
Where uploaded assets live.
Media asset
mda_
One uploaded file.
Ad
—
A media asset made playable.
Ad library
asl_
A set of ads.
Campaign
cmp_
A named plan with a schedule and a target.
Campaign ad
cad_
One ad, placed in a campaign against a screen tag.
Ad profile
apf_
The audience signature an ad is scored against.
Score
sco_
The result of scoring an ad.
A campaign starts as a draft. Publishing it is a separate, deliberate step — creating a campaign does not put anything on a screen.
Accounts and groups
Object
Prefix
What it is
Account
acc_
The tenant. Everything belongs to exactly one.
User
usr_
A person, who may belong to several accounts.
API key
key_
A machine credential, belonging to one account.
Your token already carries the account. Almost nothing takes an account id, because there is never a question of which account you mean — see Getting started.
Within an account, access can be narrowed to particular groups. A token so scoped sees only those groups' locations, and everything downstream of them, without saying so on each call. If a list looks short, scope is the first thing to check.
Ids
Every id carries its type: loc_018f5a01-1c2d-7e3f-8a4b-5c6d7e8f9012. The prefix makes an id self-describing in a log or a support ticket, and makes passing the wrong one obvious immediately rather than three calls later.
Treat them as opaque strings. Never build one, never parse one, and do not rely on the part after the prefix having any particular shape.
Authentication
Every request carries a bearer token:
text
Authorization: Bearer <token>
Treat the token as opaque. It is a JWT, signed with RS256, but nothing about its contents is part of this API: claims are added and renamed as the platform changes, and no public key set is published for you to verify it with. Send it back as you received it. A 401 means the token was rejected — expired, malformed, or not verifiable — so get a fresh one rather than inspecting it.
There are two ways to get one — as a person, or as a machine — and they produce tokens with different powers.
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:
Half
Looks like
Care
access_key_id
tac_live_7Qk2RfNp4xZ1mVdB
Public. Safe to display, log and put in a config file.
access_key_secret
40 random characters
Private. 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:
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.
The single account this session is scoped to. A user in several accounts holds one token per account and switches explicitly.
role
Their role in that account: owner, admin or member.
account_type
The account's product tier.
exp
Expiry, 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'"
}
Field
Use it for
type
Branching in code. A stable identifier for the kind of failure.
title
A short, human label for the kind. Stable, but written for people.
status
The HTTP status, repeated so a logged body is self-contained.
detail
What went wrong this time. Written for a human — never parse it.
The rule of thumb: switch on type, show detail, log all four.
Match on the last segment of type — validation-error, not-found — rather than the whole URI. The prefix is being consolidated, and a client that matches the full string will need changing when it is; one that matches the last segment will not.
400. The request was malformed, or a value in it was unacceptable.
detail names the offending value: "timezone must be an IANA zone name, got 'CET+1'". It reports the first problem found rather than every problem at once, so a request with two bad fields takes two attempts.
Fix the value it names and send it again. Retrying unchanged fails identically.
unauthorized
401. No token, a token that has expired, or one that does not verify. detail is usually "Missing or invalid bearer token".
It says nothing about whether you would be allowed to do the thing — only that we do not know who you are. For that, see `forbidden`.
As a person, exchange your refresh token at POST /v2/auth/refresh rather than logging in again. As a machine, exchange your key at POST /v2/auth/api-token for a fresh one. Do not retry with the same token.
insufficient-balance
402. The account has no tokens left for this operation.
detail gives both numbers: "Account has 12 tokens; 50 required." The operation was understood and permitted — it was not paid for.
Retrying does not help until the balance changes. Top up, or reduce what you are asking for where the cost scales with size.
forbidden
403. We know who you are, and you may not do this.
The common causes are distinct and worth checking in order: your role within the account is too low; you used an API key on an endpoint that requires a signed-in person; the key is read-only and the request writes; or you asked for a scope — an account, a group — that this credential does not hold.
Do not retry. Nothing about the request will change the answer.
not-found
404. No such resource — or not one this account can see.
The API deliberately does not distinguish the two. A resource that exists but belongs to someone else answers exactly as one that never existed, so ids cannot be discovered by probing. A 404 is therefore not proof that something is gone.
Check the id, and check which account the token is scoped to. For a resource that definitely existed and has been spent, see `gone`.
conflict
409. The request fights the current state of the thing.
Typical: "Installation already ended", "A product with SKU 'X' already exists", "Cannot modify a closed account". Either someone got there first, or the resource is in a state that does not accept this change.
Re-read the resource and decide what to do with what you find. Retrying unchanged repeats the same conflict.
gone
410. A one-time thing has been spent.
An email verification code that expired, was already used, or was attempted too many times. An invitation past its expiry, or already accepted.
Unlike `not-found`, this is a definite "this existed and is finished". Issue a new one rather than retrying the old one.
unprocessable
422. Syntactically fine, but not a thing the domain permits.
Where validation-error means the shape or value was wrong, this means the request was well formed and still cannot be honoured: "Unknown groups for this account: a, b" — real field, real format, not a thing that exists here.
Fix the meaning rather than the syntax.
rate-limited
429. Too many requests.
Limits are applied per client IP, and only to the credential endpoints — logging in, exchanging an API key, resetting a password. They exist to stop guessing, not to shape traffic, so the endpoints you integrate against are not rate limited today. See Rate limits for the figures.
Back off exponentially with jitter, and treat it as a signal to slow down generally rather than to repeat the one call. It is the only 4xx worth retrying unchanged.
upstream-error
502. A dependency we call failed.
Nothing is wrong with your request and it may succeed if repeated. Retry with backoff. If it persists, it is ours, not yours.
Status codes in use
Code
Meaning
200
OK — the response carries the result.
201
Created — a new resource; its id is in the body.
202
Accepted — queued, not finished. Poll for the outcome.
204
No content — it worked and there is nothing to return.
400, 401, 402, 403, 404, 409, 410, 422, 429
Your request. Do not retry unchanged, except 429.
502, 503
Ours. Safe to retry with backoff.
Rate limits
Only the credential endpoints are limited, and the limit is counted per client IP rather than per key or per account — so everyone behind one office NAT shares a budget.
Endpoint
Window
Limit
POST /v2/auth/signup
1 hour
5
POST /v2/auth/login
15 min
10 (failed attempts only)
POST /v2/auth/api-token
1 min
60
POST /v2/auth/refresh
1 min
30
POST /v2/auth/verify-email
15 min
5
POST /v2/auth/verify-email/resend
15 min
3
POST /v2/auth/forgot-password
15 min
3
POST /v2/auth/reset-password
15 min
5
A successful login does not count against the login budget — only failures do.
Everything else is unlimited. That is not a promise: it is where the platform stands today, and limits on the data endpoints would arrive with notice through the usual versioning route. Build as though they exist — back off on 429, do not poll in tight loops — and you will not be caught out.
Retrying
Retry 429, 502 and 503. Do not retry any other 4xx — the request will fail the same way until you change it.
Use exponential backoff with jitter. Retrying in lockstep after a 429 turns one slow client into a thundering herd.
Pagination
List endpoints are cursor-paginated. You ask for a page, and the response tells you how to ask for the next one.
It is an encoded position, not an id, an offset or a page number. Do not parse it, build one, or keep one for later — pass back exactly what you were given. Its contents are free to change without notice; its meaning as "resume here" is not.
Cursors are not stable across filter changes. Change any query parameter and start again from the first page, or you will get results that answer neither query.
Why not page numbers
Rows are being written while you read. With offsets, a row inserted before your position shifts everything down and you silently skip an entry; a deletion shows you one twice. A cursor is anchored to a row, so a full walk sees each row once even under writes.
One inconsistency to know about
Most list endpoints return their rows under entries. The campaign endpoints return them under data. Both use next_cursor in the same way. If you write a shared pagination helper, take the array key as a parameter.
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.
Prefix
Resource
Prefix
Resource
acc_
Account
ad_
Ad
usr_
User
apf_
Ad profile
inv_
Invitation
sco_
Score
key_
API key
asl_
Ad library
pln_
Plan
mlb_
Media library
led_
Ledger entry
mda_
Media asset
hld_
Token hold
anl_
Analysis
job_
Job
cmp_
Campaign
rt_
Refresh token
dev_
Device
grp_
Group
cam_
Camera
loc_
Location
bl_
Blacklist entry
zon_
Zone
pay_
Payment
scr_
Screen
ref_
Refund
ins_
Installation
cs_
Checkout session
Two are easy to confuse: `cmp_` is a campaign, `cam_` is a camera.
Dates and times
Kind
Format
Example
Timestamp
RFC 3339, UTC, Z suffix
2026-06-30T14:20:00Z
Calendar date
YYYY-MM-DD
2026-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) requires MFA, the response is an mfa_setup_required envelope with a short-lived setup_token instead of a session: enrolment is completed by the person in the TAC app, and no session exists until it is. 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) The account requires MFA and the user is not enrolled → mfa_setup_required envelope with a short-lived setup_token; enrolment is completed by the person in the TAC app, and no session exists until it is. (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. A token is always scoped to exactly one account; the active account is switched in the TAC app.
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
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
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
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
200OKarray401Not 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
200OKarray401Not 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
200OKarray401Not 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
200OKarray401Not 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
200OKarray401Not 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
200OKarray401Not 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
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.
|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.
|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.
|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.
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 — reading the estate on your account, and the details of a single unit. These are the endpoints behind the device list in the dashboard.
List devices
GET/v2/devices
device:read. Returns the devices on your account.
Responses
200Device list.array
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.
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
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
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
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
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
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
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
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
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
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.
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.
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
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
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 pointsobject404RFC 9457 error
200one card per location the campaign ran inobject404RFC 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)
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