1. Integration overview
Eros receives one continuous encoded live video stream over SRT. Official match facts arrive independently over HTTPS. Commentary audio is available either as timed callback instructions or as continuous low-latency frames over WebSocket.
Provisioning supplies the applicable HTTPS and WebSocket endpoints, role-specific tokens, SRT publish URL, and callback HMAC secret.
Start the SRT contribution, then post official events against the same match and media timeline.
Use the provisioned callback or WebSocket surface and align playback to the active video timeline.
https://api.example.com; the hostname serving this documentation is not automatically the API hostname. Credentials shown are placeholders.GET/health/ready
No authentication. A 200 response with {"status":"ready"} means the integration endpoint is ready; 503 means retry later.
2. Authentication
HTTPS requests use a role-specific Bearer token. Never put tokens in query strings, browser code, screenshots, or logs.
Authorization: Bearer <token-issued-for-this-endpoint> Content-Type: application/json
| Credential | Used for | Handling |
|---|---|---|
| Event token | Official match event submission | Server-to-server only |
| Receipt token | Audio playback receipts | Server-to-server only |
| Commentary stream token | Continuous WebSocket audio | Read-only; server-side WebSocket clients only |
| SRT publish URL | Live video contribution | Contains a secret stream ID; treat the complete URL as a password |
| Callback HMAC secret | Verify requests sent by Eros | Store in a secret manager; do not send it over the API |
3. SRT live video
SRT carries the encoded live video stream, not individual decoded frames. The contribution is normally MPEG-TS containing H.264/H.265 video and optional audio. Eros preserves source timestamps and extracts frames downstream for AI processing.
FFmpeg example
ffmpeg -re -i input.ts \ -map 0:v:0 -map 0:a? -c copy \ -f mpegts \ 'srt://<host>:<port>?streamid=<issued-stream-id>&pkt_size=1316'
Use the issued URL verbatim. Do not rewrite or log the streamid. Keep source PTS monotonic. A reconnect may create a new stream epoch; event and playback integrations must use the active epoch supplied in Eros messages.
| Item | Contract |
|---|---|
| Transport | SRT caller/publisher mode |
| Container | MPEG-TS |
| Video | H.264 recommended; confirm H.265 support before use |
| Timestamps | Continuous source PTS in milliseconds after normalization |
| Frames | No per-frame HTTP upload is required from the source video publisher |
4. Official match events
POST/v1/events
curl -X POST 'https://api.example.com/v1/events' \
-H 'Authorization: Bearer <event-token>' \
-H 'Content-Type: application/json' \
-d '{
"match_id":"bundesliga-2026-001",
"event_id":"provider-shot-88421",
"revision":1,
"canonical_id":"attack-991-shot-1",
"source_pts_ms":4382000,
"received_pts_ms":4382600,
"period":"2",
"event_type":"SHOT",
"team_id":"M05",
"player_id":"M05:10",
"player_name":"Amiri",
"outcome":null,
"status":"confirmed",
"temporal_scope":"live",
"authority":"official",
"source":"provider_name",
"stage":"attempt",
"payload":{"match_clock":"73:02"}
}'
Accepted events return 202:
{"accepted":true,"duplicate":false,"fact_ref":"match-event:provider-shot-88421:r1"}
Commentary profile
Request output.commentary_profile at match creation: "betting" for event-focused factual lines or "tv" for richer objective play descriptions and verified background. Neither promises continuous speech. The output setting overrides match_package.commentary_profile, then the service default applies (source default betting; deployments may override it). Confirm the effective profile with the operator configuring the match. Output settings are fixed at creation; package preferences can change only before arming.
Idempotency and corrections
- Retry the exact same body with the same
event_idandrevision; the response returnsduplicate:true. - To correct or retract a fact, keep
event_id, incrementrevision, and setstatustocorrectedorretracted. - Attempt and result messages share
canonical_idbut use differentevent_idvalues. received_pts_msmust be greater than or equal tosource_pts_ms.- Events are accepted only while the corresponding match is live; otherwise the API returns
409. authority: send"official"for your data feed. In abetting-profile match an event withoutauthorityis treated asofficial; in atv-profile match it is treated ascandidate. Acandidateevent is stored but its announcement is not guaranteed; the response then carries"delivery":"not_guaranteed_candidate_authority".- In a
betting-profile match only betting-relevant event types are announced:GOAL,OWN_GOAL,PENALTY,PENALTY_AWARDED,PENALTY_MISSED,YELLOW_CARD,RED_CARD,SECOND_YELLOW,YELLOW_RED_CARD,SUBSTITUTION,OFFSIDE,FOUL,FREE_KICK,VAR,VAR_CHECK,VAR_DECISION, and — at most one every few seconds —CORNER,SHOT,SHOT_ON_TARGET,SAVE,DANGEROUS_ATTACK,KICK_OFF. Any other type is stored but not announced; the response then carries"delivery":"not_narrated_in_betting_profile"and areason. Atv-profile match can consider other event types; confirmation, relevance, timing and delivery checks still apply. Acceptance is not a guarantee of narration. - A provisional goal must be sent with
"status":"candidate"(not onlyauthority): a confirmed goal is announced as a goal immediately.
5. Timed audio — callback transport
callback one; §6 is websocket. Declare it in output:
"output": { "mode": "audio", "languages": ["zh-CN"],
"audio_transport": "callback",
"audio_callback_url": "https://your-host/eros/audio" }
audio_transport has no default — omitting it in audio mode is a 400. The full integration guide for both transports is at /docs/audio.Your service exposes an HTTPS callback URL and gives it to us on each create-match request. Eros sends JSON instructions using POST. Return any 2xx only after the instruction is durably accepted.
EROS_AUDIO_CALLBACK_URL) is retired. A callback match that does not carry its own audio_callback_url is refused — there is no fallback address any more. The URL must be https, must not carry credentials, and must resolve to a public address; we re-check it immediately before every send.Headers and signature
Content-Type: application/json User-Agent: eros-live-engine/1 X-Eros-Timestamp: 1787068800 X-Eros-Signature: sha256=<64-lowercase-hex> Idempotency-Key: <match>:<epoch>:<segment>:r<revision>:<action>
Before parsing JSON, compute HMAC-SHA256 over the exact raw bytes:
signed_bytes = utf8(X-Eros-Timestamp + ".") + raw_request_body expected = HMAC-SHA256(callback_secret, signed_bytes)
Compare signatures in constant time and reject stale timestamps. The standard integration window is five minutes.
upsert example
{
"schema_version":"eros.audio-delivery.v1",
"segment_schema_version":"eros.timed-audio.v1",
"delivery_sequence":81,
"action":"upsert",
"match_id":"bundesliga-2026-001",
"stream_epoch":"epoch-...",
"segment_id":"speech-acde1234",
"revision":1,
"source_pts_ms":51600,
"target_start_pts_ms":51600,
"expires_pts_ms":61600,
"duration_ms":4500,
"priority":2,
"codec":"wav",
"sample_rate":24000,
"channels":1,
"trace_id":"frame-52",
"payload_bytes":216044,
"content_sha256":"<64-lowercase-hex>",
"idempotency_key":"bundesliga-2026-001:epoch-...:speech-acde1234:r1",
"audio_object_key":"timed-audio/.../speech-acde1234-r1.wav",
"audio_url":"https://objects.example.com/...signed...",
"commentary_text":"The ball reaches the left side of the box.",
"emotion":"Calm"
}
- Download
audio_urlimmediately and verify bothpayload_bytesandcontent_sha256. - Schedule against
target_start_pts_ms; do not play immediately based on HTTP arrival time. - Drop audio that reaches
expires_pts_msbefore playback and reportfailed. action=cancelwithdraws an unplayed revision and has noaudio_url.emotionis the delivery mood of the line, always one ofCalm,Tension,Nervous,Drama,Excited(goal); replays are alwaysCalm. The synthesized voice already follows it, and the matching subtitle line carries the same value.- Apply instructions for the same match, epoch, segment, and revision in
delivery_sequence order. Persist the highest applied sequence for that identity and ignore a lower sequence that arrives later, so a retriedupsertcannot undo a newercancel. - Deduplicate Callback retries using the five-part
Idempotency-Keyheader.
idempotency_key from the JSON body.6. Timed audio — websocket transport
The counterpart to §5: the same timed-audio segments, but you connect to us and the WAV bytes are inlined in the message. There is no inbound leg, so you do not need to host a public endpoint or configure object storage.
"output": { "mode": "audio", "languages": ["zh-CN"], "audio_transport": "websocket" }
wss://<host>/v1/matches/{match_id}/audio/stream?after_sequence=0
Authorization: Bearer <audio receipt token>
One delivery is one self-contained JSON message: every field from §5 plus delivery_sequence, with audio_url replaced by audio_base64. Receipts (§8) go back up the same socket and are confirmed with a receipt_ack.
1009. Also treat close code 1008 as fatal: a bad token does not fail the handshake, the connection is established and then closed.Details, reconnection and the full field list: /docs/audio.
7. Continuous PCM frames (v2 lane)
v2 playout mode — if you are integrating timed audio, you want §5 or §6.The WebSocket surface emits continuous, low-latency commentary frames. Each binary WebSocket message contains one MessagePack-encoded eros.commentary-frame.v2 envelope with a 20 ms PCM payload.
Connect
wss://<stream-host>/v2/commentary/stream?stream_epoch=<active-epoch>&after_sequence=<last-processed-sequence> Authorization: Bearer <commentary-stream-token>
The Authorization header is required during the WebSocket handshake. Use a server-side WebSocket client that supports custom handshake headers. On the first connection, set after_sequence=-1; the server starts with the latest retained batch rather than replaying the entire retention window.
Decoded MessagePack envelope
{
"type":"commentary_frame",
"schema_version":"eros.commentary-frame.v2",
"match_id":"bundesliga-2026-001",
"stream_epoch":"epoch-...",
"sequence":2581,
"source_pts_ms":51600,
"duration_ms":20,
"codec":"pcm_s16le",
"sample_rate":24000,
"channels":1,
"speaker_id":"voice-main",
"lane_id":"commentary.main",
"trace_id":"frame-52",
"voice_active":true,
"payload":"<MessagePack binary: 960 bytes>"
}
Audio is signed 16-bit little-endian PCM at 24 kHz, mono. A normal frame contains 480 samples and 960 payload bytes. Play frames in increasing sequence order and keep the highest fully processed sequence.
Reconnect and discontinuities
- Reconnect with the active
stream_epochand the highest processedafter_sequence; the server resumes from the next retained frame. - A
discontinuitymessage with reasonstream_epoch_changedidentifies a new active epoch. Clear buffered audio and reconnect using that epoch. - A
discontinuitymessage with reasonretention_window_exceededincludesfirst_available_sequence. Clear stale buffers and reconnect withafter_sequence = first_available_sequence - 1; the cursor is exclusive, so using the boundary itself would skip one frame. - Close code
1012follows a discontinuity. Close code1013means the consumer fell too far behind; reconnect from the latest known sequence. - Close code
1008indicates an invalid credential or query parameter and must not be blindly retried.
8. Playback receipts
POST/v1/audio/receipts
curl -X POST 'https://api.example.com/v1/audio/receipts' \
-H 'Authorization: Bearer <receipt-token>' \
-H 'Content-Type: application/json' \
-d '{
"idempotency_key":"bundesliga-2026-001:epoch-...:speech-acde1234:r1",
"status":"played"
}'
| Status | When to use it |
|---|---|
played | Audio actually finished playing. Only this status records the commentary fact as spoken. |
cancelled | The player successfully cancelled the audio before playback. |
failed | Download, integrity check, decode, expiry, scheduling, or playback failed. |
Success returns 202. Retrying the same key and status is safe and returns duplicate:true; changing the status for an existing key returns 400.
9. Subtitle delivery
Subtitle mode delivers commentary as text lines instead of audio. Each line is one JSON envelope, matching eros.subtitle.v2, delivered once per configured language. There are two transports — poll and WebSocket — and they share one sequence space per match and stream epoch, so a client may mix transports without losing or duplicating lines.
Envelope
{
"schema_version":"eros.subtitle.v1",
"match_id":"bundesliga-2026-001",
"stream_epoch":"epoch-...",
"sequence":2581,
"line_id":"line-acde1234",
"source_pts_ms":51600,
"priority":1,
"language":"en",
"text":"Amiri shoots.",
"trace_id":"frame-52",
"emitted_unix_ms":1787068800000,
"latency_ms":5100,
"audio_idempotency_key":null,
"target_start_pts_ms":null,
"duration_ms":null,
"emotion":"Tension"
}
emotion is the delivery mood of the line, always exactly one of Calm (routine play, substitutions, replays, background), Tension (shot, foul, free kick, corner, yellow card), Nervous (outcome still open: VAR check, penalty about to be taken, a chance developing), Drama (penalty awarded, red card, big save, goal ruled out) or Excited (goal). It is set from the confirmed match situation the line reports, never empty; treat an unrecognised value as Calm.
priority is numerically lower than the line currently being spoken or displayed, fade that line out and speak/show the new one immediately. Otherwise, queue the new line behind what is currently active. Lower numbers are more important.
| priority | Meaning |
|---|---|
0 | Official event (from §4 match events) |
1 | Corroborated shot, save, or goal |
2 | Other action and replay |
3 | Tactical, evaluative, or background commentary |
GET/v1/matches/{match_id}/subtitles
curl 'https://api.example.com/v1/matches/bundesliga-2026-001/subtitles?after_sequence=2580&language=en' \ -H 'Authorization: Bearer <commentary-stream-token>'
Returns lines with sequence > after_sequence for the requested language:
{
"match_id":"bundesliga-2026-001",
"stream_epoch":"epoch-...",
"language":"en",
"subtitles":[ { "schema_version":"eros.subtitle.v2", "sequence":2581, "...":"..." } ]
}
language is required; there is no default. after_sequence defaults to 0 on first poll.
WebSocket
wss://<stream-host>/v1/matches/{match_id}/subtitles/stream?language=en&after_sequence=2580
Authorization: Bearer <commentary-stream-token>
Uses the same commentary stream token as §7. The server pushes each subsequent line as one JSON text frame — not MessagePack, since the envelope is already JSON and there is no reason to add a second encoding for this transport. Close code 1008 at handshake means a missing or wrong token, a missing language, or an invalid after_sequence; it must not be blindly retried.
Sequence gaps are not packet loss
after_sequence is an exclusive cursor: requesting after_sequence=2580 returns sequence 2581 and above. sequence is shared across languages — the same commentary line carries the same sequence number in every language — but a client may legitimately observe gaps in its own language's stream. When a line fails translation into one language, that language simply does not receive that sequence number; the line still exists (and was delivered) in every language where translation succeeded. A missing sequence number is evidence of a translation failure for that language, not evidence of a dropped delivery, and must not trigger a retry against the poll or WebSocket surface — there is nothing to retry.
Append-only: what does not exist
Unlike timed audio (§5), the subtitle stream has no revision protocol. A delivered line is never corrected, retracted, or withdrawn. Concretely, the envelope omits:
- No
actionfield — there is only one kind of delivery. - No
revision— a line is delivered exactly once per language and never re-delivered with different text. - No cancellation — there is no equivalent of the audio callback's
action=cancel. - No playback receipt — there is no equivalent of §8. Delivery bookkeeping on the Eros side completes the moment a line is admitted onto the stream; the client is not required to report anything back.
10. HTTP and retry rules
| HTTP | Meaning | Recommended action |
|---|---|---|
| 200 / 202 | Request accepted | Continue; preserve the idempotency key |
| 400 | Invalid JSON, fields, timestamps, or conflicting receipt | Fix the request; do not blind-retry |
| 401 | Missing or invalid credential | Stop and rotate/check the assigned credential |
| 404 | Route or match not found | Confirm environment and match identity |
| 409 | Match is not live or state conflict | Refresh live state before retrying |
| 429 / 5xx | Temporary capacity or service failure | Retry the identical request with capped exponential backoff and jitter |
All JSON timestamps are UTC ISO 8601 unless a field is explicitly named *_unix_ms. All *_pts_ms values are milliseconds on the active source-media timeline, not Unix time.