Skip to content

Integration: Catena tickets

This page is for the developer of a storefront or billing system — the place where you sell access. Instead of answering a callback on every viewer, you create the session ahead of time and hand the viewer a ticket. The streamer admits the ticket on its own: it checks the signature, expiry, and scope locally, with no call to your systems on the playback path.

The payoff: playback no longer depends on your storefront's speed or availability, streams start and switch instantly, and there is no per-viewer callback to keep scaled. Your systems are needed at the moment you sell access — not while the viewer watches.

Create a grant

When you sell access, call the management API to create a grant. Catena signs a ticket (a JWT) and returns it — the token is handed back only at creation; the registry keeps the grant, not the ticket.

Authenticate the call the way you authenticate any management API request — with the admin login and password. So your storefront never has to hold full admin credentials, you can instead configure a dedicated issuer key in central — a bearer token whose only power is to issue and manage grants — and send it as Authorization: Bearer <issuer-key>.

POST /play-sessions/grants
{
  "streams": ["news", "arena"],
  "user_id": "subscriber-4821",
  "ttl_secs": 14400,
  "max_concurrent": 2,
  "bind_ip": "203.0.113.0/24",
  "note": "family plan"
}
Field Meaning
streams Which streams the ticket may play — its scope
user_id The account the ticket belongs to; groups the viewer's screens
ttl_secs Ticket lifetime; default 4 hours. exp is a hard end — a session cannot outlive it (players do not refresh the token)
max_concurrent Simultaneous screens; default 1, null for unlimited
bind_ip Optional IP or CIDR the ticket is useless outside of
note Optional free-text label

The reply carries the grant and the token. Hand that token to the player, which presents it on the play URL as ?token=<jwt> (or in an Authorization: Bearer header). Nothing else is required of the viewer — the streamer takes it from there.

Access modes

The access field in the POST /play-sessions/grants body limits what the ticket plays: live, archive, or part of the archive. Without the field the ticket opens the streams in full.

Allowed access
Live only {"live": true, "archive": false}
Archive only, all recordings {"live": false, "archive": true}
Live and the last 24 hours of archive {"live": true, "archive": {"depth_secs": 86400}}
One archive range only {"live": false, "archive": {"from": 1793000000, "to": 1793003600}}

live opens or closes live playback. archive takes true (all recordings), false (archive closed), a depth depth_secs — how many seconds of archive back from the current moment — or a range from–to in unix seconds. The end of the range may lie in the future: recordings that fall into the range become available as they are written.

The streamer enforces the mode from the ticket itself, without calling central or your systems, on every request:

  • a request outside the mode gets 403; the rest of the session keeps working;
  • archive playlists, recording windows, export and RTSP archive reads are clipped to the allowed part; RTSP archive playback ends at the end of the range;
  • with live closed, live playlists and live protocols are refused;
  • analytics episodes are visible only inside the allowed archive;
  • VOD and the info request are not limited by the mode.

An invalid mode is rejected with 422 and the path to the field: an empty mode ("live": false and "archive": false) — path access; depth together with a range, from not less than to, zero depth or an incomplete range — path access.archive.

The mode is set at issue and never changes: PATCH with an access field is rejected with 422. For a different mode, issue a new grant.

Streamers of the current version enforce the mode. A streamer of the previous version does not know the access field and admits the ticket to the whole stream, so upgrade the streamers first, then start issuing grants with a mode. Mode fields a streamer does not know are skipped; the known ones apply.

A grant with a mode is issued by your storefront or middleware through the API, or by an operator by hand: on the stream page of the console, the "Issue a play ticket" form has an "Access" field with "Everything", "Live only", "Archive only", "Live and archive", and for the archive "All recordings", "Last N hours" or "Range"; a server refusal is shown on the field. The Catena storefront does not yet issue grants with a mode to subscribers.

Screens

max_concurrent limits how many screens actively consume the ticket at once — a household with two TVs, say. Go over the limit and the oldest screen is dropped; the newest wins. You can change the limit later without reissuing the ticket, because it lives in the registry, not in the signed token:

PATCH /play-sessions/grants/{jti}
{ "max_concurrent": 3 }

Revoke

Cancel a grant and every streamer drops its viewers within seconds and refuses the ticket from then on — the cancellation travels to the streamers over a revocation feed, not by waiting for the ticket to expire. There is no reauthorization for ticket sessions; revocation is the single stream that ends them early.

Roaming

A viewer whose address changes — a phone leaving Wi-Fi for mobile — is admitted afresh on the new address, offline, while the old session dies by idle timeout. It counts as one journey, not a new charge, grouped by the grant. If you set bind_ip, roaming stays within that range.

List and status

GET /play-sessions/grants lists grants and their status — issued (handed out, not yet played), played (used at least once), revoked, expired — filterable by user_id and status. This is the record of what you sold and whether it was used.

Migrate at your own pace

Tickets and an existing backend run side by side — tickets for some viewers, the callback for others — so you can move audience by audience. The end state is the switch opaque_tokens: false on the auth policy: once set, any non-ticket token is refused outright, without consulting lists or a backend, and the callback path is closed for good.

Next steps