# API keys & authentication

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

Reference
10 min read
·
Updated May 8, 2026

Crossdeck uses two key types per app — a publishable key for clients (browsers, mobile apps) and a secret key for servers. This page explains what each one can do, how requests are authenticated, and the security model that keeps your data tenant-isolated and your payment credentials out of your database.

## TL;DR

- Crossdeck issues two keys per app: `cd_pub_…` (safe to ship in clients) and `cd_sk_…` (server-side only).

- Authenticate every request with `Authorization: Bearer `.

- Each key is scoped to one app, one environment, and one project. It cannot read or write data outside that scope.

- Web publishable keys can be locked to a list of allowed origins. Native publishable keys are protected by app-bundle binding (planned) and rate limits.

- Secret keys are stored as a one-way hash — Crossdeck doesn't keep the plaintext after creation. If you lose one, generate a new one and revoke the old.

- Raw payment-rail credentials (Stripe secret keys, Apple `.p8` private keys, Google service-account JSON) are never stored in our database. They live in Google Cloud Secret Manager and are read only at runtime by our backend service account.

## Publishable vs secret keys

Publishable keys (`cd_pub_…`) are designed to ship in client code. They appear in browser network tabs, inside compiled mobile binaries, and anywhere else clients run. **That's intentional and safe.** A publishable key proves "this request is from Crossdeck-app-X" — it's an app identifier, not a credential.

Secret keys (`cd_sk_…`) are credentials. They authenticate your server to Crossdeck and unlock the operations that should never be exposed to clients (manual entitlement grants, audit-log reads, bulk operations). Treat them like a database password.

Aspect
Publishable key ( cd_pub_… )
Secret key ( cd_sk_… )

Where it lives
Client bundles, mobile binaries, browser source
Server environment variables, secret managers, CI vault

Safe to commit?
Yes — but prefer environment variables for portability
No, ever

Visible to end-users?
Yes (network tab, decompilation) — by design
Never. End-users should never see it.

Can read entitlements?
Only for the customer making the call (resolved via userId / anonymousId )
Any customer in the project

Can grant/revoke entitlements?
No
Yes (audited)

Can read audit log?
No
Yes

How Crossdeck stores it
Plaintext in the appKeys index — not a credential
SHA-256 hash only — plaintext shown once at creation

Familiar shape on purpose

The publishable / secret split mirrors Stripe's `pk_…` / `sk_…` convention. If you've used Stripe, the mental model transfers exactly.

## Test mode & live mode

Every key carries an environment marker — `test` for sandbox keys, `live` for production keys:

```
cd_pub_test_kJ3v8a…    ← sandbox publishable
cd_pub_live_QZ7m1b…    ← production publishable
cd_sk_test_pX9R4t…     ← sandbox secret
cd_sk_live_aT5g2y…     ← production secret
```

The two environments are **strictly partitioned**. Crossdeck stamps an `env` field on every record we write — customers, subscriptions, entitlements, audit log — and filters every read by it. A live key cannot see test data, a test key cannot see live data, and there is no API path that crosses the boundary.

This holds at every layer:

- Stripe webhook events with `livemode: false` always land in the sandbox partition, regardless of which Crossdeck project they belong to.

- Apple App Store Server Notifications carry an `environment` field on the decoded payload — `"Production"` goes live, anything else (Sandbox, Xcode, LocalTesting) goes sandbox.

- Google Play Real-time Developer Notifications default to live; reconciliation against the Play Developer API flips records to sandbox if the purchase came from a license-tested account.

- Manual entitlement grants from the dashboard inherit the env switcher's current value, so a manual grant made while viewing sandbox data lands in the sandbox partition only.

Use test keys liberally during development

Test-mode events never appear in production dashboards or rollups. Spin up a sandbox sub, refund it, fail a renewal — your live MRR chart is untouched. Switch to live keys only when you're ready to point at real customers.

## Authenticating requests

Crossdeck accepts the API key on either of two headers. Use whichever fits your stack:

```
# Standard: HTTP Bearer auth
Authorization: Bearer cd_pub_live_QZ7m1b…

# Alternative: dedicated header (saves an Authorization slot
# if your stack uses it for something else)
Crossdeck-Api-Key: cd_pub_live_QZ7m1b…
```

A complete read of a customer's entitlements looks like this:

```
curl https://api.cross-deck.com/v1/entitlements?userId=user_847 \
  -H "Authorization: Bearer cd_pub_live_QZ7m1b…"
```

The response is a Stripe-style list envelope:

```
{
  "object": "list",
  "data": [
    {
      "object": "entitlement",
      "key": "pro",
      "isActive": true,
      "validUntil": 1717891200,
      "source": {
        "rail": "stripe",
        "productId": "monthly_pro",
        "subscriptionId": "sub_QkLhXaYzbCdEfG"
      },
      "updatedAt": 1714291800
    }
  ],
  "crossdeckCustomerId": "cdcust_8acf3deba3214890",
  "env": "production"
}
```

