Send proof of play from your players or CMS
A play that nobody can check is only a claim. This guide shows how a player, or the CMS that runs the players, reports finished plays in the format tanba.pop/1, so the platform can count them and anyone can check the counts against the stored batches.
Reference pages: Proof-of-play API and Device protocol.
Choose a channel
The channel a batch arrives on fixes how far the platform trusts it. The body never claims a tier.
| Channel | Path and host | Authentication | Evidence tier |
|---|---|---|---|
| Certificate | POST /d/v1/telemetry on https://devices.tanba-ooh.kz | Client certificate and a signature of the body by the device key | mtls |
| Signed | POST /s/v1/telemetry on https://agents.tanba-ooh.kz | A signature of the body by the key registered at enrolment; the body carries device_id and ts | signed |
| CMS (attested) | POST /api/v1/pop/batches on https://app.tanba-ooh.kz | API key with the scope write:plays and a signature by the key registered for the CMS installation | attested |
Which tiers count for the fee is set by the platform, not by this interface. Attested plays are the operator's own claim: they never clear an operator of a violation and serve as a cross-check next to what the devices report.
The batch
{"schema": "tanba.pop/1", "batch_seq": 1042, "prev_sha256": "9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123",
"sent_at": "2026-10-14T09:05:00.120Z", "events": [ ]}
batch_seqis a whole number, 0 or more, taken once per batch. The platform keeps the body exactly as received.prev_sha256is the SHA-256 in hex of the body of batchbatch_seq - 1; empty for the first batch.- At most 1000 events and 4 MB per batch (1 MB on the signed channel). Cut a batch every 300 seconds or at 1000 events, whichever comes first. The heartbeat answer carries
telemetry_cut_s, the interval the platform asks for. A CMS batch hasrecordsin place ofevents, with the limits further down. - Send batches in order and stop at the first failure. Resend a failed batch with the same bytes.
- A body without
schemais the earlier format. Once the firsttanba.pop/1batch of a device is accepted, the device cannot go back: a batch withoutschemais refused409 legacy_after_pop. Send everything queued in the earlier format before the firsttanba.pop/1batch.
Records
One play per finished play:
| Field | Type | Rule |
|---|---|---|
kind | "play" | Other kinds are reject (with code, one of NO_SIGNATURE, BAD_SIGNATURE, HASH_MISMATCH, EXPIRED_APPROVAL, REVOKED), mode_change and tamper; they carry at |
seq | whole number | Required on play. Per player, +1 for every record across batches, from 1 |
play_id | string, 8 to 64 characters of A-Za-z0-9_- | Unique for the player |
approval_number | TNB-YYYY-NNNNNN | Required unless slot is set |
slot | gov or emergency | Authority content: no approval number, never billed, counted apart |
media_sha256 | 64 hex characters | SHA-256 of the file played |
start, end | RFC 3339, milliseconds | start not after end |
duration_ms | whole number, 0 or more | Measured time on screen |
structure | structure code | Optional; must be the player's own, else flagged |
screen | {"power": ...} | Optional; state of the display during the play: on, off or unknown |
How plays are counted
- A play is an accepted record with
duration_msof 1000 or more, counted in the UTC hour ofend. Shorter records are accepted and counted nowhere. - A play is complete when it lasted to within 500 ms of the length of the approved media (a still image: 1000 ms).
- A record that is accepted but carries a flag still counts. The flags are
approval_unknown,approval_not_for_structure,approval_outside_validity,revoked_at_time,media_mismatch,structure_mismatch,duration_mismatch,clock_skew,late,screen_off,incompleteandlegacy. - A record is refused, not counted and listed in the answer, with one of
kind_unknown,approval_number_invalid,time_invalid,end_before_start,time_in_future,too_old,seq_invalid,media_sha256_invalid. On the CMS channel there is one more:player_unknown. - A play with
screen.powerofoffis counted and reported asscreen_off. A flag never removes a play from the count.
Order, repeats and the chain
- The same
batch_seqwith the same bytes is answered200withduplicate: trueand counted once. The samebatch_seqwith other bytes is409 batch_conflictand nothing is counted. - For each player the platform keeps the highest record number it took. A record at or below it is a duplicate: listed, not counted. Resending old records under a new
batch_seqnever doubles a count; the platform records it as a rewind. - A record number more than one above the highest taken is accepted and recorded as a gap. A player that loses its numbering enrols again as a new device.
- When the platform holds batch
batch_seq - 1and its hash differs fromprev_sha256, the batch is still accepted and a chain break is recorded.
The answer
{"batch_seq": 1042, "received": 12, "accepted": 10, "duplicates": 0, "refused": 2, "flagged": 1, "duplicate": false,
"items": [{"index": 1, "status": "flagged", "flags": ["incomplete"]}, {"index": 10, "status": "refused", "reason": "time_in_future"},
{"index": 11, "status": "refused", "reason": "end_before_start"}]}
items lists only records that are not plainly accepted. index counts from 0. A clean batch gets an empty list.
Report from a CMS
An operator whose players run under a CMS can report their plays from the CMS, in one signed batch for many players.
Set up once, in the console
Only an administrator of the operator organisation can do this, under Integrations. Every step is written to the audit log.
- Register the CMS installation: a name, a type and its public key, an ECDSA P-256 key as base64 of the DER SubjectPublicKeyInfo. The private key never leaves your CMS. The console shows the source identifier that goes into every batch.
- Map each player of the CMS that drives one of your structures: the id your CMS uses for it (the
playerof the records, up to 64 characters) and the structure. An external id is used once per installation. Plays of a player that is not mapped are refused asplayer_unknown. - Create an API key with the scope
write:plays. See Authentication and API keys. - To replace the key, register the new public key on the installation. The new key is used at once; the old one stays valid for 24 hours, then it is refused with
401 source_unauthorized. - If a key leaked, end it at once:
POST /api/v1/pop-sources/{id}/keys/{fingerprint}/revoke(operator administrator). The source accepts no batch until a new key is registered. The body of every batch must carrysource_id, the same as theX-Tanba-Sourceheader.
Sign a batch
The signature is ES256 over the exact bytes of the JSON body, after decompression when you send gzip. It is written raw, r then s, 32 bytes each, in base64url without padding. Save the helper below as pop_client.py. It needs Python 3 and the cryptography package.
import base64, gzip, hashlib, json, sys, urllib.request
from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils
def new_key(path):
"""Run once. Keep the private key; register the printed public key in the console."""
key = ec.generate_private_key(ec.SECP256R1())
with open(path, "wb") as f:
f.write(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
der = key.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
print("public key:", base64.b64encode(der).decode())
def load_key(path):
with open(path, "rb") as f:
return serialization.load_pem_private_key(f.read(), password=None)
def sign(key, body: bytes) -> str:
r, s = utils.decode_dss_signature(key.sign(body, ec.ECDSA(hashes.SHA256())))
return base64.urlsafe_b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).rstrip(b"=").decode()
def send(base, api_key, source_id, key, batch_seq, prev_sha256, records):
body = json.dumps({"schema": "tanba.pop/1", "source_id": source_id, "batch_seq": batch_seq, "prev_sha256": prev_sha256,
"sent_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"), "records": records},
separators=(",", ":")).encode()
req = urllib.request.Request(base + "/api/v1/pop/batches", data=gzip.compress(body), method="POST", headers={
"Authorization": "Bearer " + api_key, "X-Tanba-Source": source_id, "X-Tanba-Signature": sign(key, body),
"Content-Type": "application/json", "Content-Encoding": "gzip"})
with urllib.request.urlopen(req, timeout=60) as resp:
answer = json.load(resp)
return answer, hashlib.sha256(body).hexdigest() # the hash is the next batch's prev_sha256
if __name__ == "__main__":
if sys.argv[1] == "keygen": # python3 pop_client.py keygen cms-key.pem
new_key(sys.argv[2])
elif sys.argv[1] == "sign": # python3 pop_client.py sign cms-key.pem batch.json
print(sign(load_key(sys.argv[2]), open(sys.argv[3], "rb").read()))
Make the key once, then register the printed public key in the console:
python3 pop_client.py keygen cms-key.pem
Send with curl
Write the batch to batch.json, sign the file, and send the same bytes. Use --data-binary: -d would strip line breaks and the signature would no longer match.
export POP_API_KEY='tnbk_EXAMPLE_secret'
export POP_SOURCE='3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836'
cat > batch.json <<'EOF'
{"schema":"tanba.pop/1","source_id":"3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836","batch_seq":1042,"prev_sha256":"","sent_at":"2026-10-14T09:05:00.120Z","records":[{"kind":"play","player":"1001:55:2","seq":18,"play_id":"7c1e9a40b2d35f68","approval_number":"TNB-2026-000123","media_sha256":"9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123","start":"2026-10-14T09:04:50.000Z","end":"2026-10-14T09:05:00.000Z","duration_ms":10000}]}
EOF
curl -X POST 'https://app.tanba-ooh.kz/api/v1/pop/batches' \
-H "Authorization: Bearer $POP_API_KEY" \
-H "X-Tanba-Source: $POP_SOURCE" \
-H "X-Tanba-Signature: $(python3 pop_client.py sign cms-key.pem batch.json)" \
-H 'Content-Type: application/json' \
--data-binary @batch.json
Send with Python
from pop_client import load_key, send
key = load_key("cms-key.pem")
records = [{"kind": "play", "player": "1001:55:2", "seq": 18, "play_id": "7c1e9a40b2d35f68",
"approval_number": "TNB-2026-000123",
"media_sha256": "9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123",
"start": "2026-10-14T09:04:50.000Z", "end": "2026-10-14T09:05:00.000Z", "duration_ms": 10000}]
answer, body_hash = send("https://app.tanba-ooh.kz", "tnbk_EXAMPLE_secret", "3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836",
key, batch_seq=1042, prev_sha256="", records=records)
print(answer)
Keep body_hash as the prev_sha256 of the next batch. Keep the seq counters of the players, the batch_seq and the body hash in your own storage, so that a restart continues the numbering instead of repeating it.
Map your feed to records
player: the external id you mapped, for example the three ids of a frame joined with:.end: in UTC. If the feed has local venue time, convert it; the zone for Kazakhstan is Asia/Almaty.start:endminus the duration, when the feed gives only a duration.play_id: any stable id of the play that is unique for the player, for example the first 16 hex characters of the SHA-256 of the feed row.seq: assigned by your adapter, +1 for every record of a player in the order ofend.approval_numberandmedia_sha256: from a table you keep, linking each creative to the approval it plays under and the hash of its file. A row whose creative is not in the table cannot be reported; leave it out and list it for the operator.
Limits
| Records in a batch | 10 000 |
| Body | 4 MB on the wire; 16 MB after decompression, and not more than 20 times the compressed size |
| Batches | 2 a second for each installation (burst 10) |
| Calls | 10 a second for each API key (burst 30) |
| Records | 2 000 000 an hour for the organisation |
A request over a limit is answered 429 rate_limited with Retry-After, or 413 for the size limits.
Report from a player
A player enrols once with a token from the console, then reports itself. On the certificate channel it presents its client certificate and signs the body with its device key; on the signed channel it signs the body with the key registered at enrolment and the body carries device_id and ts, which must be within 300 seconds of the platform clock (both channels take one batch every 10 seconds for each device, with up to 3 at once). The signature is made exactly as above. The calls are listed on the Device protocol page.
Errors
Errors are problem documents with a stable code.
| Status | code | When |
|---|---|---|
| 400 | invalid_json, schema_unknown | Unreadable body, unknown schema |
| 400 / 401 | bad_signature | The signature does not verify (400 on the certificate channel, where the body is kept apart as evidence; 401 elsewhere, nothing is stored) |
| 401 | device_unauthorized, device_cert_required, stale_request | Credential unknown, revoked or expired; ts out of range on the signed channel |
| 401 | source_unauthorized | CMS channel: the source is unknown or disabled, the X-Tanba-Source header is missing, or the key was rotated out more than 24 hours ago |
| 403 | forbidden, key_scope_denied | CMS channel: the source belongs to another organisation, the caller is not an API key, or the key lacks write:plays |
| 409 | batch_conflict | The batch_seq was taken by a batch with other content |
| 409 | legacy_after_pop | A batch in the earlier format from a device that already sent tanba.pop/1; do not retry it |
| 413 | body_too_large, batch_too_large | More than the size limits |
| 415 | unsupported_media_type | CMS channel: not JSON, or an encoding other than gzip |
| 422 | validation_failed | Envelope fields: batch_seq, the format of prev_sha256, empty events, missing or mismatched source_id |
| 429 | rate_limited | Over the rate limits |
Check what was stored
Every accepted batch is stored as the exact bytes received, with the signature header, the SHA-256 of the body and the credential that signed it. Anyone holding the public key can take a stored batch, hash it, verify the signature, and walk the chain through prev_sha256 and the record numbers through seq, without trusting the platform's counts. How the platform seals the received statistics each day, and how to check a seal offline, is in Read a statement and check its evidence. The checking tool is on the Downloads page.