--- title: "Stripe Integration" audience: [admin, developer] summary: "Configure Stripe: flags and file-based secrets, the webhook endpoint and events, catalog-to-Stripe sync, and making a product purchasable." --- # Stripe This guide covers connecting the member console to Stripe for payment processing. The member console is the source of truth for the product catalog — Stripe handles payment execution. ## Prerequisites - A Stripe account (test mode is fine for initial setup) - The Stripe secret key (`sk_test_...` or `sk_live_...`) - A webhook endpoint signing secret (`whsec_...`) ## Configuration | Flag | Description | |------|-------------| | `--stripe-api-key` | Stripe secret key | | `--stripe-api-key-file` | Path to file containing the API key | | `--stripe-webhook-secret` | Webhook endpoint signing secret | | `--stripe-webhook-secret-file` | Path to file containing the webhook secret | All flags support `_FILE` variants for secret injection from mounted files. There is no mode setting. The console derives test or live from the API key's prefix at boot (`sk_live_` and `rk_live_` are live, `sk_test_` and `rk_test_` are test); any other prefix fails boot. "View in Stripe" links carry the mode and nothing else: Stripe opens them in the account and context your dashboard session is in, and offers its own switch when the object lives elsewhere. The console never reads or stores the account id: reading it needs the Connect "Accounts Read" permission on a restricted key, and a restricted key with only the permissions the console uses is the recommended deployment. Stripe's own `livemode` flag cannot narrow this further: a legacy test key and every sandbox report `livemode: false` alike, so the flag separates test from live but tells no sandbox apart from another sandbox or from legacy test mode. Only reading a stored id back under the current key shows that the object belongs to a different environment. ## Moving between environments A deployment moves environments by changing its key: from a sandbox to the live account when it goes into service, or back to a sandbox to rehearse. Swap the API key and the webhook signing secret together and restart; the two belong to one environment, and a secret left behind keeps verifying the old environment's events. Every synced object the console holds records the environment that made it (`livemode` on each `stripe.*_mappings` row, written from the object Stripe returned; rows from before the column exists hold nothing and read as unverified). After the restart: - The console compares a fingerprint of the key (a SHA-256 digest; the key itself is never stored) with the one the last check ran under and, when they differ, starts a background check that reads every synced product and price back under the new key. The Stripe integration page's **Environment check** section shows it running, then its result (`Last checked ..., 34 verified, 2 stale.`), and offers **Check now** to run it again. Nothing waits on the check: boot finishes as before. - A product whose mapping records the other environment shows it on its Purchasability panel (`Synced in test, key is live`) and is not purchasable until it is created again. The panel's sync control reads **Create in live** (or **Create in test**) in that case and creates the product and its price in the current environment; the old ids are replaced. - A mapping the check could not find under the new key (a move between two sandboxes, which Stripe reports identically) is marked `stale` and reads `Not found in live mode`; it behaves like a mapping from the other environment. - A member checkout, a subscription reconcile, a plan switch or a scheduled change against such a mapping is refused before Stripe is called and logged; nothing is sent under a key that cannot reach the object. - Webhook events from the other environment are stored as `refused`, never processed, and counted on the Stripe integration page (`3 events arrived in test mode under a live key.`). Unverified mappings block nothing. - The billing views show the rows recorded in the current environment. When rows from the other environment exist, one line above the view names how many are not shown and switches the view to all of them, each marked with its environment. Customers are not read back by the check: a customer id left behind by a move between two sandboxes surfaces when that member's next checkout fails, and the console then creates the customer again. ## Webhook endpoint Register a webhook in the Stripe Dashboard pointing to: ``` https:///webhooks/stripe ``` Set the endpoint's API version to `2025-02-24.acacia`, the version pinned by `stripe-go/v81`; later versions (Basil and after) drop the top-level `invoice.subscription` and `invoice.payment_intent` fields the invoice handler reads. The endpoint's signing secret verifies the events of the endpoint that issued it, so a valid signature proves that Stripe sent the event to this endpoint and nothing more: it does not prove the event came from the environment the current API key names. Subscribe to these event types: - `customer.created`, `customer.updated`, `customer.deleted` - `product.created`, `product.updated`, `product.deleted` - `price.created`, `price.updated` - `checkout.session.completed`, `checkout.session.expired` - `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted` - `invoice.created`, `invoice.finalized`, `invoice.paid`, `invoice.payment_failed`, `invoice.voided` - `payment_method.attached`, `payment_method.detached`, `payment_method.updated` ## How it works The member console is the catalog source of truth; Stripe holds a mirror used for payment execution. Catalog objects do **not** sync to Stripe automatically — syncing a product to Stripe is an **explicit operator action** (see "Making a product purchasable" below). Once synced, members subscribe via Stripe Checkout, Stripe runs the billing cycle, and webhooks project Stripe state (subscriptions, invoices, payments) back into the member console's core tables. Sync runs through a transactional outbox: the operator action enqueues `create_stripe_product` and `create_stripe_price` entries, and a worker drains them against the Stripe API. The resulting `stripe.product_mappings` / `stripe.price_mappings` rows record the mapping and its `sync_status`, which moves from `pending` to `synced` (or to `deleted`, or to `stale` when the environment check cannot find the id under the current key). `dead_letter` is a status on the outbox entry itself, not on the mapping: when an entry fails repeatedly and the outbox marks it `dead_letter`, the mapping it was trying to create stays at `pending` until an operator retries the entry and the retry succeeds. Every Stripe object the console reads carries a `livemode` field naming the environment it exists in, except `SubscriptionItem`, which has no such field: a subscription item's environment can only be taken from its parent subscription. ## Making a product purchasable A member can only buy a product once **every** purchasability precondition is met. The operator product edit page (`/operator/products/{id}`) shows a **Purchasability** panel that evaluates each one and renders a single verdict — **Purchasable** or **Incomplete** — naming exactly what is missing. The preconditions: 1. **Published** — set the product's lifecycle to `published`. 2. **Public & active** — mark it Active and Public so it appears in the member catalog. 3. **Structural kind** — put it on a plan ladder to sell it as a plan (a single-tier ladder is the idiom for a standalone plan), **or** give it a product type (`addon` / `usage` / `one_time`). A published, untyped product on no ladder is invisible "limbo", which the panel flags. 4. **Active price** — add a price on the product's Prices view. 5. **Stripe-mapped price** — the active price is mapped to a live Stripe price. The last step is the one to do **explicitly**: on the Purchasability panel, click **Sync to Stripe**. That enqueues the product and price sync; the precondition shows **Sync pending** until the outbox worker drains, then flips to **Met** and the verdict becomes **Purchasable**. Adding a price does **not** auto-sync — click Sync to Stripe whenever a product or its active price is not yet mapped. When the mapping records another environment, or the environment check marked it stale, the same control reads **Create in live** (or **Create in test**) and creates the product and price again in the current one ("Moving between environments" above). If Stripe is **not configured** for the deployment (no API key), the panel says so on the Stripe-mapped precondition instead of a dead "missing" marker — pricing simply cannot be Stripe-mapped, though grant-based access still works without Stripe. ## Operator guidelines ### Do in the member console - Create and manage products, prices, and entitlement sets - Monitor sync status in the operator panel - Create grants for admin-granted access (bypasses billing) ### Do not do in Stripe - **Do not create products or prices in the Stripe Dashboard.** They will not be recognized by the member console. - **Do not delete products or prices in Stripe.** Deactivate them in the member console instead. - **Do not modify subscription quantities or prices in Stripe.** Changes should flow through the member console. ### Acceptable in Stripe - View payment details, invoices, and customer records - Handle disputes and issue refunds (until member console tooling exists) - Configure tax settings, payment method types, and Stripe-level features the member console does not manage