Prism — the API
Prism is Crossdeck's intelligence layer. This API reads Prism — surfacing this project's data — revenue, errors, read-cost, per-host analytics, and the cross-layer cross-match — inside your own product, server-to-server behind a scoped secret key. The key's scope is the security boundary (the Stripe restricted-key model), so the API serves two tiers: aggregate counts by default — embed them freely — and a deliberate identity scope — granted on the key at mint — that answers the question only Crossdeck can — "this error hit user_847, who pays you $90/mo" — because Crossdeck owns the identity that joins revenue, entitlements, errors, and cost. Every read is scoped, rate-limited, and tamper-evidently audited; the full security model is documented in Security & Trust.
The two tiers at a glance. Aggregate: /v1/reporting/events, /metrics, /v1/revenue, /v1/errors, /v1/buckets — counts and totals, never an individual. Identity (the moat, row-level, granted on the key at mint): /v1/errors/affected (who an error hit + what they pay), /v1/reporting/people (a host's people) and /journey (one person's events), and /v1/crossmatch.
Authentication
Every Reporting endpoint requires a secret key on https://api.cross-deck.com:
Authorization: Bearer cd_sk_live_…
Keys are scoped (the Stripe restricted-key model). Each secret key carries a per-resource scope map, fixed at mint, so a key reads only what it was granted — and the row-level identity scope (reporting:identity) is reachable only by a key you consciously minted with it, which requires acknowledging the row-level-data terms in the same step. The key's scope IS the boundary — there is no separate project-wide switch, and enabling anything in project settings does not add a scope to a key that already exists. A key without the scope an endpoint needs is denied 403 insufficient_scope before any data is read. Mint scoped keys in Developers → API (tick "Identity (row-level)" to grant it). The full model is in Security & Trust.
- Publishable keys (
cd_pub_…) are rejected (401 secret_key_required). These endpoints read private data; a publishable key lives in browsers and can't be trusted to scope it. Never call the Reporting API from a browser — only from your server. - The key resolves to your project. You never pass a project id; scope is derived from the key. There is no path by which one project's key can read another project's data.
- Derive any
host/customerIdfrom your own authenticated session, never from client input. That's what keeps one of your own users from reading another's data.
Conventions
- Aggregates by default; row-level behind a scoped key. Aggregate endpoints return counts and totals — never a named customer, an individual charge, or a stack trace. Row/identity-level surfaces (
/v1/errors/affected,/v1/reporting/people+/journey,/v1/crossmatch) are the identity scope — a deliberate exception reached only by a key minted withreporting:identity, and PII-minimized to your own identifiers. See Security & Trust. - Keys are scoped. Each secret key carries per-resource scopes (the Stripe restricted-key model); an endpoint denies a key that lacks its scope with
403 insufficient_scope. Mint scoped keys in Settings → API keys. - Environments are isolated. A key is scoped to
productionorsandbox. Passing a mismatched?envis a hard403. - Every response carries
meta.requestId(echo it in support tickets) andmeta.generatedAt.
Errors
{ "error": { "type": "authentication_error", "code": "secret_key_required",
"message": "…", "request_id": "req_…" } }
| HTTP | type | When |
|---|---|---|
400 | invalid_request_error | missing or invalid parameter |
401 | authentication_error | missing / non-secret / invalid key |
403 | permission_error | host not owned, ?env mismatch, or insufficient_scope (the key wasn't minted with the scope this endpoint needs) |
429 | rate_limit_error | over the rate limit (see Retry-After) |
Rate limits
~2 requests/second sustained, 120 burst, per key. Over the limit returns 429 with a Retry-After header. Generous because every call is an O(range) point-read — capped to deter scraping.
GET /v1/revenue
Project-wide revenue, from the maintained MRR ledger.
| Param | ||
|---|---|---|
granularity | optional | total (default) or day |
days | optional | with granularity=day, window length (≤366, default 90) |
GET /v1/revenue?granularity=day&days=30
Authorization: Bearer cd_sk_live_…
200
{
"data": {
"currency": "usd",
"current": { "mrrCents": 412900, "payingCustomers": 318,
"byRail": { "stripe": 280100, "apple": 110200, "google": 22600 } },
"series": [ { "date": "2026-06-01", "mrrCents": 401200, "payingCustomers": 309, "byRail": {…} } ]
},
"meta": { "generatedAt": "…", "freshness": "ledger", "requestId": "req_…" }
}
GET /v1/errors
One issue, stitched to the identities it touched — the moat, in the errors layer.
| Param | ||
|---|---|---|
fingerprint | required | the issue id |
GET /v1/errors?fingerprint=a1b2c3…
200
{
"data": {
"fingerprint": "a1b2c3…", "exceptionType": "TypeError", "level": "error",
"status": "open", "occurrences": 142,
"firstSeen": "…", "lastSeen": "…", "lastRelease": "1.4.2",
"affected": { "users": 40, "payingUsers": 12 }
},
"meta": { … }
}
affected.users = how many distinct people hit it. affected.payingUsers = how many of them pay you. Never a stack trace, never the exception message (it can carry user data), never a named user.
GET /v1/buckets
Read-cost: the per-user-vs-overhead split + reads by operation.
| Param | ||
|---|---|---|
days | optional | window (≤90, default 30) |
200
{
"data": {
"window": { "days": 30, "from": "2026-05-26", "to": "2026-06-25" },
"totalReads": 1840221,
"split": { "perUserReads": 1410880, "overheadReads": 429341, "overheadPct": 23 },
"identifiedUsers": 614,
"byOperation": [ { "operation": "analytics", "reads": 980400 }, … ]
},
"meta": { … }
}
perUserReads + overheadReads = totalReads, by construction. Overhead is your account's own background/platform reads — the un-attributed remainder.
GET /v1/crossmatch
The moat in one call. One customer, every layer Crossdeck owns, stitched by identity. Identify the customer with any of: customerId (a cdcust_…), userId (your own id), anonymousId, appleOriginalTransactionId, googlePurchaseToken, stripeCustomerId.
GET /v1/crossmatch?userId=agent_8842
200
{
"data": {
"customer": { "crossdeckCustomerId": "cdcust_…" },
"revenue": { "monthlyCents": 500, "paying": true },
"entitlements": { "active": 2 },
"readCost": { "reads": 4021, "windowDays": 30 }
},
"meta": { … }
}
The sentence no competitor can produce: this customer pays $5/mo, holds 2 entitlements, and cost you 4,021 reads. data is null if the identifier resolves to no customer. Entitlement detail lives in GET /v1/entitlements.
GET /v1/reporting/metrics
Per-host analytics — for apps with many subdomains, each user sees only their own (wes.example.com).
| Param | ||
|---|---|---|
host | required | must belong to your project (validated against your verified origins) |
granularity | optional | total (default) or day |
days | optional | window (≤90, default 30) |
200
{
"data": {
"host": "wes.example.com",
"range": { "from": "…", "to": "…", "granularity": "day" },
"totals": { "views": 4210, "uniqueVisitors": 1890 },
"series": [ { "date": "2026-06-01", "views": 210, "uniqueVisitors": 95 } ]
},
"meta": { "freshness": "rollup", … }
}
A host your project doesn't own returns 403 — never a confirm/deny of another tenant's host.
GET /v1/reporting/breakdown
| Param | ||
|---|---|---|
host | required | as above |
dimension | optional | top_pages (default) or top_referrers |
days | optional | window (≤90, default 30) |
limit | optional | rows (≤100, default 25) |
200
{
"data": { "host": "wes.example.com", "dimension": "top_pages",
"rows": [ { "key": "/calculator", "count": 1820 }, { "key": "/apply", "count": 410 } ] },
"meta": { "truncated": false, … }
}
The cross-match is the point
Don't think of these as six separate readouts. Identity joins them. The same person is a row in your revenue, a name behind an error, a slice of your read-cost, and a visitor in your analytics — and Crossdeck is the only thing that knows it's the same person. Start from any layer and pivot to the others:
- From an error →
/v1/errorsgives you who and how many pay. - From a customer →
/v1/crossmatchgives you revenue × entitlements × read-cost.
GET /v1/reporting/events
Event-level segment intelligence: how many people fired an event, and the counts grouped by any property your events carry. Requires reporting:aggregate scope. host is optional — omit it for a project-wide answer (the only way to reach custom track() events, which carry no host of their own), or pass a host you own to scope to one subdomain. Query one event at a time (so every read is a bounded point-read).
GET /v1/reporting/events?event=product_selected&groupBy=plan&days=30 # project-wide
GET /v1/reporting/events?host=app.example.com&event=product_selected&groupBy=plan&days=30 # one host
Authorization: Bearer cd_sk_live_…
{ "data": { "host": "app.example.com", "event": "product_selected", "groupBy": "plan",
"rows": [ { "key": "annual", "count": 120 }, { "key": "monthly", "count": 60 } ],
"uniques": 88 },
"meta": { "truncated": false, "freshness": "rollup", "requestId": "req_…" } }
- Without
groupBy: returns{ event, count, uniques }— "how many fired it, by how many distinct people". groupByworks on any property your events carry — you compute your own metrics. Every result is a count, never an individual: money/PII property names are hard-locked and can never be grouped, PII-shaped values (emails, ids) are dropped, and runaway-cardinality properties are auto-removed. Row-level / person data is a separate, opt-in tier (reporting:identity), never reachable here.
Get the data; build your own metrics. This endpoint returns aggregated counts for the custom events you send with Crossdeck.track(), grouped by your own event properties. Derived metrics — conversion rate, product mix, cost per lead, retention — are yours to compute in your own backend, however your business defines them. Crossdeck provides the aggregated event data; the calculations are yours.
For example, track a custom event when a user selects a product, then compute a conversion rate in your backend:
// Client — instrument the events you care about
Crossdeck.track("product_selected", { plan: "annual" });
Crossdeck.track("checkout_started");
// Server — read the aggregates, compute your metric
const selected = await cd("/v1/reporting/events?host=app.example.com&event=product_selected&groupBy=plan");
const started = await cd("/v1/reporting/events?host=app.example.com&event=checkout_started");
const annual = selected.data.rows.find(r => r.key === "annual")?.count ?? 0;
const conversionRate = annual === 0 ? 0 : started.data.count / annual;
GET /v1/errors/affected identity tier
The moat at row level — the answer the aggregate /v1/errors only counts: who this error hit and what they pay. Requires a key minted with the reporting:identity scope. PII-minimized to your own user id; every call is hash-chain audited.
GET /v1/errors/affected?fingerprint=err_abc123
Authorization: Bearer cd_sk_live_… # a key with reporting:identity scope
{ "data": { "fingerprint": "err_abc123",
"affected": [ { "developerUserId": "user_847", "monthlyCents": 9000, "lastSeen": "2026-06-26T…" } ],
"payingUsers": 1, "monthlyRevenueAtRiskCents": 9000 },
"meta": { "truncated": false, "freshness": "ledger", "requestId": "req_…" } }
The identity-tier endpoints are the deliberate, scoped exception to aggregates-only. They require a key consciously minted with the reporting:identity scope (acknowledging the row-level-data terms at mint), and they return your own identifiers plus facts — never an email or name you didn't already hold. See Security & Trust.
GET /v1/reporting/people identity tier
The people who came through a host you own — newest-active first. This is the row-level enumeration the aggregates can't give you: a paginated list of the individuals on, say, one of your subdomains. Requires a key minted with the reporting:identity scope and a host you own. PII-minimized to your own ids; every call is hash-chain audited.
GET /v1/reporting/people?host=app.example.com&limit=50
Authorization: Bearer cd_sk_live_… # a key with reporting:identity scope
{ "data": { "host": "app.example.com",
"people": [
{ "id": "anon:9f2…", "kind": "anonymous", "lastSeen": "2026-06-27T09:14:…", "lastEvent": "product_selected" },
{ "id": "cd:cust_71", "kind": "identified", "lastSeen": "2026-06-27T08:02:…", "lastEvent": "checkout_started" }
] },
"meta": { "hasMore": true, "nextBefore": 1751014940000, "freshness": "ledger", "requestId": "req_…" } }
- Bounded by design.
limitdefaults to 50, caps at 100. The list is served from a maintained per-host index —limitrows islimitreads, never a scan over your events. - Paginate with
before: pass themeta.nextBeforefrom the previous page to fetch the next one (older-than cursor). Stop whenhasMoreisfalse. - Each
idis the kind-prefixed person key you pass straight to the journey endpoint below.
GET /v1/reporting/people/{id}/journey identity tier
One person's reverse-chronological event timeline — and this is where the full richness of your custom events surfaces: each event comes back with the exact properties your own Crossdeck.track() call attached. Same gating as the list (a key with the reporting:identity scope, a host you own); hash-chain audited. {id} is a person id from /v1/reporting/people.
GET /v1/reporting/people/anon:9f2.../journey?host=app.example.com&limit=100
Authorization: Bearer cd_sk_live_… # a key with reporting:identity scope
{ "data": {
"person": { "id": "anon:9f2…", "kind": "anonymous", "developerUserId": null,
"state": "ANON", "sessions": 3, "firstSeen": "2026-06-25T…", "traits": null },
"events": [
{ "at": "2026-06-27T09:14:…", "name": "product_selected",
"properties": { "plan": "annual", "amount": 200000, "step": "results", "visit": 3 } },
{ "at": "2026-06-27T09:11:…", "name": "page_viewed",
"properties": { "url": "https://app.example.com/quote" } }
] },
"meta": { "hasMore": false, "nextBefore": null, "freshness": "ledger", "requestId": "req_…" } }
- Bounded by design.
limitdefaults to 100, caps at 200, and the read is a single per-person query on an existing index — never a project-wide scan. Page older events withbefore/meta.nextBefore. - The properties are yours, verbatim. Whatever you put on an event —
plan,amount,step, a risk score, time-on-step — comes back exactly as you sent it. The richness is unlimited and entirely in your owntrack()calls; this endpoint just hands it back, per person, to your own backend. person.traitsis your developer-suppliedidentify(uid, { traits })bag — your channel for a name or email you captured yourself. Crossdeck-derived display fields are not echoed.
Related
- Security & Trust — the security model, point for point, for your security review: scoped keys, two-tier access, fail-closed gating, isolation, tamper-evident audit, leak detection, and PII posture.
- API reference — the full API surface: events, entitlements, identity, purchases.
- API keys — issuing and rotating the
cd_sk_secret keys these endpoints require. - Entitlements — what
crossmatch.entitlementsand/v1/entitlementsresolve.