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"
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
/v1/eventsfrom 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
/v1/peopledevices 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
}/v1/people/{story_id}/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
/v1/replayscluster_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"
}/v1/replays/{session_id}/events{
"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
| Status | Meaning |
|---|---|
| 401 | No bearer token, or the key is unknown, revoked, or belongs to a deactivated site. |
| 403 | The key lacks the required scope. |
| 404 | No 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. |
| 200 | An 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.