> Public copy of the ViewStream developer guide (also for AI agents). All guides: https://www.viewstream.co.il/developers/guide/index.md · API reference: https://api.viewstream.co.il/docs/

# API conventions

Rules that hold for every route of `https://api.viewstream.co.il/v1`. Each rule links to the code it comes from.

## Requests and responses

- **JSON in, JSON out.** Send `Content-Type: application/json`. Responses are `application/json`, or
  `application/problem+json` for errors.
- **Unknown fields are refused.** Request bodies are decoded strictly: a field the route does not know answers
  `400 validation_error` with `detail: "invalid JSON body: json: unknown field …"`. Bodies are size-limited per route
  (typically 4 KB – 1 MB) (`decodeJSON` in problem.go).
- **Ids are UUIDs** (UUIDv7, so they sort by creation time). Channels and tenants also have a slug (`main`,
  `acme`) that appears in delivery URLs.
- **No caching.** Every API answer carries `Cache-Control: no-store`. The public documents outside `/v1` (EPG exports,
  player configurations, Sites Delivery API, manifests) set their own cache headers.
- **Request id.** Every response carries `X-Request-Id`. Send your own (up to 128 characters) to correlate logs; it is
  echoed back. Error bodies repeat it as `request_id`. Quote it to support
  (telemetry).

## Time formats and time zones

| Where | Format |
|---|---|
| Timestamps in responses | RFC 3339 in UTC, e.g. `2026-10-06T08:15:00Z` |
| Time query parameters (`from`, `to`, `at` on stats, catch-up, recording thumbs) | RFC 3339 **or** epoch milliseconds (`parseInstant` in thumbs.go, `statsFilter` in stats.go) |
| Durations | named with their unit: `duration_ms`, `duration_s`, `ttl_s`, `for_s` |
| Catch-up / clip windows in playback URLs | epoch milliseconds (`/m/catchup/<channel>/<start ms>/<end ms or live>/…`) |
| Public XMLTV export | local guide time, `Asia/Jerusalem` (config `GuideLocation`) |
| Sites Delivery API `epg?day=` | a calendar day in `Asia/Jerusalem` |
| Webhook `X-VS-Timestamp` | Unix seconds |

When you omit `from`/`to` on statistics routes you get the last 24 hours. Statistics ranges may be up to 2 years;
raw exports up to 31 days (stats.go).

## Errors (problem+json)

Errors follow RFC 9457 (problem.go):

```json
{
  "type": "https://viewstream.co.il/problems/validation_error",
  "title": "Validation failed",
  "status": 422,
  "detail": "invalid api key",
  "request_id": "0192a4c1-…",
  "errors": [
    {"field": "scopes", "detail": "you cannot grant a scope you do not hold: billing:read"}
  ]
}
```

Branch on `status` and the last path segment of `type`; `title` is fixed per type, `detail` is human-readable and may
change.

| `type` suffix | Status | Typical cause (example `detail` from the code) |
|---|---|---|
| `invalid_credentials` | 401 | no or bad key — `"API key is unknown or invalid"`, `"API key is revoked"` |
| `insufficient_scope` | 403 | `"this route requires scope assets:write"` |
| `forbidden` | 403 | no current tenant (`"no current tenant; call POST /v1/me/tenant first"`) |
| `tenant_suspended` | 403 | the tenant is suspended: playback keeps working, changes are blocked (admin_p6.go) |
| `feature_disabled` | 403 / 503 | CDN-only tenant (`"assets, uploads, channels, clips and player config are not available to CDN-only tenants"`), or a feature not switched on for this deployment |
| `not_found` | 404 | the id does not exist **or belongs to another tenant** (the API never tells the two apart) |
| `conflict` | 409 | `"an asset with that external_id exists for this tenant"`, `"only a ready asset can be published (status encoding)"`, another stats export already running |
| `lock_window` | 409 | an EPG change reaches into the guide's locked hours |
| `misdirected_request` | 421 | the `Host` header is not served here |
| `validation_error` | 400 / 422 | 400 = the body is not valid JSON or has unknown fields; 422 = the values are wrong, `errors[]` lists `{field, detail}` |
| `no_recording` | 404 | manifests (`/m/…`) only: a start-over / catch-up window with nothing recorded, or an excluded programme |
| `rate_limited` | 429 | see below; always with `Retry-After` |
| `internal_error` | 500 / 502 | our side; retry later and quote `request_id` |
| `not_ready` | 503 | a dependency is down or not configured (`"search is not available"`, `"object storage is not configured"`) |

Example — a key without the scope:

```bash
curl -s -X POST https://api.viewstream.co.il/v1/clips -H "Authorization: Bearer $VS_KEY" -d '{}'
```
```json
{"type":"https://viewstream.co.il/problems/insufficient_scope","title":"The API key lacks the scope this route needs","status":403,"detail":"this route requires scope clips:write","request_id":"…"}
```

## Pagination

Lists that can grow without bound use **cursor pagination**, newest first:

```
GET /v1/assets?limit=50                 → {"items": [...], "next_cursor": "MDE5Mj…"}
GET /v1/assets?limit=50&cursor=MDE5Mj…  → {"items": [...], "next_cursor": null}
```

- `limit` defaults to 50; the maximum is 200 on assets, clips and jobs (`422` outside 1–200). Each route's maximum is
  in the API reference.
