Crossdeck Docs
Dashboard

Prism — the API

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.

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.

Conventions

Errors

{ "error": { "type": "authentication_error", "code": "secret_key_required",
             "message": "…", "request_id": "req_…" } }
HTTPtypeWhen
400invalid_request_errormissing or invalid parameter
401authentication_errormissing / non-secret / invalid key
403permission_errorhost not owned, ?env mismatch, or insufficient_scope (the key wasn't minted with the scope this endpoint needs)
429rate_limit_errorover 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
granularityoptionaltotal (default) or day
daysoptionalwith 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
fingerprintrequiredthe 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
daysoptionalwindow (≤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
hostrequiredmust belong to your project (validated against your verified origins)
granularityoptionaltotal (default) or day
daysoptionalwindow (≤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
hostrequiredas above
dimensionoptionaltop_pages (default) or top_referrers
daysoptionalwindow (≤90, default 30)
limitoptionalrows (≤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:

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_…" } }

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_…" } }

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_…" } }