Crossdeck Docs
Dashboard

API reference

API Reference · Frozen wire contracts — response shapes never change

The Crossdeck API is served from https://api.cross-deck.com. It is JSON over HTTPS, authenticated by API key, with Stripe-style typed errors and a request_id on every response. Each path is accepted with or without the /v1 prefix; the prefixed form is canonical. Every response shape on this page is a frozen contract — field names and enum values do not change within v1.

Authentication

Send your API key on every request, either way:

Authorization: Bearer cd_pub_…        # Stripe convention
Crossdeck-Api-Key: cd_pub_…           # equivalent alias

Two key prefixes:

PrefixTypeWhere it lives
cd_pub_PublishableSafe to embed in client bundles. Locked to a platform identity (below).
cd_sk_SecretServer-side only. Stored hashed — Crossdeck never holds the raw key. Required for the server endpoints, source-map uploads, and bulk migration.

A key resolves to one app in one project, and to one environment: every app holds a test and a live publishable key, and the environment (sandbox / production) is derived from which key you presented — not from any client-declared setting. Revoked keys, expired rotated-out keys, and keys belonging to archived projects fail closed with 401 invalid_api_key.

Platform identity locks

Publishable keys are locked to the app's platform identity. Every authenticated endpoint enforces the lock; a mismatch is 403 permission_error. Secret keys skip these checks entirely.

PlatformChecked againstError code
WebBrowser Origin header vs the key's allowed origins. Exact match (scheme + host + port) or a single-level wildcard entry (https://*.example.com matches one subdomain label, never nested). Empty allowlist fails closed.origin_not_allowed
iOSX-Crossdeck-Bundle-Id header vs the key's locked bundle ID. Exact, case-sensitive.bundle_id_not_allowed
AndroidX-Crossdeck-Package-Name header vs the key's locked package name.package_name_not_allowed

CORS

OPTIONS on any v1 path returns 204 with the request's Origin echoed, methods GET, POST, DELETE, OPTIONS, allowed headers Authorization, Crossdeck-Api-Key, Crossdeck-Sdk-Version, Idempotency-Key, Content-Type, and Access-Control-Max-Age: 600. The preflight is permissive by design — the actual request enforces the origin allowlist.

Errors

Every error is a typed envelope, mirrored on Stripe's shape:

{
  "error": {
    "type": "invalid_request_error",
    "code": "missing_customer",
    "message": "Customer identifier is required.",
    "request_id": "req_abc123"
  }
}

On a 426 (and only there) the envelope additionally carries minVersion (the SDK version floor) and surface (web / node / swift / react-native).

TypeHTTPCodes
authentication_error401 missing_api_key, invalid_api_key, key_revoked, identity_token_invalid
permission_error403 origin_not_allowed, bundle_id_not_allowed, package_name_not_allowed, env_mismatch
invalid_request_error400 missing_customer, invalid_customer, missing_required_param, invalid_param_value, invalid_release, release_exists, release_not_found, release_finalized, invalid_sourcemap, sourcemap_too_large, missing_runtime_url, invalid_runtime_url, missing_bundle_file, missing_sourcemap, idempotency_key_in_use
rate_limit_error429 rate_limited
version_error426 sdk_version_unsupported
internal_error500 internal_error
426 means PARK, not drop.

version_error / sdk_version_unsupported says the data is good but the wire format is too old. Conformant SDKs hold the events on-device, surface an "update to resume" advisory, and deliver the backlog after upgrade — they do not retry and do not discard. The body's minVersion names the exact version to update to. A request to an unknown route returns 400 invalid_request_error / missing_required_param with the method and path in the message.

Every response — success or error — carries an X-Request-Id header matching the body's request_id (req_…). Quote it in support requests; it links directly to the server-side decision log.

Rate limits

Limits are token buckets, per project per environment per endpoint: tokens refill linearly at the sustained rate and accumulate up to the burst cap, so an idle project can spend a full burst at once. On /v1/events one event costs one token (a 100-event batch costs 100).

EndpointSustainedBurst
events100/s1,000
purchases10/s100
entitlements100/s1,000
heartbeat1/s10
forget1/s10
alias10/s100

A throttled response is 429 rate_limit_error / rate_limited with two headers:

The limiter fails open: if Crossdeck's own rate-limit storage is unavailable, requests are allowed rather than dropped. Separately, /v1/identify enforces a per-user velocity cap — more than 30 identify calls for the same userId within 60 seconds returns 429 rate_limited. Project-level overrides are available through support.

Headers

