TAŇBA-OOH Developers

Report plays from a CMS, one signed batch for many players

POST /api/v1/pop/batches

https://app.tanba-ooh.kz/api/v1/pop/batches

Scope write:plays. Operator organisations only. A batch is sent with an API key and a signature: the CMS installation is registered once in the console (name, kind and an ECDSA P-256 public key as base64 of its SubjectPublicKeyInfo), each of its players is mapped there to a structure of the organisation, and every batch names the installation in X-Tanba-Source and carries in X-Tanba-Signature the ES256 signature (raw r||s, base64url, no padding) of the exact bytes of the body. The body may be sent with Content-Encoding: gzip; the signature then covers the bytes after decompression.

The body is tanba.pop/1 with records in place of events: schema, batch_seq (a whole number, 0 or more, taken once per installation), prev_sha256 (SHA-256 of the body of batch batch_seq - 1, empty for the first), sent_at and records. A record is a play as described in the guide "Send proof of play from your players or CMS", plus player, the external id the player was mapped under; seq runs on by one for each player across batches. Records of other kinds are refused. At most 10 000 records, 4 MB on the wire and 16 MB after decompression (a compressed body may not inflate to more than 20 times its size).

Records are judged one by one. A record is refused, listed in items and not counted when the player is not mapped (or its mapping was removed) as player_unknown, or for the reasons listed in that guide; the rest of the batch counts. Counts are stored on the mapped player as attested plays: reported by the operator's system and signed by its key, not seen on the device. Same batch_seq with the same bytes is answered duplicate: true and counted once; with other bytes it is refused 409 batch_conflict. Records at or below the highest number already taken for their player are not counted again.

Limits: 2 batches a second for each installation (burst 10), 10 calls a second for the key (burst 30), and 2 000 000 records an hour for the organisation; beyond them the answer is 429 rate_limited with Retry-After. A key that was rotated out is accepted for 24 hours after the new one was registered; after that the answer is 401 source_unauthorized. A signature that does not verify is 401 bad_signature and nothing is stored.

Authentication

API key in the Authorization header.

Scope: write:plays

Parameters

NameInTypeRequiredDescriptionExample
X-Tanba-Sourceheaderstring (uuid)yesIdentifier of the registered CMS installation that signed the batch.3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836
X-Tanba-SignatureheaderstringyesES256 signature of the body (after decompression), raw r||s, base64url without padding.q3J2y0uO8e1m4n7pXc5Zk9d2Wv6Tg0hLsB-aRfN1YjE6uHxPzD3oVtKiC8wMbQe2nSgA4rUl7yFhJ9XcT0dZaw
Content-Encodingheaderstringnogzip when the body is compressed. gzipgzip

Request body

application/json

NameTypeRequiredDescriptionExample
schemastringyesFormat of the batch. tanba.pop/1
source_idstring (uuid)yesThe source, inside the signed bytes; must equal X-Tanba-Source."3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836"
batch_seqintegeryesNumber of the batch for this installation; taken once.1042
prev_sha256stringHex SHA-256 of the body of the previous batch; empty for the first."9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123"
sent_atstring (date-time)When the CMS cut the batch."2026-10-14T09:05:00.120Z"
recordsarray of objectyesOne entry for each finished play, in the order the players reported them.
records[].kindstringyesOnly plays are taken from a CMS. play
records[].playerstringyesThe external id the player was mapped under in the console."1001:55:2"
records[].seqintegeryesNumber of the record for this player, +1 for every record across batches, from 1.18
records[].play_idstringyesIdentifier of the play, unique for the player; characters A-Za-z0-9_-."7c1e9a40b2d35f68"
records[].approval_numberstringNumber of the approval that was played; required unless slot is set."TNB-2026-000123"
records[].slotstringAuthority content: no approval number, never billed. gov, emergency
records[].media_sha256stringyesSHA-256 of the file played, 64 lowercase hex characters."9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123"
records[].startstring (date-time)yesWhen the play began, RFC 3339."2026-10-14T09:04:50.000Z"
records[].endstring (date-time)yesWhen the play ended, RFC 3339."2026-10-14T09:05:00.000Z"
records[].duration_msintegeryesTime on screen in milliseconds.10000
records[].structurestringCode of the structure the player believes it stands at; a different one is flagged."ALM-0012"

Example

{
  "schema": "tanba.pop/1",
  "source_id": "3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836",
  "batch_seq": 1042,
  "prev_sha256": "9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123",
  "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
    }
  ]
}