Every response — success or error — includes an `X-Request-Id` header. If something looks wrong, copy the request ID and send it to support; we'll have a one-line log entry that explains exactly what we decided and why.

## What each key can do

This is the full capability matrix. Endpoints not listed yet will appear here as they ship.

Operation
Endpoint
cd_pub_…
cd_sk_…

Read entitlements for a single customer
GET /v1/entitlements
✓ (resolved via userId / anonymousId )
✓

Send telemetry events
POST /v1/events
✓
✓

Forward a purchase for verification
POST /v1/purchases
✓
✓

Link an anonymous device to a user
POST /v1/identity/alias
✓
✓

SDK heartbeat (boot ping)
GET /v1/sdk/heartbeat
✓
—

Read any customer's entitlements (server-side)
GET /v1/server/customers/{id}/entitlements
✗
✓

Manually grant an entitlement
POST /v1/server/customers/{id}/grant
✗
✓

Manually revoke an entitlement
POST /v1/server/customers/{id}/revoke
✗
✓

Read an audit-log entry
GET /v1/server/audit/{eventId}
✗
✓

Bulk customer alias
POST /v1/server/customers/alias
✗
✓

The pattern is: anything a logged-in end-user could legitimately do for their own account is on the publishable-key side. Anything an admin or support agent does on behalf of a user is on the secret-key side.

## Prism permissions (restricted keys)

