What this API is for
The recorder captures your server continuously. This API lets your own plugin draw logical match boundaries over that continuous recording, cut clips out of them, hold them under embargo, and hand your website a catalogue to read. Use it when the replay a player should see does not line up with a single recorder archive: a BedWars game that ran across an archive rotation, four arenas recording at once on one backend, or a tournament final you must not publish until the bracket is announced.
If you only need to list replays your recorder already produced, use the Replays API instead. This is the larger surface, and it has a model you need to understand before you call it.
The model
A collection is a logical match. It is independent of the physical recording, several collections may cover portions of one recording including overlapping portions, and one collection may span several recordings. Opening a collection never starts, stops, rotates or cuts the recording, which is what allows concurrent scopes on one backend.
A collection maps to segments, not to one archive. The recorder rotates archives on duration and size, so a long match legitimately spans several archives and each archive carries its own segment-local tick origin. The collection holds one segment row per contributing archive. Anything that resolves a collection to playable bytes iterates those segments. Treating a collection as a single replay id with one start and end tick compiles, returns 200, and serves the wrong footage the first time a rotation happens.
Tick windows are minted in ticks, by you, at the moment the event happens. Never convert wall-clock time into a tick window. Ticks stop advancing when a server is empty, so no wall-clock origin survives an idle period.
An asset is a playable child of a collection: FULL_MATCH, EVENT_CLIP, CUSTOM_CLIP or HIGHLIGHT. One real event produces exactly one asset, and per-player meaning lives in relationship rows, so the killer relates to a kill clip as KILL and the victim as DEATH. The same asset then appears in both players' feeds with no duplicate stored. Creating one asset per viewer doubles storage and processing and makes retention and revocation inconsistent between rows describing the same event.
Every collection and asset carries three independent states, and collapsing them into one status loses information you need. processing_state runs recording, finalizing, queued, uploading, processing, ready, failed, expired, deleted. release_state is held, released or revoked. visibility is public, unlisted, participants, staff_only or private. A collection that is ready, held and public is fully prepared and completely unavailable until someone releases it, which is a normal and important combination. Render ready and released as different things.
Authentication and scopes
Every endpoint takes an rc_live_ bearer key, the same credential the API keys article covers. Each endpoint requires one named scope, by exact match. collections:write creates, updates and finalises a collection, and replays a webhook delivery. clips:write creates and finalises assets. release:write holds, releases and revokes. catalog:read opens every read endpoint including bulk lookup. playback:issue mints a watch ticket.
release:write is deliberately separate from collections:write. A key that records and finalises matches cannot publish embargoed footage with that authority alone, which is the point of running an embargo. Mint the recording key and the publishing key separately and give the publishing key to whatever actually makes the release decision.
The admin scope acts as a superset inside the authorisation helpers, but the routing layer checks the named scope first and by exact match, so an admin-only key opens nothing here. Grant a key the scopes its endpoints actually name.
The endpoints
Writes: POST /v1/replay-collections creates a collection, POST /v1/replay-collections/{id} updates an open one, POST /v1/replay-collections/{id}/finalize closes it, POST /v1/replay-collections/{id}/assets creates an asset, and POST /v1/replay-assets/{id}/finalize finalises an asset. Note that the update is POST and not PATCH, because the shipped Java recorder client runs on HttpURLConnection, which refuses to send the literal method PATCH.
Embargo: POST /v1/replay-collections/{id}/hold, /release and /revoke. None takes a request body, each returns the updated collection, and each is audited. Revoke is terminal, so hold or release on a revoked collection returns 409 INVALID_RELEASE_TRANSITION.
Reads: GET /v1/replay-collections lists and filters, GET /v1/replay-collections/{id} fetches one, GET /v1/replay-collections/{id}/assets lists a collection's assets, GET /v1/replay-assets/{id} fetches one asset with its relationships, and GET /v1/players/{uuid}/replays returns one player's assets across every collection. POST /v1/replay-collections/bulk-lookup is a read despite the verb: it carries a batch of up to 100 identifiers in its body and is gated by catalog:read.
Playback and delivery: POST /v1/replay-assets/{id}/watch-ticket mints a ticket, and POST /v1/webhooks/deliveries/{id}/replay retries a webhook delivery your endpoint dropped.
One gap to plan around: creating an asset requires the segment_id of the collection segment the clip window falls inside, and no read endpoint on this API returns segment ids today. The recorder holds them as it opens each scope and rotates each archive. If you drive scopes from a plugin through ReplayCoreMatchApi you already have what you need. If you are integrating from a website with nothing running on the game server, create assets from the plugin side and use this REST surface for the catalogue, embargo and playback endpoints, none of which need a segment id.
curl -X POST https://api.replaycore.com/v1/replay-collections \
-H "Authorization: Bearer $RC_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: bedwars-4471-begin' \
-d '{
"external_match_id": "bedwars-4471",
"category": "bedwars",
"mode": "solo-4x1",
"policy_id": "post-match",
"policy_snapshot": {
"full-replay": {"release-trigger": "collection_end"},
"event-clips": {"release-trigger": "asset_ready"},
"visibility": "public"
},
"server_id": "1f8a5e70-0b2a-4c6d-9a11-3d5e7c9b2401",
"start_tick": 1842300,
"world_ids": ["bw_arena_07"],
"segments": [
{"archive_id": "b17c4d8e-5f30-42a9-8e61-0c7d3a9f1b26",
"sequence_index": 0, "start_tick": 1842300}
]
}'Idempotency
Every mutating endpoint except the watch ticket and the webhook replay requires an Idempotency-Key header of 1 to 200 characters, with no carriage return, line feed or NUL byte. A missing or malformed header is 400 MISSING_IDEMPOTENCY_KEY.
Create, update, finalise and asset creation are recorded in a durable operation ledger keyed on that value, so resending the same key returns the original outcome rather than doing the work twice, and the result survives a plugin restart. That is what you rely on when a server crashes halfway through finalising a match: replay the same calls with the same keys and you converge on the same collection. A second call while the first is still running returns 409 OPERATION_IN_PROGRESS. A pending operation older than 10 minutes is failed automatically and becomes retryable.
Hold, release and revoke require the header but do not deduplicate on it, because the underlying state machine is already idempotent. The watch ticket takes no key at all, since every call mints a fresh single-use ticket by design.
Reading the catalogue
List endpoints follow the platform convention: page_size and page_token in, results and next_page_token out. page_size is 1 to 100 and defaults to 20. The page token is opaque, so pass it back unmodified rather than parsing it. A token that does not decode is 400 INVALID_PAGE_TOKEN. The page_size in the response echoes what you asked for, not how many rows came back.
Collections filter on category, mode, competition_id, region, external_match_id, server_id, player_uuid, from, to, processing_state, release_state, visibility, asset_kind, event_kind, viewer_relation, team_size, ranked and tournament. Your own namespaced metadata is queryable as facet.<key>=<value>, up to 32 facets per request.
Every read applies the availability gate inside its own query, so a held or revoked row is never returned, never counted towards a page and never assigned a cursor position. You do not filter for that yourself. A row you may not see and a row that does not exist both answer 404 NOT_FOUND, byte for byte, so a caller cannot learn that a match exists by probing ids. Do not write UI copy that distinguishes the two, because the API does not give you the information to do it.
Bulk lookup exists because of the rate limit. A leaderboard page that resolves 100 matches one at a time spends 100 requests of your per-minute budget. The same page through bulk lookup spends one. Send external_match_ids, collection_ids or both, up to 100 combined, and match the results back up yourself. Ids that resolve to nothing, or to something you may not see, are simply absent from the results.
Watch tickets
A watch ticket is how a browser plays an asset. It names the segments to play and carries no storage URL of any kind, which is what makes a revoke effective against a ticket already in a caller's hands: the ticket keeps parsing and the byte fetch it references stops being authorised. Tickets are single use and short lived, so mint one per playback attempt.
A 200 response carries the ticket, the playback segments, and its expiry. If the asset is authorised but still processing you get 202 Accepted instead, carrying the pipeline stage and a retry_after_seconds value. Render that as a determinate preparing state and poll on the interval given. Do not show an indefinite spinner or invent your own delay.
The optional viewer_player_uuid in the request body can only narrow the participants visibility check. It cannot grant staff bypass and it cannot widen release state or visibility. Staff bypass derives solely from a staff:bypass scope on the presented key, resolved server-side, and never from anything in a request body. Every denial reason collapses into the same 404 as an unknown id: held, revoked, staff-only, private, not a participant, expired, deleted and failed all answer identically.
Errors and limits
Every error is an RFC 9457 problem document with Content-Type application/problem+json, carrying type, title, status, code and detail. Match on code, which is stable. The detail field is written for a human and may change.
The two worth retrying automatically with backoff are 409 ARCHIVE_NOT_YET_KNOWN, meaning a closed segment names an archive the cloud has not seen yet, and 409 OPERATION_IN_PROGRESS. The rest indicate your bug or your authorisation, and retrying will not help. 403 INSUFFICIENT_SCOPE means the key does not carry the exact scope the endpoint names. 401 INVALID_API_KEY covers malformed, unknown, revoked and expired keys, which are indistinguishable on purpose.
Two rate limits apply. Before authentication, 120 requests per minute per client IP front the key lookup, and exceeding it returns 429 with a Retry-After header. After authentication, 600 requests per minute per tenant by default cover writes, catalogue reads, watch tickets and webhook replay together, and exceeding that returns 429 with no Retry-After, so back off on your own schedule.
Bodies are bounded: 512 KiB for a lifecycle request, 64 KiB for bulk lookup. Arrays are bounded per request at 256 participants, 64 teams, 512 spectators and 256 relationships. A tenant may hold 100,000 live collections.
What this API does not do
It runs no tournament logic. The competition hierarchy fields are stored and filtered, and no standings, seeding or bracket progression is derived from them. It does not own your presentation either: the catalogue returns structured categories, states and relationships, and labels, icons and ordering are yours.
It does not let you set processing_state directly, since only the finalize endpoints move it. It does not accept wall-clock times for tick windows, and there is no wall-clock equivalent field. It does not let a clip span an archive rotation, because an asset's window is segment-local. It does not deduplicate assets for you. It does not expose storage URLs anywhere, under any scope.
The full endpoint reference, with every request field, response shape and error code, is maintained alongside the source in the repository wiki under api/network-integration.md.