TAC API

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

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

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

What each part owns

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

How to read this reference

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

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

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


Getting started

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

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

1. Get a token

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

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

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

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

2. Make a call

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

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

3. Create something

Reads are the easy half. A write shows the rest of the shape — a JSON body, snake_case in as well as out, and a 201 carrying the thing you made:

bash
curl -s -X POST https://api.stage.theattco.net/v2/campaigns   -H "Authorization: Bearer $TOKEN"   -H 'content-type: application/json'   -d '{"name":"Spring launch"}'
json
{
  "id": "cmp_018f5a01-1c2d-7e3f-8a4b-5c6d7e8f9012",
  "name": "Spring launch",
  "status": "draft",
  "current_version": null
}

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:

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

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

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.

Where to go next

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

A note on ids

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


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.

ObjectPrefixWhat it is
Groupgrp_A set of locations sharing one zone catalogue — a chain, a brand, a region.
Locationloc_An individual site. Usually a store. Carries the IANA timezone everything local is measured in.
Zonezon_A named area within a location — entrance, aisle, checkout.
Screenscr_A display in a zone.
Devicedev_The physical unit. Claimed from inventory, then installed.
Installationins_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.

ObjectPrefixWhat it is
Media librarymlb_Where uploaded assets live.
Media assetmda_One uploaded file.
Ad—A media asset made playable.
Ad libraryasl_A set of ads.
Campaigncmp_A named plan with a schedule and a target.
Campaign adcad_One ad, placed in a campaign against a screen tag.
Ad profileapf_The audience signature an ad is scored against.
Scoresco_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

ObjectPrefixWhat it is
Accountacc_The tenant. Everything belongs to exactly one.
Userusr_A person, who may belong to several accounts.
API keykey_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.

As a person

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

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

As a machine

API key credentials come in two halves, issued together:

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

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

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

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

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

What a key token cannot do

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

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

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

What is inside the token

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

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

Group scope

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

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

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

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

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

Devices

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

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


Errors

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

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

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

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.

Kinds

validation-error

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

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

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.

EndpointWindowLimit
POST /v2/auth/signup1 hour5
POST /v2/auth/login15 min10 (failed attempts only)
POST /v2/auth/api-token1 min60
POST /v2/auth/refresh1 min30
POST /v2/auth/verify-email15 min5
POST /v2/auth/verify-email/resend15 min3
POST /v2/auth/forgot-password15 min3
POST /v2/auth/reset-password15 min5

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.


Versioning

The version is in the path:

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

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

What we may change without a new version

These are additive, and your client must tolerate them:

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

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

What we will not do inside v2

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

Anything in that list needs a new version.

Retiring things

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

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

Naming is settled

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


Conventions

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

Naming

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

Identifiers

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

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

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

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

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

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

Dates and times

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

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

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

Absent values

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

Comma-separated filters

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

text
?campaign_ids=cmp_018f6a01-…,cmp_018f6a02-…

Money and counts

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


API reference

Auth

Auth

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

POST/v2/auth/signup

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

Request body

emailstringrequired
passwordstringrequired
full_namestringrequired

Responses

202Accepted — verification code emailed
user_idstring
account_idstring
verificationobject

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

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

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

POST/v2/auth/verify-email

200 has two shapes, mirroring POST /v2/auth/login. When the target account (or the target user individually) 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

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

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

POST/v2/api-keys

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

Request body

namestringrequired

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

rolestring
typestring

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

readonlyboolean
group_scopestring

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

groupsarray

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

expires_instring

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

expires_atstring

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

Responses

201Created — secret included exactly once
idstring
namestring
access_key_idstring

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

rolestring

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

typestring

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

readonlyboolean

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

group_scopestring

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

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

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

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

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

revoked_atstring,null
created_atstring
access_key_secretstring

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

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

Body not documented.

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

Body not documented.

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

One key (never its secret)

GET/v2/api-keys/{id}

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

path parameters

idstringrequired

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

Responses

200OK
idstring
namestring
access_key_idstring

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

rolestring

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

typestring

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

readonlyboolean

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

group_scopestring

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

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

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

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

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

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

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

PATCH/v2/api-keys/{id}

No description yet.

path parameters

idstringrequired

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

Request body

namestring
readonlyboolean
group_scopestring

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

groupsarray

Replaces the key's whole grant set.

expires_atstring,null

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

Responses

200OK
idstring
namestring
access_key_idstring

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

