filmroom

Docs/API tokens

API tokens

An API token lets a script read your workspace's sessions without borrowing anyone's browser session. Tokens are read-only unless you grant the erasure scope when you create one.

What a token is

A token belongs to one workspace. It carries no user, so nothing it reads is attributed to a person: a script pulling a replay does not mark that recording as watched by one of your teammates, and it cannot see your member roster.

The secret looks like frk_.... Only a hash of it is stored, so it cannot be recovered later, and the first eight characters are kept in the clear purely so you can tell two tokens apart in the list.

Create a token

Open your workspace dashboard at app.filmroom.dev, go to Settings and find the API tokens card. Only a workspace admin sees it.

  1. Give the token a name (1 to 80 characters). The name is how you will recognize it later, so name it after the thing that will use it, not after yourself.
  2. Choose a scope. Read only is the default.
  3. Copy the secret. It is shown once, at creation, and never again. If you lose it, revoke the token and create another.

The list then shows each token's name, its prefix, its scope, when it was created, and when it was last used. Last-used is refreshed at most once a minute, so a script polling in a tight loop does not turn every read into a write.

Revoke a token

Press Revoke on the row. It takes effect immediately: the next request made with that token gets a 401. Revoking is not reversible and a revoked token cannot be restored, so create a replacement rather than expecting one back.

Scopes

  • Read only (default): the four read endpoints below, and nothing else.
  • Allow erasure: the same reads, plus the single erasure endpoint. Every token can read; erasure is an addition, never a replacement.

Scopes are fixed at creation. To change what a token may do, revoke it and create a new one.

Authentication

Send the secret as a bearer token against https://api.filmroom.dev:

Authorization: Bearer frk_...

The token resolves to its own workspace, so no request names a workspace. Every route that is not listed on this page answers 403 even when the token is valid, including every other write. That is the contract, not a temporary limitation: a read credential that could be pointed at a mutation would be an escalation from watching replays to deleting them.

Read endpoints

Four routes, all GET, all returning JSON. Timestamps are milliseconds since the epoch.

GET /api/sessions

Session summaries, newest last-event first. Returns the live sessions; pass archived=1 for the archived set instead.

With no query parameters it returns every session in the workspace, as a plain JSON array. That is worth knowing before you call it against a busy workspace: use the filters and limit below instead of pulling everything and discarding most of it. Filtering happens in the database, inside your workspace, so a filtered answer describes the whole workspace rather than whichever rows you had already downloaded.

List sessions
curl -s https://api.filmroom.dev/api/sessions \
  -H "Authorization: Bearer $FILMROOM_TOKEN"
Response (one row)
[
  {
    "tenantId": "wk_3f9c1a",
    "id": "s_01J8Z4Q2",
    "siteId": "site_7d21",
    "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
    "ip": "203.0.113.44",
    "entryUrl": "https://example.com/pricing",
    "label": null,
    "archived": false,
    "kept": false,
    "startedAt": 1757712004123,
    "lastEventAt": 1757712361901,
    "eventCount": 812,
    "errorCount": 1,
    "identityId": "u_5512",
    "identityName": "Dana Reyes",
    "identityEmail": "dana@example.com",
    "viewportWidth": 1512,
    "viewportHeight": 857,
    "activeMs": 214300,
    "referrer": "https://news.ycombinator.com/",
    "utmSource": null,
    "utmMedium": null,
    "utmCampaign": null,
    "firstSource": "news.ycombinator.com",
    "timeZone": "America/Chicago",
    "locale": "en-US"
  }
]

GET /api/sessions/:id

One session summary, the same shape as a row in the list. An id that does not exist in this workspace answers 404 with a plain text body.

One session
curl -s https://api.filmroom.dev/api/sessions/s_01J8Z4Q2 \
  -H "Authorization: Bearer $FILMROOM_TOKEN"
Response (trimmed)
{
  "tenantId": "wk_3f9c1a",
  "id": "s_01J8Z4Q2",
  "siteId": "site_7d21",
  "entryUrl": "https://example.com/pricing",
  "archived": false,
  "kept": false,
  "startedAt": 1757712004123,
  "lastEventAt": 1757712361901,
  "eventCount": 812,
  "errorCount": 1,
  "activeMs": 214300
}

GET /api/sessions/:id/events

The rrweb event stream for one session, in order. Captured assets are inlined as data URIs, so the array plays back on its own without fetching anything else. This is the large response: a busy session is thousands of events and can run to megabytes.

Session events
curl -s https://api.filmroom.dev/api/sessions/s_01J8Z4Q2/events \
  -H "Authorization: Bearer $FILMROOM_TOKEN"
Response (three events)
[
  { "type": 4, "timestamp": 1757712004123, "data": { "href": "https://example.com/pricing", "width": 1512, "height": 857 } },
  { "type": 2, "timestamp": 1757712004180, "data": { "node": { "type": 0, "childNodes": [] } } },
  { "type": 3, "timestamp": 1757712005044, "data": { "source": 2, "type": 1, "id": 148 } }
]

GET /api/dashboard

Everything the dashboard screen loads in one request: the session list plus its counts, storage total, IP aliases and flagged ids. It returns the 500 most recent sessions by default; limit=all returns every one. Pass view=archived for the archived set.

views and viewers come back empty on a token, because a token has no person behind it and there is no roster to resolve.

Dashboard
curl -s "https://api.filmroom.dev/api/dashboard?limit=all" \
  -H "Authorization: Bearer $FILMROOM_TOKEN"
Response (trimmed)
{
  "sessions": [ /* session summaries, with an "interest" score where one exists */ ],
  "total": 1284,
  "grandTotal": 1731,
  "storageBytes": 4913772544,
  "ipAliases": [{ "ip": "203.0.113.44", "label": "Office" }],
  "flaggedIds": ["s_01J8Z4Q2"],
  "views": [],
  "viewers": [],
  "videoAvailable": true,
  "archived": false
}

Filtering the session list

/api/sessions takes the same filters the dashboard offers, as query parameters. Combining several narrows the set: every one must match.

  • archived=1: the archived sessions instead of the live ones.
  • site=<id>: one site.
  • since= and until=: when the session started, as an ISO 8601 date (2026-09-01 or a full timestamp) or unix milliseconds. Both bounds are inclusive.
  • visitor=<text>: a case-insensitive substring of the visitor's identity id, name or email.
  • identified=1 or identified=0: sessions that have a name or email attached, or the anonymous ones.
  • errors=1: sessions that captured at least one error. errors=0 for the clean ones.
  • mobile=1: sessions recorded on a narrow portrait viewport.
  • pinned=1: pinned (kept) sessions, the ones protected from pruning.
  • route=/pricing: the landing page's path, as a prefix. The query string is not part of the match, and the value is matched literally, so /a_b means an underscore.
  • minActive= and maxActive=: engaged (idle-skipped) duration in seconds. Sessions whose active duration has not been computed yet are kept either way, so a filter never hides a recording that is still catching up.
  • source=<name>: the traffic source shown in the dashboard's Source column, matched case-insensitively. source=Direct for sessions with no source at all.

A parameter this API does not know is ignored, so you can append your own. A parameter it does know with a value it cannot read is a 400 that names the field, rather than a filter silently dropped:

{"error":"invalid_query","field":"limit","message":"must be between 1 and 500, got 900"}

Sorting

sort= takes recent (last activity, the default), started, active (engaged duration) or errors. order= takes asc or desc, defaulting to desc. Ties are broken by session id, so the order is total and a page boundary cannot land in the middle of an ambiguous run.

Pagination

limit= takes 1 to 500. Leave it off and you get every matching session in one response, which is what this endpoint has always done and why adding a default page size would have broken existing scripts.

When there is another page, the response carries the token for it in two headers: X-Next-Cursor, and a standard Link header with rel="next" holding the whole next URL. The body stays a plain array, so nothing you already parse changes shape. No header means no more pages: that is how a walk terminates.

Identified sessions that hit errors on /pricing
curl -s -D - \
  "https://api.filmroom.dev/api/sessions?identified=1&errors=1&since=2026-09-01&route=/pricing&sort=started&order=desc&limit=2" \
  -H "Authorization: Bearer $FILMROOM_TOKEN"
Response (headers and a trimmed body)
HTTP/2 200
content-type: application/json; charset=utf-8
x-next-cursor: eyJrIjoic3RhcnRlZCIsInYiOjE3NTc2MjU2MDQxMjMsImkiOiJzXzAxSjhZUTExIn0
link: </api/sessions?identified=1&errors=1&since=2026-09-01&route=/pricing&sort=started&order=desc&limit=2&cursor=eyJrIjoic3RhcnRlZCIsInYiOjE3NTc2MjU2MDQxMjMsImkiOiJzXzAxSjhZUTExIn0>; rel="next"

