---
updatedAt: 2026-09-01T09:39:18.000Z
agentTools:
  projectIndex: https://developers.zentag.ai/llms.txt
---

# Streams

Register a stream for metadata, or hand Zentag a feed to ingest and analyse.

A stream is the match or session that clips and highlights belong to. `POST /streams`
supports two quite different uses, and which one you get depends on a single field.

|                              | Without `video_template_id` | With `video_template_id`              |
| ---------------------------- | --------------------------- | ------------------------------------- |
| What Zentag does             | Stores metadata             | Ingests the feed and runs analysis    |
| `url` required               | No                          | Yes for recorded and pull input types |
| Produces clips automatically | No                          | Yes, when the template has AI enabled |
| Ready when `201` returns     | Yes                         | No - provisioning continues           |

Both require the `streams:write` scope.

## Metadata-only streams

Use this when you are producing clips yourself and need something to attach them to,
or when you are mirroring a fixture list into Zentag.

```bash
curl -X POST "https://studio.zentag.ai/api/v1/partner/streams" \
  -H "Authorization: Bearer $ZENTAG_SECRET_KEY" \
  -H "Idempotency-Key: stream-fixture-88213" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Rovers vs City",
    "category": "football",
    "team1_id": "team_rovers",
    "team2_id": "team_city",
    "competition_id": "comp_prem",
    "match_date": "2026-09-14T14:00:00.000Z"
  }'
```

The stream is created with `status: "pending"` and stays there. Nothing is ingested,
and no clips appear on their own.

`team1_id`, `team2_id` and `competition_id` are Zentag catalog ids. Create or look
them up through `GET /teams` and `GET /competitions` first — they are returned on
read, so you can rely on them round-tripping.

## Match ids

Search fixtures with `GET /matches`, then pass `match_id`, `team1_id`, and `team2_id`
on stream create or update. This is the same fixture id Studio stores when you pick a
match in Add Video Feed.

```bash
curl "https://studio.zentag.ai/api/v1/partner/matches?competition_id=comp_prem&search=rovers" \
  -H "Authorization: Bearer $ZENTAG_SECRET_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "object": "match",
      "match_id": "12345678",
      "name": "Rovers vs City · 2026-09-14",
      "team1_id": "team_rovers",
      "team2_id": "team_city"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Fetch one fixture by id (recommended when you already know the match):

```bash
curl "https://studio.zentag.ai/api/v1/partner/matches?competition_id=comp_prem&match_id=12345678" \
  -H "Authorization: Bearer $ZENTAG_SECRET_KEY"
```

```bash
curl -X POST "https://studio.zentag.ai/api/v1/partner/streams" \
  -H "Authorization: Bearer $ZENTAG_SECRET_KEY" \
  -H "Idempotency-Key: stream-fixture-88213" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Rovers vs City",
    "category": "football",
    "team1_id": "team_rovers",
    "team2_id": "team_city",
    "competition_id": "comp_prem",
    "match_id": "12345678",
    "match_date": "2026-09-14T14:00:00.000Z"
  }'
```

Prefer `competition_id` on the search call so results come from that competition's
season schedule (with team ids). Without it, Zentag searches cached match metadata
and streams that already have a `match_id`.

## Ingested streams

Supplying `video_template_id` asks Zentag to take the feed and process it.

### 1. Pick a template

A template fixes the input type, resolution, frame rate, audio tracks and whether AI
analysis runs. List the ones available to you:

```bash
curl "https://studio.zentag.ai/api/v1/partner/stream-templates" \
  -H "Authorization: Bearer $ZENTAG_SECRET_KEY"