rolestring

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

typestring

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

readonlyboolean

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

group_scopestring

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

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

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

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

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

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

Body not documented.

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

Revoke a key

DELETE/v2/api-keys/{id}

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

path parameters

idstringrequired

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

Responses

204Revoked

No body — 204 returns nothing.

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

Issue a replacement secret, with an overlap window

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

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

path parameters

idstringrequired

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

Request body

gracestring

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

Responses

201Created — new secret included exactly once
idstring
namestring
access_key_idstring

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

rolestring

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

typestring

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

readonlyboolean

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

group_scopestring

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

groupsarray
created_bystring,null

attribution only — the key keeps working if that user leaves

expires_atstring,null

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

last_used_atstring,null

best-effort — updated at most once a minute

rotated_atstring,null

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

revoked_atstring,null
created_atstring
access_key_secretstring

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

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

Body not documented.


Plans

Catalog of plans (entitlements)

GET/v2/plans

No description yet.

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

AccountTypes

Catalog of active account types

GET/v2/account-types

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

Responses

200OKarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

API reference

Billing

Payments

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

POST/v2/checkout-sessions

No description yet.

Request body

modestringrequired
plan_keystring

price to charge (token pack or subscription plan)

return_urlstring

Responses

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

Checkout session status

GET/v2/checkout-sessions/{id}

No description yet.

path parameters

idstringrequired

Responses

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

Purchase/charge history for the active account

GET/v2/payments

No description yet.

query parameters

cursorstring
limitinteger

Responses

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

A payment + receipt link

GET/v2/payments/{id}

No description yet.

path parameters

idstringrequired

Responses

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

Refunds

Refund history for the active account

GET/v2/refunds

No description yet.

query parameters

cursorstring
limitinteger

Responses

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

A refund

GET/v2/refunds/{id}

No description yet.

path parameters

idstringrequired

Responses

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

Customer

Billing summary — Stripe customer + dollar store-credit balance

GET/v2/billing

No description yet.

Responses

200OK
account_idstringrequired
provider_customer_idstring,null
credit_balance_centsintegerrequired
currencystringrequired
active_subscriptionnullrequired

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

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

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

POST/v2/billing/subscription/cancel

No description yet.

Responses

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

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

POST/v2/billing/subscription/resume

No description yet.

Responses

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

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

POST/v2/billing/subscription/change-plan

No description yet.

Request body

plan_keystringrequired

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

Responses

200OK
plan_keystringrequired
change_typestringrequired

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

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

Money ledger (cents)

GET/v2/billing/ledger

No description yet.

query parameters

cursorstring
limitinteger

Responses

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

Saved payment methods

GET/v2/payment-methods

No description yet.

Responses

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

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

POST/v2/payment-methods

No description yet.

Responses

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

Remove a payment method

DELETE/v2/payment-methods/{id}

No description yet.

path parameters

idstringrequired

Responses

204Removed

No body — 204 returns nothing.

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

Prices

Plan → price mappings

GET/v2/plan-prices

No description yet.

Responses

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

Per-action credit cost catalog

GET/v2/usage-prices

No description yet.

Responses

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

API reference

Campaign

Campaigns

List campaigns for the active account

GET/v2/campaigns

No description yet.

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

statusstring

Responses

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

Create a campaign (draft)

POST/v2/campaigns

No description yet.

Request body

namestringrequired
descriptionstring,null
briefobject

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

start_datestring,null
end_datestring,null
priorityinteger
metadataobject

Responses

201Campaign created (status=draft)
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

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

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

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

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

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

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

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

GET/v2/campaigns/{id}

No description yet.

path parameters

idstringrequired

campaign id

Responses

200The campaign
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

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

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

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

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

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

targetobject
adsarray
latest_validationobject

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

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

Update campaign brief fields (draft only)

PATCH/v2/campaigns/{id}

No description yet.

path parameters

idstringrequired

campaign id

Request body

namestring
descriptionstring,null
briefobject

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

start_datestring,null
end_datestring,null
priorityinteger
metadataobject

Responses

200Updated
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

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

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

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

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

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

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

Delete a draft campaign

DELETE/v2/campaigns/{id}

No description yet.

path parameters

idstringrequired

campaign id

Responses

204Deleted

No body — 204 returns nothing.

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

Set the campaign target (group, zones, locations)

PUT/v2/campaigns/{id}/target

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

path parameters

idstringrequired

campaign id

Request body

