Files
member-console/docs/stripe.md
T

155 lines
9.5 KiB
Markdown

---
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://<your-domain>/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