When a secret key reads [Prism](https://cross-deck.com/docs/prism/index.html) (revenue, errors, read-cost, analytics, the cross-match), what it can read is governed by a per-resource **scope map** set on the key *at mint* — the Stripe restricted-key model. The key's scope *is* the boundary; there is no separate project-wide switch.

Permission Scope Returns

Aggregate (default) reporting:aggregate , revenue:read , errors:read , buckets:read Counts and totals — never an individual. Safe to embed.
Identity (row-level) reporting:identity (+ crossmatch:read ) The moat: who an error hit + what they pay, a host's people, a person's journey, the per-customer cross-match. Your own identifiers only.

- **Grant it at mint.** In **Developers → API → Create**, tick *Identity (row-level)* and acknowledge the row-level-data terms in the same step. Leave it unticked for an aggregate-only key.

- **Scope is fixed at creation.** You cannot widen an existing key — enabling anything in settings does not add a scope to a key that already exists. Mint a new key (or rotate to one) with the scope you need. A key lacking an endpoint's scope is denied `403 insufficient_scope` before any data is read.

- **Least privilege.** A key embedded in one system can be strictly narrower than your others — grant only what that integration needs.

**For an AI assistant, use a workspace key instead.** A **workspace key** (`cd_wk_…`), minted in **Developers → Prism → Connect AI**, is your account-level credential for the MCP connector: it reads every app you own, full suite, and is what "ask Prism" runs on. Don't put a workspace key in a third-party or distributed surface — for that, mint a project-scoped `cd_sk_` with exactly the permissions it needs.

## Origin allowlists (web)

Web publishable keys can be locked to a fixed list of allowed origins. When set, Crossdeck rejects any request whose `Origin` header doesn't match one of the configured values exactly.

Configure the allowlist on each web app's settings page in the dashboard:

```
Allowed origins:
  https://app.example.com
  https://staging.example.com
  http://localhost:3000   ← include localhost for dev work
```

Match rules:

- **Exact match.** Scheme, host, and port all matter — `https://example.com` does not match `https://example.com:3000` or `http://example.com`.

- **Case-sensitive.** Per RFC, `https://APP.example.com` is a different origin from `https://app.example.com`.

- **No wildcards.** If you need to allow many subdomains, list them. Wildcards are intentionally not supported.

Empty allowlist accepts any origin

If you don't configure any allowed origins, Crossdeck accepts requests from any origin. That's fine while you're prototyping — but before you launch, **set at least one origin on every production web app**. An open allowlist is the difference between "leaked publishable key is a non-event" and "leaked publishable key lets a third-party site impersonate yours."

Native (iOS / Android) publishable keys ignore the allowlist — there's no Origin header on a native HTTP client. Native keys are protected by app-bundle binding (planned) and rate limits.

## How key storage works

When you create an app in onboarding, Crossdeck does two things:

1

Generate the keys

The publishable key is a random ~32-character suffix on the `cd_pub_{env}_` prefix. The secret key is generated the same way with the `cd_sk_{env}_` prefix. Generation happens in the dashboard backend; we use the platform's cryptographic random source (not `Math.random`).

2

Index the publishable key

We store a small Firestore document at `appKeys/{publicKey}` containing the project ID, environment, platform, and the web app's allowed origins. Firestore security rules deny all client access to this collection — only the Crossdeck backend (which uses the Firebase Admin SDK and bypasses rules) can read or write it. This is fine because publishable keys aren't credentials; the entry is an index, not a secret store.

3

Hash and discard the secret key

The plaintext secret key is shown to you exactly once, at creation. Crossdeck stores only the SHA-256 hash. If you lose the plaintext, we cannot recover it — generate a new one and revoke the old. This is the same lose-and-rotate model Stripe and AWS use for their secret keys.

The dashboard always shows your publishable key

Open **Apps** in the dashboard sidebar at any time — your publishable keys are listed in plain text. They're not secrets, so we don't need to hide them. Your secret keys are listed there too but masked except for the prefix; the dashboard never re-displays the plaintext after creation.

## How verification works

When a request arrives at `api.cross-deck.com/v1/…`, here's what happens before any data leaves our database:

- **Extract the key.** Read `Authorization: Bearer …` or `Crossdeck-Api-Key: …`. Missing or malformed → `401 missing_api_key`.

- **Look up the key.** For publishable keys, query `appKeys/{key}` directly. For secret keys, hash the incoming key with SHA-256 and query `appKeys/sk_{hash}`. Either way we resolve to a project ID, environment, platform, and (for web) allowed origins. Unknown key → `401 invalid_api_key`.

- **Enforce origin (web only).** If the resolved app has allowed origins configured, the request's `Origin` header must match one exactly. Mismatch → `403 origin_not_allowed`.

- **Resolve the customer.** The endpoint reads `userId`, `anonymousId`, or `customerId` from the request. We look these up in *that project's* `customerIndex` subcollection — there's no path that reads another tenant's customers. Unknown customer is treated as "no entitlements" rather than an error, because first-time SDK callers legitimately hit this case.

- **Filter by environment.** Every record we read is filtered by the resolved key's `env`. A live publishable key never sees test data and vice versa.

- **Return.** Stripe-style envelope, `X-Request-Id` header, deterministic ordering for cacheability. Any rejection along the way produces a single audit-log line you can correlate with the request ID.

The whole pipeline runs in single-digit milliseconds in our region. None of these steps require a network call to a third-party — the index lookup, customer resolution, and entitlement read are all local Firestore reads.

## Rotation & revocation

Rotate or revoke any key from **Developers → API** — the **API** page. Each key has a **Rotate** and a **Revoke** action, and every change is recorded in **Key activity** on the same page.

1

Rotate is a zero-downtime grace roll

Click **Rotate** on the key and confirm. In a single operation Crossdeck mints the replacement *and* puts the old key into a **7-day grace window** — both keys authenticate during that window, so nothing breaks the instant you rotate. The new value is shown **once**; copy it before closing the dialog (we keep only its hash). Rotation is for routine hygiene. If a key is *compromised*, use **Revoke** instead — that kills it immediately, with no grace (see below).

2

Deploy the new key within the grace window

The old key keeps working for 7 days after you rotate, then stops on its own — auth fails it closed once the window lapses. Update your environment variables, redeploy your server, or ship the new client build any time before then; there is no scramble and no outage. The **Heartbeat** page lets you confirm traffic has moved to the new key. (For a compromised key, don't wait for the window — **Revoke** kills the old key now.)

3

Revoke to kill a key outright

Use **Revoke** when you want to retire a key *without* minting a replacement — for example a suspected leak. Revocation is immediate and irreversible: the next request signed by that key returns `401 key_revoked`.

Suspected secret-key leak: revoke first, ask questions later

If you think a `cd_sk_…` may be exposed (committed to a public repo, posted in chat, found in a screenshot), revoke it before doing anything else. Generate a new one, rotate everywhere, then audit. We'd rather you cause a brief outage than leave a leaked credential live.

## Errors you might see

All API errors follow a Stripe-style envelope so generic SDK error handlers can read them uniformly:

```
{
  "error": {
    "type": "authentication_error",
    "code": "invalid_api_key",
    "message": "Unknown publishable key. Check the key matches an app registered in your project.",
    "request_id": "req_lqr4y2k3a1b2"
  }
}
```

HTTP
type
code
Cause

401
authentication_error
missing_api_key
No Authorization or Crossdeck-Api-Key header

401
authentication_error
invalid_api_key
Key doesn't match any app, or wrong key type for the endpoint

401
authentication_error
key_revoked
Key was revoked from the dashboard

403
permission_error
origin_not_allowed
Web key with origin allowlist; request Origin didn't match

403
permission_error
env_mismatch
Tried to access data tagged with a different environment

400
invalid_request_error
missing_customer
No userId , anonymousId , or customerId in the request

429
rate_limit_error
rate_limited
Too many requests on this key in the current window. Backoff and retry.

500
internal_error
internal_error
Crossdeck-side problem. The request_id is the fastest path to a diagnosis — send it to support.

## What we never store

Crossdeck's storage rules are strict by design. The audit-log invariant — every entitlement decision must be explainable, with the evidence trail intact — only works if we don't accidentally accumulate sensitive data we don't need.

**We never store:**

- **Apple `.p8` private keys** — uploaded directly to Google Cloud Secret Manager from the dashboard, never touch our application database.

- **Stripe secret keys** — Crossdeck Connect uses OAuth, so we hold per-account access tokens that the developer can revoke from their Stripe dashboard at any time. Platform-level keys live in Secret Manager.

- **Google Play service-account JSON** — same pattern: Secret Manager, runtime-only.

- **Webhook signing secrets** — same pattern.

- **Plaintext secret API keys** — only the SHA-256 hash is retained.

- **End-user credit card numbers, CVVs, or full PANs** — these never leave the payment rail. Crossdeck reads only the metadata (last 4, brand, expiry) the rail provides.

- **End-user passwords or biometric data** — Crossdeck doesn't run end-user authentication; that's the developer's auth system.

**We do store** (per [backend-schema.md](https://cross-deck.com/backend-schema.md)):

- Project configuration: app metadata, public SDK keys, payment-rail metadata + `secretRef` pointers, products, entitlements.

- Customer state: identity-graph keys (rail customer IDs, developer-supplied user IDs, anonymous device hashes), email if provided, current entitlement projection.

- Subscription records: state, period dates, last event ID, productId.

- Append-only audit log: every entitlement decision with the event ID, signature-verification status, and reconciliation status that produced it. Retained 24 months.

- Telemetry events (when wired): batched into ClickHouse, keyed by Crossdeck customer ID. Retention configurable per plan.

Privacy by default

Crossdeck is operable without collecting any end-user PII. Hashed identifiers, opaque IDs, no fingerprinting. GDPR/POPIA compliance is automatic, not opt-in. If you do supply email or a developer user ID, we keep it; if you don't, we don't.

## FAQ

### Is it actually safe to put my publishable key in client code?

Yes. The publishable key is an app identifier, not a credential. It can only do operations a logged-in end-user could legitimately do for their own account. Treat it like the API key embedded in your Stripe Checkout button or your Firebase project's `apiKey` field — it's safe to ship.

### What if someone copies my publishable key and uses it on their own site?

If you've configured an origin allowlist on the web app, requests from another origin are rejected with `403 origin_not_allowed`. If you haven't, they could send telemetry events as if they were your app — but they still can't read other customers' data, can't grant entitlements, can't see your audit log. Set the allowlist before you go live and the attack surface goes to zero.

### Can I use one key for both test and live?

No. Test and live keys are separate, and there's no API path that lets one read or write data in the other. This is deliberate: it's the same separation Stripe enforces, and it's how we guarantee that sandbox events never contaminate production metrics.

### How many keys can I have per project?

One publishable + one secret per app, per environment. So a project with iOS, Android, and web apps in production has 3 publishable + 3 secret live keys — and another 3 + 3 in sandbox. During a rotation's 7-day grace window you'll briefly hold two live keys for that app (the new one plus the rotated-out one), by design — it's how the cutover stays zero-downtime. The old one expires automatically when the window closes.

### Does Crossdeck rate-limit my keys?

Yes — the limits are documented on the [Analytics](https://cross-deck.com/docs/index.html#analytics) [Errors](https://cross-deck.com/docs/index.html#errors) page (event ingestion), [Revenue intelligence](https://cross-deck.com/docs/index.html#revenue-intelligence) page (read endpoints), and your plan's **Settings → Usage** tab. Free-tier limits are intentionally generous for development and small production deployments. Going over a rate limit returns `429 rate_limited`; back off and retry.

### What's the difference between revoking a key and deleting an app?

Revoking a key invalidates that one credential while keeping the app's audit history, customer records, subscriptions, and entitlements intact. Deleting an app removes the entire app surface — its keys are revoked, its records are archived, and you'd need to rebuild it from scratch. Almost always you want to revoke, not delete.

### What happens to a customer's entitlements if I rotate the publishable key?

Nothing. Entitlements are scoped to the project and the customer, not the key. The new key sees the same customers, the same subscription state, the same audit log. Rotation only changes what the SDK uses to authenticate.

### Is there an SDK that handles this for me?

Yes — the published SDK (`@cross-deck/web`, `crossdeck-ios (coming soon)`, `crossdeck-android (coming soon)`) wraps every endpoint, handles retries, persists the resolved `crossdeckCustomerId` for follow-up calls, and maintains a local entitlement cache so `isEntitled("pro")` answers in microseconds. See the [SDKs](https://cross-deck.com/docs/index.html#sdks) section for install snippets.

Last updated when the GET /v1/entitlements endpoint shipped (May 8, 2026). New endpoints are added to the capability matrix as they land.