[
  {
    "id": "s_01J8Z4Q2",
    "siteId": "site_7d21",
    "entryUrl": "https://example.com/pricing",
    "startedAt": 1757712004123,
    "lastEventAt": 1757712361901,
    "errorCount": 1,
    "identityName": "Dana Reyes",
    "identityEmail": "dana@example.com",
    "activeMs": 214300
  },
  {
    "id": "s_01J8YQ11",
    "siteId": "site_7d21",
    "entryUrl": "https://example.com/pricing?plan=team",
    "startedAt": 1757625604123,
    "lastEventAt": 1757625999012,
    "errorCount": 3,
    "identityName": "Sam Okafor",
    "identityEmail": "sam@example.com",
    "activeMs": 91200
  }
]

Pass that cursor back, with the same filters and the same sort, to get the next page. Changing sort mid-walk is refused with a 400 rather than served as a scrambled page, because the cursor is a position in one ordering and means nothing in another.

The next page
curl -s \
  "https://api.filmroom.dev/api/sessions?identified=1&errors=1&since=2026-09-01&route=/pricing&sort=started&order=desc&limit=2&cursor=$CURSOR" \
  -H "Authorization: Bearer $FILMROOM_TOKEN"

Treat the cursor as opaque and never build one. It encodes a position, not a permission: it carries no workspace, so a cursor from one workspace used with another workspace's token simply positions inside that second workspace and can never reach across.

/api/dashboard has its own two parameters and no cursor: view=archived for the archived set, and limit=all for every session rather than the most recent 500.

Erasure

A token created with Allow erasuremay call one write, for the case a control plane actually has: a right-to-erasure request that arrives knowing a person's email address and nothing else.

This deletes data permanently

Erasure removes that person's recordings in this workspace, along with the page views, captured errors, key event hits, AI annotations and visitor profile rows that reference them. None of it can be restored, there is no confirmation step, and the call is idempotent, so a repeat simply deletes nothing more. Test with an address you are willing to lose.

Erase one person
curl -s -X POST https://api.filmroom.dev/api/internal/erasure \
  -H "Authorization: Bearer $FILMROOM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"person@example.com"}'
Response
{
  "ok": true,
  "deleted": {
    "page_view": 37,
    "error_event": 2,
    "key_event_hit": 4,
    "session_annotation": 6,
    "visitor_story": 1,
    "visitor_profile": 1,
    "recording": 6,
    "ip_alias": 1
  },
  "total": 58,
  "errors": [],
  "scope": { "tenantId": "wk_3f9c1a" }
}

deleted is a count per table rather than a single sum, so a partial result is visible instead of hidden. When a table cannot be cleared it is named in errors and ok is false; the other tables are still cleared. scope echoes the workspace that was actually swept.

A token erases in its own workspace and nowhere else. You may name that workspace in the body with tenantId or tenantSlug and it is worth doing, because it turns a misconfigured caller (a staging token in production) into a 400 instead of a successful erasure in the wrong place. Naming any other workspace is refused.

Errors

  • 401 {"error":"invalid_token"}: the token is unknown, revoked or malformed. The same answer for all three, so the API is no oracle for which tokens exist.
  • 403 {"error":"token_read_only"}: a valid token pointed at a route no token may serve.
  • 403 {"error":"token_missing_scope"}: the route exists for tokens, but this one was not granted that scope. Erasure with a read-only token lands here.
  • 400 {"error":"erasure_malformed"}: the erasure body had no usable email address, or named both tenantId and tenantSlug.
  • 400 {"error":"erasure_tenant_mismatch"}: the erasure body named a workspace that is not the token's.
  • 404: an unknown session id, as plain text.
  • 429 {"error":"rate_limited"}: see below. The response carries a Retry-After header in seconds.

Rate limits

An authenticated token gets a burst of 60 requests refilling at 5 per second, counted per token rather than per IP, so one token behind a NAT is still one caller. That is far above a triage script and far below anything that would strain the database.

Requests that present a token which does not resolve are limited separately and much more tightly, by client IP: a burst of 10 refilling at 1 per second. Nothing legitimate presents a wrong token twice, so this only bites someone guessing. Successful reads never consume that budget.

Security

  • Treat the secret like a password. Put it in an environment variable or a secret store, never in a repository, a shell history or a log line.
  • One token per integration. Separate tokens are what make last-used meaningful and what let you revoke one consumer without breaking the rest.
  • Revoke immediately on a leak, then create a replacement. The frk_ prefix is deliberate: it makes a leaked token greppable in a log or a repository scan.
  • Grant erasure only to the one system that fulfills erasure requests. Everything else gets a read-only token.

Using a token from a script

Anything that can set a header works. The session explorer script that ships with the Filmroom engine reads SR_BASE for the API origin and SR_TOKEN for the secret, which is the shape to copy: the base URL and the token both come from the environment, so neither is baked into the code.

Something here not behaving as documented? Email support@filmroom.dev. A person reads it.