group_idstringrequired
zone_idsarray
location_idsarray

Responses

200Target set
group_idstring
zone_idsarray
location_idsarray

chosen include set (frozen at selection)

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

Copy a campaign's configuration into a new draft

POST/v2/campaigns/{id}/copy

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

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

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

path parameters

idstringrequired

campaign id

Responses

201The new draft campaign
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

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

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

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

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

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

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

Campaign Ads

List campaign ads (playlists) for the campaign

GET/v2/campaigns/{id}/ads

No description yet.

path parameters

idstringrequired

campaign id

Responses

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

Place an ad on a screen tag

POST/v2/campaigns/{id}/ads

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

path parameters

idstringrequired

campaign id

Request body

ad_idstringrequired

the shared creative to place (prefixed ad_…)

ad_profile_idstring,null

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

screen_idstringrequired
sort_orderinteger
play_duration_sinteger
triggersobject

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

Responses

201Campaign ad created
idstring
campaign_idstring
ad_idstring

the shared creative (prefixed ad_…)

ad_profile_idstring,null

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

screen_idstring
sort_orderinteger
play_duration_sinteger
triggersobject

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

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

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

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

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

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

Request body

sort_orderinteger
play_duration_sinteger
screen_idstring
ad_profile_idstring,null

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

Responses

200Updated
idstring
campaign_idstring
ad_idstring

the shared creative (prefixed ad_…)

ad_profile_idstring,null

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

screen_idstring
sort_orderinteger
play_duration_sinteger
triggersobject

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

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

Remove a campaign ad from the campaign

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

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

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

Responses

204Deleted

No body — 204 returns nothing.

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

Get a campaign ad's playback triggers

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

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

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

Responses

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

Replace the campaign ad's triggers

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

No description yet.

path parameters

idstringrequired

campaign id

campaign_ad_idstringrequired

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

Request body

keywordsobject

Responses

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

Scoring & Publishing

Validate + score the campaign (per campaign ad)

POST/v2/campaigns/{id}/validate

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

path parameters

idstringrequired

campaign id

Responses

200Validation + scoring result
campaign_idstring
okboolean

true only when there are zero errors — the publish gate

errorsarray
warningsarray
campaign_scorenumber,null

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

scored_ad_countinteger
total_ad_countinteger
screen_countinteger
estimated_locationsinteger

reach proxy = target include-set size

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

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

POST/v2/campaigns/{id}/activate

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

path parameters

idstringrequired

campaign id

Responses

201Published version
idstring
campaign_idstring
version_numberinteger
manifestobject

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

resolved_location_idsarray
resolved_zone_idsarray
affected_csidsarray
validation_summaryobject

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

published_atstring
notifyobject

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

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

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

POST/v2/campaigns/{id}/deactivate

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

path parameters

idstringrequired

campaign id

Responses

200The campaign
idstringrequired

cmp_…

account_idstringrequired

acc_…

group_idstring,null

grp_… (organization group, access filtering) or null

namestringrequired
descriptionstring,null
briefobjectrequired

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

start_datestring,null
end_datestring,null
priorityinteger

Cross-campaign precedence on a shared screen

statusstringrequired

draft | published | archived

current_versioninteger,null

Latest published version number; null until first publish

metadataobjectrequired

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

created_atstringrequired
updated_atstringrequired
total_ad_countinteger
screen_countinteger

Distinct screens with a placement

estimated_locationsinteger

Reach proxy: the stored target include-set size

scored_ad_countinteger
campaign_scorenumber,null

Average of the SCORED adverts; null if none

media_asset_idstring,null

mda_… — the cover

poster_urlstring,null
scored_signaturestring,null

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

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

Archive (soft-delete) a campaign

POST/v2/campaigns/{id}/archive

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

path parameters

idstringrequired

campaign id

Responses

204Archived

No body — 204 returns nothing.

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

Versions

List published versions (newest first)

GET/v2/campaigns/{id}/versions

No description yet.

path parameters

idstringrequired

campaign id

Responses

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

Get one published version (frozen manifest)

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

No description yet.

path parameters

idstringrequired

campaign id

versionintegerrequired

Responses

200The version
idstring
campaign_idstring
version_numberinteger
manifestobject

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

resolved_location_idsarray
resolved_zone_idsarray
affected_csidsarray
validation_summaryobject

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

published_atstring
notifyobject

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

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

Ad Libraries

List ad libraries for the active account

