Crossdeck Docs
Dashboard

Products — mirrored from your payment rail

Reference Current version: 1.6.3 · ~10 min read · Updated May 17, 2026

A product in Crossdeck is a strict mirror of a product and price on your payment rail — a Stripe product+price, an Apple in-app product, a Google base plan. You create products on the rail, where they must exist for billing to work at all. Crossdeck mirrors every one of them automatically — on connect, then continuously. You never author a product inside Crossdeck, and there is no “create product” form. The one thing you do with a product here is map it to the entitlements it should grant.

TL;DR

The product model

Crossdeck's catalog has three concepts, each owned by exactly one place. Getting the ownership right is what keeps the system honest — there is never ambiguity about who is correct.

ConceptSource of truthAuthored whereExample
Rail product The payment rail Stripe / Apple / Google console Stripe prod_HX… + price_1NY…, Apple com.app.pro, a Google base plan
Crossdeck product The payment rail (mirrored) Nowhere — mirrored automatically One product doc per rail product+price, at projects/{p}/products/{rail}_{railSkuId}
Entitlement Crossdeck Crossdeck dashboard pro, cloud_sync, ai_addon — the capability key your SDK answers about

The resolution chain is short and one-directional:

Active subscription on the rail
   → the Crossdeck product mirroring that rail SKU   (matched 1:1 by rail + railSkuId)
   → the entitlements that product is mapped to       (product.grantsEntitlements[])
   → materialised on the customer, served by the SDK

Why products mirror instead of being authored. A product is defined by its price, its billing interval, its identity — all created in, and enforced by, the rail. You have to create it on the rail regardless, because that is where the money moves. Re-declaring it inside Crossdeck would be double-entry: two copies of the same fact that can silently disagree. Mirroring eliminates that drift surface entirely — Crossdeck never holds an independent opinion about a product, so there is nothing to keep in sync. This is the decision recorded in ADR-0002.

You will not find a “Create product” button.

That is deliberate. Create the product in Stripe (or App Store Connect, or Google Play) the way you already do for billing. Crossdeck detects it — the dashboard's Products page shows guidance to that effect plus a Refresh control. The only authoring you do in Crossdeck is on the entitlement side.

How products appear

A rail product becomes a Crossdeck product through three paths, all of which converge on the same mirror:

PathWhen it runsWhat it does
Catalog backfill The moment you connect a rail Walks the rail's full product catalog and mirrors every product+price. One-time, on connect.
Live webhooks Continuously, after connect product.created/updated/deleted and price.created/updated/deleted events keep the mirror current as you change your catalog on the rail.
Refresh from rail On demand, from the Products page Re-runs the backfill immediately. Use it if you just created a product on the rail and do not want to wait for the webhook.

Every one of these is idempotent. A product's payload is hashed; if nothing changed, there is no write and no journal entry. Re-running a backfill, or a re-delivered webhook, is a no-op — so the catalog cannot drift from running ingestion twice.

The product's productKey — its identity inside Crossdeck — is derived deterministically from the rail: {rail}_{railSkuId}. The same rail SKU always lands on the same product doc, which is what makes re-ingest safe and makes the audit trail replayable.

The mirror & the display name

A product doc carries two field groups, and the line between them is structural — enforced by which code path is allowed to write which field, not by convention.

Field groupWritten byRead by
The mirror — rail product id, name, description, price, currency, billing interval, recurring-vs-one-off, active status, lineage, payload hash The rail-ingest path only. No API or dashboard route can mutate it. Resolution, webhook matching, reconciliation — everything load-bearing.
The display namedisplayName An operator, via the dashboard's display-name edit. Journaled, rationale required. The dashboard only. Nothing else.

The point of displayName is at-a-glance readability — a Stripe price id is not something you want to scan a list of. So Crossdeck lets you set a human label. But because the load-bearing fields have no edit path and the editable field is read by nothing load-bearing, the property holds by construction: an operator can make a product readable, but cannot break infrastructure by editing it.

The bank-account model.

The account number is immutable and routes the money; the account nickname is yours to set and routes nothing. displayName is the nickname. The rail product id is the account number. If displayName is empty, the dashboard falls back to the rail's product name — never a problem either way.

Mapping products to entitlements

Mirroring gets the product into Crossdeck. It does not, on its own, give any customer access — because Crossdeck has no opinion yet about what that product should unlock. That opinion is the product → entitlement mapping, and it is the one piece of the catalog Crossdeck owns.

The mapping is many-to-many. A product can grant several entitlements (a “Pro Plus” product granting both pro and pro_plus); an entitlement can be granted by several products. You edit it from either side:

