API keys & authentication
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) andcd_sk_…(server-side only). - Authenticate every request with
Authorization: Bearer <key>. - 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
.p8private 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 |
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: falsealways land in the sandbox partition, regardless of which Crossdeck project they belong to. - Apple App Store Server Notifications carry an
environmentfield 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.
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:
- Exact match. Scheme, host, and port all matter —
https://example.comdoes not matchhttps://example.com:3000orhttp://example.com. - Case-sensitive. Per RFC,
https://APP.example.comis a different origin fromhttps://app.example.com. - No wildcards. If you need to allow many subdomains, list them. Wildcards are intentionally not supported.
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:
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).
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.
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.
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 …orCrossdeck-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 queryappKeys/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
Originheader must match one exactly. Mismatch →403 origin_not_allowed. - Resolve the customer. The endpoint reads
userId,anonymousId, orcustomerIdfrom the request. We look these up in that project'scustomerIndexsubcollection — 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-Idheader, 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.
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.
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.
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.
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
.p8private 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):
- Project configuration: app metadata, public SDK keys, payment-rail metadata +
secretRefpointers, 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.
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.