Die API ist das Produkt
Der DOOH Hub bündelt Bildschirme aus vielen Digital-Signage-Systemen zu einem verkaufbaren Werbenetz. Die Verbindung dorthin ist diese Schnittstelle — und sonst nichts.
Es gibt keine interne Abkürzung. Der AIO Hub benutzt genau dieselben Endpunkte wie Xibo, Yodeck oder Signagelive. Kein privilegierter Weg, kein zusätzliches Feld für den eigenen Hub.
Das ist keine Selbstdisziplin, sondern die Bedingung dafür, dass überhaupt jemand anbindet: Sobald es einen kürzeren Weg für einen Beteiligten gibt, ist der offizielle Weg der schlechtere — und wird entsprechend gepflegt.
Zwei Rollen
Ein angebundenes System nimmt eine von zwei Rollen ein — oder beide:
| Rolle | Bedeutung | Beispiel |
|---|---|---|
| Angebot supply | liefert Bildschirme, empfängt Spots, meldet Ausspielungen | AIO Hub, Xibo, Yodeck |
| Nachfrage demand | bucht, lädt Motive hoch, entscheidet über Freigaben | Agentur-Werkzeug, fremdes Buchungsportal |
Drei Konformitätsstufen
Damit ein kleines CMS nicht alles bauen muss. Jede höhere Stufe schliesst die darunter ein.
| Stufe | Was das System kann | Pflicht-Endpunkte |
|---|---|---|
| 1 · Gemeldet | Bildschirme sind sichtbar und buchbar; die Ausspielung organisiert der Betreiber von Hand | PUT /v1/screens/{externalId}POST /v1/playback-proof |
| 2 · Automatisch | Der DOOH Hub plant, das CMS holt den Spielplan und spielt ihn ab | zusätzlich GET /v1/screens/{externalId}/scheduleGET /v1/creatives/{id}/file |
| 3 · Programmatisch | Externe Nachfrage (SSP/DSP) kommt automatisch dazu | OpenRTB-Brücke — noch nicht gebaut |
Der AIO Hub zielt auf Stufe 2. Stufe 3 liegt beim DOOH Hub, nicht beim CMS: Ein einzelner CMS-Kunde wird nie SSP-fähig, ein gebündeltes Portal mit vielen CMS dahinter schon.
Anmeldung
OAuth 2.0 client credentials — ein Schlüsselpaar je anbindendem System. Das
Merkzeichen gilt eine Stunde und wird als Authorization: Bearer …
mitgeschickt.
# Merkzeichen holen (formularkodiert, nicht JSON — so schreibt es OAuth 2.0 vor) TOK=$(curl -s -X POST %BASIS%/oauth/token \ -d grant_type=client_credentials \ -d client_id=mein-cms -d client_secret=… \ | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") # Bildschirm melden curl -X PUT %BASIS%/v1/screens/player-4711 \ -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \ -d '{"name":"Apotheke am Marktplatz","venueTypeId":"207", "breitePx":1080,"hoehePx":1920, "kontakteProTag":420,"kontakteQuelle":"counted"}'
Das Geheimnis kommt nach dem Anlegen nie wieder heraus. Es liegt nur als BCrypt-Hash; wer es verliert, bekommt ein neues. Ein „Geheimnis anzeigen" wäre bequem und wäre der Tag, an dem eine Kundendatenbank zur Schlüsselsammlung wird.
Ein Fehlertext für beide Fälle. Ob Kennung oder Geheimnis falsch war, wird nicht verraten — ein unterschiedlicher Text wäre ein Werkzeug, um gültige Kennungen zu erraten.
Auch Maschinen werden gebremst, je Konto statt je Adresse (eine IP teilen sich ganze
Firmen). Während der Wartezeit antwortet auch ein richtiges Geheimnis mit 401.
Bereiche (scopes)
Fehlt einem Zugang der verlangte Bereich, kommt 403 FORBIDDEN — nicht
404. Der Zugang ist gültig, ihm fehlt nur dieser Bereich; das ist eine
Auskunft, die beim Anbinden hilft und nichts verrät, was der Anbindende nicht ohnehin weiss.
| Bereich | Erlaubt |
|---|---|
inventory:write | Bildschirme melden, ändern, abmelden; Sperren setzen und lesen; Herzschlag melden und lesen |
schedule:read | Spielplan holen |
proof:write | Ausspielungen melden, Meldelücken lesen, Sendequote und Auffälligkeiten lesen |
creative:read | Motiv-Metadaten und Motivinhalt lesen |
booking:write | Kampagnen, Motive anmelden und hochladen, Buchungen, Entscheidungen über die Standortbahn |
booking:decide | Seit 2.75.0: über die Netzbahn einer Buchung entscheiden — wird nur ausdrücklich vergeben |
avails:read · report:read |
vorgemerkt — gewährt heute nichts Die zugehörigen Wege gibt es noch nicht: GET /v1/avails ist nicht gebaut, und den Kampagnenbericht gibt
es nur in der Oberfläche. Sie anzufordern schadet nicht, bringt aber auch
nichts. |
Grundsätze
| Grundsatz | Warum |
|---|---|
| Alles wird geholt, nichts zugestellt | Ein CMS steht hinter Firewall/NAT. Kein eingehender Port, keine feste IP nötig. |
Das CMS behält seine Kennungen (externalId) |
Niemand muss eine Fremd-ID in seiner Datenbank speichern. Der fachliche Schlüssel ist
(Netz, externalId) — zwei Netze dürfen beide ein s1 melden. |
Zeit ist int64 in Millisekunden seit Epoche |
Nicht RFC 3339. Das ist die Form, die Player und CMS ohne Zeitzonen-Unfall durchreichen; die IANA-Zeitzone steht am Bildschirm und gilt für Tagesfenster. |
| Geld in Cent als ganze Zahl, Anteile in Promille | Nie Gleitkomma. 67,5 % sind ein üblicher Satz und in ganzen Prozent nicht darstellbar. |
ETag / If-None-Match |
Der Spielplan wird im Minutentakt abgefragt; leere Antworten müssen billig sein
(304). |
Idempotency-Key auf dem Nachweis |
Ein Melder mit Zeitüberschreitung schickt denselben Rumpf erneut. Doppelt abgerechnet wird nie. |
Angebot — ein CMS meldet Bildschirme
Bildschirme melden, Sperren pflegen, Spielplan holen, Ausspielungen nachweisen — und den Zustand der Fläche zurückmelden.
PUT/v1/screens/{externalId} Bildschirm anlegen oder ändern
inventory:write — idempotent über die externalId
des CMS. Antwortet 201 beim ersten Mal, danach 200.
{
"name": "Apotheke am Marktplatz",
"venueTypeId": "207",
"venueTypeTaxonomy": "openooh-1.1",
// Anschrift — freiwillig, aber der halbe Verkauf
"standortName": "Apotheke am Markt",
"strasse": "Marktplatz 3",
"ort": "Münster",
"oeffnungszeiten": "Mo-Fr 08-18, Sa 09-13",
"plz": "48143",
// AGS — achtstellig, als TEXT (fuehrende Null gehoert dazu)
"ags": "05515000",
"breitengrad": 51.9607,
"laengengrad": 7.6261,
"breitePx": 1080,
"hoehePx": 1920,
"schleifeSek": 60,
"spotSek": 10,
"werbeplaetzeProSchleife": 2,
// Was diese Fläche darf und wie lange sie braucht
"bewegtbildErlaubt": true,
"tonErlaubt": false,
"vorlaufMinuten": 120,
"kontakteProTag": 420,
"kontakteQuelle": "counted",
"kontakteMethode": "Kundenzählung Kasse",
"tkpNettoCent": 2194,
"etiketten": ["apotheke", "innenstadt"]
}
{
"netzId": "n-aio",
"externalId": "player-4711",
"name": "Apotheke am Marktplatz",
"venueTypeId": "207",
"venueTypeTaxonomy": "openooh-1.1",
"zeitzone": "Europe/Berlin",
"aktiv": true,
"gemeldetAm": 1786994402000
}
// 422 bei Mängeln — alle auf einmal:
{ "code": "VALIDATION",
"message": "Angaben unvollstaendig …",
"details": ["kontakteQuelle ist Pflicht, …"] }
kontakteQuelle ist Pflicht, sobald kontakteProTag gesetzt
ist — counted, estimated oder modelled. Eine
geschätzte Zahl darf verkauft werden, aber niemals als Messung ausgegeben. Ohne diese
Prüfung wäre die Unterscheidung eine Bitte, kein Vertrag.
ags ist eine Zeichenkette, keine Zahl. Der Amtliche
Gemeindeschlüssel hat acht Stellen, und die führende Null gehört dazu:
"01001000" ist Flensburg. Als Zahl übertragen bleibt 1001000
übrig — acht der sechzehn Bundesländer beginnen mit einer Null, und der Fehler wirft
nirgends eine Meldung. Der Marktstandard schliesst Objekte ohne AGS ausdrücklich aus
der Kontaktberechnung aus: Ohne ihn bleibt die Fläche buchbar, wird aber je 1.000
Ausspielungen abgerechnet statt je 1.000 Kontakte. Weggelassen heisst
„unverändert" — wer ihn entfernen will, schickt eine leere Zeichenkette.
werbeplaetzeProSchleife ist eine Obergrenze, keine Zusage. Der
Bildschirm gehört weiter dem Betreiber; sein redaktioneller Inhalt behält immer die
Mehrheit der Schleife. Weggelassen heisst „unverändert" — das gilt auch für
schleifeSek und spotSek; eine neue Fläche ohne Angabe bekommt
60 / 10 / 2.
Die Taxonomie kennt nicht jedes Umfeld — es gibt weder Autohaus noch Bäckerei.
Die Elternebene ("2" für Retail) ist eine gültige, ehrliche Angabe: lieber
grob richtig als fein falsch, eine falsche Feinkennung führt zum No-Bid. Was die Taxonomie
nicht kennt, gehört in etiketten.
Wer nach iDOOH meldet, sagt es — "venueTypeTaxonomy": "idooh-1.0".
Erst dann sind punktierte Kennungen wie 1.1.2.4 gültig. Bis Fassung 0.4.0
wurde das Feld entgegengenommen, gespeichert und nie gelesen: Ein deutscher
Netzbetreiber bekam für sein eigenes Inventar 422.
Die vier Felder, die den Unterschied machen
| Feld | Was es bewirkt |
|---|---|
standortNamestrasseortoeffnungszeiten |
Freiwillig — und trotzdem der halbe Verkauf. Ein Werbekunde sucht nicht
„Bildschirm 47", er sucht „Apotheke, Fussgängerzone, Kassenbereich". Ohne diese
Angaben ist die Fläche über Freitextsuche, Ortsfilter und Umkreis nicht
auffindbar und wird über Postleitzahl und Umfeldtyp verkauft.
oeffnungszeiten ist bewusst Freitext und kein Zeitmodell — das
Tagesfenster einer Buchung ist etwas anderes. |
⚠ Weggelassen heisst: der bisherige Wert bleibt — seit Fassung 0.13.1.
PUT ist ein Vollersatz, und genau daraus entstand ein Fehler,
der zweimal Geld gekostet hat: Ein Feld, das Ihr System nicht kennt und deshalb
nicht mitschickt, wurde bei jedem Bericht auf die Vorgabe zurückgesetzt —
im Minutentakt, mit 200 OK, ohne eine Zeile im Protokoll.
Betroffen und jetzt geschützt sind die Angaben, die der Betreiber in der Pflegemaske setzt und die kein CMS kennt: bewegtbildErlaubt · tonErlaubt · fotoUrl ·
etiketten · aktiv · vorlaufMinuten ·
tkpNettoCent · seit 3.9.6 auch breitengrad/laengengrad
(eine in der Pflegemaske gesetzte Lage bleibt; ändern = neuen Wert schicken, entfernen nur in der Pflegemaske).
Seit 3.9.6, freiwillig: "koordinatenAusPlz": true — hat die Fläche danach keine Koordinate,
setzt der Hub die Mitte des PLZ-Gebiets aus seiner eingecheckten PLZ-Tabelle (DE, AT, CH; ca. 1–5 km genau;
nur bei eindeutiger PLZ). Ohne das Feld wird nichts geraten. Geodaten: GeoNames,
CC BY 4.0.
Zurücksetzen geht weiterhin — aber ausdrücklich: den Vorgabewert mitschicken. Bei fotoUrl leert die leere Zeichenkette
(""); null und „gar nicht mitgeschickt" sind im JSON
dasselbe und können deshalb nicht „löschen" heissen.
Seit 3.0.0: Ein im Portal hochgeladenes Standortfoto hat Vorrang vor fotoUrl. Ob es eines gibt, steht als standortfotoAm in jeder
Flächenantwort; das Bild holen Sie mit GET /v1/screens/{externalId}/photo
(JPEG, höchstens 1600 Pixel). Ihr PUT ändert daran nichts.
⚠ Für Sie am wichtigsten: aktiv. Ein
POST /v1/screens/{externalId}/withdraw nimmt eine Fläche aus der
Vermarktung, kürzt laufende Buchungen und benachrichtigt die Werbekunden — und ein
Routinebericht ohne aktiv stellte sie danach wieder in den
Verkauf. Wollen Sie eine zurückgezogene Fläche wieder listen, schicken Sie
aktiv: true ausdrücklich mit.
|
|
bewegtbildErlaubtVorgabe true · weggelassen = unverändert |
Ein Rechtsfeld, kein Geschmacksfeld. An Strassen und Autobahnen ist bewegte
Werbung vielfach untersagt — die Ablenkung ist der Grund, und die Folge trägt der
Betreiber der Fläche, nicht der Werbekunde. Vorgabe true, weil
ein false jedes bestehende Netz stumm entwerten würde; wer eine
Strassenfläche betreibt, setzt es bewusst — genau wie die Sperren. |
tonErlaubtVorgabe false · weggelassen = unverändert |
Ton in einem fremden Laden ist die Ausnahme, nicht der Normalfall.
⚠ Wurde bis 0.13.1 von jedem Bericht auf false zurückgesetzt —
verkaufte Tonwerbung lief dann stumm, und dass Ton fehlt, hört niemand. |
vorlaufMinutenVorgabe 120, 0 bis 10080 |
Nur Ihr System kennt diesen Wert. Ein Player mit Aktualisierungsfenster
(lädt nur zwischen 02:00 und 05:00, damit die Leitung tagsüber der Kasse gehört)
erfährt von einer um 11:00 gebuchten Kampagne erst in der Nacht. Melden Sie dann die
echte Zahl — z. B. 900. Ein zu kurz gemeldeter Vorlauf endet nicht in
einem Fehler, sondern in lauter expired-Zeilen im Nachweis, die niemand
erklären kann. |
GET/v1/screens eigene Bildschirme auflisten
inventory:write — dient dem Abgleich nach einem Datenverlust auf CMS-Seite: Was kennt der DOOH Hub, was das CMS nicht mehr kennt?
Dazu: GET /v1/screens/{externalId} für einen einzelnen und
DELETE /v1/screens/{externalId} zum Löschen. Wer nur vorübergehend
stilllegen will, setzt aktiv: false — dann bleiben Nachweis und
Historie an ihrer Stelle.
DELETE löscht nur noch, wenn nichts daran hängt. Sonst
409 HAENGT_DARAN mit der Zahl der Buchungen und Nachweissätze und dem Verweis
auf withdraw.
Warum die Bremse da ist: Der Aufruf antwortete einmal 204 und liess zwei
freigegebene Buchungen stehen. Der Werbekunde sah in seinem Portal eine laufende
Kampagne, die auf eine Fläche zeigte, die es nicht mehr gab; sie konnte nie ausspielen,
und niemand erfuhr davon. Die Fremdschlüssel räumen die Sperren ab — die Buchungen nicht,
weil sie an der Kampagne hängen und nicht am Bildschirm.
POST/v1/screens/{externalId}/withdraw Fläche aus der Vermarktung nehmen
inventory:write — der Rückweg der Freigabe. Der Standortpartner kündigt, das Gerät wird abgebaut, jemand nimmt in Ihrem CMS die Freigabe zurück: Dann melden Sie es hierher.
{ "grund": "Standortpartner hat gekündigt" }
// grund ist freiwillig — landet aber
// im Klartext beim Werbekunden.
{
"betroffen": [
{ "buchungId": "b-8812aa01",
"neuerStatus": "beendet",
"gekuerztBisMs": 1787016000000,
"grund": "Die Fläche wurde …" },
{ "buchungId": "b-8931cc07",
"neuerStatus": "abgelehnt",
"gekuerztBisMs": null,
"grund": "… bevor die Buchung begann." }
],
"unberuehrt": 3
}
Abmelden geht immer — aber sichtbar. Es Ihnen zu verweigern hiesse, dass wir Werbung auf einem Bildschirm halten, über den Sie keine Verfügung mehr haben; das ist rechtlich schlechter als eine abgebrochene Kampagne. Deshalb: mit Grund, mit Post an den betroffenen Werbekunden, und die Antwort sagt Ihnen, wen es getroffen hat.
Drei Fälle, und der mittlere ist der teure. Eine noch nicht begonnene Buchung
wird abgelehnt — es lief nichts, es kostet nichts. Eine laufende wird
auf das bereits Gelieferte gekürzt und beendet: Der geleistete Teil
wird abgerechnet, der Rest entfällt. Was ohnehin vorbei oder abgelehnt war, bleibt
unberührt.
Der Bildschirm wird stillgelegt, nicht gelöscht (aktiv: false). An
ihm hängt der Ausspielungsnachweis, und der ist ein steuerlich aufbewahrungspflichtiger
Beleg (§ 147 AO). Ein Nachweis, dessen Bildschirm verschwunden ist, belegt nichts
mehr.
PUT/v1/screens/{externalId}/restrictions Sperren setzen
inventory:write — ersetzt alle Sperren der angegebenen
Stufe. Die anderen Stufen bleiben unangetastet. Lesen: dieselbe Adresse mit GET.
{
"stufe": "standort",
"sperren": [
{ "art": "kategorie", "wert": "alkohol" },
{ "art": "branche", "wert": "nace:47.73" },
{ "art": "wettbewerber", "wert": "Wettbewerber GmbH" }
]
}
Drei Stufen, und die strengste gewinnt: gesetz · netz ·
standort. Der Standortpartner bestimmt, was auf seinem Bildschirm nicht
laufen darf — nicht der Vermarkter.
Die Gesetzesstufe kann ein CMS nicht setzen
(403 GESETZ_NICHT_SETZBAR). Sonst befreite sich jeder Anbindende von ihr,
indem er sie leer meldet — und das Haftungsrisiko läge trotzdem bei uns.
Sie kennt auch keinen Übersteuerungsknopf: Ein „trotzdem freigeben" macht aus einem Systemfehler eine bewusste Gesetzesverletzung. Das ist der Unterschied zwischen Fahrlässigkeit und Vorsatz, und er ist nicht verhandelbar.
GET/v1/screens/{externalId}/smil Für Player ohne API
Die einzige Route ohne Anmeldung — ein SMIL-Player kann kein OAuth. Er kennt genau eine Adresse, ruft sie im Takt ab und spielt, was zurückkommt. Das erreicht IDSAPI-, Garlic- und IAdea-Geräte und den browserbasierten AIO Web Player: ein Netzbetreiber ganz ohne CMS kann seine Geräte unmittelbar hierher richten.
Der Schlüssel steht in der Adresse und wählt zugleich das Netz — er ist ein HMAC
über netzId|externalId. Zwei Netze dürfen also dieselbe
externalId führen. Er steht in der Betreiberoberfläche hinter
„Adresse kopieren".
curl "%BASIS%/v1/screens/player-4711/smil?key=9f2c…" \ -H 'If-None-Match: "a7f3c19b…"' # → 304, wenn unverändert
<smil>
<head>
<layout><root-layout width="1080" height="1920" />…</layout>
<prefetch src="…/creatives/m-5590bb02/file?key=9f2c…" /> # sonst: schwarzes Bild
</head>
<body><seq repeatCount="indefinite">
<img region="screen" src="…" dur="10s" expr="…">
<param name="logContentId" value="4718293"></param> # MUSS zuerst stehen
<param name="cacheControl" value="onlyIfCached"></param>
</img>
</seq></body>
</smil>
⚠ Drei Dinge, an denen es sonst scheitert:
Last-Modifiedist Pflicht. Fehlt der Kopf, meldet der Player „index file is empty" und registriert sich schlicht nicht — bei perfekter SMIL und Status 200. Von aussen ist das nicht zu sehen. Der Zeitstempel entsteht aus dem Fingerabdruck der Schleife, nicht aus der Uhr: Sonst lädt jedes Gerät bei jedem Takt alles neu.logContentIdvorcacheControl. Umgekehrt bleibt die Kennung im Playlog leer — der Spot läuft, ist aber nicht abrechenbar.- Der Schlüssel ist langlebig. Ein Player erkennt seine Dateien an der URL; eine kurzlebige Signatur lässt ihn bei jedem Durchlauf alles neu laden.
Falscher oder fehlender Schlüssel → 404, nicht 403: Ein 403 bestätigte, dass es diese Fläche gibt. Eine stillgelegte oder unbelegte Fläche bekommt eine gültige leere Schleife — bei einem 404 behielte der Player seine alte Datei und spielte abgelaufene Werbung weiter.
GET/v1/screens/{externalId}/creatives/{id}/file Motivdatei für den SMIL-Weg
Die Adressen aus der SMIL, mit demselben Schlüssel. Geprüft wird, ob dieses Motiv auf dieser Fläche gebucht ist — der Schlüssel allein wäre sonst ein Generalschlüssel für den ganzen Motivbestand des Netzes, und das Werbemittel eines Wettbewerbers ist die teuerste Einzelauskunft, die dieses Portal hergibt.
Stabile, unsignierte Adresse mit
Cache-Control: public, max-age=31536000, immutable — der Inhalt
hinter einer Motivkennung ändert sich nie.
GET/v1/screens/{externalId}/schedule Spielplan (Stufe 2)
schedule:read — die zentrale Route der Stufe 2.
Ohne from/to liefert sie das Fenster
+2 h bis +6 h, auf die volle Stunde gerundet.
curl "%BASIS%/v1/screens/player-4711/schedule" \ -H "Authorization: Bearer $TOK" \ -H 'If-None-Match: "a7f3c19b…"' # → 304, wenn unverändert
{
"screenId": "player-4711",
"validFrom": 1787001600000, "validTo": 1787016000000,
"generatedAt": 1786994402000,
"loopLengthSec": 60, "capacityPerHour": 120, "bookedPerHour": 6,
"spots": [{
"bookingId": "b-8812aa01", "creativeId": "m-5590bb02",
"playsPerHour": 6,
"windowFrom": "10:00", "windowTo": "18:00",
"advertiser": "Möbelhaus Nord", "category": "moebel",
"proofRequired": true,
"file": {
"url": "%BASIS%/v1/creatives/m-5590bb02/file",
"sha256": "9f2c…", "bytes": 4210233,
"mimeType": "video/mp4", "durationSec": 10,
"widthPx": 1080, "heightPx": 1920
}
}]
}
Der DOOH Hub sagt was, wie oft, in welchem Fenster — nie wo in der Schleife. Das CMS entscheidet die Position, es kennt seinen redaktionellen Inhalt. Wer diese Linie überschreitet, zwingt jedes anbindende System, seine Playlistenlogik offenzulegen — und dann bindet keiner mehr an.
Der Vorlauf gehört dem Bildschirm, nicht dem Spielplan: Er kommt aus
vorlaufMinuten (Vorgabe 120). Ein Spielplan „ab sofort" ist für ein
Signage-Netz wertlos — er kommt zu spät im Zwischenspeicher an, und ein Spot, der dort
nicht liegt, wird schwarz statt bunt. Wer ein Aktualisierungsfenster hat, meldet seine
echte Zahl; wir richten das Fenster danach.
sha256 und bytes sind Pflicht, damit das CMS vorladen
und prüfen kann. Ein Motiv ohne hochgeladene Datei wird gar nicht erst eingeplant — ein
leerer Werbeplatz ist billiger als ein schwarzer Bildschirm.
POST/v1/playback-proof Ausspielungen melden
proof:write — Grundlage jeder Rechnung und jeder Gutschrift.
Idempotency-Key ist Pflicht.
# Idempotency-Key: aio-2026-08-22-4711-03 { "plays": [ { "screenId": "player-4711", "playoutRef": "p-000143", "bookingId": "b-8812aa01", "creativeId": "m-5590bb02", "startedAt": 1787002452000, "durationMs": 10000, "outcome": "played", "playerTimeSource": "ntp" }, { "screenId": "player-4711", "playoutRef": "p-000144", "startedAt": 1787002512000, "outcome": "expired", "expiryReason": "NOT_CACHED" } ]}
{
"accepted": 143,
"duplicate": 12,
"rejected": 1,
"rejections": [
{ "index": 87,
"reason": "OUTSIDE_BOOKING_WINDOW" }
]
}
// Derselbe Idempotency-Key liefert
// Wort für Wort dieselbe Antwort zurück —
// gezählt wird nichts ein zweites Mal.
Jeder eingeplante Platz wird bestätigt ODER storniert — nie einfach weggelassen.
Ein Spot, der nicht lief, gehört als expired mit Grund gemeldet. Sonst ist
„keine Meldung" mehrdeutig: entweder lief es nicht, oder der Bericht ist noch unterwegs.
Auf dieser Mehrdeutigkeit lässt sich nicht abrechnen.
playoutRef ist nicht dasselbe wie der Idempotency-Key.
Der Kopf schützt den einzelnen Aufruf, playoutRef den einzelnen
Satz. Ein Offline-Puffer liefert Wochen später in anderer Bündelung nach — dann
greift nur playoutRef.
Abgelehnte Zeilen werden nicht verschwiegen. Wer sie stillschweigend schluckt, rechnet irgendwann Luft ab.
GET/v1/screens/{externalId}/proof-gaps Tage ohne Meldung
proof:write — ?tage=30 (1 bis 366).
Zurück kommen die Tagesanfänge in Millisekunden.
{ "screenId": "player-4711", "gaps": [1786867200000, 1786953600000] }
Eine Lücke heisst „wir wissen es nicht", nicht „es lief nichts". Der Unterschied ist der ganze Sinn dieser Route: Offene Meldelücken blockieren die Festschreibung einer Rechnung, denn wer sie als Null behandelt, rechnet Luft ab.
POST/v1/screens/{externalId}/heartbeat Zustand des Bildschirms melden
inventory:write — der deutsche Marktstandard sagt es
unmissverständlich: „Playout-Reporting sollte nur erfolgen, wenn der angeschlossene Screen
funktionsfähig ist." Ohne Herzschlag ist ein played bei ausgeschaltetem
Bildschirm von uns nicht widerlegbar.
{ "state": "on",
"brightness": 80,
"temperatureC": 41,
"playerVersion": "3.2.1",
"reportedAt": 1787002452000 }
// state: on | off | fault
// alles ausser state ist freiwillig
{
"screenId": "player-4711",
"state": "on",
"reportedAt": 1787002452000,
"receivedAt": 1787002453100,
"clockSkewMs": -1100,
"hint": null
}
// Lesen: GET auf dieselbe Adresse.
// Ohne Herzschlag antwortet sie
// state "unknown" — nicht "off".
Kein Herzschlag heisst nicht „Bildschirm aus". Es heisst „wir wissen es nicht" — dieselbe Unterscheidung wie bei den Meldelücken. Ein CMS, das den Herzschlag nicht kann (und das sind heute fast alle), wird dadurch nicht schlechter gestellt: Es bekommt keine schlechtere Sendequote, sondern gar keine Aussage.
Er kostet keinen neuen Schlüssel. Der Bereich ist inventory:write —
den hat jedes CMS auf Stufe 1 bereits. Der Zustand des Panels ist eine Eigenschaft der
Fläche, kein Lesen von Verfügbarkeiten.
Die Antwort meldet eine abweichende Gerätuhr zurück
(clockSkewMs, ab fünf Minuten mit Hinweistext). Eine falsch gehende Uhr
vergiftet lautlos jedes startedAt im Nachweis — und man sucht den Fehler
dann im falschen Zimmer. Ebenso benannt wird state: "on" bei
brightness: 0: Das ist ein schwarzer Bildschirm.
GET/v1/broadcast-rate Sendequote — Ihre eigene Zahl
proof:write — je Fläche
(/v1/screens/{externalId}/broadcast-rate) oder für Ihr ganzes Netz.
Der deutsche Marktstandard verlangt ≥ 99 % im Jahresmittel; die Quote wird aus genau
den Sätzen gerechnet, die dieser Zugang selbst gemeldet hat.
{ "summary": {
"ebene": "network", "gemeldet": 18422,
"gespielt": 18301, "ausgefallen": 121,
"ausfaelle": { "NOT_CACHED": 88, "SCREEN_OFF": 33 },
"lueckenTage": 0, "bestimmt": true,
"promille": 993, "prozentText": "99,3 %" },
"screens": [ … ] }
Meldelücken senken die Quote nicht — sie machen sie unbestimmt
(bestimmt: false). Eine Sendequote von 99,2 %, die auf halben Daten beruht,
ist keine Zahl, sondern eine Vermutung mit Nachkommastelle. In die Netzquote gehen
deshalb nur die lückenlos gemeldeten Flächen ein.
Der Herzschlag fliesst hier nicht ein. Eine Fläche ohne Herzschlag bekommt keine schlechtere Quote — das bestrafte den Ehrlichen. Was er beiträgt, ist eine Auffälligkeit, keine Kennzahl.
GET/v1/anomalies Auffälligkeiten — die Arbeitsliste
proof:write — offene standardmässig,
?all=true für alle. Erledigen:
POST /v1/screens/{externalId}/anomalies/{kind}/resolve.
kind | Bedeutung | Folge |
|---|---|---|
implausible_volume |
mehr Meldungen, als die Schleife hergibt | abgewiesen — die betroffenen Sätze |
played_while_screen_off |
played gemeldet, obwohl der letzte Herzschlag davor
off oder fault sagte |
nur angezeigt |
plays_not_multiple_of_loop |
eine Buchung belegt keine ganzen Durchläufe — ein Werbeplatz kommt einmal je Durchlauf und lässt sich nicht teilen, der Spot läuft deshalb in jedem Durchlauf und damit öfter als gebucht | nur angezeigt — neue Buchungen weist
AUSSPIELUNGEN_JE_DURCHLAUF ab |
creative_aspect_mismatch |
die Auflösung der Fläche wurde geändert, und laufende Buchungen tragen jetzt ein Motiv, das nicht mehr passt — falsches Seitenverhältnis (läuft verzerrt oder mit Balken) oder kleiner als die Fläche (läuft unscharf). Der Text nennt alte und neue Auflösung, damit erkennbar bleibt, ob ein angeschlossenes CMS die Änderung geschickt hat | nur angezeigt — der Werbekunde hat bezahlt; eine stumm abgeschaltete Buchung wäre schlimmer als eine sichtbar schiefe |
kapazitaet_gekuerzt |
der Spielplan musste kürzen: Die Fläche gibt nicht mehr her, die betroffenen Buchungen laufen mit weniger Ausspielungen je Stunde als gebucht. Verkauft ist ein Anteil an der Schleife — wer die Schleife verlängert, ändert die Zusage. Der Werbekunde bekommt dieselbe Sache als Meldung mit Quittung in seinem Kampagnenbericht | nur angezeigt — die Buchung läuft, sie liefert nur weniger; berechnet wird ohnehin nur, was nachweislich lief |
Nicht sperren, sondern anzeigen. Eine automatische Sperre legt im Zweifel eine echte Fläche lahm, und Sie suchen den Fehler bei sich. Der Herzschlag kann veraltet oder selbst falsch sein — wer daraufhin Nachweise verwirft, vernichtet Umsatz auf Verdacht.
Erledigen heisst nicht stumm schalten. Tritt dieselbe Auffälligkeit wieder auf, wird sie erneut offen. Einmal abgehakt und nie wieder gesehen wäre das Ende der Arbeitsliste.
Nachfrage — buchen, Motive, Freigaben
Kampagne anlegen, Motiv anmelden und hochladen, buchen, entscheiden.
POST/v1/campaigns Kampagne samt Werbekunde
booking:write — legt Werbekunde und Kampagne in einem
Schritt an. Auflisten: GET /v1/campaigns.
{
"name": "Herbstaktion 2026",
"kundeName": "Möbelhaus Nord GmbH",
"kundeBranche": "moebel",
"kundeUstStatus": "regel",
"startMs": 1787000000000,
"endeMs": 1789592000000,
"budgetNettoCent": 150000,
"wettbewerbKategorie": "moebel"
}
{
"id": "c-3f9a17b2",
"netzId": "n-aio",
"kundeId": "k-77c2ab10",
"name": "Herbstaktion 2026",
"budgetNettoCent": 150000,
"waehrung": "EUR",
"status": "entwurf",
"angelegtAm": 1786994402000
}
kundeUstStatus darf leer bleiben — aber niemals mit einem
Vorgabewert belegt werden. Eine Gutschrift mit 19 % an einen Kleinunternehmer lässt
ihn die Steuer schulden (§ 14c Abs. 2 UStG): Er hat sie nie bekommen und
muss sie trotzdem abführen. Ohne Status wird nicht abgerechnet — das ist die beabsichtigte
Bremse, kein Mangel.
POST/v1/creatives Motiv anmelden
booking:write — Metadaten zuerst, der Inhalt kommt getrennt.
Erlaubt sind image/jpeg, image/png (bis 10 MB) und
video/mp4 (bis 200 MB) — eine Positivliste. Geprüft wird darüber hinaus
gegen die iDOOH Creative Specifications V2.0, siehe unten.
{ "dateiname": "herbst-1080x1920.mp4", "mimeTyp": "video/mp4",
"bytes": 4210233, "sha256": "9f2c…64 Hexstellen…",
"breitePx": 1080, "hoehePx": 1920, "dauerSek": 10 }
# → 201 { "id": "m-5590bb02", "pruefStatus": "offen", … }
# Dann der Inhalt — roher Rumpf, kein Multipart:
curl -X PUT %BASIS%/v1/creatives/m-5590bb02/file \
-H "Authorization: Bearer $TOK" \
--data-binary @herbst-1080x1920.mp4 # → 204
Warum getrennt und kein Multipart? Weil die Prüfsumme vor dem Inhalt da
sein muss. Nur dann ist das Nachrechnen eine echte Prüfung. Kämen sha256 und
Datei im selben Rumpf, könnte der Server die Summe genauso gut selbst bilden — dann prüft
er seine eigene Rechnung gegen sich selbst und bemerkt eine unterwegs beschädigte Datei
nie. Nebenbei: Ein abgebrochener 200-MB-Upload kostet dann nicht die Anmeldung.
Ein Motiv ohne Datei steht auf offen und wird nicht eingeplant.
Ohne Inhalt ist es eine Ankündigung, keine Werbung.
Creative Specs — was ablehnt und was nur hinweist
Die Trennung folgt einer einzigen Frage: Wird das Motiv dadurch unspielbar?
| Marke | Wann | Warum diese Seite |
|---|---|---|
| Ablehnung | fremder Codec · 10-bit-Farbtiefe · Datei jenseits der Toleranz ·
seit 3.7.2 auch hier: Inhalt ist nicht der angemeldete Typ
(422 INHALT_PASST_NICHT_ZUM_TYP) · Video ist keine buchbare Spotlänge
(422 SPOTLAENGE, Vorgabe 10, 15 oder 20 s, ± 0,5 s) |
Ein Player mit Hardware-Dekoder zeigt das nicht an — der Bildschirm bliebe schwarz. Das sind keine Geschmacksfragen. |
| Hinweis | Bildrate · Bitrate am Rand · krumme Auflösung · Tonspur · Transparenz | Sieht aus wie ein Fehler, läuft aber. Ein Motiv dafür abzulehnen erzeugt einen Anruf und keine bessere Datei. Bei der Auflösung wäre es sogar falsch: Wir haben Bildschirme mit eigenen Auflösungen, und ein Standard, der die unbuchbar macht, wäre gegen uns gerichtet. |
Die Bildrate ist trotzdem kein Schönheitsfehler. 30 fps auf einem 25-Hz-Player ruckelt sichtbar, und das ist der klassische Reklamationsfall. Sie steht als Hinweis und nicht als Ablehnung, weil das Ruckeln behebbar ist (Umrechnen auf 25 fps) und die Ablehnung nicht.
Die Grössengrenze ist 1 MB je Sekunde (Full-HD) bzw. 4 MB je Sekunde (UHD), plus 25 % Toleranz — und die Toleranz ist gerechnet, nicht gegriffen: Der Standard erlaubt 10.000 kbit/s Bitrate, das sind 1,19 MiB je Sekunde, und verfehlt damit seine eigene Grössengrenze um 19 %. Wer hier ein Fallbeil setzt, lehnt Dateien ab, die nach dem anderen Absatz desselben Papiers einwandfrei sind.
Die Klasse entscheidet die Pixelzahl, nicht die Kantenlänge.
1080×1920 ist hochkant und trotzdem Full-HD. Die sechs Normauflösungen sind
eine Erwartungsliste, keine Positivliste.
Bewegtbild ist an manchen Flächen unzulässig, und das fängt nicht der Upload
ab, sondern die Freigabe: Eine Buchung, die ein Video auf einen Bildschirm mit
bewegtbildErlaubt: false legt, wird auf der Bahn gesetz
abgelehnt — und die hat keinen Übersteuerungsknopf. Das Motiv selbst bleibt gültig, es ist
nur auf dieser Fläche nicht buchbar.
POST/v1/campaigns/{id}/bookings buchen — hier läuft die Freigabeprüfung
booking:write — eine Buchung ist: diese Kampagne, dieses
Motiv, auf diesem Bildschirm, in diesem Zeitraum. Auflisten:
GET /v1/bookings.
{
"screenId": "player-4711",
"motivId": "m-5590bb02",
"vonMs": 1787000000000,
"bisMs": 1789592000000,
"fensterVon": "10:00",
"fensterBis": "18:00",
"ausspielungenProStunde": 6,
"tkpNettoCent": 2194
}
{
"buchung": { "id": "b-8812aa01",
"status": "offen", … },
"freigaben": [
{ "bahn": "gesetz",
"entscheidung": "freigegeben",
"entschiedenVon": "system" },
{ "bahn": "netz",
"entscheidung": "freigegeben" },
{ "bahn": "standort",
"entscheidung": "offen" }
]
}
Die Freigabeprüfung läuft beim Anlegen, nicht erst beim Ausspielen. Ein Werbekunde soll sofort erfahren, dass seine Branche an diesem Standort ausgeschlossen ist — und nicht erst, wenn er auf den Bericht wartet.
ausspielungenProStunde ist eine Absicht, kein Versprechen. Was
tatsächlich lief, steht ausschliesslich im Ausspielnachweis — und nur danach wird
abgerechnet.
Der Preis wird eingefroren. Eine spätere Preisanpassung am Bildschirm ändert keine laufende Kampagne rückwirkend.
Das Budget der Kampagne ist seit 0.14.0 eine Grenze, keine Notiz. Übersteigt die
Summe aller platzhaltenden Buchungen dieser Kampagne samt der neuen ihr
budgetNettoCent, antwortet der Dienst
422 BUDGET_UEBERSCHRITTEN und nennt beide Beträge.
⚠ Bis dahin wurde das Feld beim Anlegen der Kampagne verlangt, geschrieben — und
danach las es niemand mehr, der etwas hätte verhindern können: An eine Kampagne mit
1.500 € liessen sich beliebig viele Buchungen hängen.
⚠ budgetNettoCent <= 0 gilt als Platzhalter und wird nicht geprüft —
ein bestehendes System mit einer Null im Feld legt also weiterhin Buchungen an.
Der Füllinhalt des Netzes ist nicht buchbar
(422 HAUSMOTIV_NICHT_BUCHBAR). Er hält die Sendeschleife auf ihrer zugesagten
Länge und wird gezählt, aber nie berechnet.
POST/v1/bookings/{id}/decision über eine Bahn entscheiden
booking:write (Bahn netz: zusätzlich booking:decide) — drei Bahnen, die strengste gewinnt. Der
Buchungsstatus wird danach neu zusammengefasst.
{ "bahn": "standort", "entscheidung": "abgelehnt",
"grund": "Wettbewerber läuft im selben Zeitraum." }
Über die Bahn gesetz entscheidet niemand von Hand
(403 GESETZ_NICHT_ENTSCHEIDBAR) — auch der Portalbetreiber nicht. Wer meint,
die Sperre sei falsch, korrigiert die Sperre, nicht die Entscheidung.
Eine Ablehnung ohne Grund ist unzulässig (422 GRUND_FEHLT). Sie
kostet die nächste Buchung: Der Werbekunde erfährt sonst nur, dass es nicht ging, und
probiert es nicht wieder.
In der Regel wird sofort beim Buchen entschieden: Die Antwort auf
POST /v1/campaigns/{id}/bookings trägt den Status und alle Freigabebahnen
schon mit.
Ausnahme seit 2.75.0 — die Freigabepflicht je Werbetreibendem: Prüft das Netz
diesen Werbetreibenden vor dem Start, kommt die Buchung mit Status offen und
offener Bahn netz zurück. Sie hält den Platz, spielt aber nicht. Die
Frist ist der Start der Buchung: Gibt das Netz bis dahin nicht frei, lehnt der
Dienst sie ab (entschiedenVon: frist) — ohne Kosten für den Werbekunden. Ist der
Start erreicht, antwortet eine Freigabe mit 409 FRIST_ABGELAUFEN.
Eine freigegebene Buchung kann das Netz danach noch zurückziehen (Veto) —
aber nur, solange sie nicht begonnen hat und nicht abgerechnet ist; danach geht es
nur über einen Storno. Das erscheint als Änderung an der Buchung, nicht als Ablauf einer
Frist.
⚠ Verhaltensänderung 2.75.0: Über die Bahn netz entscheidet nur ein
Zugang mit dem Bereich booking:decide (403 FREIGABE_RECHT_FEHLT).
booking:write allein genügt dafür nicht mehr.
GET/v1/creatives/{id}/file Motivinhalt ausliefern
creative:read — genau diese Adresse steht im Spielplan.
Cache-Control: public, max-age=31536000, immutable, ETag
über die Prüfsumme. Metadaten ohne Inhalt: GET /v1/creatives/{id}.
Ein abgelehntes Motiv wird nicht ausgeliefert (seit 3.7.2): 404, auch auf
If-None-Match. Den Grund nennt GET /v1/creatives/{id}.
Stabile, unsignierte Adresse. Der Inhalt hinter einer Motiv-Kennung ändert sich nie — ein neuer Inhalt bekommt eine neue Kennung. Warum das zählt, steht unter Fallstricke.
Die /ui/**-Routen sind intern
Kein Bestandteil der zugesagten API. Alles unter /ui/, dazu
/ und /auth/sso/**, bedient ausschliesslich die mitgelieferte
Weboberfläche. Diese Routen hängen am Sitzungs-Cookie statt an einem Merkzeichen und
dürfen sich ohne Vorwarnung und ohne MAJOR-Sprung ändern oder wegfallen.
Sie stehen vollständig in openapi.yaml (mit x-intern: true) —
damit niemand sie im Netzwerk-Fenster des Browsers entdeckt und für öffentlich hält. Wer
darauf baut, wird bei der nächsten Änderung überrascht. Wer dieselbe Auskunft dauerhaft
braucht, nimmt /v1/**; fehlt dort etwas, ist das eine Anfrage an uns und kein
Grund, den internen Weg zu nehmen.
Fehler und Codes
Ein einziges Fehlerformat, überall. code ist maschinenlesbar und
stabil, message für Menschen, details zählt
einzelne Mängel auf — damit das anbindende System sie in einem Anlauf beheben kann statt in
fünf.
{ "code": "VALIDATION",
"message": "Angaben unvollstaendig oder unzulaessig.",
"details": ["kontakteQuelle ist Pflicht, sobald kontakteProTag gesetzt ist"] }
Statuscodes
| Code | Bedeutung |
|---|---|
| 200 / 201 / 204 | gelesen · angelegt · erledigt ohne Rumpf |
| 304 | Der ETag passt — es gibt nichts Neues. |
| 400 | BAD_BODY, IDEMPOTENCY_KEY_FEHLT, MISSING_ID |
| 401 | UNAUTHORIZED — kein gültiges Merkzeichen |
| 403 | FORBIDDEN (Bereich fehlt), GESETZ_NICHT_SETZBAR, GESETZ_NICHT_ENTSCHEIDBAR, FREIGABE_RECHT_FEHLT (Bahn netz ohne booking:decide, seit 2.75.0) |
| 404 | NOT_FOUND — Bildschirm, Kampagne, Buchung oder Motiv unbekannt |
| 409 | Konflikt: HAENGT_DARAN (Bildschirm hat Buchungen oder Nachweise → withdraw nehmen), SCHON_FESTGESCHRIEBEN, MOTIV_VERWENDET, BENUTZER_BESTEHT, ZUSTAND (der Wechsel ist an dieser Stelle nicht mehr erlaubt — der Grund steht im Klartext in message), FRIST_ABGELAUFEN (eine wartende Buchung nach ihrem Start freigeben, seit 2.75.0) |
| 413 | ZU_GROSS — Bild über 10 MB, Video über 200 MB |
| 422 | VALIDATION, INHALT_PASST_NICHT, GRUND_FEHLT, UST_STATUS_FEHLT, MELDELUECKEN, SPOTLAENGE, MOTIVLAENGE, BUDGET_UEBERSCHRITTEN |
| 429 | ZU_VIELE_VERSUCHE — mit Retry-After |
| 500 | INTERNAL — mehr nicht, siehe unten |
Ein 500 trägt nur die Kennung INTERNAL und keine
Ausnahmemeldung. Ein Stacktrace in der Antwort verriete Pfade, Fassungen und Tabellennamen —
für den Anbindenden nutzlos, für einen Angreifer eine Landkarte. Das Ganze steht im Protokoll
des Portals.
Zulässige Werte
| Feld | Werte |
|---|---|
kontakteQuelle | counted · estimated · modelled |
| Sperrstufe / Bahn | gesetz · netz · standort |
art einer Sperre | kategorie · branche · wettbewerber · wort |
outcome | played · expired |
expiryReason | NOT_CACHED · SCREEN_OFF · MAINTENANCE · PREEMPTED · PLAYER_ERROR |
playerTimeSource | ntp · manual · unknown |
entscheidung | offen · freigegeben · abgelehnt |
| Buchungsstatus | offen · freigegeben · abgelehnt · laeuft · beendet |
| Kampagnenstatus | entwurf · in_pruefung · freigegeben · laeuft · beendet · abgelehnt |
pruefStatus (Motiv) | offen · angenommen · abgelehnt |
| Umsatzsteuerstatus | regel · kleinunternehmer · reverse_charge |
mimeTyp | image/jpeg · image/png · video/mp4 |
venueTypeTaxonomy | openooh-1.1 · idooh-1.0 — kurz (openooh, idooh) wird angenommen und normiert gespeichert |
venueTypeId | bei openooh-1.1: Eltern 1–11, darunter drei-, fünf- und sechsstellig (auch 1001, 110101) · bei idooh-1.0: ein bis vier punktgetrennte Zahlen, z. B. 4.2.2, 1.1.2.4 |
bewegtbildErlaubt · tonErlaubt | true · false — Vorgabe true bzw. false |
vorlaufMinuten | ganze Zahl von 0 bis 10080 (7 Tage), Vorgabe 120 |
neuerStatus (Abmeldung) | beendet · abgelehnt |
state (Herzschlag) | on · off · fault — beim Lesen zusätzlich unknown (nie gemeldet) |
kind (Auffälligkeit) | implausible_volume · played_while_screen_off · plays_not_multiple_of_loop · creative_aspect_mismatch · kapazitaet_gekuerzt |
Fallstricke
Acht Stellen, an denen eine Anbindung technisch funktioniert und fachlich trotzdem falsch ist. Jede davon ist schon einmal jemandem passiert.
1 · Kurzlebig signierte Motiv-Adressen zerstören den Zwischenspeicher
Ein SMIL-Player erkennt seine Mediendateien an der URL. Wechselt die Signatur, lädt er dieselbe Datei bei jedem Durchlauf neu: Der Cache ist tot, das Datenvolumen explodiert, und bei Netzausfall bleibt der Bildschirm schwarz.
Deshalb ist file.url stabil und unsigniert,
mit immutable. Zugriffsschutz gehört in ein langlebiges,
motivgebundenes Merkmal — nicht in eine ablaufende Signatur.
2 · Ein Spielplan „ab sofort" ist wertlos
Der Vorlauf beträgt mindestens zwei Stunden. Ein Spot, der beim Ausspielzeitpunkt noch nicht im Zwischenspeicher liegt, wird nicht bunt, sondern schwarz — und dafür haftet der Bildschirmbetreiber, nicht der Werbekunde.
Ohne from/to
liefert /schedule deshalb das Fenster +2 h bis +6 h, gerundet
auf die volle Stunde. Die Rundung ist kein Detail: Wanderte das Standardfenster mit jeder
Millisekunde, änderte sich der ETag bei jeder Anfrage und 304 gäbe es
nie.
3 · Schweigen ist keine Meldung
Jeder eingeplante Platz wird bestätigt oder storniert. Wer einen nicht gelaufenen Spot einfach weglässt, macht „keine Meldung" mehrdeutig: Entweder lief es nicht, oder der Bericht ist noch unterwegs. Auf dieser Mehrdeutigkeit lässt sich nicht abrechnen — und deshalb blockieren offene Meldelücken die Festschreibung einer Rechnung.
Melden Sie "outcome": "expired" mit
expiryReason. Ein expired kostet nichts und wird nicht
berechnet — es beantwortet nur die Frage, die sonst offen bliebe.
4 · Kontakt ist nicht Ausspielung
Eine Ausspielung erreicht mehrere Menschen; der Faktor ist in der Branche eine Dezimalzahl und je Bildschirm und Wochenstunde verschieden — eine Apotheke um drei Uhr nachts erreicht weniger als eine Person je Ausspielung.
Zwei Regeln: Nie selbst erfinden — der Faktor stammt aus einer
benannten Quelle und wird mit ihr ausgewiesen (kontakteQuelle,
kontakteMethode). Und nie runden und dann summieren. Eine
geschätzte Zahl darf verkauft werden; als Messung ausgegeben werden darf sie nie. Das ist die
Stelle, an der Werbekundenvertrauen entsteht oder verspielt wird.
5 · Die Gesetzesstufe gehört Ihnen nicht
stufe: "gesetz" lässt sich über die API weder setzen
(403 GESETZ_NICHT_SETZBAR) noch entscheiden
(403 GESETZ_NICHT_ENTSCHEIDBAR). Könnte ein CMS sie leer melden, befreite sich jeder
Anbindende selbst von ihr — und das Haftungsrisiko läge trotzdem beim Portal.
Es gibt auch keinen Übersteuerungsknopf. Ein „trotzdem freigeben" macht aus einem Systemfehler eine bewusste Gesetzesverletzung: der Unterschied zwischen Fahrlässigkeit und Vorsatz. Wer meint, die Sperre sei falsch, korrigiert die Sperre.
6 · Idempotency-Key und playoutRef sind zwei Dinge
Der Kopf schützt den einzelnen Aufruf: Derselbe Schlüssel liefert Wort für Wort
dieselbe gespeicherte Antwort, ohne dass etwas zweimal gezählt wird. Die Satzkennung
playoutRef schützt den einzelnen Satz.
Beides wird gebraucht: Ein Offline-Puffer liefert Wochen später in
ganz anderer Bündelung nach — derselbe Satz steckt dann in einem anderen Aufruf, und nur
playoutRef erkennt ihn wieder. Ein duplicate in der Antwort ist
deshalb kein Fehler, sondern der Normalfall bei Nachlieferung.
7 · Den Vorlauf zu niedrig melden ist teurer, als ihn wegzulassen
vorlaufMinuten ist die einzige Angabe, die nur Ihr System
kennt: Wann Ihre Player laden dürfen, steht in Ihrer Konfiguration, nicht in unserer. Ein Player
mit Aktualisierungsfenster (02:00–05:00, damit die Leitung tagsüber der Kasse gehört) erfährt
von einer um 11:00 gebuchten Kampagne erst in der Nacht.
Melden Sie 120, wo 900 richtig wäre, dann schicken wir einen
Spielplan für den Nachmittag, den der Player nie bekommt. Es gibt keine Fehlermeldung.
Jeder Platz landet als expired im Nachweis: Der Werbekunde zahlt nichts (richtig),
der Netzbetreiber verliert Umsatz, und niemand kann erklären warum. Wer den Wert weglässt,
bekommt 120 — das ist der Normalfall und in Ordnung. Wer ihn kennt, meldet ihn.
8 · Eine Fläche zurückziehen heisst withdraw, nicht DELETE
Der Standortpartner kündigt, das Gerät wird abgebaut — und in Ihrem CMS verschwindet der
Player. Wer das als DELETE weiterreicht, hinterlässt hier
freigegebene Buchungen ohne Bildschirm: Der Werbekunde sieht eine laufende Kampagne, die
nie ausspielen kann, und erfährt nichts.
POST …/withdraw tut das Richtige: kürzt
Laufendes auf das Gelieferte, lehnt Künftiges ab, schickt dem Betroffenen Post und sagt Ihnen in
der Antwort, wen es getroffen hat. Seit dieser Bremse antwortet DELETE mit
409 HAENGT_DARAN, wenn etwas daran hängt — es gibt also keinen stillen Weg
mehr. Vorübergehend stilllegen geht weiterhin über aktiv: false.
Was die API bewusst nicht tut
- Keine Playlistenverwaltung. Der DOOH Hub sagt nie, was sonst noch auf dem Bildschirm läuft — nur was, wie oft und in welchem Fenster.
- Keine Geräteverwaltung. Kein Neustart, kein Firmware-Update, keine Fernwartung. Das ist CMS-Sache und bliebe eine Vertrauensfrage, die niemand beantworten will.
- Keine personenbezogenen Zuschauerdaten. Gezählt werden Ausspielungen. Kontakte sind eine hinterlegte Standortkennzahl mit ausgewiesener Herkunft.
- Kein Zwang zu unseren Kennungen. Die
externalIdbleibt beim CMS. - Keine ausführbaren Motive mit Aussenverbindung. Ein Bildschirmnetz ist ein
geschlossener Bereich: keine Zählpixel, keine nachgeladenen Schriften oder Skripte.
image/svg+xmlist ein Bild und zugleich ein Programm — deshalb eine Positivliste der Dateitypen und keine Sperrliste. Wer das aufweicht, öffnet jedem Werbekunden einen Weg in die Netze fremder Kunden. - Keine Standortbestimmung über die IP-Adresse. Der Ort steht am Bildschirm; eine IP-Auflösung wäre ungenauer und würde in der programmatischen Anbindung als Betrugsverdacht gewertet.
openapi.yaml im Quellbaum. Sie wird zweifach
geprüft: redocly lint auf Gültigkeit und
scripts/pruefe-openapi-abgleich.mjs auf Deckungsgleichheit mit dem Code.
⚠ Der erste Befehl prüft die Datei nur gegen sich selbst — ohne den zweiten weiss niemand, ob dort
steht, was der Dienst wirklich kann.