GET/v2/ad-libraries

No description yet.

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

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

Create an ad library

POST/v2/ad-libraries

No description yet.

Request body

namestringrequired
descriptionstring,null
metadataobject

Responses

201Library created
idstring
account_idstring
group_idstring,null

org group (access filtering)

namestring
descriptionstring,null
metadataobject
statusstring
ad_countinteger

folded-in summary — ads in this library

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

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

GET/v2/ad-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Responses

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

org group (access filtering)

namestring
descriptionstring,null
metadataobject
statusstring
ad_countinteger

folded-in summary — ads in this library

created_atstring
updated_atstring
adsarray

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

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

Rename or update an ad library

PATCH/v2/ad-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Request body

namestring
descriptionstring,null
metadataobject
statusstring

Responses

200Updated library
idstring
account_idstring
group_idstring,null

org group (access filtering)

namestring
descriptionstring,null
metadataobject
statusstring
ad_countinteger

folded-in summary — ads in this library

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

Delete an ad library

DELETE/v2/ad-libraries/{id}

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

path parameters

idstringrequired

ad library or media library id

Responses

204Deleted

No body — 204 returns nothing.

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

Add an ad to a library (idempotent)

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

No description yet.

path parameters

idstringrequired

ad library or media library id

ad_idstringrequired

ad id, within the library named by the path

Responses

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

No body — 204 returns nothing.

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

Remove an ad from a library

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

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

path parameters

idstringrequired

ad library or media library id

ad_idstringrequired

ad id, within the library named by the path

Responses

204Membership removed

No body — 204 returns nothing.

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

Ads

List ads

GET/v2/ads

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

query parameters

library_idstring

Filter to one library (prefixed asl_…)

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

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

Create an ad from a media-library asset

POST/v2/ads

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

Request body

media_asset_idstringrequired

the asset to wrap (prefixed mda_…)

library_idstring

target library; defaults to the account Default

namestring
metadataobject

Responses

201Ad created
idstring
account_idstring
group_idstring,null

org group (access filtering)

media_asset_idstring

the media-library asset this ad wraps

namestring,null

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

metadataobject
library_idsarray

libraries this ad belongs to

score_countinteger

folded-in summary — scoring runs so far

latest_scoreobject

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

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

Get one ad

GET/v2/ads/{id}

No description yet.

path parameters

idstringrequired

ad id

Responses

200The ad
idstring
account_idstring
group_idstring,null

org group (access filtering)

media_asset_idstring

the media-library asset this ad wraps

namestring,null

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

metadataobject
library_idsarray

libraries this ad belongs to

score_countinteger

folded-in summary — scoring runs so far

latest_scoreobject

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

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

Update an ad's editable fields

PATCH/v2/ads/{id}

No description yet.

path parameters

idstringrequired

ad id

Request body

namestring
metadataobject

Responses

200Updated ad
idstring
account_idstring
group_idstring,null

org group (access filtering)

media_asset_idstring

the media-library asset this ad wraps

namestring,null

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

metadataobject
library_idsarray

libraries this ad belongs to

score_countinteger

folded-in summary — scoring runs so far

latest_scoreobject

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

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

Delete an ad

DELETE/v2/ads/{id}

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

path parameters

idstringrequired

ad id

Responses

204Deleted

No body — 204 returns nothing.

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

Ad Profiles

List an ad's saved profiles (newest first)

GET/v2/ads/{id}/profiles

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

path parameters

idstringrequired

ad id

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

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

Scores

List the scores of an ad

GET/v2/ads/{id}/scores

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

path parameters

idstringrequired

ad id

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

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

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

POST/v2/ads/{id}/scores

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

path parameters

idstringrequired

ad id

header parameters

X-Idempotency-Keystring

Request body

any

Responses

202Scoring run queued
idstring
account_idstring
group_idstring,null
ad_profile_idstring

the profile that was scored

ad_profileobject

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

statusstring
resultobject,null

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

comment_analysisobject,null
divergence_scorenumber,null

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

prompt_versionobject,null

prompt versions used (calibration provenance)

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

Get one score

GET/v2/scores/{id}

No description yet.

path parameters

idstringrequired

score id

Responses

200The score
idstring
account_idstring
group_idstring,null
ad_profile_idstring

the profile that was scored

ad_profileobject

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

statusstring
resultobject,null

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

comment_analysisobject,null
divergence_scorenumber,null

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

prompt_versionobject,null

