TAŇBA-OOH Developers

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.

ChannelPath and hostAuthenticationEvidence tier
CertificatePOST /d/v1/telemetry on https://devices.tanba-ooh.kzClient certificate and a signature of the body by the device keymtls
SignedPOST /s/v1/telemetry on https://agents.tanba-ooh.kzA signature of the body by the key registered at enrolment; the body carries device_id and tssigned
CMS (attested)POST /api/v1/pop/batches on https://app.tanba-ooh.kzAPI key with the scope write:plays and a signature by the key registered for the CMS installationattested

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": [ ]}

Records

One play per finished play:

FieldTypeRule
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
seqwhole numberRequired on play. Per player, +1 for every record across batches, from 1
play_idstring, 8 to 64 characters of A-Za-z0-9_-Unique for the player
approval_numberTNB-YYYY-NNNNNNRequired unless slot is set
slotgov or emergencyAuthority content: no approval number, never billed, counted apart
media_sha25664 hex charactersSHA-256 of the file played
start, endRFC 3339, millisecondsstart not after end
duration_mswhole number, 0 or moreMeasured time on screen
structurestructure codeOptional; 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

Order, repeats and the chain

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.

  1. 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.
  2. Map each player of the CMS that drives one of your structures: the id your CMS uses for it (the player of 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 as player_unknown.
  3. Create an API key with the scope write:plays. See Authentication and API keys.
  4. 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.
  5. 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 carry source_id, the same as the X-Tanba-Source header.

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

Limits

Records in a batch10 000
Body4 MB on the wire; 16 MB after decompression, and not more than 20 times the compressed size
Batches2 a second for each installation (burst 10)
Calls10 a second for each API key (burst 30)
Records2 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.

StatuscodeWhen
400invalid_json, schema_unknownUnreadable body, unknown schema
400 / 401bad_signatureThe signature does not verify (400 on the certificate channel, where the body is kept apart as evidence; 401 elsewhere, nothing is stored)
401device_unauthorized, device_cert_required, stale_requestCredential unknown, revoked or expired; ts out of range on the signed channel
401source_unauthorizedCMS 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
403forbidden, key_scope_deniedCMS channel: the source belongs to another organisation, the caller is not an API key, or the key lacks write:plays
409batch_conflictThe batch_seq was taken by a batch with other content
409legacy_after_popA batch in the earlier format from a device that already sent tanba.pop/1; do not retry it
413body_too_large, batch_too_largeMore than the size limits
415unsupported_media_typeCMS channel: not JSON, or an encoding other than gzip
422validation_failedEnvelope fields: batch_seq, the format of prev_sha256, empty events, missing or mismatched source_id
429rate_limitedOver 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.