HeaderDirectionMeaning
Authorization / Crossdeck-Api-KeyRequestAPI key. Bearer cd_pub_… or the bare key in the alias header.
Idempotency-KeyRequestHonoured on POST /v1/purchases/sync. A retried request with the same key and the same body replays the cached response (with idempotent_replay: true in the body and Idempotent-Replayed: true on the wire). Same key with a different body is 400 idempotency_key_in_use.
Crossdeck-Sdk-VersionRequestSDK self-identification, <name>@<version>. On heartbeats it resolves the SDK surface for the dashboard's liveness records.
X-Crossdeck-Bundle-IdRequestiOS identity lock — must equal the key's locked bundle ID.
X-Crossdeck-Package-NameRequestAndroid identity lock — must equal the key's locked package name.
X-Request-IdResponseOn every response. Matches the error body's request_id.
Retry-After, X-Crossdeck-Sample-RateResponseOn 429 only. Seconds to wait; sampling hint.
Idempotent-ReplayedResponseOn a purchase-sync idempotency replay only.

GET /v1/healthz

Auth: none. Public health probe for uptime monitors. Cache-Control: no-store. (The bare /healthz path is intercepted by the platform front-end and never reaches the API — use the /v1 form.)

200
{
  "status": "ok",
  "service": "crossdeck-v1",
  "timestamp": 1765500000000,
  "region": "us-east4"
}

POST /v1/events

Auth: cd_pub_ or cd_sk_. Telemetry batch ingestion — the SDK's track() pipeline. Sub-50ms ack contract: identity is recorded as hints and resolved out-of-band.

Request

{
  "events": [
    {
      "name": "purchase_initiated",        // required, 1–128 chars
      "eventId": "evt_local_xxx",          // optional, 1–64 chars; retry idempotency
      "timestamp": 1717891200000,          // optional ms; clamped to receivedAt beyond ±24h skew
      "properties": { … },                 // optional, ≤ 8 KB JSON-serialised
      "developerUserId": "user_847",       // identity hints — at least one of
      "anonymousId": "device_a91f",        //   developerUserId / anonymousId /
      "crossdeckCustomerId": "cdcust_…"    //   crossdeckCustomerId; multiple allowed
    }
  ],
  "appId": "app_web_…",                    // optional batch envelope
  "environment": "production",             // optional; MUST match the key's env if present
  "sdk": { "name": "web", "version": "1.7.0" },
  "envelopeVersion": 1                     // optional integer; observed, not yet enforced
}

Response

202
{
  "object": "list",
  "received": 12,
  "env": "production",
  "throttled": {                  // present only on a partially-throttled batch
    "dropped": 88,
    "sampleRate": 0.12,
    "retryAfterMs": 4000
  }
}

During throttling, one error event per fingerprint per minute still lands so issues stay visible; the rest are counted as dropped. received is what actually landed. Dropped events should not be retried — they are aggregated as throttle-sampled counts in the dashboard. If nothing in the batch can land, the response is 429.

Notable errors

POST /v1/identify

Auth: cd_pub_ or cd_sk_. Links an anonymous device ID to your own user ID — the first call after a successful login. POST /v1/identity/alias is an accepted legacy alias for the same handler.

Request

{
  "userId": "user_847",            // required, 1–256 chars, letters/digits/_-.:@
  "anonymousId": "device_a91f",    // required, 1–128 chars, letters/digits/_-
  "email": "[email protected]",        // optional; a verified idToken's email claim overrides it
  "idToken": "eyJ…",               // optional IdP token; verified sub must equal userId
  "appAccountToken": "uuid",       // optional, lowercase RFC 4122 UUID (Apple rail binding)
  "traits": { "plan": "pro" }      // optional; ≤32 keys, primitive values only
}

With idToken, the identity lands as cryptographically verified; without one the call still succeeds but is journalled as self-asserted. Trait values: strings ≤1,024 chars, finite numbers, booleans, null — nested objects and arrays are dropped. Traits merge additively per key across calls.

Response

200
{
  "object": "alias_result",
  "crossdeckCustomerId": "cdcust_…",
  "linked": [
    { "type": "developer", "id": "user_847" },
    { "type": "anonymous", "id": "device_a91f" }
  ],
  "mergePending": false,
  "env": "production"
}

mergePending: true means both identifiers already resolved to different customers. Crossdeck refuses to silently merge: the developer-userId customer is returned as canonical, and the conflict is queued for review in the dashboard. Re-identifying an already-linked pair is an idempotent 200.

Notable errors

