Reference

Storylight API

Read a site's events, customer stories and session recordings from your own server. Everything below is read-only — data goes in through the tracking snippet's public key, and comes out through a secret key.

The API is open to sites in Storylight's closed alpha. Join the waitlist to get a spot for yours.

Authentication

Create a secret key under Settings → API. It's shown once, so store it then — nothing here can show it again. Send it as a bearer token:

curl https://api.storylight.ai/v1/events \
  -H "Authorization: Bearer sk_your_secret_key"
These endpoints cannot be called from a browser. They answer with no CORS headers and refuse preflight, so a page-level fetch fails even with a valid key. That's deliberate: a secret key that reaches a browser is already leaked. Call the API from your server and pass the result to your frontend.

A key belongs to exactly one site, which is why no site id appears in any path — you cannot address another site's data with it. Keys are independent, so to rotate without downtime: create the replacement, move your integration across, then revoke the old one.

Pagination

List endpoints take limit (default 100, max 1000) and cursor, and return { data, next_cursor }. Keep passing next_cursor until it comes back null. Cursors are opaque — don't parse them.

Paging is keyset rather than offset-based, so rows arriving mid-walk can't shift the window and make you skip or repeat records.

let cursor = null;
do {
  const qs = new URLSearchParams({ limit: "1000", ...(cursor && { cursor }) });
  const page = await fetch(`https://api.storylight.ai/v1/events?${qs}`, {
    headers: { Authorization: `Bearer ${key}` },
  }).then((r) => r.json());

  handle(page.data);
  cursor = page.next_cursor;   // null means you have reached the end
} while (cursor);

Events

GET/v1/events
Every event for the site, oldest first. Optional from and to ISO timestamps narrow the range (to is exclusive).

Rows the dashboard hides are included with a flag rather than dropped, so your totals can reconcile: excluded: true marks internal traffic (excluded IPs or email domains), and revenue events carry revenue_amount_cents. story_id is on every event, so an export can be grouped by person in one pass.

{
  "data": [
    {
      "event_id": "5e64618f-7553-420e-bd3b-a6142553a3be",
      "ts": "2026-05-29T20:39:00",
      "tracker_id": "dev-T-010-win",
      "story_id": "954925b8-f23a-4f59-a058-33a79b6e8e1f",
      "event_type": "page_view",
      "url": "https://acme.com/pricing?utm_source=newsletter&utm_campaign=weekly",
      "path": "/pricing",
      "referrer": "https://l.facebook.com/",
      "channel": "Email",
      "country": "US",
      "ua_family": "Chrome", "ua_os": "Windows", "ua_device": "desktop",
      "hostname": "acme.com",
      "utm_source": "newsletter", "utm_medium": "email",
      "utm_campaign": "weekly", "utm_term": null, "utm_content": null,
      "el": null, "fbclid": null, "gclid": null, "ttclid": null,
      "excluded": false,
      "revenue_amount_cents": null, "revenue_currency": null,
      "payload": {}
    }
  ],
  "next_cursor": "MjAyNi0wNS0yOVQyMDozOTowMB81ZTY0NjE4Zg"
}

Customer stories

GET/v1/people
One row per person, most recently active first. A story spans devices and browsers, so devices counts how many resolved to the same person on this site.
{
  "data": [
    {
      "story_id": "954925b8-f23a-4f59-a058-33a79b6e8e1f",
      "first_seen": "2026-09-01T15:33:47.021+00:00",
      "last_seen": "2026-09-01T15:34:02.884+00:00",
      "events": 11,
      "devices": 2
    }
  ],
  "next_cursor": null
}
GET/v1/people/{story_id}
One story's full timeline on this site, oldest first, interleaved across its devices. The event objects are identical to those from /v1/events.
{
  "story_id": "954925b8-f23a-4f59-a058-33a79b6e8e1f",
  "first_seen": "2026-09-01T15:33:47.021+00:00",
  "last_seen": "2026-09-01T15:34:02.884+00:00",
  "devices": 2,
  "tracker_ids": ["dev-T-005-mac", "dev-T-014-android"],
  "events": [ /* same event objects as /v1/events, oldest first */ ]
}

A story can be seen on more than one site. Your key only ever returns its own site's half, and a story with no events here answers 404.

Session recordings

GET/v1/replays
Recording metadata, newest first. cluster_id is the story_id, so recordings join to people without a lookup. start_ts, end_ts and duration_ms are epoch milliseconds.
{
  "data": [
    {
      "session_id": "0192abcd-2222-7000-8000-feedface0001",
      "cluster_id": "954925b8-f23a-4f59-a058-33a79b6e8e1f",
      "visitor_id": "dev-T-005-mac",
      "start_ts": 1788400000000,
      "end_ts": 1788400002236,
      "duration_ms": 2236,
      "event_count": 5,
      "click_count": 1, "rage_count": 0, "dead_count": 0, "error_count": 0,
      "url_first": "https://acme.com/pricing",
      "url_last": "https://acme.com/pricing",
      "truncated_reason": null,
      "finalized": true
    }
  ],
  "next_cursor": "MTc4ODQwMDAwMDAwMB8wMTkyYWJjZA"
}
GET/v1/replays/{session_id}/events
The recording itself: the raw rrweb event stream, decompressed and concatenated across chunks.
{
  "session_id": "0192abcd-2222-7000-8000-feedface0001",
  "events": [
    { "type": 4, "timestamp": 1788400000000,
      "data": { "href": "https://acme.com/pricing", "width": 1280, "height": 800 } },
    { "type": 2, "timestamp": 1788400000100,
      "data": { "node": { /* full DOM snapshot */ }, "initialOffset": { "left": 0, "top": 0 } } },
    { "type": 3, "timestamp": 1788400001500,
      "data": { "source": 2, "type": 2, "id": 42, "x": 610, "y": 320 } }
  ]
}

The type: 2 full snapshot carries a serialized copy of the whole DOM, so a player rebuilds the page without any access to your site. Hand the array straight to rrweb-player — no transformation:

// On your server — a secret key must never reach a browser.
const { events } = await fetch(
  `https://api.storylight.ai/v1/replays/${sessionId}/events`,
  { headers: { Authorization: `Bearer ${process.env.STORYLIGHT_SECRET_KEY}` } }
).then((r) => r.json());

// Hand that array to your frontend, then:
import rrwebPlayer from "rrweb-player";
import "rrweb-player/dist/style.css";

new rrwebPlayer({ target: document.getElementById("player"), props: { events } });

Two things to plan for. Recordings are purged after the site's retention window, so an archive has to keep pace with it. And because the snapshot is the entire DOM, a single response is large — tens of kilobytes for a trivial page, megabytes for a long session. Fetch recordings one at a time rather than in a loop.

Errors

StatusMeaning
401No bearer token, or the key is unknown, revoked, or belongs to a deactivated site.
403The key lacks the required scope.
404No such story or recording on this site. Also returned for one that exists elsewhere, so the response can't be used to probe for other sites' ids.
200An unreadable cursor restarts the walk rather than erroring — usually it's a truncated copy-paste.

Sending data in

Secret keys are read-only. Events are reported by the tracking snippet using the site's public key, which can write but cannot read anything back. Each credential has exactly one job, so a leaked secret can disclose data but never corrupt it. Both live under Settings → API.

← Back to Storylight