# API reference

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

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:

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

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).

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` — a `0..1` hint 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), and `categoryTags` (≤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 `400` naming the index and field of the first failure.

- De-duplication is server-side on `(projectId, eventId)` — retries with the same `eventId` overwrite 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": "u@example.com",        // 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/malformed `userId`, `anonymousId`, or non-UUID `appAccountToken`.

- `401 identity_token_invalid` — `idToken` supplied but failed verification.

- `429 rate_limited` — more than 30 identify calls for one `userId` in 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** (`customerId` or `userId` present) requires proof: either a secret key (`cd_sk_`, developer-backend consent) *or* a verified `idToken` whose subject equals `userId`. A publishable key with neither is `401 identity_token_invalid` — this prevents impersonated erasure.

- **Anonymous-only erasure** (`anonymousId` alone) 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` — `customerId` without the `cdcust_` 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 UNLESS a verified userId+idToken pair is supplied
  "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": "u@example.com", "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` — no identity to resolve. Provide either an `anonymousId` (from the client SDK) or a verified identity (`userId` + a signed `idToken`). For server-to-server calls the verified identity alone is sufficient; `anonymousId` is not required.

## 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](#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": "u@example.com",                // 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-row `users` array.

## 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](#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](https://cross-deck.com/docs/api-keys/index.html) — issuing, rotating, and revoking `cd_pub_` / `cd_sk_` keys, and the full capability matrix.

- [Identify users](https://cross-deck.com/docs/identify-users/index.html) — how the alias graph turns identity hints into one customer.

- [Entitlements](https://cross-deck.com/docs/entitlements/index.html) — how rails project entitlements and what `isActive` means.

- [Source maps](https://cross-deck.com/docs/source-maps/index.html) — the CLI workflow behind `/v1/releases/sourcemaps`.
