Crossdeck Docs
Dashboard

API keys & authentication

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

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:

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.

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:

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:

  1. Extract the key. Read Authorization: Bearer … or Crossdeck-Api-Key: …. Missing or malformed → 401 missing_api_key.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

You can rotate or revoke any key from the dashboard's Apps page.

1
Generate a replacement

Open the app's settings, click Rotate publishable key or Rotate secret key. Crossdeck generates a new key alongside the old one — both keep working. This gives you a window to update your clients or servers before retiring the old key.

2
Roll your callers

Update environment variables, redeploy clients, push a new mobile build. Watch the Heartbeat table on the Apps page — it shows which key version each install is using.

3
Revoke the old key

Once heartbeat shows zero installs on the old key, click Revoke. Revocation is immediate — the next request signed by the revoked 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:

We do store (per backend-schema.md):

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. Rotate by generating a new key alongside the old one and revoking the old after callers have migrated.

Does Crossdeck rate-limit my keys?

Yes — the limits are documented on the Telemetry page (event ingestion), 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 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.