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.
Scopes
ScopeGrantsTicked by default
recordings:readList recordings and read their metadata, timeline and review status.Yes
reviews:writeSet the review status, labels and note on a recording.Yes
recordings:contentDownload 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.com directly. The API is not redirected between hosts — another host answers 400 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

Query parameters
NameValuesMeaning
reviewany · unreviewed · decided · reviewed · needs_attention · ignoredReview state. decided means any verdict at all. An unrecognised value is a 400, never silently ignored.
labelone label from GET /api/v1/meOnly recordings carrying it.
errorsany · with · withoutAt least one JavaScript error, or none.
frustrationany · with · withoutAt least one rage click or dead click, or none.
sinceISO 8601, or unix millisecondsLast activity at or after this time.
untilISO 8601, or unix millisecondsLast activity at or before this time.
settled_for0–86400 secondsOnly recordings with no activity for this long, which leaves out ones still being written.
min_duration_mswhole numberSkip visits shorter than this.
url_containstextPart of the URL the visitor landed on. Only the landing URL is matched, not later pages.
devicedesktop · mobile · tabletDevice class.
countryISO 3166-1 alpha-2, e.g. INVisitor country.
limit1–200, default 50Recordings per page.
cursornext_cursor from the previous pageOpaque 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

JSON body — absent fields mean unchanged
NameValuesMeaning
statusunreviewed · reviewed · needs_attention · ignoredThe verdict. unreviewed clears it; there is no DELETE.
labelsarray of labels, up to 10Replaces the set. Every label must already exist in the workspace.
add_labelsarray of labelsMerge these in instead of replacing.
remove_labelsarray of labelsTake these off.
notetext up to 500 characters, or nullA sentence for whoever reads the recording next.
expected_versionintegerThe 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/review

An 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

JSON body
NameValuesMeaning
ttl_seconds300–86400, default 3600How 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

Query parameters
NameValuesMeaning
active_afterISO 8601, or unix millisecondsStart of the window. Default 7 days ago; never more than 30 days back.
active_beforeISO 8601, or unix millisecondsEnd of the window.
reviewany · unreviewed · decided · reviewed · needs_attention · ignoredReview state, as on the recordings list. An unrecognised value is a 400.
url_containstextPart of the landing URL.
devicedesktop · mobile · tabletDevice class. An unrecognised value is a 400.
analyse0–15, default 10How 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

BudgetLimitCounted perRetry-After
Digests120 per hourAPI key600 seconds
Error clusters60 per hourAPI key600 seconds
Replay links60 per hourAPI key600 seconds
Failed authentication60 per hourIP address3600 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 429 with code rate_limited and a Retry-After header.
  • The failed-authentication budget only affects failing requests: past it, a bad key gets 429 instead of 401. A valid key from the same address keeps working.
  • The other endpoints have no hourly budget of their own. Poll the recordings list with since and settled_for rather 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.

StatusCodes
400invalid_request · wrong_host
401unauthorized · invalid_token · token_revoked · token_expired
403insufficient_scope
404not_found
405method_not_allowed
409conflict
413payload_too_large
429rate_limited
500server_error

Polling for new recordings

GET /api/v1/projects/{projectId}/recordings?since=<iso>&settled_for=300&limit=200

Keep 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 give div.a7f3c, which is much weaker evidence.
  • Recordings expire with the site's retention. A 404 for 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.