POST /v1/identity/forget

Auth: cd_pub_ or cd_sk_, with authority rules below. GDPR / CCPA right-to-be-forgotten: marks the customer record, writes an audit-journal entry, and queues deletion for the retention worker (30-day SLA).

Request

{
  "customerId": "cdcust_…",      // at least one of customerId / userId / anonymousId
  "userId": "user_847",
  "anonymousId": "device_a91f",
  "idToken": "eyJ…"              // see authority rules
}

Authority

Response

200
{
  "object": "forgot",
  "crossdeckCustomerId": "cdcust_…",   // null if no hint matched a known customer (still 200)
  "queuedAt": 1765500000000,
  "env": "production"
}

Idempotent — repeating the call for the same customer collapses to a single audited decision. Payment-rail records (Stripe et al.) are retained where financial-record law supersedes erasure.

GET /v1/entitlements

Auth: cd_pub_ or cd_sk_. Returns the customer's currently-active entitlements. Read-only. Cache-Control: private, no-store.

Query

Exactly one customer hint is required: ?customerId=cdcust_… (must start with cdcust_), ?userId=…, or ?anonymousId=….

Response

200
{
  "object": "list",
  "data": [
    {
      "object": "entitlement",
      "key": "pro",
      "isActive": true,
      "validUntil": 1768092000,        // unix seconds; null/absent = lifetime
      "source": {
        "rail": "apple",               // or "stripe" / "google" / "manual"
        "productId": "com.acme.pro.monthly",
        "subscriptionId": "…"
      },
      "updatedAt": 1765500000
    }
  ],
  "crossdeckCustomerId": "cdcust_…",
  "env": "production"
}

An unknown customer returns 200 with data: [] and crossdeckCustomerId: "" — not a 404. First SDK boot before any purchase legitimately hits this case.

Notable errors

POST /v1/resolve

Auth: cd_pub_ or cd_sk_. The one call: tells your app where the current visitor sits on the trust ladder, what profile fields it may render, what they can access, and what to do next. Crossdeck recommends; your auth executes. Cache-Control: private, no-store.

Request

{
  "anonymousId": "device_a91f",   // required — minted on first SDK boot
  "userId": "user_847",           // optional, only with idToken
  "idToken": "eyJ…",              // optional verified-identity proof
  "passkeyAvailable": true        // optional — this device has a registered passkey
}

Trust tiers

The tier is server-derived and fail-closed. An invalid or expired idToken never errors and never elevates — it falls through to recognition by the stored device binding. A client can never assert "I am user X" and receive X's entitlements; only a verified token reaches Tier 3.

TierstatusHow it's reacheduserentitlementsrecommendedAction
0anonymousNo known user behind this device.nullnullonboard
1recognizedDevice binding maps to a known user; never verified on this device.{ id, email? }nullwelcome_back
2recognizedKnown user and this device reached Tier 3 within the last 90 days (trusted-device window).{ id, email? }nullone_tap if a passkey is registered, else silent_refresh
3authenticatedCryptographically verified idToken for userId, right now.{ id, email?, firstName? }Active entitlementsenter

Response

200
{
  "object": "resolve_result",
  "status": "authenticated",          // "authenticated" | "recognized" | "anonymous"
  "trustTier": 3,                     // 0 | 1 | 2 | 3
  "user": { "id": "user_847", "email": "[email protected]", "firstName": "Sam" },
  "entitlements": [ … ],              // ONLY at Tier 3; null below
  "recommendedAction": "enter",       // enter | silent_refresh | one_tap | welcome_back | onboard
  "anonymousId": "device_a91f"
}

Notable errors

POST /v1/purchases/sync

Auth: cd_pub_ or cd_sk_. Forward a rail-signed purchase so entitlements provision before the platform webhook lands. POST /v1/purchases is an accepted legacy alias. Supports Idempotency-Key (see Headers).

Request

// Apple — fully implemented
{
  "rail": "apple",
  "signedTransactionInfo": "eyJ…",   // StoreKit 2 JWS, verified against Apple's cert chain
  "signedRenewalInfo": "eyJ…",       // optional; verification failure is non-fatal
  "appAccountToken": "uuid"          // optional
}

Apple flow: the JWS is verified against Apple's certificate chain (production first, sandbox fallback), the environment is derived from the decoded payload, and the transaction's environment must match the API key's environment. transactionId de-duplicates re-sends; the customer resolves via originalTransactionId (stable across renewals). Family-shared transactions are never bound to the caller's identity — the organizer owns the subscription.