prompt versions used (calibration provenance)

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

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

GET/v2/scores/{id}/events

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

path parameters

idstringrequired

score id

Responses

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

Returns text/event-stream.

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

Cancel a queued or running score

POST/v2/scores/{id}/cancel

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

path parameters

idstringrequired

score id

Responses

200Cancelled
idstring
account_idstring
group_idstring,null
ad_profile_idstring

the profile that was scored

ad_profileobject

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

statusstring
resultobject,null

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

comment_analysisobject,null
divergence_scorenumber,null

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

prompt_versionobject,null

prompt versions used (calibration provenance)

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

Media Libraries

List media libraries for the active account

GET/v2/media-libraries

No description yet.

query parameters

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

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

Create a media library

POST/v2/media-libraries

No description yet.

Request body

namestringrequired
descriptionstring,null
metadataobject

Responses

201Library created
idstring
account_idstring
namestring
descriptionstring,null
metadataobject

arbitrary extra info

asset_countinteger

folded-in summary — assets in this library

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

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

GET/v2/media-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Responses

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

arbitrary extra info

asset_countinteger

folded-in summary — assets in this library

created_atstring
updated_atstring
assetsarray

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

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

Rename or update a media library

PATCH/v2/media-libraries/{id}

No description yet.

path parameters

idstringrequired

ad library or media library id

Request body

namestring
descriptionstring,null
metadataobject

Responses

200Updated library
idstring
account_idstring
namestring
descriptionstring,null
metadataobject

arbitrary extra info

asset_countinteger

folded-in summary — assets in this library

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

Delete a media library

DELETE/v2/media-libraries/{id}

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

path parameters

idstringrequired

ad library or media library id

Responses

204Deleted

No body — 204 returns nothing.

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

Add an asset to a library (idempotent)

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

No description yet.

path parameters

idstringrequired

ad library or media library id

asset_idstringrequired

media asset id, within the library named by the path

Responses

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

No body — 204 returns nothing.

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

Remove an asset from a library

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

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

path parameters

idstringrequired

ad library or media library id

asset_idstringrequired

media asset id, within the library named by the path

Responses

204Membership removed

No body — 204 returns nothing.

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

Media Assets

List media assets

GET/v2/media-assets

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

query parameters

library_idstring

Filter to one library (prefixed mlb_…)

asset_rolestring

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

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

maximum rows to return

Responses

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

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

POST/v2/media-assets/upload-url

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

Request body

filenamestringrequired
media_typestringrequired
asset_rolestring

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

brand_namestring

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

content_typestringrequired
library_idstringrequired

Target library (required; validated on confirm)

Responses

200Presigned upload reserved
storage_keystring
uploadobject

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

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

Confirm a presigned direct-to-S3 upload

POST/v2/media-assets/confirm

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

Request body

storage_keystringrequired

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

filenamestringrequired
media_typestringrequired
asset_rolestring

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

brand_namestring

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

library_idstringrequired

Target library (required)

namestring

Responses

201Asset created
idstring
account_idstring
namestring
media_typestring
asset_rolestring

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

brand_namestring,null

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

source_kindstring
source_urlstring,null

set when source_kind = url

filenamestring,null
duration_snumber,null

video/audio only; null for image/pdf

urlstring,null

derived browsable URL (presigned); not stored

metadataobject

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

statusstring
library_idsarray

libraries this asset belongs to

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

Get one media asset

GET/v2/media-assets/{id}

No description yet.

path parameters

idstringrequired

media asset id

Responses

200The asset
idstring
account_idstring
namestring
media_typestring
asset_rolestring

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

brand_namestring,null

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

source_kindstring
source_urlstring,null

set when source_kind = url

filenamestring,null
duration_snumber,null

video/audio only; null for image/pdf

urlstring,null

derived browsable URL (presigned); not stored

metadataobject

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

statusstring
library_idsarray

libraries this asset belongs to

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

Update a media asset's editable fields

PATCH/v2/media-assets/{id}

No description yet.

path parameters

idstringrequired

media asset id

Request body

namestring
asset_rolestring

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

brand_namestring,null

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

metadataobject

Responses

200Updated asset
idstring
account_idstring
namestring
media_typestring
asset_rolestring

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

brand_namestring,null

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

source_kindstring
source_urlstring,null

set when source_kind = url

filenamestring,null
duration_snumber,null

video/audio only; null for image/pdf

urlstring,null