- `next_cursor: null` = last page. Cursors are opaque; do not build or decode them.
- Some lists are windows rather than pages: the catch-up list takes `from`/`to` (at most 8 days apart), catch-up search
  returns at most 50 hits with `truncated: true` when there are more.

## Rate limits

| Limit | Value | Source |
|---|---|---|
| Every authenticated request, per API key (or per Studio session) | 20 requests/s, burst 100 | `ratelimit.New(20, 100, …)` in public.go, ratelimit |
| Failed key checks | 30 per client IP / 10 per key prefix in 10 minutes | middleware.go |
| `POST /v1/prewarm` | 6 per minute per tenant | `PrewarmPerMinute` |
| Statistics raw exports | 1 running at a time, 10 per hour, 3 prepared files waiting | stats_export.go, statsexport/store.go |
| `POST /v1/playback/session` (public issuer) | per client IP, plus the policy's own issue rate | protection.go |

Over a limit the API answers `429 rate_limited` with `Retry-After` in seconds. Back off exponentially on `429` and
`503`.

The per-key limiter lives in each API process (not shared between replicas) .

## Idempotency and retries

There is **no `Idempotency-Key` header**. What is safe to retry:

- `GET`, `PUT` and `DELETE` are idempotent. `DELETE /v1/api-keys/{id}` on an already revoked key answers `404`.
- `POST /v1/assets` (and `POST /v1/uploads/{id}/complete`) with an `external_id`: a retry of a create that already
  succeeded answers `409 conflict` instead of making a second asset. Always send an `external_id` from your CMS.
- Webhook deliveries carry `X-VS-Delivery`, stable across retries — de-duplicate on it.
- Other `POST`s (clips, prewarm, purge, exports) create a new object each time.

## Optimistic concurrency

Only the Sites builder uses it: `PATCH /v1/pages/{id}` needs `If-Match: <draft_rev>` (`428` without it, `409` when stale)
(sites.go).

## Asynchronous work: 202 and polling

Long work runs as **jobs**. The pattern:

1. The request answers `201` (the object exists and work has started) or `202 Accepted` (work queued). The answer
   names what to watch — a job id, an object with a `status`, or `{"queued": true}`.
2. Watch it by **webhook** (preferred), by the **event stream**, or by **polling**.

| Started by | Answer | Watch |
|---|---|---|
| `POST /v1/assets`, `POST /v1/uploads/{id}/complete` | `201` asset, `status: probing`, `jobs[]` | `asset.ready` / `asset.failed` webhook, or `GET /v1/assets/{id}` until `status` is `ready` or `failed` |
| `POST /v1/assets/{id}/reencode` | `202 {job, version}` | `GET /v1/jobs/{id}` |
| `POST /v1/clips` with `precision: "frame"` | `201` clip, `status: finalizing` | `clip.final` webhook or `GET /v1/clips/{id}` (`final` / `failed`) |
| `POST /v1/prewarm` | `202 {id, status, run}` | `prewarm.finished` webhook or `GET /v1/prewarm/{id}` |
| `POST /v1/purge` | `202 {job_id, keys, prefix}` | `GET /v1/jobs/{id}` |
| `POST /v1/stats/exports` | `202` export, `status: queued` | `GET /v1/stats/exports/{id}` until `ready` |
| `POST /v1/channels/{id}/programmes/{pid}/publish-vod` | `202` | catch-up list `vod.status` |
| `POST /v1/library/imports/{id}/start` | `202` import | `GET /v1/library/imports/{id}` |

Asset statuses: `registered → probing → queued → encoding → packaging → ready`, or `failed`; `deleted` = in the trash.
Job statuses: `queued`, `dispatched`, `running`, `succeeded`, `failed`, `cancelled`
(jobs.go). `GET /v1/jobs/{id}` returns the job with its `events[]`;
error texts are redacted of internal addresses.

Poll gently: every 5–10 seconds for a single object is plenty, and remember the 20 requests/s budget.

## The event stream (Server-Sent Events)

`GET /v1/events/stream` pushes your tenant's events as they happen (sse.go,
sse/hub.go). An API key needs `events:read`.

```bash
curl -N https://api.viewstream.co.il/v1/events/stream -H "Authorization: Bearer $VS_KEY"
```
```
: connected

id: 01J9…
event: asset.status
data: {"asset_id":"…","status":"encoding"}

: ping
```

- Frames are `id:` / `event:` / `data:` (JSON). A comment `: ping` arrives every 15 s.
- **Reconnect with `Last-Event-ID`**: events of the last 5 minutes are replayed. If your id is older, the first frame
  is `event: resync` — refetch the state you care about.
- A connection lasts at most one hour; it ends with `event: reconnect` (`{"reason":"max_lifetime"}`). Reconnect.
- The stream carries UI events too (`asset.status`, `job.status`, `job.progress`, `stats.realtime`, …) that are never
  sent as webhooks. For server-to-server integration prefer [webhooks](recipes.md#webhooks): they are retried for
  12 hours; the stream is best-effort.

## Tenants

A key belongs to one tenant and always acts in it. Studio users with several tenants switch with
`POST /v1/me/tenant` (session only). Objects of another tenant answer `404`, never `403`.

A **suspended** tenant is read-only: writes answer `403 tenant_suspended`; playback keeps working.