Responses

200 The batch was read; see `items` for the records that were not plainly accepted.

application/json

NameTypeRequiredDescriptionExample
batch_seqintegeryesNumber of the batch.1042
receivedintegeryesNumber of records read from the body.12
acceptedintegeryesNumber of records counted, flagged ones included.10
duplicatesintegeryesNumber of records at or below the highest number already taken for their player.0
refusedintegeryesNumber of records refused.2
flaggedintegeryesNumber of accepted records that carry a flag.1
duplicatebooleanyesTrue when this very batch was received before; nothing was counted again.false
itemsarray of objectyesOnly the records that are not plainly accepted; a clean batch gets an empty list.
items[].indexintegeryesPosition in records, from 0.3
items[].statusstringyesWhat became of the record. duplicate, refused, flagged
items[].reasonstringPresent on refused records, for example player_unknown or time_in_future."player_unknown"
items[].flagsarray of stringPresent on flagged records; the flags are listed in the guide on sending proof of play.

Example

{
  "batch_seq": 1042,
  "received": 12,
  "accepted": 10,
  "duplicates": 0,
  "refused": 2,
  "flagged": 1,
  "duplicate": false,
  "items": [
    {
      "index": 3,
      "status": "flagged",
      "flags": [
        "incomplete"
      ]
    },
    {
      "index": 7,
      "status": "refused",
      "reason": "player_unknown"
    }
  ]
}

Errors

Errors are problem documents (application/problem+json) with a stable code.

StatusMeaning
401No valid session or API key (unauthenticated, session_expired, invalid_api_key).
403The role or the key's scope does not allow this (forbidden, key_scope_denied, key_route_denied).
409The batch_seq was taken by a batch with other content (batch_conflict).
413Body over the size limits (body_too_large, batch_too_large).
415Not JSON, or an encoding other than gzip (unsupported_media_type).
422Field errors (validation_failed).
429Rate limit; see Retry-After.
defaultAn error as a problem document.

Code samples

curl

curl -X POST 'https://app.tanba-ooh.kz/api/v1/pop/batches' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'X-Tanba-Source: 3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836' \
  -H 'X-Tanba-Signature: q3J2y0uO8e1m4n7pXc5Zk9d2Wv6Tg0hLsB-aRfN1YjE6uHxPzD3oVtKiC8wMbQe2nSgA4rUl7yFhJ9XcT0dZaw' \
  -H 'Content-Type: application/json' \
  -d '{
  "schema": "tanba.pop/1",
  "source_id": "3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836",
  "batch_seq": 1042,
  "prev_sha256": "9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123",
  "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
    }
  ]
}'
Python
import requests

response = requests.post(
    "https://app.tanba-ooh.kz/api/v1/pop/batches",
    headers={
        "Authorization": "Bearer YOUR_API_KEY",
        "X-Tanba-Source": "3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836",
        "X-Tanba-Signature": "q3J2y0uO8e1m4n7pXc5Zk9d2Wv6Tg0hLsB-aRfN1YjE6uHxPzD3oVtKiC8wMbQe2nSgA4rUl7yFhJ9XcT0dZaw",
        "Content-Type": "application/json",
    },
    json={
        "schema": "tanba.pop/1",
        "source_id": "3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836",
        "batch_seq": 1042,
        "prev_sha256": "9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123",
        "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,
            },
        ],
    },
)
print(response.status_code, response.text)
Go
package main

import (
	"fmt"
	"io"
	"net/http"
	"strings"
)

func main() {
	body := strings.NewReader(`{
  "schema": "tanba.pop/1",
  "source_id": "3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836",
  "batch_seq": 1042,
  "prev_sha256": "9f2b6a41c7d83e05a1b4c6d7e8f90123456789abcdef0123456789abcdef0123",
  "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
    }
  ]
}`)
	req, err := http.NewRequest("POST", "https://app.tanba-ooh.kz/api/v1/pop/batches", body)
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
	req.Header.Set("X-Tanba-Source", "3f6c1d2a-8b47-4e19-a5d0-7c2e9b14f836")
	req.Header.Set("X-Tanba-Signature", "q3J2y0uO8e1m4n7pXc5Zk9d2Wv6Tg0hLsB-aRfN1YjE6uHxPzD3oVtKiC8wMbQe2nSgA4rUl7yFhJ9XcT0dZaw")
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	data, _ := io.ReadAll(resp.Body)
	fmt.Println(resp.Status, string(data))
}