Products — mirrored from your payment rail
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
- A product is a mirrored rail SKU. One Stripe product+price pair becomes one Crossdeck product. The rail is the source of truth; Crossdeck reflects it and never authors it.
- Products appear by themselves. Connecting a rail runs a catalog backfill; from then on
product.*andprice.*webhooks keep the mirror live. The Products page has a Refresh from rail button to pull immediately. There is no create-product step. - The structural fields are read-only. Rail product id, price, currency, billing interval, recurring-vs-one-off, active status — all a strict mirror of the rail, written only by the rail-ingest path. No dashboard or API route can edit them.
- One field is yours:
displayName. A cosmetic label so you can scan products at a glance without decoding rail naming. It is read by the dashboard only — never by resolution, matching, or reconciliation — so editing it cannot break anything. - The product → entitlement mapping is the part Crossdeck owns. A customer with an active subscription on a product receives the entitlements that product is mapped to. Every mapping change is journaled with an operator and a rationale.
- A product with no entitlements grants nothing. A customer can pay for it and receive zero access. The dashboard flags this as a “revenue without entitlement” risk until you map an entitlement.
- Webhooks are the nudge; the rail API is the truth. Crossdeck never projects a webhook payload as state — a webhook only signals “something changed,” and Crossdeck re-fetches the authoritative product from the rail before mirroring it.
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.
| Concept | Source of truth | Authored where | Example |
|---|---|---|---|
| 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.
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:
| Path | When it runs | What 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 group | Written by | Read 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 name — displayName |
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 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:
- From the Products page — open a product, and in Grants entitlements attach the entitlement keys it should grant.
- From the Entitlements page — open an entitlement, and in Mapped to products attach the products that grant it.
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.
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.
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
- Give the webhook a few seconds — ingestion is near-real-time but not instant.
- Click Refresh from rail on the Products page to pull the catalog immediately.
- 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.
Related
- Entitlements & gating — the capability keys products grant, and how your app reads them.
- Connect Stripe — wire the rail so catalog backfill and product webhooks start flowing.
- Migrate to Crossdeck — bringing an existing customer base and catalog across.
- ADR-0002 — Products mirror from the rail — the architectural decision this document describes.
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.