Crossdeck Docs
Dashboard

Troubleshooting & FAQ

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 and Web SDK reference.

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

  1. 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 below.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

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:

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:

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.

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:

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.

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.

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.

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.

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:

  1. Add crossorigin="anonymous" to the <script> tag.
  2. 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.

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:

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.

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.

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.

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.

Why am I seeing an identity conflict?

What you see: a conflict flagged at Settings → 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:

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 → Conflicts and decide per conflict:

How identities stitch in the first place is covered in Identity verification.

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:

  1. An entitlement keyed foo exists in your catalog. Check Dashboard → Entitlements; create it if not.
  2. 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.
  3. 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.

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.

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.

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.

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.

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:

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

My webhook endpoint isn't receiving deliveries

If your endpoint returns anything other than 2xx (or times out at 30 seconds), Crossdeck retries with exponential backoff — 1m → 5m → 15m → 1h → 6h → 24h — then moves the delivery to Failed. Inspect the body, headers, and your endpoint's response at Developers → Webhooks → Deliveries, and replay any failed delivery from there; replays carry the same Crossdeck-Idempotency-Key, so your handler can safely no-op on a second attempt. Signature verification and setup in Receive webhooks.

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.

CodeHTTPWhat it meansFix
missing_api_key401No key on the request.Send the key as Authorization: Bearer or Crossdeck-Api-Key.
invalid_api_key401Key is malformed or unknown.Check the prefix and value against Developers → API.
key_revoked401A secret key that was revoked in the dashboard.Mint a new secret key and rotate it into your server config.
origin_not_allowed403Browser Origin isn't on the web key's allowlist.Add the origin in Apps → Allowed origins.
bundle_id_not_allowed403iOS request's bundle ID doesn't match the key's locked bundle ID.Check Apps → Bundle ID against Bundle.main.bundleIdentifier.
package_name_not_allowed403Android request's package name doesn't match the key's locked package.Check Apps → Package name against your applicationId.
env_mismatch403SDK declared one environment, key resolves to the other.Reconcile init and key.
missing_customer / invalid_customer400Customer identifier absent or unresolvable.Pass the customer identifier the endpoint documents.
missing_required_param / invalid_param_value400Request validation failed; message names the field and event index.Fix the named field and resend.
idempotency_key_in_use400The 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_limited429Project exceeded its event throughput.Honor Retry-After; the SDK does this for you.
sdk_version_unsupported426SDK wire format below the server's version floor. Events are parked, not lost.Update the SDK to the minVersion in the body.
internal_error500Crossdeck-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.

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:

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.

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:

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 or contact us 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" 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:

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.