Crossdeck Docs
Dashboard

Reporting API

API Headless analytics · Server-to-server · secret key only

The Reporting API lets you render Crossdeck's data — revenue, errors, read-cost, per-host analytics, and the cross-layer cross-match — inside your own product. You call it from your backend with a secret key; the data lands as clean JSON aggregates. It is the only API that can answer a question across layers — "why did this error happen, who did it affect, and how much do they pay you?" — because Crossdeck owns the identity that joins those layers. That join is /v1/crossmatch.

Authentication

Every Reporting endpoint requires a secret key on https://api.cross-deck.com:

Authorization: Bearer cd_sk_live_…

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 by your project, or ?env mismatch
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: