Skip to content

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.

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.

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.

POST /v1alpha/inventory/devices:listByContentSource

Every 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.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6...
X-Verve-Namespace: organization:01KMDH2GRKP9ZXZW241Z26B5DS
Content-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 combine with AND — a device must satisfy every one you send. Omit them all to list every device you are entitled to, still paginated.

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.

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

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

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.

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.

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

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.

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

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.