# Prism — the API

> Source: https://cross-deck.com/docs/reporting-api/ · Crossdeck developer docs (generated from the published page).

API
Bank-grade · scoped keys
·
Server-to-server · secret key only

**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](https://cross-deck.com/docs/outbound-api-security/index.html).

**The two tiers at a glance.** Aggregate: [/v1/reporting/events](#get-v1-reporting-events), [/metrics](#get-v1-reporting-metrics), [/v1/revenue](#get-v1-revenue), [/v1/errors](#get-v1-errors), [/v1/buckets](#get-v1-buckets) — counts and totals, never an individual. Identity (the moat, row-level, granted on the key at mint): [/v1/errors/affected](#get-v1-errors-affected) (who an error hit + what they pay), [/v1/reporting/people](#get-v1-reporting-people) (a host's people) and [/journey](#get-v1-reporting-people-journey) (one person's events), and [/v1/crossmatch](#get-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](https://cross-deck.com/docs/outbound-api-security/index.html).

- **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` / `customerId` from 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/customers/ranked](#get-v1-customers-ranked), [/v1/errors/affected](#get-v1-errors-affected), [/v1/reporting/people](#get-v1-reporting-people) + [/journey](#get-v1-reporting-people-journey), [/v1/crossmatch](#get-v1-crossmatch)) are the **identity** scope — a deliberate exception reached only by a key minted with `reporting:identity`, and PII-minimized to your own identifiers. See [Security & Trust](https://cross-deck.com/docs/outbound-api-security/index.html).

- **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 `production` or `sandbox`. Passing a mismatched `?env` is a hard `403`.

- Every response carries `meta.requestId` (echo it in support tickets) and `meta.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.

## Endpoints

Every endpoint the Prism read API serves. Aggregate is the default scope; identity-tier rows are reached only by a key minted with `reporting:identity`. Each links to its full reference below. *This table is generated from the routes in the backend — it can't fall behind the code.*

Endpoint Scope Tier Returns

GET /v1/reporting/events reporting:aggregate aggregate Per-host event counts grouped by an approved property
GET /v1/reporting/metrics reporting:aggregate aggregate Per-host views + unique visitors
GET /v1/reporting/breakdown reporting:aggregate aggregate Per-host top pages / top referrers
GET /v1/acquisition reporting:aggregate aggregate Signups by first-touch source — your biggest channel
GET /v1/revenue revenue:read aggregate Project revenue from the MRR ledger
GET /v1/revenue/averages revenue:read aggregate ARPU + average lifetime value per paying customer
GET /v1/customers revenue:read aggregate The customer census — paying customers (total/active not yet instrumented)
GET /v1/errors errors:read aggregate An issue + how many were affected / pay (counts)
GET /v1/buckets buckets:read aggregate Database read-cost aggregates
GET /v1/customers/ranked reporting:identity identity The moat leaderboard — customers ranked by read-cost or MRR (row-level)
GET /v1/errors/affected reporting:identity identity Who an error hit + what they pay (row-level)
GET /v1/reporting/people reporting:identity identity A host's people, newest-active first (capped + paged)
GET /v1/reporting/people/{id}/journey reporting:identity identity One person's event timeline + raw custom-event payload
GET /v1/crossmatch crossmatch:read identity One customer × revenue × entitlements × read-cost

## 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/revenue/averages

ARPU and average lifetime value **per paying customer**, from the maintained MRR snapshot + revenue totals-ledger (two point-reads, no scan). No parameters.

```
GET /v1/revenue/averages

200
{
  "data": {
    "currency": "usd",
    "payingCustomers": 318,
    "avgMrrPerPayingCustomer": 1298,
    "combinedLifetimeValueCents": 5920400,
    "avgLifetimeValuePerCustomer": 18617
  },
  "meta": { … }
}
```

`avgMrrPerPayingCustomer` = current MRR ÷ paying customers. `avgLifetimeValuePerCustomer` = combined lifetime value ÷ paying customers. Averages are over the **paying** base — there is no total-user denominator (a live census would be a read monster).

## GET /v1/customers

The customer census — the canonical "how many paying customers" answer, so an AI never improvises it. No parameters.

```
GET /v1/customers

200
{
  "data": {
    "payingCustomers": 318,
    "coverage": {
      "payingCustomers": { "state": "measured" },
      "totalCustomers":   { "state": "not_instrumented" }
    }
  },
  "meta": { … }
}
```

`payingCustomers` is a point-read of the maintained `mrrSnapshots` ledger. `totalCustomers` is **not_instrumented** — Crossdeck keeps no total-customer counter (a live census would be a read monster); that is *not zero*, it's unwired. Use `payingCustomers` for a real count.

## GET /v1/acquisition

Signups by **first-touch source** — your biggest acquisition channel — from the maintained `acquisitionRollups` ledger (one point-read). Forward-only: each customer is attributed once, at create time, to the referrer/utm on their earliest anonymous event.

Param

limit optional max rows (default 25)

```
GET /v1/acquisition?limit=10

200
{
  "data": {
    "sources": [
      { "source": "google", "signups": 142 },
      { "source": "direct", "signups": 98 },
      { "source": "producthunt.com", "signups": 40 }
    ],
    "coverage": { "sources": { "state": "measured" } }
  },
  "meta": { … }
}
```

## GET /v1/customers/ranked

**Identity tier.** The moat leaderboard — your customer book pivoted by an owned axis (who costs the most reads, who pays the most). Bounded point-reads over the maintained `bucketRollups.byActor` + active subscriptions; rows are PII-minimized to your own `developerUserId`. Needs a key minted with `reporting:identity`.

Param

by optional read_cost (default) · mrr · ltv · total_spend · errors · activity · tenure · recency · cost_ratio
segment optional paying · non_paying · all
limit optional rows (≤100, default 25)
days optional read-cost window (default 30)

```
GET /v1/customers/ranked?by=read_cost&limit=25

200
{
  "data": {
    "by": "read_cost",
    "rows": [
      { "developerUserId": "usr_…", "readCost": 184203, "mrrCents": 4900,
        "ltvCents": 21400, "currency": "usd", "costRatio": 3760, "createdAtMs": … }
    ]
  },
  "meta": { … }
}
```

Never a name or an email — only your own `developerUserId`. `costRatio` = reads ÷ revenue (raw counts, never a made-up $/read rate); `null` for non-paying customers — use the `read_cost` axis for heavy free users.

## 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](https://cross-deck.com/docs/api-reference/index.html#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/errors](#get-v1-errors) gives you *who* and *how many pay*.

- **From a customer** → [/v1/crossmatch](#get-v1-crossmatch) gives 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".

- `groupBy` works 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](#get-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](https://cross-deck.com/docs/outbound-api-security/index.html).

## 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.** `limit` defaults to 50, caps at 100. The list is served from a maintained per-host index — `limit` rows is `limit` reads, never a scan over your events.

- **Paginate** with `before`: pass the `meta.nextBefore` from the previous page to fetch the next one (older-than cursor). Stop when `hasMore` is `false`.

- Each `id` is 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).

```
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.** `limit` defaults 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 with `before` / `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 own `track()` calls; this endpoint just hands it back, per person, to your own backend.

- `person.traits` is your developer-supplied `identify(uid, { traits })` bag — your channel for a name or email you captured yourself. Crossdeck-derived display fields are not echoed.

## Related

- [Security & Trust](https://cross-deck.com/docs/outbound-api-security/index.html) — 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](https://cross-deck.com/docs/api-reference/index.html) — the full API surface: events, entitlements, identity, purchases.

- [API keys](https://cross-deck.com/docs/api-keys/index.html) — issuing and rotating the `cd_sk_` secret keys these endpoints require.

- [Entitlements](https://cross-deck.com/docs/entitlements/index.html) — what `crossmatch.entitlements` and `/v1/entitlements` resolve.