rail: "stripe" and rail: "google" are not supported on this endpoint: both return 400 invalid_request_error / invalid_param_value directing you to the webhook path (Stripe Checkout webhooks; Google Real-time Developer Notifications).

Response

200
{
  "object": "purchase_result",
  "crossdeckCustomerId": "cdcust_…",
  "env": "production",
  "entitlements": [ … ],             // PublicEntitlement[], same shape as GET /v1/entitlements
  "idempotent_replay": true          // only present on an Idempotency-Key replay
}

Notable errors

GET /v1/config

Auth: cd_pub_ only — secret keys are rejected. App configuration the SDK fetches at boot. Edge-cached: Cache-Control: public, max-age=60.

Response

200
{
  "object": "config",
  "env": "production",
  "projectId": "proj_…",
  "appId": "app_…",
  "platform": "web",                       // "ios" | "android" | "web"
  "allowedOrigins": ["https://acme.com"],
  "sdk": {
    "minVersion": "0.3.0",                 // hard floor — older SDKs should refuse to run
    "deprecatedVersions": ["0.1.x", "0.2.x"]
  },
  "verifierConfig": {
    "logVerifierResults": null,            // tri-state: true / false / null (= SDK default)
    "verifyContractsAtBoot": null
  },
  "errorNoiseDenyList": { … },             // versioned drop-at-source patterns
  "serverTime": 1765500000000
}

GET /v1/sdk/heartbeat

Auth: cd_pub_ or cd_sk_. SDK install verification — fired once per boot. Updates the dashboard's per-app and per-surface liveness records; the SDK surface is resolved from the Crossdeck-Sdk-Version header, never inferred from the key. Cache-Control: no-store.

One onboarding affordance: a brand-new web app's allowlist is empty, and the first heartbeat learns the observed Origin (plus its registrable apex) into the allowlist so setup goes green. Once non-empty, the allowlist locks normally.

Response

200
{
  "object": "heartbeat",
  "ok": true,
  "projectId": "proj_…",
  "appId": "app_…",
  "platform": "web",
  "env": "production",
  "serverTime": 1765500000000      // SDKs use this for clock-skew detection
}

The heartbeat write is best-effort — a storage hiccup still returns ok: true; the SDK retries on next init.

GET /v1/sdk/versions

Auth: none — version metadata only. The publish-fed SDK version manifest: latest reflects actually-published artifacts; minSupported is the wire-format floor a future 426 would name. Cache-Control: public, max-age=300.

Response

200
{
  "object": "sdk_versions",
  "surfaces": {
    "web":  { "name": "@cross-deck/web", "latest": "1.7.0", "minSupported": "1.0.0" },
    "node": { "name": "@cross-deck/node", "latest": "…", "minSupported": "…" },
    …
  },
  "generatedAt": 1765500000000
}

POST /v1/releases/sourcemaps

Auth: cd_sk_ only — source maps reveal original source, so publishable keys (embedded in client bundles) are rejected outright. Batch upload used by @cross-deck/cli; storage only — stack-trace resolution happens lazily at read time.

Request

{
  "release": "v1.2.3",                 // 1–64 chars: letters, digits, . _ -
  "environment": "production",         // or "sandbox"
  "files": [                           // 1–100 per request
    {
      "fileUrl": "https://acme.com/app.js",   // any scheme:// URL — app:///, webpack:// accepted
      "fileUrlHash": "…",                     // optional; server recomputes
      "sourceMap": "eyJ2ZXJzaW9uIjozLi4u"     // base64-encoded source-map JSON
    }
  ]
}

Response

200
{
  "object": "sourcemap_batch",
  "release": "v1.2.3",
  "environment": "production",
  "uploaded": 41,
  "skipped": 2,
  "errors": [
    { "fileUrl": "…", "status": "error",
      "error": { "code": "invalid_sourcemap", "message": "…" } }
  ],
  "results": [ … ]                    // per-file outcome rows: uploaded | skipped | error
}

Per-file failures land in errors / results without failing the batch; the request-level errors are invalid_release, invalid_param_value (bad environment / >100 files), and missing_required_param (empty files). Per-file codes include invalid_runtime_url, missing_sourcemap, invalid_sourcemap, sourcemap_too_large.

POST /v1/migration/users

