# Troubleshooting & FAQ

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

Support
12 min read
·
Symptom-first, deep-linkable answers

Start from what you're seeing — a dashboard that stays empty, a stack trace that's unreadable, an `isEntitled()` that returns false for a paying customer, a 4xx from the API — and this page walks you to the cause and the fix. Every answer has a stable anchor, so support can link you straight to the relevant entry. If your question is broader than a symptom, each entry links to the full reference doc.

## Nothing is arriving

### I installed the SDK but no events show up

Sending a first event is three steps — install `@cross-deck/web`, call `Crossdeck.init()` with your publishable key at your app's entry point, and fire `Crossdeck.track()`. The event lands in your Overview within seconds, and autotrack means `page.viewed` fires even before you wire anything. The full walkthrough, including framework-specific init placement for Next.js, React, Vue, and Svelte, is in the [web quickstart](https://cross-deck.com/docs/quickstart-web/index.html) and [Web SDK reference](https://cross-deck.com/docs/web-sdk/index.html).

When events still don't arrive, work through these in order — they cover the overwhelming majority of cases:

- **Open the browser console.** The SDK is deliberately loud about delivery failures: a rejected batch logs `[crossdeck] Event batch DROPPED (status …)` with the reason, and a version-parked SDK logs a one-line advisory. Whatever it printed maps to an entry in [API errors](#api-errors) below.

- **Check the key.** Publishable keys start with `cd_pub_` and come from **Developers → API** in the dashboard. A typo'd or unknown key fails with `invalid_api_key` — see [below](#missing-or-invalid-api-key).

- **Check the environment pill.** Sandbox keys send to the Sandbox environment; if the dashboard's environment switcher (top-right) is on Production, sandbox events are invisible. See [the environment entry](#sandbox-or-production).

- **Check the origin.** Web publishable keys are locked to an origin allowlist. A deployed domain that isn't on the list is rejected with `origin_not_allowed` — see [the fix](#origin-not-allowed).

- **Check your CSP.** A `connect-src` policy that doesn't include `api.cross-deck.com` blocks every request before it leaves the browser — see [the next entry](#csp).

### My Content Security Policy is blocking the SDK

**What you see:** CSP violation reports in the console (*"Refused to connect to https://api.cross-deck.com…"*) and no events on the dashboard.

**Why:** the SDK posts events to `https://api.cross-deck.com`, and your CSP's `connect-src` directive doesn't allow that origin.

**Fix:** add the API origin to `connect-src`:

```
Content-Security-Policy: connect-src 'self' https://api.cross-deck.com;
```

No other CSP directive needs loosening:

- The SDK does **not** use `eval()` or `new Function()` — `script-src 'unsafe-eval'` is not required.

- The SDK does **not** inject inline ` ` — `'unsafe-inline'` is not required.

- The SDK does **not** load worker scripts — `worker-src` is unaffected.

If you serve the UMD bundle from your own CDN, add that origin to `script-src` too.

### The console says my events are "PARKED"

**What you see:** events stop arriving, and the console shows a single warning: *"[Crossdeck] SDK outdated — the server is no longer accepting this version's event format. Your events are PARKED on-device (held, not lost)…"*. The dashboard shows a matching update advisory.

**Why:** the server answered an event batch with **HTTP 426 Upgrade Required** (`sdk_version_unsupported`). Your SDK build's wire format is older than the version floor the server accepts. This is deliberately different from a normal 4xx rejection: the data is good, only the format is stale, so the SDK *parks* instead of dropping.

**What parking means — your events are not lost:**

- The in-flight batch and everything queued after it are held in the SDK's persisted on-device buffer (up to 1,000 events; beyond that, oldest are evicted first).

- The SDK stops retrying for the rest of the session — every retry of the same stale format would fail identically.

- On the next boot with an upgraded SDK, the persisted buffer rehydrates and the held events deliver automatically. No replay step, no data request.

**Fix:** update the SDK package and redeploy. The 426 response body names the exact floor in its `minVersion` field (and the `surface` — web, node, swift, or react-native), and the console warning repeats it:

```
npm install @cross-deck/web@latest
```

Full details on the buffering and persistence model are in [SDK event durability](https://cross-deck.com/docs/sdk-event-durability/index.html).

### What does the SDK track automatically?

Useful to know both ways — when an event you expected is "missing" because you assumed it needed wiring, and when events appear that you never wrote. By default the Web SDK fires the following for you:

- `session.started` / `session.ended` — 30-minute idle session model

- `page.viewed` — initial load *and* SPA navigation (`history.pushState` + `popstate`)

- `element.clicked` — every clickable element (skips inputs, passwords, and `.cd-noTrack` subtrees)

- `webvitals.lcp` / `inp` / `cls` / `fcp` / `ttfb` — Core Web Vitals

- `error.unhandled` / `error.unhandledrejection` / `error.http` — error capture

- Device fingerprint — OS, browser, locale, screen size — attached to every event

Disable any of them at init:

```
Crossdeck.init({
  publishableKey: "cd_pub_...",
  autoTrack: { clicks: false, webVitals: false },
});
```

Tag specific elements as untracked with `data-cd-no-track`, or wrap a subtree in a `.cd-noTrack` class. Full list and options in [Track events](https://cross-deck.com/docs/track-events/index.html).

### Events are landing in the wrong environment

Crossdeck runs two fully isolated environments per workspace — Production and Sandbox — with separate keys, separate webhook URLs, and separate storage, selected by the environment pill at the top of the dashboard. If the dashboard looks empty, the most common cause is sending with a sandbox key (`cd_pub_test_…`) while viewing Production, or the reverse. The full model, including how Stripe test mode and the App Store sandbox map onto it, is in [Sandbox vs production](https://cross-deck.com/docs/sandbox-vs-production/index.html).

One related failure is loud by design: if your `Crossdeck.init({ environment })` declares one environment but your key resolves to the other, the API rejects the batch with `env_mismatch` rather than silently filing events in the wrong place — see [the API errors entry](#env-mismatch).

## Errors look wrong

### Stack traces are minified and unreadable

**What you see:** errors in **Dashboard → Errors** with frames like `a.js:1:48213` instead of your source files.

**Why:** the SDK ships the raw stack from the browser, which runs your minified production bundle. Crossdeck needs your source maps to translate frames back to source.

**Fix:** upload source maps after each production build:

```
npx @cross-deck/cli sourcemaps upload \
  --release "1.4.2" \
  --dist "build/static/js"
```

The CLI walks the directory, finds every `*.js.map`, and uploads each one tagged with your release. Stack frames are rewritten at view time — your source bundles never leave your build pipeline (the maps do, but they're held privately). Wire the command as the last step of your CI build. Full setup, including release naming, in [Source maps](https://cross-deck.com/docs/source-maps/index.html).

### Errors show only "Script error." with a blank stack

**What you see:** an issue whose message is just `Script error.` with no stack frames.

**Why:** the browser, not Crossdeck. When an exception is thrown by a script loaded from a different origin without CORS opt-in, the browser strips the message and stack before any error handler sees them. Crossdeck deliberately does *not* drop these — they're real errors in your third-party or CDN-hosted scripts. They're captured with an explicit message naming the fix, tagged `cross_origin: "true"`, and grouped under a single fingerprint, because one CORS change fixes all of them.

**Fix:** two changes on the script you're loading cross-origin:

- Add `crossorigin="anonymous"` to the ` ` tag.

- Ensure the script's host serves it with an `Access-Control-Allow-Origin` header that covers your page's origin.

Once both are in place, the browser hands full messages and stacks to the SDK. If you'd rather mute these issues instead, add `"Script error"` to `ignoreErrors` at init. More in [Capture an error](https://cross-deck.com/docs/capture-an-error/index.html).

### An error I expected isn't in the Errors tab

**What you see:** an exception you know occurred doesn't appear as an issue.

**Why:** the SDK captures three classes of failure automatically — synchronous throws and script parse failures (`window.onerror` plus a capture-phase listener), unhandled Promise rejections, and HTTP failures via a fetch/XHR wrap. Two things it deliberately does *not* capture:

- **Caught exceptions.** If your `try/catch` handles the error, the SDK never sees it. Report it explicitly: 

```
try { riskyThing(); }
catch (err) {
  Crossdeck.captureException(err, { tags: { feature: "checkout" } });
}
```

- **4xx responses.** The HTTP wrap reports 5xx and network failures only — a 404 or 422 is an expected application outcome, not an error, and capturing them would flood the queue.

Tags surface in **Dashboard → Errors** as filterable facets — set sensible ones early and you can slice by feature, region, or experiment later. Full reference in [Capture an error](https://cross-deck.com/docs/capture-an-error/index.html).

### I didn't get an alert email for an error

**What you see:** an issue has hundreds of occurrences but you only ever got one email — or none.

**Why:** alerts fire on *new issues*, not occurrences. Crossdeck groups errors by fingerprint (a hash of the exception type plus the stack-frame shape); each unique fingerprint is one issue. An email fires when an issue appears for the first time, and again if an issue you marked resolved regresses. Subsequent occurrences of a known issue never email — the occurrence counter on the Errors page is the truth for volume.

**Fix:** if you got no email at all for a genuinely new issue, check the recipient list and thresholds at **Dashboard → Errors → Settings**, and check whether the issue was muted — per-issue mute silences one noisy fingerprint without disabling alerts globally. Triage workflow in [Alerts and triage](https://cross-deck.com/docs/alerts-and-triage/index.html).

## Identity & entitlements

### What's the difference between anonymous and identified users?

Every visitor gets an anonymous ID the moment the SDK boots; events fire under it until your app calls `Crossdeck.identify(userId, traits)`. On identify, the anonymous timeline merges into the identified user's history — nothing is lost — and any matching payment-rail customer stitches in automatically. Call `identify()` on every page load that has a signed-in user (the SDK no-ops if the ID hasn't changed). Full mechanics in [Identify users](https://cross-deck.com/docs/identify-users/index.html).

### developerUserId vs cdcust — which do I use where?

`developerUserId` is yours — the stable user ID from your own database that you pass to `identify()`. `cdcust_*` is Crossdeck's internal customer ID: one per real human across every rail and every merged anonymous session, used by the dashboard, webhooks, and audit log. You almost never write `cdcust` from code — the one place you need it is support tickets and per-customer dashboard URLs (`?id=cdcust_*`). More in [Identify users](https://cross-deck.com/docs/identify-users/index.html).

### Why am I seeing an identity conflict?

**What you see:** a conflict flagged at **Settings → Identity conflicts**, or a migration that reports conflicts.

**Why:** two of Crossdeck's internal customer records (`cdcust_*`) point at what looks like the same person. Common causes:

- Your migration linked the same `stripeCustomerId` to two different developer user IDs

- Apple and Google identifiers for the same user came in via separate `identify()` calls

- A user signed in with email, then later with Google, and both sessions identified with different developer IDs

Crossdeck does **not** silently merge. Merging is irreversible from the rail's perspective, and one bad merge contaminates the customer's revenue truth — so conflicts wait for your review instead.

**Fix:** open **Settings → Identity conflicts** and decide per conflict:

- **Merge** — both records become one. The winning `cdcust_*` keeps history; the other archives with a forwarding pointer.

- **Acknowledge distinct** — two real people who happen to share an identifier (shared Stripe account, family Apple ID). Both records stay.

How identities stitch in the first place is covered in [Identity verification](https://cross-deck.com/docs/identity-verification/index.html).

### isEntitled() returns false but my customer paid

**What you see:** a customer with a live subscription is gated out of a feature; `isEntitled("foo")` returns inactive.

**Why:** three things must all be true for the check to pass. Walk them in order:

- **An entitlement keyed `foo` exists** in your catalog. Check **Dashboard → Entitlements**; create it if not.

- **A product is attached to that entitlement.** Each product can grant multiple entitlements and an entitlement can be granted by multiple products. Check the product's "Grants entitlements" section in **Dashboard → Products**.

- **The customer has a revenue-bearing subscription on that product.** Revenue-bearing means state in `ACTIVE / BILLING_RETRY / GRACE_PERIOD / PAUSED`. `EXPIRED`, `CANCELED`, and `REFUNDED` don't grant.

Matching between subscription and product is direct: `subscription.productId === product.productId`. If the customer's subscription references a Stripe product that has no corresponding catalog row, the entitlement won't resolve — open the customer's detail page and the row will show "Unknown product" so you know to act.

One thing you do *not* need to wait for: when you attach an entitlement to a product in the dashboard, every existing subscriber's entitlement re-projects within seconds — no next-webhook delay. Full model in [Entitlements](https://cross-deck.com/docs/entitlements/index.html).

### What does isEntitled() actually return?

A `PublicEntitlement` record, not a bare boolean — `isActive` for the gate, `validUntil` for renewal-aware UI, and `source` (rail, product, subscription) for "who granted this?" forensics. The bare-boolean usage still works for one-off checks, but you lose the metadata. Field-by-field reference in [Entitlements](https://cross-deck.com/docs/entitlements/index.html).

### Do monthly and yearly need separate entitlements?

Usually no. One entitlement per capability your code branches on: attach a single `pro` entitlement to *both* the monthly and yearly products and `isEntitled("pro")` passes on either. Use separate entitlements only when the two periods genuinely unlock different capability (e.g. a lifetime perk only yearly buyers get). Reasoning and patterns in [Entitlements](https://cross-deck.com/docs/entitlements/index.html).

## Payment rails

### How do I connect Stripe?

One OAuth round-trip: **Dashboard → Payment rails → Connect Stripe**, approve on Stripe's consent screen, done. Crossdeck asks for `read_write` scope — needed to read subscription state and to register the webhook endpoint that verifies revenue in real time — and touches nothing else in your account. Disconnecting from the same page removes the webhook on the way out. Step-by-step in [Connect Stripe](https://cross-deck.com/docs/connect-stripe/index.html).

### How do I connect the Apple App Store?

Apple has no OAuth for the App Store Server API, so you upload three things from App Store Connect: your **Bundle ID**, your team's **Issuer ID**, and a **private key** (`.p8`) minted with the App Store Server API role. Crossdeck stores the key in Secret Manager, registers Server Notifications V2 at the URL shown on the connect page, and verifies Apple's signature on every payload. Walkthrough in [Connect Apple](https://cross-deck.com/docs/connect-apple/index.html).

### Stripe shows "disconnected" after I connected

**What you see:** the Stripe rail flips to disconnected on **Dashboard → Payment rails** some time after a successful connect.

**Why:** one of two states, and the Rails page tells you which:

- **OAuth token rejected** — Stripe revoked Crossdeck's access. Most common cause: someone on the Stripe account changed something under *Account → Connected Apps*.

- **Webhook endpoint missing** — OAuth is fine but the webhook URL is no longer registered on your Stripe account, so revenue verification can't flow.

**Fix:** for a rejected token, click **Reconnect** on the same page — one click through OAuth again. For a missing webhook, click **Re-register webhook** and Crossdeck writes it back. If neither matches, [contact support](#still-stuck) and paste the connected-account ID from the Rails page so we can inspect your account's webhook list directly.

### My migration finished but says "X customers still unlinked"

**What you see:** the migration import completes, but the Overview banner reports customers still unlinked.

**Why:** two common causes:

- **The identifier doesn't match anything Crossdeck has received yet.** No event has arrived from that customer's rail — perhaps they're subscribed but inactive, or the rail's webhook hasn't replayed yet. Their entry stays pending, and the next rail event for that identifier links them automatically. No action needed.

- **Two of your rows resolved to the same Crossdeck customer.** That's an [identity conflict](#identity-conflict) — review it at **Settings → Identity conflicts** and either merge or acknowledge distinct.

The banner deep-links you to whichever applies: "Re-run snippet & try again" when there are unlinked rows and no conflicts, or straight to the Conflicts surface when conflicts are pending. Import mechanics in [Migration](https://cross-deck.com/docs/migration/index.html).

### My webhook endpoint isn't receiving deliveries

**Note:** a general-purpose outbound webhook delivery system — endpoints you register to receive every event, with automatic retries, a delivery log, and replay — is on the roadmap but *not yet shipped*. There is currently no **Developers → Webhooks → Deliveries** surface, no outbound retry/backoff schedule, and no replay action. (The [alert-rule](https://cross-deck.com/dashboard/alerts/index.html) webhook channel — a webhook fired when a custom alert rule trips — is a separate, real feature and is unaffected by this.) If you were expecting general outbound delivery, watch the changelog for when it ships; in the meantime, pull the data you need via the documented endpoints and the SDKs.

## API errors

Every v1 API error is a structured, Stripe-shaped body:

```
{
  "error": {
    "type": "permission_error",
    "code": "origin_not_allowed",
    "message": "Request Origin is not on this app's allowed list.",
    "request_id": "req_abc123"
  }
}
```

Always include the request_id in support tickets.

It's in the body and in the `X-Request-Id` response header, and the same ID is logged on our side at the moment the decision was made — support can trace your report back to the exact request. Error responses also carry CORS headers, so the structured body is readable from browser code rather than disappearing into opaque CORS noise.

Code HTTP What it means Fix

missing_api_key 401 No key on the request. Send the key as Authorization: Bearer or Crossdeck-Api-Key .
invalid_api_key 401 Key is malformed or unknown. Check the prefix and value against Developers → API.
key_revoked 401 A secret key that was revoked in the dashboard. Mint a new secret key and rotate it into your server config.
origin_not_allowed 403 Browser Origin isn't on the web key's allowlist. Add the origin in Apps → Allowed origins.
bundle_id_not_allowed 403 iOS request's bundle ID doesn't match the key's locked bundle ID. Check Apps → Bundle ID against Bundle.main.bundleIdentifier .
package_name_not_allowed 403 Android request's package name doesn't match the key's locked package. Check Apps → Package name against your applicationId .
env_mismatch 403 SDK declared one environment, key resolves to the other. Reconcile init and key .
missing_customer / invalid_customer 400 Customer identifier absent or unresolvable. Pass the customer identifier the endpoint documents.
missing_required_param / invalid_param_value 400 Request validation failed; message names the field and event index. Fix the named field and resend.
idempotency_key_in_use 400 The same idempotency key was reused with a different payload while the original is in flight. Reuse a key only with the identical request; otherwise mint a new one.
rate_limited 429 Project exceeded its event throughput. Honor Retry-After ; the SDK does this for you.
sdk_version_unsupported 426 SDK wire format below the server's version floor. Events are parked, not lost . Update the SDK to the minVersion in the body.
internal_error 500 Crossdeck-side failure. Safe to retry; the SDK retries with backoff automatically. Report persistent 500s with the request_id.

### 401 — missing_api_key / invalid_api_key

**What you see:** every request fails with 401; the browser console shows `[crossdeck] Event batch DROPPED (status 401)` — the SDK drops the batch rather than retrying a key the server will never accept.

**Why:** the request carried no key, a key with the wrong shape, or a key Crossdeck doesn't recognize. The API accepts the key as `Authorization: Bearer cd_pub_…` (Stripe convention) or as a `Crossdeck-Api-Key` header, and the key must start with `cd_pub_` (publishable) or `cd_sk_` (secret).

**Fix:** copy the key fresh from **Developers → API** and check three things — the prefix matches the surface (publishable in clients, secret on servers only), the environment matches (sandbox keys carry `test`, production keys carry `live`), and nothing was truncated in an env-var or paste. If a previously working *secret* key starts failing with `key_revoked`, it was revoked in the dashboard — revocation fails closed immediately; mint and rotate a new one. Key model in [API keys](https://cross-deck.com/docs/api-keys/index.html).

### 403 — origin_not_allowed

**What you see:** the SDK works on one domain but every request from another (a new production domain, a preview deploy, a staging host) fails with 403 `origin_not_allowed`.

**Why:** web publishable keys are locked to an explicit origin allowlist, enforced on every request. The browser's `Origin` header must match an entry exactly — scheme, host, and port. An empty allowlist rejects everything (fail-closed), with one onboarding exception: a brand-new web app learns its first origin automatically from the first SDK heartbeat, so the project you set up yesterday worked without you typing a domain.

**Fix:** add the missing origin in the dashboard under **Apps → Allowed origins**. Two details that save round-trips:

- **Exact match includes scheme and port.** `https://app.example.com` does not cover `http://app.example.com` or `https://app.example.com:8080`.

- **Single-level wildcards are supported.** `https://*.example.com` matches `https://anything.example.com` — one label only, so it does *not* match `https://a.b.example.com` or the apex. List `https://example.com` and `https://*.example.com` together to cover both. This is the pattern for per-tenant subdomains and Vercel preview URLs.

The iOS and Android equivalents are `bundle_id_not_allowed` and `package_name_not_allowed` — same lock, keyed on the app's OS-canonical identifier instead of an origin, fixed under **Apps → Bundle ID** / **Apps → Package name**.

### 403 — env_mismatch

**What you see:** 403 with a message like *"SDK declared environment "sandbox" but the API key resolves to "production"."*

**Why:** your `Crossdeck.init({ environment })` and your publishable key disagree about which environment this app is. Crossdeck refuses the batch rather than guessing, because the alternative is worse: a typo'd key silently leaking sandbox data into production metrics, or production events vanishing into sandbox.

**Fix:** make the two agree. Either change the `environment` value in your init to match the key, or swap the key — the environment is visible in the key itself (`cd_pub_test_…` vs `cd_pub_live_…`). The standard setup is sandbox keys in staging and CI, live keys in production, wired through your deploy environment's config. See [Sandbox vs production](https://cross-deck.com/docs/sandbox-vs-production/index.html).

### 429 — rate_limited

**What you see:** bursts of 429 on `/v1/events`, with a `Retry-After` header (seconds) and an `X-Crossdeck-Sample-Rate` hint. The dashboard shows a throttle banner for the affected window.

**Why:** the project exceeded its event throughput and the token bucket couldn't cover the batch. This is almost always an error storm — a crash loop emitting thousands of identical exceptions — rather than legitimate traffic.

**What you don't lose:** throttling is built to keep the dashboard truthful during a storm:

- **Error visibility survives.** Even while throttled, one event per error fingerprint per minute is let through, so every distinct issue stays visible — you lose occurrence volume, not signal. Dropped occurrences are counted in aggregate and surface in the throttle banner.

- **The SDK retries.** 429 is a retryable failure: the batch stays queued and re-sends with backoff under the *same* idempotency key, so client-side events are neither lost nor duplicated.

**Fix:** nothing, usually — honor `Retry-After` (the SDK already does) and fix the underlying error storm. If you're hitting limits with legitimate traffic, see [Limits and quotas](https://cross-deck.com/docs/limits-and-quotas/index.html) or [contact us](#still-stuck) about raising your project's limits.

### 426 — sdk_version_unsupported

The one error that *holds* your data instead of refusing it: the SDK parks the batch on-device and delivers it after you upgrade. Full walkthrough in ["The console says my events are PARKED"](#sdk-parked) above. The response body carries `minVersion` (the floor to update past) and `surface` (which SDK), so the fix is always named in the rejection itself.

## Still stuck

Two ways to reach a human:

- [Contact us](https://cross-deck.com/contact/index.html) — the public contact form, no account needed.

- [Dashboard help center](https://app.cross-deck.com/help) — open a ticket from inside the dashboard. Tickets opened there attach to your project automatically, so support sees your configuration without a back-and-forth.

Whichever you use: include the `request_id` from any failing API response, and for rail issues the connected-account ID from the Rails page. Those two identifiers usually turn a multi-day thread into a single reply.