Both routes write the same ledger. Every attach and detach is a journaled entry — hash-chained, stamped with the operator and a required rationale (at least 20 characters). When an auditor asks “why does this customer have pro?”, the answer is a replayable chain: their active subscription → the mirrored product → the mapping entry that granted pro, with the operator and reason attached.

A rail price change never wipes your mapping.

The mapping lives in the CD-owned field group. When the rail updates a product's price or name, the ingest path refreshes the mirror fields and carries the mapping (and your displayName) forward untouched. Re-pricing a plan on Stripe does not cost you the entitlement wiring.

Products without entitlements

Because mirroring and mapping are separate steps, there is a window where a product exists in Crossdeck but grants nothing — a brand-new product you created on the rail and have not mapped yet. A customer can subscribe to it and receive zero entitlements.

That is a real risk — revenue arriving with no access attached — so the dashboard surfaces it loudly. The Products page shows a banner: “N products grant no entitlements.” It links straight to the offending products so you can map them. An active product should always grant at least one entitlement; if it should genuinely grant nothing, that is a deliberate choice you make by leaving it — but the banner will keep reminding you.

One product, many rails

The same plan sold on three platforms is three different rail products with three unrelated identifiers: a Stripe product+price for web, an Apple in-app product for iOS, a Google base plan for Android. Crossdeck mirrors all three as three separate products.

They are unified not by a product-grouping concept, but at the entitlement layer: you map the same entitlement — pro — to all three products. A customer who subscribes through any one of them resolves to pro. Identical outcome, one fewer concept to keep in sync.

stripe_prod_pro_price_monthly   →  grants: ["pro"]
apple_com_app_pro               →  grants: ["pro"]
google_pro_sub_monthly_base     →  grants: ["pro"]

           a customer on ANY of the three  →  isEntitled("pro") === true

This is why cross-platform access is “buy once, have it everywhere” without any special handling: the entitlement is attached to the customer, and any rail is just one more way to earn it.

Inactive products

When you archive or deactivate a product on the rail, Crossdeck does not delete its mirror — it flips the product's active status to false and keeps the doc. This is settlement finality: history is never erased.

An inactive product still resolves for customers who already hold an active subscription on it. The rail deactivating a product means “stop selling it to new customers” — it does not cancel anyone's existing subscription. So an existing subscriber keeps their entitlement; only the subscription's own lifecycle (a cancellation, a failed renewal) ends it. The dashboard hides inactive products by default behind a Show inactive toggle.

Webhooks vs. the rail API

Crossdeck treats a rail webhook as a nudge, never as data. A webhook payload is a snapshot from the moment the event was sent — it can arrive late, out of order, be re-delivered, or be a replay. Projecting that payload directly would let the mirror drift from the rail.

So when a product.* or price.* webhook arrives, Crossdeck does not trust its body. It takes the changed product id, calls the rail's API to fetch the authoritative current state of that product, and mirrors that. The webhook only tells Crossdeck when to look; the rail's API tells it what is true.

Why this matters for you.

It means a missed or duplicated webhook cannot quietly corrupt your catalog. The worst a dropped webhook does is delay an update until the next event or the next backfill. You do not need to engineer around webhook reliability — that is Crossdeck's job, and the rail's API is always the backstop.

There is no products API

Products are managed entirely through the dashboard and the rail. There is no /v1/products HTTP endpoint, and your SDK never reads products directly. That is intentional: your application code should ask about capabilities, not catalog rows.

Your app calls isEntitled("pro") / getEntitlements() — it asks “does this customer have access?” The product, the price, and the rail that earned it are implementation detail behind that answer. This keeps your gate sites stable across every pricing change you will ever make. See the Entitlements doc for the read path.

Troubleshooting

A product I created on the rail is not showing up

  1. Give the webhook a few seconds — ingestion is near-real-time but not instant.
  2. Click Refresh from rail on the Products page to pull the catalog immediately.
  3. If it still does not appear, the rail connection itself may be the issue — check Connect Stripe and confirm the rail webhook endpoint is configured.

A customer paid but has no access

Almost always an unmapped product. Open the Products page — the “products without entitlements” banner will name it. Map the entitlement it should grant; the next projection picks up active subscriptions on that product and materialises the entitlement. No replay command is needed.

I want to rename a product

Set its displayName on the Products page. You cannot change the rail-side name from Crossdeck — that is a mirrored field. Edit the name on the rail if you need the underlying name to change; Crossdeck reflects it on the next event.

I re-priced a plan on Stripe — did I lose my entitlement mapping?

No. The mapping and displayName are CD-owned and carried forward across every rail update. Only the mirror fields (price, interval, …) change.


Updated 2026-05-17, reflecting ADR-0002 — products are mirrored from the payment rail, never authored in Crossdeck. The contract is locked through v1.x; future revisions are journaled here and in the ADR.