CMS integration
If you are a content partner — your CMS content is routed to screens across Verve inventory — this guide covers how to read the devices that play it.
You do not register inventory; the operators who own those screens do. What you hold is an entitlement: one or more content sources, and Devices.ListByContentSource returns every device configured to serve from them.
Your namespace and entitlement
Section titled “Your namespace and entitlement”As a content partner you address the API with the organization provisioned for you at onboarding:
X-Verve-Namespace: organization:<ORGANIZATION_ID>Any other namespace type is rejected on this endpoint.
Your organization is not where the devices live — it is where your entitlement lives. The platform reads the content sources granted to it, then returns every device in the same tenant configured to serve from one of them, across every project and every other organization in that tenant. You see the devices that play your content, and nothing else.
What counts as configured to serve
Section titled “What counts as configured to serve”A device’s deployment carries allocation targets, of two kinds:
- static — a fixed percentage of impressions routed to named sources. This is curated content delivery.
- nonStatic — the remaining share, sold through the bidding auction.
This endpoint matches on static targets only. A non-static target is the bidding auction, not curated content, so it does not make the device yours.
List devices by content source
Section titled “List devices by content source”POST /v1alpha/inventory/devices:listByContentSourceEvery field in the body is optional — {} returns the first page at the default size. The full request and response schemas are in the API reference.
POST /v1alpha/inventory/devices:listByContentSource HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6...X-Verve-Namespace: organization:01KMDH2GRKP9ZXZW241Z26B5DSContent-Type: application/json
{ "page": 1, "pageSize": 25, "filters": { "contentSources": ["mock-verve-cms-1"], "externalId": "screen-a", "name": "Produce", "locationId": "store-123", "modifiedSince": "2026-09-01T00:00:00Z" }, "sort": { "field": "DEVICE_SORT_FIELD_EXTERNAL_ID", "direction": "SORT_DIRECTION_ASC" }}| Field | Description |
|---|---|
page |
1-based page number. Defaults to 1. |
pageSize |
Results per page. Defaults to 50, capped at 100 — a larger value is silently reduced, not rejected. |
filters |
Filter envelope. See below. |
sort |
Result ordering. See below. |
Filters
Section titled “Filters”Filters combine with AND — a device must satisfy every one you send. Omit them all to list every device you are entitled to, still paginated.
contentSources
Section titled “contentSources”Narrows the listing to a subset of the sources you hold. Multiple ids combine with OR: a device matches if any one of its static targets names any one of these sources.
{ "contentSources": ["mock-verve-cms-1", "mock-verve-cms-2"] }Omit it to list devices serving any source you hold, which is what most integrations want. It is only useful when you hold more than one source and want to look at them separately.
externalId
Section titled “externalId”Case-insensitive substring match on the device’s own externalId.
{ "externalId": "screen-a" }A device’s externalId is derived from its identifier as "{key}_{playerId}", so acme_screen-a matches on screen-a, ACME, or me_scr. Regex metacharacters are matched literally — a.b finds a.b, not axb.
Case-insensitive substring match on the device’s metadata.name. Same matching rules as externalId.
{ "name": "Produce" }locationId
Section titled “locationId”Exact match on the location’s externalId — for example its TDLinx code. This is the value carried by the device’s location.externalId, not the location’s metadata.id. Unlike the two filters above it is exact, not a substring.
{ "locationId": "store-123" }modifiedSince
Section titled “modifiedSince”Only devices modified strictly after the given instant, compared against metadata.lastUpdatedAt. RFC 3339 timestamp.
{ "modifiedSince": "2026-09-01T00:00:00Z" }Intended for incremental sync: record the highest lastUpdatedAt you have seen, then pass it back on the next poll to fetch only what changed.
Sorting
Section titled “Sorting”field |
Orders by |
|---|---|
DEVICE_SORT_FIELD_UNSPECIFIED |
Default — same as DEVICE_SORT_FIELD_EXTERNAL_ID. |
DEVICE_SORT_FIELD_EXTERNAL_ID |
The device’s externalId. |
DEVICE_SORT_FIELD_FIRST_CREATED_AT |
metadata.firstCreatedAt — when the device was registered. |
direction |
Order |
|---|---|
SORT_DIRECTION_UNSPECIFIED |
Default — ascending. |
SORT_DIRECTION_ASC |
Ascending. |
SORT_DIRECTION_DESC |
Descending. |
Omitting sort sorts by externalId ascending.
Both sort fields are stable under concurrent edits: externalId cannot be changed once set, and firstCreatedAt is fixed at registration. Sorting on lastUpdatedAt is deliberately not offered — it is the one field an edit moves, and paging through it while devices are being updated is how a walk silently skips one.
Response
Section titled “Response”{ "results": [ { "device": { "metadata": { "id": "01HHXTNJ3K1QZ8V6X6Y7Z8A9B0", "name": "Screen A — Produce Aisle", "firstCreatedAt": "2026-02-11T09:14:03Z", "lastUpdatedAt": "2026-09-03T16:22:41Z" }, "externalId": "acme_screen-a", "identifier": { "key": "acme", "playerId": "screen-a" }, "type": "DEVICE_TYPE_STELE", "status": "STATUS_ACTIVE", "location": { "externalId": "store-123" }, "screenCount": 1, "deviceOwner": "Viewento", "deployment": { "adNetworks": ["01KMDH2GRKP9ZXZW242G9C8FBB"], "allocationTargets": [ { "static": { "percentage": 10, "adServers": [{ "reference": { "id": "mock-verve-cms-1" } }] } }, { "nonStatic": { "balancePolicy": "AD_SERVER_BALANCE_POLICY_BIDDING", "programmaticPercentage": 30 } } ] } }, "location": { "metadata": { "name": "Acme Market — Karl-Marx-Straße" }, "externalId": "store-123", "timezone": "Europe/Berlin", "address": { "country": "DE", "city": "Berlin", "street": "Karl-Marx-Straße", "number": "12", "zipCode": "12043" } } } ], "totalCount": 1, "totalPages": 1, "currentPage": 1}| Field | Description |
|---|---|
results |
The rows on this page. Each is a device with the location it is installed at. |
totalCount |
Total devices matching the filters, across all pages. |
totalPages |
Number of pages at the requested pageSize. |
currentPage |
The page echoed back (1-based). |
Selected device fields
Section titled “Selected device fields”Paths below are relative to a row’s device, so metadata.id is results[].device.metadata.id.
| Field | Description |
|---|---|
metadata.id |
Platform-assigned identifier. |
metadata.name |
Human-readable name. Matched by the name filter. |
metadata.firstCreatedAt / lastUpdatedAt |
RFC 3339 timestamps. Both are sort/filter inputs — see above. |
externalId |
Derived as "{identifier.key}_{identifier.playerId}". Read-only. |
identifier |
The device’s identity: key + playerId. |
status |
STATUS_ACTIVE or STATUS_INACTIVE (STATUS_UNSPECIFIED if never set). |
location.externalId |
The location the device is installed at. Matched by the locationId filter. |
deployment.allocationTargets |
Static and non-static routing. Your source appears in a static target. |
The row’s location
Section titled “The row’s location”Each row carries the location the device is installed at, so you do not have to fetch it separately. location.externalId is the same value the locationId filter matches, and the same one the device carries under device.location.externalId.
location is absent when the device names an externalId no location in the tenant carries. The device is still returned — it is real inventory, and withholding it would hide a screen from your sync because of a separate bookkeeping problem. Treat an absent location as “position unknown”, not as “device does not exist”.
Errors
Section titled “Errors”| HTTP | gRPC code | Cause |
|---|---|---|
401 |
UNAUTHENTICATED |
Missing Authorization header, malformed Bearer value, or an expired/invalid token. |
403 |
PERMISSION_DENIED |
No X-Verve-Namespace header, or your service account lacks access to that organization. |
400 |
INVALID_ARGUMENT |
Namespace header malformed (not type:id), an id that does not exist, or a namespace type other than organization on this endpoint. |
500 |
INTERNAL |
Server-side failure. Safe to retry with backoff. |
Holding no content sources is not an error: you receive an empty page with totalCount: 0. If you expect devices and get none, check the source ids issued at onboarding before assuming the inventory is empty.