derived browsable URL (presigned); not stored

metadataobject

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

statusstring
library_idsarray

libraries this asset belongs to

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

Delete a media asset

DELETE/v2/media-assets/{id}

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

path parameters

idstringrequired

media asset id

Responses

204Deleted

No body — 204 returns nothing.

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

API reference

Device

Devices

The device as an asset — 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.

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

Curated audience profiles (optionally filtered)

GET/v2/geo/audience-profiles

No description yet.

query parameters

qstring

Responses

200Profiles
audience_profilesarray
401Not authenticated
typestring
titlestring
statusinteger
detailstring

One audience profile by id

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

No description yet.

path parameters

idstringrequired

Location-profile id

Responses

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

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

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

No description yet.

path parameters

zipstringrequired

5-digit US ZIP

Responses

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

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

401Not authenticated
typestring
titlestring
statusinteger
detailstring

Resolve a city name to the states it exists in

GET/v2/geo/cities/lookup

No description yet.

query parameters

namestringrequired

Responses

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

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

GET/v2/geo/geographies

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

query parameters

qstringrequired
limitinteger

Responses

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

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

GET/v2/geo/reference-data

No description yet.

Responses

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

Coverage Areas

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

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

GET/v2/coverage-areas/geocode

No description yet.

query parameters

addressstringrequired
citystring
state_codestring
zipstring

Responses

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

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

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

No description yet.

path parameters

zipstringrequired

Responses

200ZIP snapshot
okboolean
demographicsobject

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

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

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

GET/v2/coverage-areas/radius

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

query parameters

radius_metersintegerrequired
latitudenumber
longitudenumber
addressstring
citystring
state_codestring
zipstring
radius_labelstring

Responses

200Catchment aggregate
okboolean
centerobject
radius_metersinteger
radius_labelstring
zipsarray
totalsobject
truncatedboolean

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

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

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

POST/v2/coverage-areas/locations

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

Request body

locationsarrayrequired

Responses

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

Context

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

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

GET/v2/context/weather

No description yet.

query parameters

latitudenumberrequired
longitudenumberrequired

Responses

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

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

GET/v2/context/climatology

No description yet.

query parameters

latitudenumberrequired
longitudenumberrequired

Responses

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

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

GET/v2/context/traffic

No description yet.

query parameters

business_typestring
campaign_zonestring

Responses

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

Weather

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

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

GET/v2/weather/current

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

query parameters

latnumberrequired
lonnumberrequired

Responses

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

API reference

Events

Analytics

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

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

GET/v2/analytics/executive

No description yet.

query parameters

fromstring

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

tostring

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

locationsstring

comma-separated location ids

groupsstring

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

Responses

200executive metrics
visitsobject

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

repeat_pctobject

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

avg_dwell_secondsobject

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

location_countobject

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

genderobject
footfallobject

parallel arrays; this vs last comparable period

top_locationsarray
by_locationarray

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

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

GET/v2/analytics/location

No description yet.

query parameters

fromstring

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

tostring

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

locationsstring

comma-separated location ids

groupsstring

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

Responses

200location metrics
overviewobject
visits_by_weekdayarray
visits_by_hourarray
recent_visitsarray

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

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

GET/v2/analytics/demographics

No description yet.

query parameters

fromstring

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

tostring

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

locationsstring

comma-separated location ids

groupsstring

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

Responses

200demographics metrics
genderobject
age_splitarray
gender_by_hourarray
age_by_hourarray
gender_by_dayarray
age_by_dayarray

Loyalty

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

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

GET/v2/analytics/loyalty

No description yet.

query parameters

locationsstring

comma-separated location ids

groupsstring

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

Responses

200loyalty metrics
new_repeatobject

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

frequencyarray
recencyarray
new_vs_repeatarray

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

PUT/v2/analytics/loyalty/frequency

No description yet.

Request body

thresholdsarrayrequired

cut points in days

Responses

200recomputed frequency across the 30/60/90 lookbacksarray

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

PUT/v2/analytics/loyalty/recency

No description yet.

Request body

thresholdsarrayrequired

cut points in days

Responses

200recomputed recency across the 30/60/90 lookbacksarray

Campaign performance

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

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

GET/v2/analytics/campaigns

No description yet.

query parameters

fromstring

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

tostring

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

campaign_idsstring

comma-separated campaign ids to scope the result to

group_idsstring

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

Responses

200per-campaign summary rows
campaignsarray

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

