API reference
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:
| Prefix | Type | Where it lives |
|---|---|---|
cd_pub_ | Publishable | Safe to embed in client bundles. Locked to a platform identity (below). |
cd_sk_ | Secret | Server-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.
| Platform | Checked against | Error code |
|---|---|---|
| Web | Browser 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 |
| iOS | X-Crossdeck-Bundle-Id header vs the key's locked bundle ID. Exact, case-sensitive. | bundle_id_not_allowed |
| Android | X-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).
| Type | HTTP | Codes |
|---|---|---|
authentication_error | 401 | missing_api_key, invalid_api_key, key_revoked, identity_token_invalid |
permission_error | 403 | origin_not_allowed, bundle_id_not_allowed, package_name_not_allowed, env_mismatch |
invalid_request_error | 400 | 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_error | 429 | rate_limited |
version_error | 426 | sdk_version_unsupported |
internal_error | 500 | internal_error |
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).
| Endpoint | Sustained | Burst |
|---|---|---|
events | 100/s | 1,000 |
purchases | 10/s | 100 |
entitlements | 100/s | 1,000 |
heartbeat | 1/s | 10 |
forget | 1/s | 10 |
alias | 10/s | 100 |
A throttled response is 429 rate_limit_error / rate_limited with two headers:
Retry-After— seconds to wait before retrying.X-Crossdeck-Sample-Rate— a0..1hint for proportional local sampling instead of full retry.
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
| Header | Direction | Meaning |
|---|---|---|
Authorization / Crossdeck-Api-Key | Request | API key. Bearer cd_pub_… or the bare key in the alias header. |
Idempotency-Key | Request | Honoured 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-Version | Request | SDK self-identification, <name>@<version>. On heartbeats it resolves the SDK surface for the dashboard's liveness records. |
X-Crossdeck-Bundle-Id | Request | iOS identity lock — must equal the key's locked bundle ID. |
X-Crossdeck-Package-Name | Request | Android identity lock — must equal the key's locked package name. |
X-Request-Id | Response | On every response. Matches the error body's request_id. |
Retry-After, X-Crossdeck-Sample-Rate | Response | On 429 only. Seconds to wait; sampling hint. |
Idempotent-Replayed | Response | On 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
}
- 1–100 events per batch; ~1 MB max request body.
- Error events may also carry
level(error/warning/info),tags(≤32 string keys, values ≤64 chars), andcategoryTags(≤16 entries, ≤32 chars each). Malformed values in these fields are soft-dropped, never rejected. - Validation is all-or-nothing: any invalid event rejects the whole batch with
400naming the index and field of the first failure. - De-duplication is server-side on
(projectId, eventId)— retries with the sameeventIdoverwrite themselves.
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
400 invalid_param_value— batch validation failure (index + field in the log context).403 env_mismatch— the envelope declared an environment that disagrees with the API key's.426 sdk_version_unsupported— wire format below the floor; SDKs PARK (currently ships dark behind an off flag).429 rate_limited— entire batch throttled.
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
400 invalid_param_value— missing/malformeduserId,anonymousId, or non-UUIDappAccountToken.401 identity_token_invalid—idTokensupplied but failed verification.429 rate_limited— more than 30 identify calls for oneuserIdin 60 seconds.
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
- Identified erasure (
customerIdoruserIdpresent) requires proof: either a secret key (cd_sk_, developer-backend consent) or a verifiedidTokenwhose subject equalsuserId. A publishable key with neither is401 identity_token_invalid— this prevents impersonated erasure. - Anonymous-only erasure (
anonymousIdalone) is exempt — whoever holds the session may erase it.
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
400 missing_customer— no hint supplied.400 invalid_customer—customerIdwithout thecdcust_prefix.
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.
| Tier | status | How it's reached | user | entitlements | recommendedAction |
|---|---|---|---|---|---|
| 0 | anonymous | No known user behind this device. | null | null | onboard |
| 1 | recognized | Device binding maps to a known user; never verified on this device. | { id, email? } | null | welcome_back |
| 2 | recognized | Known user and this device reached Tier 3 within the last 90 days (trusted-device window). | { id, email? } | null | one_tap if a passkey is registered, else silent_refresh |
| 3 | authenticated | Cryptographically verified idToken for userId, right now. | { id, email?, firstName? } | Active entitlements | enter |
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
400 invalid_param_value— missinganonymousId.
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
401 invalid_api_key— the JWS failed verification under both production and sandbox (the code is overloaded; the message names the JWS).403 env_mismatch— sandbox key with a production transaction, or vice versa.400 invalid_param_value— no Apple rail configured for the project, malformed body, or an unsupported rail.400 idempotency_key_in_use— key reused with a different body.
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
401 invalid_api_key— publishable key presented.400 missing_required_param/invalid_param_value— missing, empty, or >1000-rowusersarray.
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",
…
}
}
Related
- API keys — issuing, rotating, and revoking
cd_pub_/cd_sk_keys, and the full capability matrix. - Identify users — how the alias graph turns identity hints into one customer.
- Entitlements — how rails project entitlements and what
isActivemeans. - Source maps — the CLI workflow behind
/v1/releases/sourcemaps.