Auth: cd_sk_ only — a leaked publishable key must not be able to mass-relink a customer base. Bulk-link your existing users (from your own backend's source of truth) to the rail-keyed customers Crossdeck already minted, before the SDK starts firing identify(). Idempotent: re-running a partial migration converges. Rows are processed sequentially; a bad row never blocks its siblings.

Request

{
  "users": [                                   // 1–1000 rows per request
    {
      "developerUserId": "user_847",           // required, ≤256 chars
      "email": "[email protected]",                // stored, never used to merge
      "displayName": "Sam",
      "stripeCustomerId": "cus_…",
      "appleOriginalTransactionId": "…",
      "appleAppAccountToken": "…",
      "googlePurchaseToken": "…",
      "googleObfuscatedAccountId": "…",
      "traits": { "cohort": "2024" },
      "entitlements": { "pro": true }          // truthy keys become migration grants — see gate
    }
  ]
}

Asserted entitlements pass a rail-owned gate: if the row's Stripe customer holds a live subscription, the rail owns the entitlement and the grant is skipped; a lapsed subscription is also skipped (the rail says they're not entitled — re-grant manually if intended); only customers with no rail behind them receive the asserted manual grant. Asserted keys are always registered into the project catalog.

Response

200
{
  "object": "migration_result",
  "env": "production",
  "totalRows": 500,
  "matched": 420,                       // existing rail customer linked
  "created": 60,                        // no rail matched; fresh customer minted
  "conflicts": 5,                       // rail keys resolved to different customers — never auto-merged
  "errors": 15,
  "entitlementsGranted": 38,
  "entitlementsSkippedRailBacked": 301,
  "entitlementsSkippedLapsed": 12,
  "entitlementsUndetermined": 3,        // rail unreadable — no grant minted; re-run resolves
  "entitlementKeysRegistered": 2,
  "details": {
    "conflicts": [
      { "developerUserId": "…",
        "railResolutions": { "stripe": "cdcust_a", "apple": "cdcust_b" },
        "reason": "…" }
    ],
    "errors": [
      { "rowIndex": 17, "developerUserId": "…", "reason": "developerUserId_required" }
    ]
  },
  "processedAt": 1765500000000
}

Notable errors

Server endpoints (cd_sk_ only)

The privileged surface for your backend and support tooling. Every endpoint here requires a secret key; publishable keys are 401 invalid_api_key. All paths take a customerId that must start with cdcust_.

GET /v1/server/customers/{customerId}/entitlements

Read any customer's active entitlements server-side. Same response shape as GET /v1/entitlements{ object: "list", data: PublicEntitlement[], crossdeckCustomerId, env } — resolved through the same read path, so the two can never disagree. Unlike the public endpoint, an unknown customer here is 400 invalid_customer, not an empty list. Cache-Control: private, no-store.

POST /v1/server/customers/{customerId}/grant

Manually grant an entitlement. The grant is journalled, transactional, and attributed to the operator surface.

{
  "entitlementKey": "pro",              // snake_case, 2–40 chars
  "duration": "P30D",                   // "P30D" | "P90D" | "P1Y" | "lifetime"
  "reason": "Comp for outage 2026-06-01 — support ticket #4821"
                                        // required, 20–500 chars; becomes the audit rationale
}
200
{
  "object": "entitlement_mutation",
  "action": "grant",
  "crossdeckCustomerId": "cdcust_…",
  "entitlement": { "object": "entitlement", "key": "pro", "isActive": true, … },
  "env": "production"
}

POST /v1/server/customers/{customerId}/revoke

Revoke a manually-granted entitlement. Rail-derived entitlements cannot be revoked here — the rail is the source of truth and would silently re-grant on the next renewal; cancel or refund on the rail instead (400 invalid_param_value explains this). Revoking an already-revoked entitlement is also 400.

{
  "entitlementKey": "pro",     // snake_case, 2–40 chars
  "reason": "Refund issued"    // required, ≤500 chars
}
200
{
  "object": "entitlement_mutation",
  "action": "revoke",
  "crossdeckCustomerId": "cdcust_…",
  "entitlement": { "object": "entitlement", "key": "pro", "isActive": false, … },
  "env": "production"
}

GET /v1/server/audit/{eventId}

Fetch a single audit-log entry by its event ID — every rail webhook, manual grant, and revoke writes one. Unknown eventId is 400 invalid_param_value. Cache-Control: private, no-store.

200
{
  "object": "audit_entry",
  "data": {
    "eventId": "srv_revoke_…",
    "rail": "manual",
    "env": "production",
    "eventType": "entitlement.revoked_manually",
    "projectId": "proj_…",
    "customerId": "cdcust_…",
    "decision": "applied",
    "reason": "Refund issued",
    …
  }
}