GET/v2/analytics/campaigns/summary

No description yet.

query parameters

fromstring

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

tostring

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

campaign_idsstring

comma-separated campaign ids to scope the result to

group_idsstring

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

Responses

200portfolio-level KPI cards
total_reachobject

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

total_ad_playsobject

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

total_ad_viewsobject

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

average_attention_rateobject

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

average_dwell_timeobject

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

screensobject

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

total_purchase_intentobject

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

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

GET/v2/analytics/campaigns/{campaign_id}

No description yet.

path parameters

campaign_idstringrequired

campaign id

query parameters

fromstring

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

tostring

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

Responses

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

distinct people exposed

playsinteger
dwell_secondsnumber
locationsarray
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

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

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

No description yet.

path parameters

campaign_idstringrequired

campaign id

query parameters

fromstring

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

tostring

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

Responses

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

Per-creative performance within one campaign

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

No description yet.

path parameters

campaign_idstringrequired

campaign id

Responses

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

Audience split for one campaign — age and gender

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

No description yet.

path parameters

campaign_idstringrequired

campaign id

Responses

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

Per-location cards for one campaign

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

No description yet.

path parameters

campaign_idstringrequired

campaign id

Responses

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

Live

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

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

GET/v2/events/live

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

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

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

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

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

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

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

query parameters

installation_idsstringrequired

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

Responses

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

Returns text/event-stream.

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

Body not documented.

401missing or invalid bearer token

Body not documented.

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

Body not documented.

Live visit stream for one installation (kept for compatibility)

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

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

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

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

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

path parameters

installation_idstringrequired

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

Responses

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

Returns text/event-stream.

400installation_id is not a uuid

Body not documented.

401missing or invalid bearer token

Body not documented.

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

Body not documented.


Blacklist

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

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

GET/v2/events/blacklist

No description yet.

query parameters

location_idstring

filter the blacklist to one location

cursorstring

pagination cursor from the previous response's next_cursor

limitinteger

max entries per page

Responses

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

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

Add a manual blacklist entry for a location

POST/v2/events/blacklist

No description yet.

Request body

location_idstringrequired
person_idstringrequired
reasonstring

Responses

201created
idstring
location_idstring
person_idstring
auto_detectedboolean

true = staff-scoring cron, false = manual

staff_scoreinteger

set on auto-detected rows

reasonstring
created_atstring
updated_atstring

Get one blacklist entry

GET/v2/events/blacklist/{blacklist_id}

No description yet.

path parameters

blacklist_idstringrequired

blacklist entry id

Responses

200blacklist entry
idstring
location_idstring
person_idstring
auto_detectedboolean

true = staff-scoring cron, false = manual

staff_scoreinteger

set on auto-detected rows

reasonstring
created_atstring
updated_atstring
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Update a manual blacklist entry (reason)

PATCH/v2/events/blacklist/{blacklist_id}

No description yet.

path parameters

blacklist_idstringrequired

blacklist entry id

Request body

reasonstringrequired

Responses

200updated
idstring
location_idstring
person_idstring
auto_detectedboolean

true = staff-scoring cron, false = manual

staff_scoreinteger

set on auto-detected rows

reasonstring
created_atstring
updated_atstring
404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Remove a blacklist entry

DELETE/v2/events/blacklist/{blacklist_id}

No description yet.

path parameters

blacklist_idstringrequired

blacklist entry id

Responses

204removed

No body — 204 returns nothing.

404RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Exports

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

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

GET/v2/events/exports/visits/estimate

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

query parameters

fromstringrequired

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

tostring

exclusive end of the window, RFC3339; defaults to now

groupstring

one group id; mutually exclusive with `location`

locationstring

one location id; mutually exclusive with `group`

Responses

200size of the export
rowsinteger

rows that will appear in the file

window_rowsinteger

rows scanned to produce it - this is what sets mode

bytes_estimateinteger

rough uncompressed CSV size in bytes

modestring

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

400RFC 9457 error
typestring
titlestring
statusinteger
detailstring

Download raw visits for a time window as CSV

GET/v2/events/exports/visits

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

query parameters

fromstringrequired

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

tostring

exclusive end of the window, RFC3339; defaults to now

groupstring

one group id; mutually exclusive with `location`

locationstring

one location id; mutually exclusive with `group`

Responses

200CSV file

Returns text/csv.

400RFC 9457 error
typestring
titlestring
statusinteger
detailstring