1. Delivery surfaces
Two surfaces carry an identical payload and share one sequence space, so you can develop against polling and move to WebSocket without changing how you handle a line.
| Surface | Endpoint | Use when |
|---|---|---|
| Polling | GET /v1/matches/{match_id}/subtitles | Simplest to integrate and debug. Start here. |
| WebSocket | wss://…/v1/matches/{match_id}/subtitles/stream | Lowest latency. Switch before production if the polling interval matters to you. |
subtitle_callback when arming a match, the API returns 400 and tells you to use polling or WebSocket. The feature is absent, not unreliable.2. Credentials
Provisioning issues these separately. They are not interchangeable, and you need both of the first two: one to send the match in, one to read the commentary back out.
| Credential | Opens | Notes |
|---|---|---|
| Match token | Create, arm, end and read your own matches | Server-to-server. Self-service — no operator involvement. |
| Commentary stream token | Read subtitles (polling and WebSocket) | Read-only. This is the one your subtitle consumer uses. |
| Event token | POST /v1/events | Issued only if you have an official match data feed. See §6. |
| SRT publish URL | Video contribution | Returned by arm, contains a secret stream id. Treat the whole URL as a password. |
All HTTPS requests use Authorization: Bearer <token>. Base URL: https://live.nextmoment.ai.
401. The match token does not open that route and never will. If your stream publishes fine and you receive nothing, check which token your consumer is sending.3. Create the match, and declare your languages
POST/v1/matches
This is where you choose which languages you receive. It cannot be changed after the match is created.
curl -X POST 'https://live.nextmoment.ai/v1/matches' \
-H 'Authorization: Bearer <match-token>' \
-H 'Content-Type: application/json' \
-d '{
"match_id": "bundesliga-2026-001",
"match_package": { "...": "team and squad information, supplied separately" },
"output": {
"mode": "subtitle",
"languages": ["zh-CN", "en"],
"commentary_profile": "betting",
"deadline_ms": 8000
}
}'
| Field | Meaning |
|---|---|
mode | "subtitle" for text or "audio" for synthesised audio. Required whenever output is supplied; missing it returns 400. Only omitting the entire output object uses service defaults. |
languages | Non-empty list. Every language you intend to read must be listed here. |
deadline_ms | Optional positive-integer time budget in milliseconds. Precedence: arm.buffer_ms, then output.deadline_ms, then the service-configured budget. The example explicitly requests 8000; it is not a universal default. See §10. |
output.commentary_profile | Optional: "betting" gives event-focused factual lines; "tv" adds objective play descriptions and relevant verified background. Neither promises continuous speech or complete event coverage. Precedence: output.commentary_profile, then match_package.commentary_profile, then the service setting (source default betting, which a deployment can override). Null or blank means omitted; surrounding whitespace and case are normalized. Other nonblank values return 400. GET /v1/matches/{match_id} returns output.effective_commentary_profile. |
Response is 201 with the stored match, including the output you declared. Output settings are fixed at creation. The package, including its profile preference, may be updated only before arming; an explicit output profile still takes precedence.
4. Arm the match and get your SRT URL
POST/v1/matches/{match_id}/arm
{
"match": { "status": "ARMED" },
"session": { "stream_epoch": "epoch-7f3a...", "...": "..." },
"ingest": {
"protocol": "srt",
"url": "srt://34.85.178.237:8890",
"stream_id": "publish:live/<secret>:publisher:<secret>",
"ffmpeg_url": "srt://34.85.178.237:8890?streamid=<issued>&pkt_size=1316"
}
}
Arming reserves capacity and issues the publish credential. The match goes live when you actually start publishing, not when you arm it — deliberately two steps, so you can obtain the URL ahead of kickoff. Note the stream_epoch; a reconnect creates a new one.
ARMED and the issued SRT URL remains valid — simply publish again. arm will refuse with “only a READY match can be armed” because the match is already armed, which is expected. Only after POST /v1/matches/{id}/end does the match reach a terminal state, and then you must create a new match_id.arm calls fail with active match capacity is already reserved.5. Publish the video
ffmpeg -re -i input.ts \ -map 0:v:0 -map 0:a? -c copy \ -f mpegts \ 'srt://34.85.178.237:8890?streamid=<issued-stream-id>&pkt_size=1316'
Use the issued URL verbatim. Do not rewrite or log the streamid. Keep source PTS monotonic.
| Item | Contract |
|---|---|
| Transport | SRT, caller/publisher mode, UDP 8890 |
| Container | MPEG-TS |
| Video codec | H.264 recommended. H.265 is accepted by the ingest layer but has not been validated end to end — confirm before relying on it. |
| Audio | Optional and ignored for subtitle purposes |
| Timestamps | Continuous source PTS, milliseconds after normalisation |
ffmpeg egresses from the address we allowlisted — it is often not the same machine that ran your connectivity tests. Send us the correct address and we will add it.Resolution matters; bitrate mostly does not
We decode your stream and analyse sampled frames at their source resolution — we do not downscale.
- Bitrate affects only decode cost on our side. There is no cap and no recommendation.
- Resolution directly drives per-frame processing cost and latency.
Recommendation: publish 1080p. 720p is fine. 4K buys nothing for commentary quality and multiplies cost and latency. The hard per-frame limit is 12 MB encoded, which 1080p never approaches.
6. Official match events — optional
POST/v1/events
If you have an official data feed, posting it materially improves accuracy: goals, cards and substitutions become confirmed facts rather than visual inferences. This requires a separate event token, issued on request.
{
"match_id": "bundesliga-2026-001",
"event_id": "provider-shot-88421",
"revision": 1,
"source_pts_ms": 4382000,
"received_pts_ms": 4382600,
"period": "2",
"event_type": "SHOT",
"team_id": "M05",
"player_id": "M05:10",
"player_name": "Amiri",
"status": "confirmed",
"authority": "official"
}
Retry the identical body to deduplicate; to correct a fact, keep event_id, increment revision, and set status to corrected or retracted. Events are accepted only while the match is live.
7. Read the subtitles
GET/v1/matches/{match_id}/subtitles
curl 'https://live.nextmoment.ai/v1/matches/bundesliga-2026-001/subtitles?after_sequence=0&language=en' \ -H 'Authorization: Bearer <commentary-stream-token>'
{
"match_id": "bundesliga-2026-001",
"stream_epoch": "epoch-7f3a...",
"language": "en",
"subtitles": [
{
"schema_version": "eros.subtitle.v2",
"sequence": 128,
"line_id": "line-acde1234",
"source_pts_ms": 51600,
"priority": 1,
"language": "en",
"text": "Amiri cuts inside and shoots \u2014 saved.",
"trace_id": "frame-52",
"emitted_unix_ms": 1787068800123,
"latency_ms": 5100,
"audio_idempotency_key": null,
"target_start_pts_ms": null,
"duration_ms": null,
"emotion": "Drama"
}
]
}
language is required. There is no default — a request without it returns 400, so a missing parameter can never silently hand you a language you did not ask for. A language not listed in output.languages also returns 400.
after_sequence is an exclusive cursor: pass the highest sequence you have processed. It defaults to 0, meaning “from the beginning of what is retained”.
200 with an empty list, not an error, as long as the match is armed. You can attach your poller ahead of the broadcast and leave it running.WebSocket
wss://live.nextmoment.ai/v1/matches/{match_id}/subtitles/stream?after_sequence=<n>&language=en
Authorization: Bearer <commentary-stream-token>
The Authorization header is required during the handshake, so use a server-side WebSocket client that supports custom handshake headers. Each message is one JSON envelope, identical in shape to a polling entry. An unauthenticated connection is accepted at the HTTP layer and then closed immediately with code 1008; no data is sent.
8. Languages — how it works
Declare every language you want in output.languages at match creation. There is no way to add a language mid-match; asking for an undeclared language returns 400, so a typo cannot silently start a new language stream.
Availability can differ by language
Delivered versions of a line refer to the same source moment. They may become available at different times or be absent in a language; do not assume simultaneous arrival or complete coverage.
- A line can occasionally be missing in one language when it cannot be produced in time for that language. It is then skipped in that language rather than delivered late.
Sequence numbers are shared across languages, and may have gaps
The same line carries the same sequence and the same line_id in every language, which makes cross-language reconciliation trivial: line 128 in English and line 128 in Chinese are the same moment of the match.
Player and team names
Supply authoritative spellings in match_package: name_zh, name_en, name_fr, name_es, name_pt-br and name_tr. The Portuguese output language is pt-BR, while its canonical package field is lowercase name_pt-br. Missing spellings may lead to another supplied spelling, a shirt number, generic wording, or an omitted language output; automatic naming is not guaranteed. Use short_name or the matching short_name_<lang> for spoken team names; keep the official name for recognition. aliases and tla help recognition; event team IDs should use the package team code.
9. Playing the lines — three rules
The stream is append-only. There is no revision, no cancellation, no acknowledgement to send back. A line you receive is final.
Rule 1 — Interruption is driven by priority
priority | Content |
|---|---|
| 0 | Official match event |
| 1 | Corroborated shot, save or goal |
| 2 | Other on-ball action, and replays |
| 3 | Tactical, evaluative, background |
priority is numerically lower than the line you are currently speaking, fade that line out and speak the new one. Otherwise, queue it. That single rule is why we send no cancellations: you already have everything needed to decide, and you can act on it at your own playback position rather than ours.Rule 2 — Schedule against source_pts_ms
source_pts_ms is the moment in your media timeline that the line describes. Use it to align — not the HTTP arrival time.
Rule 3 — Lines can overlap in time
Two lines may describe overlapping moments. That is intentional; Rule 1 tells you what to do about it.
Voicing a line — emotion
emotion is the delivery mood of the line — exactly one of five fixed values, present on every line, never empty. It is set from the confirmed match situation the line reports, not from its wording. A replay is always Calm: it is never voiced as if it were happening again.
emotion | When |
|---|---|
Calm | Routine play, substitutions, kick-off, replays, background information |
Tension | Danger or a caution: a shot, a foul, a free kick, a corner, a yellow card |
Nervous | The outcome is still open: a VAR check, a penalty about to be taken, a chance developing |
Drama | A decisive turn that is not a goal: penalty awarded, red card, big save, goal ruled out |
Excited | A goal |
If you synthesise the line yourself, map these values onto your voice’s style controls. Treat any value you do not recognise as Calm.
10. Latency — read this before you integrate
Use each line’s latency_ms to measure the service you are integrating; historical measurements are not a latency guarantee.
A line that misses its configured source-time deadline is dropped. The budget comes from arm.buffer_ms, then output.deadline_ms, then the service configuration. The source-code fallback is 30000 ms; confirm the actual deployment setting before integration.
Every line carries latency_ms, so you can measure our half yourself rather than taking our word for it.
11. Errors
| HTTP | Meaning | What to do |
|---|---|---|
200 | Request accepted | Continue |
400 | Missing or unconfigured language; malformed cursor; subtitle_callback | Fix the request. Do not retry unchanged. |
401 | Missing or wrong credential | Reading subtitles needs the commentary stream token; the match token does not open that route. |
404 | Unknown match, or the match has no session yet | Confirm environment and match id; arm the match before reading. |
409 | State conflict — e.g. arming an already-armed match, or ending a match with no active session | Refresh state before retrying. See §4. |
429 / 5xx | Temporary | Retry the identical request with capped exponential backoff and jitter. |
All *_pts_ms values are milliseconds on your source media timeline, not wall-clock. emitted_unix_ms is wall-clock.
12. Integration checklist
Confirm your subtitle consumer uses the commentary stream token, not the match token.
With
output.mode = "subtitle" and every language you need.stream_epoch.Confirm the machine that will publish egresses from an allowlisted IP.
Using the issued URL verbatim.
Advancing the cursor by the highest
sequence you processed.priority interruption ruleAnd tolerate sequence gaps without retrying.
To absorb your own synthesis time.
No goal announcements without an official feed is expected behaviour.
13. Changelog
This page is the current version. Re-fetch this URL rather than working from a downloaded copy.
| Date | Change |
|---|---|
| 2026-09-25 | Goals are announced without an official feed once the broadcast confirms them (§6). Languages section rewritten (§8). |
| 2026-09-24 | Every line now carries emotion (§9). The field is additive; the envelope stays eros.subtitle.v2. If you validate payloads strictly against a schema copy taken before this date, refresh it: the schemas do not allow unknown keys. |
| 2026-09-05 | Published at a stable public URL. Added: recovery flow after a failed publish (§4), armed-match capacity behaviour (§4), SRT IP allowlisting (§5), pre-kickoff polling returns 200 not 409 (§7, §11), goal-announcement gate stated explicitly (§6). |
| 2026-09-04 | Subtitle delivery, first release. |