Cloud API Endpoints
This page is the inventory of every remote HTTP endpoint in the Playroll ecosystem — everything a client can call over the internet, as opposed to the localhost core API documented in Core HTTP Routes.
There are three hosting surfaces. Which one an endpoint lives on tells you how it is authenticated and who is allowed to call it:
| Surface | Base URL | Typical caller |
|---|---|---|
| AWS API Gateway (Lambda) | https://ld608uple1.execute-api.us-east-1.amazonaws.com/prod | The recording core and server-side operator tools |
| Supabase Edge Functions | https://vtyolotvuvlbvqbgbzde.supabase.co/functions/v1/<name> | The launcher UI and internal automation |
| CDN / telemetry domains | https://releases.playroll.gg, https://otel.playroll.gg | The installer/updater and the telemetry pipeline |
The contributor portal has its own Supabase project and function boundary — see Contributor portal — architecture; it is not repeated here.
AWS API Gateway (Lambda-backed)
All routes share the single prod stage and speak JSON. CORS preflight
(OPTIONS) exists on every route and is not listed. Unless stated otherwise,
auth is a Bearer Supabase user JWT in the Authorization header, and
errors come back as {"error": "<message>", "code": "<STABLE_CODE>"} —
403 AUTH_FAILED for a bad token, 400 for a malformed request.
Upload routes enforce ownership structurally: every s3_key must start with
the caller's <user_id>/ prefix, so a valid token can never touch another
user's objects.
| Method + path | Auth | Caller and purpose |
|---|---|---|
POST /auth/presigned-url | user JWT | The core requests a presigned S3 URL (PUT, GET, or HEAD) for one of its own artifacts. |
POST /auth/session-started | user JWT | The core registers a recording session server-side at start and receives the uploadability gate decision. |
POST /auth/session-ended | user JWT | The core marks the session ended, reports duration/versions, and gets the final gate decision back. |
POST /auth/multipart/create | user JWT | Start a multipart upload for a large artifact. |
POST /auth/multipart/part-url | user JWT | Presigned URL for one part of a multipart upload. |
POST /auth/multipart/complete | user JWT | Finalize a multipart upload from the collected part ETags. |
POST /auth/multipart/abort | user JWT | Abandon a multipart upload and free the parts. |
GET /sessions/{session_id}/feedback | user JWT | The launcher fetches the processed session's accept/reject verdict with plain-English reasons, projected from the pipeline's eval certification. The session must belong to the calling user. |
POST /admin/presigned-download | X-Admin-Secret | Operator-only, server-to-server. The CRM backend batch-requests presigned S3 GET URLs so an operator can download any user's recording artifacts (for example a whole sync group). The secret lives only on the CRM server and the Lambda — never in a browser. |
Two more Lambdas are event-driven, not HTTP: the upload trigger (fires on S3 object creation) and the L0 pipeline trigger it invokes. They have no callable endpoint.
Contracts
POST /auth/presigned-url
Request:
{
"s3_key": "<user_id>/<game>/2026/07/02/rec-.../file.mp4",
"action": "upload",
"content_type": "video/mp4"
}
action is upload (PUT), download (GET) or head; default upload.
content_type only applies to uploads.
Response 200:
{
"presigned_url": "https://playroll-captures.s3.amazonaws.com/...",
"user_id": "<uuid>",
"expires_in": 3600,
"s3_bucket": "playroll-captures",
"s3_region": "us-east-1"
}
Errors: 400 MISSING_S3_KEY | INVALID_S3_KEY | S3_KEY_TOO_LONG | INVALID_ACTION,
403 ACCESS_DENIED (key outside your prefix), 500 URL_GENERATION_FAILED.
POST /auth/session-started
Request (session_id required, the rest optional):
{
"session_id": "rec-1781813920001",
"game_name": "Baldur's Gate 3",
"game_slug": "baldurs-gate-3",
"lobby_id": "<uuid-or-null>",
"recording_started_at": "2026-07-02T09:00:00Z",
"core_version": "5.0.1",
"installer_version": "5.0.1",
"ui_version": "5.0.1"
}
Response 200 — the uploadability gate decision:
{
"success": true,
"session_id": "rec-1781813920001",
"uploadability_status": "uploadable",
"block_reason": null,
"required_artifact_mode": "mp4_csv_json"
}
Errors: 400 MISSING_SESSION_ID, 500 DB_ERROR.
POST /auth/session-ended
Request (session_id required, the rest optional — omitted fields are left
untouched server-side):
{
"session_id": "rec-1781813920001",
"status": "ended",
"game_name": "Baldur's Gate 3",
"game_slug": "baldurs-gate-3",
"lobby_id": "<uuid-or-null>",
"recording_started_at": "2026-07-02T09:00:00Z",
"recording_ended_at": "2026-07-02T09:34:00Z",
"duration_seconds": 2040,
"uploadability_status": "uploadable",
"block_reason": null,
"transcript_expected": true,
"core_version": "5.0.1"
}
If status is omitted it is derived: failed when
uploadability_status = "blocked", else ended. duration_seconds falls back
to the recording timestamps when absent.
Response 200:
{
"success": true,
"session_id": "rec-1781813920001",
"uploadability_status": "uploadable",
"block_reason": null,
"duration_seconds": 2040,
"required_artifact_mode": "mp4_csv_json"
}
Errors: 400 MISSING_SESSION_ID, 500 DB_ERROR.
POST /auth/multipart/create · part-url · complete · abort
The standard S3 multipart dance, proxied so credentials stay server-side.
All four take the same ownership-checked s3_key.
create — request {"s3_key": "...", "content_type": "video/mp4"} →
{ "upload_id": "...", "s3_key": "...", "s3_bucket": "playroll-captures", "s3_region": "us-east-1" }
part-url — request {"s3_key": "...", "upload_id": "...", "part_number": 1}
(part_number 1–10000) →
{ "presigned_url": "https://...", "part_number": 1, "expires_in": 3600 }
complete — request:
{
"s3_key": "...",
"upload_id": "...",
"parts": [ { "PartNumber": 1, "ETag": "\"abc...\"" } ]
}
→ { "success": true, "s3_key": "...", "etag": "\"...\"" }
abort — request {"s3_key": "...", "upload_id": "..."} → { "success": true }
Errors: 400 MISSING_S3_KEY | MISSING_UPLOAD_ID | INVALID_PART_NUMBER | MISSING_PARTS,
403 ACCESS_DENIED, 500 MULTIPART_*_FAILED | PART_URL_FAILED.
GET /sessions/{session_id}/feedback
Request — no body:
curl "https://ld608uple1.execute-api.us-east-1.amazonaws.com/prod/sessions/<session_id>/feedback" \
-H "Authorization: Bearer <supabase-user-jwt>"
Response 200:
{
"verdict": "pass",
"headline": "...player-facing one-liner...",
"dataset_id": "rec-...::full-vlm::0746105a5fff",
"checks": [ { "criterion": "...", "status": "ok", "message": "..." } ],
"failed_reasons": [ "...plain-English reason per failed criterion..." ],
"summary": { "passed": 4, "failed": 0, "pending": 1 }
}
verdict is pass, fail, or pending (session exists but the pipeline has
not produced its eval yet — pending is not a failure). Free-play sessions
simply carry no quest criteria in checks.
Errors: 401 missing/invalid token, 404 session unknown or not owned by
the caller — deliberately indistinguishable.
POST /admin/presigned-download
Request (server-to-server only; batch — a sync group has many files):
curl -X POST "https://ld608uple1.execute-api.us-east-1.amazonaws.com/prod/admin/presigned-download" \
-H "Content-Type: application/json" \
-H "X-Admin-Secret: <shared secret>" \
-d '{"s3_keys": ["<user>/<game>/.../file.mp4", "<user2>/<game>/.../file.mp4"]}'
Response 200 — per-key results, so one bad key does not fail the batch:
{
"urls": [
{ "s3_key": "...", "presigned_url": "https://...", "error": null },
{ "s3_key": "...", "presigned_url": null, "error": "not found" }
]
}
Errors: 403 wrong or missing secret, 400 missing/empty s3_keys.
Supabase Edge Functions
Base: https://vtyolotvuvlbvqbgbzde.supabase.co/functions/v1/<name>. All take
POST with a JSON body unless noted. "User JWT" means a Supabase user token in
the Authorization: Bearer header; "open" functions do their own auth
internally (shared secret or provider callback) — open never means
unauthenticated in practice.
| Function | Auth | Purpose |
|---|---|---|
steam-login | open (OpenID flow) | Steam sign-in for the launcher. |
resolve-update | open | The launcher asks which version it should be on — the read side of staged rollouts. |
redeem-activation-code | user JWT | Activation-code redemption during onboarding (see Onboarding gate flow). |
consents-status / consents-accept / consents-revoke | user JWT | Read and update the user's consent state (see Consent versioning). |
register-device-hw-snapshot | user JWT | The launcher registers the device hardware inventory (see Device HW inventory). |
nvenc-contention-report | user JWT | The core reports encoder-contention diagnostics. |
report-bug | user JWT | In-app bug reporting entry point (see Bug reporting). |
submit-feedback | user JWT | Post-upload feedback popup: 1–5 stars on the active feedback dimensions plus an optional comment. |
bug-report-to-linear | open (internal secret) | Internal hop that files the bug in the tracker — not called by clients. |
emit_notification | open (admin secret) | Operator tooling emits an in-app notification (see Notification delivery flow). |
create-contributor-link-intent | user JWT | Creates the link intent the contributor portal consumes (see Identity link). |
Contracts (client-called functions)
resolve-update
Request — all fields optional; rollout selection stays server-side:
{
"installId": "<stable install identifier>",
"track": "stable",
"os": "windows",
"installed": { "...": "installed component versions" }
}
Absent/unknown os resolves as windows (fail-closed for legacy clients).
Response tells the client which release (if any) to move to for its track and
platform.
redeem-activation-code
Request:
{ "code": "<activation code>" }
Rate-limited per user, per IP, and per failed-attempt history. Success binds the code to the calling account; errors are deliberately generic.
consents-status · consents-accept · consents-revoke
Consent types: age_confirmation, tos, privacy, eula, edc_gameplay.
consents-status — no body; returns the caller's current consent state per
type.
consents-accept — request (1–16 entries):
{
"acceptances": [
{ "consent_type": "tos" },
{ "consent_type": "age_confirmation", "country": "IT" }
]
}
country is ISO 3166-1 alpha-2 and matters for age_confirmation; when
omitted it falls back to the user's stored country, and "no country anywhere"
is rejected.
consents-revoke — request:
{ "consent_type": "edc_gameplay" }
Only withdrawable types are accepted. Repeat withdrawal returns
{ "withdrawn": false, "alreadyWithdrawn": true }. All three are rate-limited.
register-device-hw-snapshot
Request — the hardware inventory object as collected by the launcher (size-capped JSON object):
{ "hardware": { "cpu": "...", "gpu": "...", "ram_gb": 32, "...": "..." } }
Upserts the snapshot for the calling user's device; see Device HW inventory for the fields the inventory carries.
nvenc-contention-report
Request — up to 16 encoder sessions observed on the device:
{ "sessions": [ { "...": "per-process encoder session fields" } ] }
Each session is recorded through an advisory-locked RPC so concurrent reports of the same process stay consistent.
report-bug
Request:
{
"title": "Recording didn't start on first launch",
"description": "What went wrong, from the user",
"category": "recording",
"images": ["https://...storage-url..."]
}
title and description are both required. category is one of ui,
recording, performance, onboarding, other — plus feedback once the
EC-509 problem-vs-feedback split is deployed (general feedback rides the same
pipeline and is labeled Improvement instead of Bug in the tracker). images
is optional (max 3), holding URLs produced by the separate screenshot-upload
path. The launcher's main process also attaches hw_config,
electron_runtime, connection, and component versions to the body so the
renderer cannot spoof them. Rate-limited per user per minute. The function
resolves the caller's Steam identity server-side and files the report; the
bug-report-to-linear hop then creates the tracker issue.
submit-feedback
The post-upload popup's write path. The dimension set is data-driven: the
launcher reads the active dimensions (with labels and player-friendly
descriptions) from the feedback_dimensions table (RLS allows authenticated
reads of active rows) and renders one skippable 1–5 star row per dimension
plus an optional comment.
Request — at least one rating or a comment required; ratings keys must be
active dimension keys:
{
"session_id": "rec-1781813920001",
"ratings": { "usability": 4, "fun": 3 },
"comment": "Upload went smooth this time.",
"ui_version": "5.0.1",
"core_version": "5.0.1",
"event_id": null
}
Responses: 201 {ok, id}; 400 invalid payload or unknown/inactive
dimension; 401 bad token; 409 feedback already submitted for this session
(one per user per session — the launcher should not re-show the popup);
429 rate limited.
steam-login
Not a JSON API — a browser OpenID redirect flow. The launcher opens the
function URL, Steam authenticates the user and redirects back with
openid.* query parameters, and the function verifies the assertion
(single-use nonce), provisions the Supabase user on first login, and returns
the session to the launcher.
CDN and telemetry domains
| Domain | Purpose |
|---|---|
releases.playroll.gg | Installer and update artifacts (/playroll/vX.Y.Z/*, with a latest rewrite). Consumed by the installer and the updater, not by application code. |
otel.playroll.gg | Telemetry ingest for the observability pipeline (see OTel pipeline). Writes are gated by an edge secret; not a client-facing API. |
The operator CRM also exposes its own private REST API, but it is reachable only through the operators' access gateway and is an implementation detail of that tool, not a platform surface.