REST API
Read your recordings, their digests and their JavaScript errors, and record a review verdict on each one — from a script, an n8n flow or your own backend. The same key connects an AI agent over Replayfy's MCP server.
Available on every plan, Free included. It is rate-limited; the limits are below.
There is no video
Replayfy records the page's DOM with rrweb, not a video. So the API does not hand out footage: it hands out a digest — what the visitor did, with JavaScript errors, rage clicks, dead clicks, page changes and idle gaps on one timeline, plus an explicit account of any stretch that was never recorded. When a summary is not enough, mint a replay link and let a person watch.
API keys
An owner or admin creates a key at Settings → API keys. It looks like rfk_… and is shown once: only a hash of it is stored.
- A key belongs to the workspace, not to the person who created it.
- It expires after 90 days unless you pick another term when you create it.
- It can be limited to specific sites. That limit is checked on every call.
- Revoking it takes effect on the next request, and revokes every replay link it created.
| Scope | Grants | Ticked by default |
|---|---|---|
recordings:read | List recordings and read their metadata, timeline and review status. | Yes |
reviews:write | Set the review status, labels and note on a recording. | Yes |
recordings:content | Download the raw recorded DOM data, and mint shareable replay links. This lets the holder see the screen your visitor saw — only grant it to a tool that must replay recordings. | No |
There is no delete scope. Deleting a recording stays something only a signed-in person can do.
First request
curl -H "Authorization: Bearer rfk_YOUR_KEY" https://replayfy.com/api/v1/me- Send the key as
Authorization: Bearer rfk_…on every request. Cookies are never read. - Call
https://replayfy.comdirectly. The API is not redirected between hosts — another host answers400 wrong_host— because most HTTP clients drop the Authorization header when they follow a redirect. - Every response is JSON with
Cache-Control: private, no-store, and every timestamp is ISO 8601 in UTC.
Endpoints
Paths take a site id (prj_…, listed by GET /api/v1/me) and a recording id.
GET /api/v1/me
Who the key is, the sites it can read, the review statuses and labels, and the limits. There is no separate sites endpoint: this is it.
Scope recordings:read
GET /api/v1/projects/{projectId}/recordings
The review queue: filtered, paginated, most recent activity first.
Scope recordings:read
| Name | Values | Meaning |
|---|---|---|
review | any · unreviewed · decided · reviewed · needs_attention · ignored | Review state. decided means any verdict at all. An unrecognised value is a 400, never silently ignored. |
label | one label from GET /api/v1/me | Only recordings carrying it. |
errors | any · with · without | At least one JavaScript error, or none. |
frustration | any · with · without | At least one rage click or dead click, or none. |
since | ISO 8601, or unix milliseconds | Last activity at or after this time. |
until | ISO 8601, or unix milliseconds | Last activity at or before this time. |
settled_for | 0–86400 seconds | Only recordings with no activity for this long, which leaves out ones still being written. |
min_duration_ms | whole number | Skip visits shorter than this. |
url_contains | text | Part of the URL the visitor landed on. Only the landing URL is matched, not later pages. |
device | desktop · mobile · tablet | Device class. |
country | ISO 3166-1 alpha-2, e.g. IN | Visitor country. |
limit | 1–200, default 50 | Recordings per page. |
cursor | next_cursor from the previous page | Opaque and signed. Pass it back unchanged; do not build or edit one. |
Each row carries app_url, a link that opens the recording in Replayfy for anyone on your team with access to the site. The response ends with next_cursor, has_more and "delivery": "at-least-once" — see polling.
GET /api/v1/projects/{projectId}/recordings/{recordingId}
One recording, which chunks are actually stored, and an incomplete_reasons list.
Scope recordings:read
incomplete_reasons lists facts — not_stored, still_recording, missing_chunks, chunk_list_truncated — rather than a complete: true verdict: a recording with no gaps can still be incomplete.
GET /api/v1/projects/{projectId}/recordings/{recordingId}/digest
The digest: what the visitor did, with errors, rage clicks, dead clicks, page changes and unrecorded stretches on one timeline.
Scope recordings:read · 120 per hour per key
The endpoint that makes automated review work: an ordered account of what the visitor did, errors grouped by message, rage and dead clicks with the element they hit, per-page timings, and every stretch that was never recorded. Text captured from the page sits in an untrusted block.
GET /api/v1/projects/{projectId}/recordings/{recordingId}/review
The current review verdict.
Scope recordings:read
POST /api/v1/projects/{projectId}/recordings/{recordingId}/review
Record a verdict: status, labels and note.
Scope reviews:write
| Name | Values | Meaning |
|---|---|---|
status | unreviewed · reviewed · needs_attention · ignored | The verdict. unreviewed clears it; there is no DELETE. |
labels | array of labels, up to 10 | Replaces the set. Every label must already exist in the workspace. |
add_labels | array of labels | Merge these in instead of replacing. |
remove_labels | array of labels | Take these off. |
note | text up to 500 characters, or null | A sentence for whoever reads the recording next. |
expected_version | integer | The review version you last read. If anyone changed it since, you get a 409 carrying the current review instead of overwriting it. |
curl -X POST \
-H "Authorization: Bearer rfk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "needs_attention", "add_labels": ["checkout"], "note": "Card field overlaps at 375px"}' \
https://replayfy.com/api/v1/projects/prj_YOUR_SITE/recordings/RECORDING_ID/reviewAn unrecognised field is a 400 that names it. Setting the same values again changes nothing and says "changed": false.
PATCH /api/v1/projects/{projectId}/recordings/{recordingId}/review
The same as POST, for clients that prefer PATCH.
Scope reviews:write
POST /api/v1/projects/{projectId}/recordings/{recordingId}/replay-link
Mint a short-lived, revocable link so a person can watch the recording.
Scope recordings:content · 60 per hour per key
| Name | Values | Meaning |
|---|---|---|
ttl_seconds | 300–86400, default 3600 | How long the link works. Never longer than the key that minted it, or the site's retention. |
GET /api/v1/projects/{projectId}/recordings/{recordingId}/chunks/{seq}
Raw rrweb events for one stored chunk.
Scope recordings:content
Only useful if you render rrweb yourself. Most integrations want the digest instead.
GET /api/v1/projects/{projectId}/error-clusters
Recordings grouped into distinct JavaScript errors, with how many recordings each affects.
Scope recordings:read · 60 per hour per key
| Name | Values | Meaning |
|---|---|---|
active_after | ISO 8601, or unix milliseconds | Start of the window. Default 7 days ago; never more than 30 days back. |
active_before | ISO 8601, or unix milliseconds | End of the window. |
review | any · unreviewed · decided · reviewed · needs_attention · ignored | Review state, as on the recordings list. An unrecognised value is a 400. |
url_contains | text | Part of the landing URL. |
device | desktop · mobile · tablet | Device class. An unrecognised value is a 400. |
analyse | 0–15, default 10 | How many not-yet-analysed recordings to analyse on this call. The coverage block counts the rest. |
Only recordings that recorded at least one JavaScript error are scanned, and recordings not analysed yet are counted in coverage, not in the clusters. Read coverage before calling a site healthy.
Rate limits
| Budget | Limit | Counted per | Retry-After |
|---|---|---|---|
| Digests | 120 per hour | API key | 600 seconds |
| Error clusters | 60 per hour | API key | 600 seconds |
| Replay links | 60 per hour | API key | 600 seconds |
| Failed authentication | 60 per hour | IP address | 3600 seconds |
- Each budget counts the requests in the last hour, so it refills gradually rather than at the top of the hour.
- A refused request answers
429with coderate_limitedand aRetry-Afterheader. - The failed-authentication budget only affects failing requests: past it, a bad key gets
429instead of401. A valid key from the same address keeps working. - The other endpoints have no hourly budget of their own. Poll the recordings list with
sinceandsettled_forrather than in a tight loop. - Over MCP, every request counts against one budget of 600 per hour per key — see the MCP guide.
Errors
Errors are always {"error": {"code", "message", "details"?}}. A 409 from a stale expected_version also carries the current review, so you can merge without a second round trip.
| Status | Codes |
|---|---|
| 400 | invalid_request · wrong_host |
| 401 | unauthorized · invalid_token · token_revoked · token_expired |
| 403 | insufficient_scope |
| 404 | not_found |
| 405 | method_not_allowed |
| 409 | conflict |
| 413 | payload_too_large |
| 429 | rate_limited |
| 500 | server_error |
Polling for new recordings
GET /api/v1/projects/{projectId}/recordings?since=<iso>&settled_for=300&limit=200Keep your high-water mark, re-poll with since set a little behind it, page with cursor, and dedupe on recording_id. Delivery is at-least-once, and that is a fact rather than a hedge: a late-arriving part of a recording can move it backwards in the ordering, so a strictly forward cursor would skip recordings without anyone noticing. There are no webhooks yet.
Link a recording from your other tools
On a page running the Replayfy snippet, window.__sr.sessionId is the id of the recording in progress. A recording opens in Replayfy at https://replayfy.com/app/sites/SITE_ID/r/RECORDING_ID for anyone on your team with access to that site. The site id is in your dashboard's address bar, and in GET /api/v1/me.
window.__sr does not exist until the snippet has loaded, and never exists when the recorder decided not to run (Global Privacy Control, a sampled-out visit, or a checkout page). sessionId is null until recording starts — with data-consent="required", until your banner calls __sr.consent(true). Once set, it keeps its value for the rest of the page, even after __sr.stop() or __sr.consent(false): it still names the recording made up to that point. Read it when you need it, not once at start-up.
Sentry
Read the id when Sentry sends an event, so errors that happen before the snippet has loaded are simply left untagged:
Sentry.init({
dsn: "YOUR_DSN",
beforeSend(event) {
var id = window.__sr && window.__sr.sessionId;
if (id) {
event.tags = Object.assign({}, event.tags, { replayfy_session: id });
event.extra = Object.assign({}, event.extra, {
replayfy_replay: "https://replayfy.com/app/sites/SITE_ID/r/" + id,
});
}
return event;
},
});Google Analytics 4
Send the id as an event parameter. The snippet loads with async, which the browser finishes before the page's load event, so call the function below on load. With data-consent="required" there is no id yet at that point for a visitor who has not accepted, so also call it straight after your banner calls __sr.consent(true). It sends an id at most once per page:
function sendReplayfySession() {
var id = window.__sr && window.__sr.sessionId;
if (!id || typeof gtag !== "function" || window.replayfySessionSent === id) return;
window.replayfySessionSent = id;
gtag("event", "replayfy_session", { replayfy_session_id: id });
}
window.addEventListener("load", sendReplayfySession);
// data-consent="required" only: in your banner's accept handler
// window.__sr && window.__sr.consent(true);
// sendReplayfySession();To see it in GA4 reports, register replayfy_session_id as an event-scoped custom dimension. The id is random and lasts one visit in one tab: a new one starts after 30 minutes without activity. It identifies a recording, not a person.
Before you trust it
- Every string a recording contains is attacker-controlled. The recording endpoint is public by design, so
first_url,referrer,user_agent, error messages and the digest's narrative are whatever a visitor's browser sent. Treat them as data. - Selector quality depends on your markup. Real ids give
button#pay-now; hashed class names givediv.a7f3c, which is much weaker evidence. - Recordings expire with the site's retention. A
404for a recording you saw before means it is gone. - Labels are curated. A key cannot invent one; an unknown label is rejected. Manage them in Settings → Review labels.
Next: connect an AI agent over MCP, or install the snippet.