```

```json
{
  "object": "list",
  "data": [
    {
      "object": "stream_template",
      "id": "665f1a2b3c4d5e6f70819200",
      "name": "Football 1080p30 - RTMP push",
      "input_type": "RTMP_PUSH",
      "width": 1920,
      "height": 1080,
      "fps": 30,
      "ai_enabled": true,
      "audio_languages": [{ "code": "en", "label": "English", "track": 1 }]
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Only templates belonging to your organization are listed, and only listed templates
are accepted on create. Ask your Zentag contact if you need one added or changed.

### 2. Match `url` to the template's input type

The `input_type` decides whether you supply a source URL or Zentag supplies you an
ingest target:

| `input_type`                        | `url` on create                                    |
| ----------------------------------- | -------------------------------------------------- |
| `RECORDED`                          | Required - the file to process                     |
| `RTMP_PULL`, `SRT_PULL`, `HLS_PULL` | Required, and must use that protocol               |
| `RTMP_PUSH`, `SRT_PUSH`             | Not used - read `GET /streams/{id}/ingest` instead |

A `url` whose protocol does not match the input type is rejected with `400`, so this
surfaces immediately rather than as a silent ingest failure later.

### 3. Create the stream

For a live feed, also send the schedule. Zentag brings the pipeline up shortly before
`scheduled_start_at` and tears it down after the end:

```bash
curl -X POST "https://studio.zentag.ai/api/v1/partner/streams" \
  -H "Authorization: Bearer $ZENTAG_SECRET_KEY" \
  -H "Idempotency-Key: stream-fixture-88213-live" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Rovers vs City",
    "category": "football",
    "video_template_id": "665f1a2b3c4d5e6f70819200",
    "video_type": "live",
    "scheduled_start_at": "2026-09-14T14:00:00.000Z",
    "scheduled_duration_hours": 2,
    "timezone": "Europe/London"
  }'
```

Send `scheduled_end_at` **or** `scheduled_duration_hours` (1–8), not necessarily both.
`timezone` is an IANA name recorded for display; the schedule itself is resolved from
the absolute instants, so the times above mean the same thing regardless of what you
send for `timezone`.

Omit `video_type` (or send `recorded`) for a file, in which case no schedule is needed
and processing starts right away.

### 4. Wait for provisioning

**The `201` does not mean the stream is ready.** Provisioning and analysis continue
after the response returns, so the stream comes back with a non-terminal status:

```json
{
  "object": "stream",
  "id": "s_9f2c1d4e",
  "title": "Rovers vs City",
  "status": "scheduled",
  "is_live": false,
  "video_type": "live",
  "scheduled_start_at": "2026-09-14T14:00:00.000Z",
  "scheduled_end_at": "2026-09-14T16:00:00.000Z",
  "timezone": "Europe/London",
  "clips_count": 0,
  "highlights_count": 0
}
```

Subscribe to `stream.started` and `stream.ended`, or poll `GET /streams/{id}` and read
`status`. Do not treat the create response as confirmation that ingest is running.

### 5. Push the feed, for push input types

Once provisioning finishes, fetch your ingest target:

```bash
curl "https://studio.zentag.ai/api/v1/partner/streams/s_9f2c1d4e/ingest" \
  -H "Authorization: Bearer $ZENTAG_SECRET_KEY"
```

```json
{
  "object": "stream_ingest",
  "stream_id": "s_9f2c1d4e",
  "input_type": "RTMP_PUSH",
  "protocol": "RTMP",
  "ingest_server": "rtmp://ingest.zentag.ai/live",
  "stream_key": "sk_live_9f2c1d4e7a8b",
  "ingest_url": "rtmp://ingest.zentag.ai/live/sk_live_9f2c1d4e7a8b",
  "pull_source_url": null
}
```

This endpoint requires `streams:write` rather than `streams:read`, because
`stream_key` authorizes publishing to the stream. Treat it as a secret.

The fields are `null` until provisioning completes  retry rather than treating an
empty response as an error.

## Reading streams

`GET /streams` supports filtering and ordering:

| Parameter                               | Notes                                                                                      |
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `status`                                | One of `pending`, `scheduled`, `processing`, `completed`, `failed`, `cancelled`, `delayed` |
| `is_live`                               | `true` or `false`                                                                          |
| `video_type`                            | `live` or `recorded`                                                                       |
| `category`, `competition_id`            | Exact match                                                                                |
| `search`                                | Case-insensitive substring of the title                                                    |
| `created_after`, `created_before`       | ISO 8601                                                                                   |
| `match_date_after`, `match_date_before` | ISO 8601                                                                                   |
| `sort_by`                               | `latest` (default) or `oldest`                                                             |

Prefer `status` over `is_live` when you care about progress: `is_live` is `false` for a
stream that finished, one that failed and one that has not started, which are three
very different situations.

Streams are also available through `GET /media`, alongside clips and highlights, when
you want one list across all three. See [Media library filters](./media-filters.md).

## Notes

* Stream ids are the short `id` on the resource, not a Mongo ObjectId. Template ids
  are the `id` from `GET /stream-templates`.
* Every id on a stream is a Zentag id. Third-party sports-data provider identifiers
  are deliberately not exposed.
* `POST /streams/{id}/end` marks a stream complete and emits `stream.ended`. It is
  idempotent: ending an already-ended stream does not emit a second event.
* Deleted streams are not returned, and cannot be updated, ended, or used as the
  target for a new clip or highlight.