= Stripe billing :nav-title: Stripe :description: Take payments from your app without a payment backend: subscriptions or a product catalog with checkout, billed through your own Stripe account, in five steps. :keywords: Stripe integration, payments backend, subscriptions, checkout, sell products web app, Stripe webhook :group: Build :order: 60 NOTE: The `stripe` plugin is available from RESTHeart v9.8, and on RESTHeart Cloud from the marketplace. Take payments from your service without writing a payment backend. image::/assets/docs-images/stripe.png[The Stripe page: the plugin in test mode, and the five setup steps] Two things you can sell: * **Products** — a catalog, a cart, card payment, and an order you can look up afterwards. * **Subscriptions** — recurring plans, seats, and the Stripe billing portal. Either one, or both. They share the same keys and the same webhook. TIP: Want a working shop to copy? The https://github.com/SoftInstigate/restheart-cloud-starter-ecommerce[ecommerce starter] is one, set up by a single `rhc setup` command. == Before you start You need a https://stripe.com[Stripe account]. It is free, and **it is yours** — RESTHeart Cloud bills you for the service, your Stripe account bills your customers. The two never mix. Stay in **test mode** until the whole flow works. The toggle is at the top right of the Stripe dashboard, and the test card is `4242 4242 4242 4242` with any future expiry and any CVC. == 1. Get your secret key https://dashboard.stripe.com/test/apikeys[Developers → API keys]. Copy the one starting `sk_test_`. == 2. Create the webhook This is how Stripe tells your service that a payment went through. Without it, people pay and their orders stay pending forever. https://dashboard.stripe.com/test/webhooks[Developers → Webhooks] → add an event destination. **Destination URL** — your service, plus `/stripe/webhook`: ---- https://f3a9c1.eu-central-1-free-1.restheart.com/stripe/webhook ---- IMPORTANT: Your service's own URL, from its *Connect* page. Not `cloud-api.restheart.com`, which is RESTHeart Cloud's control panel and serves none of your collections. **Events** — select these six: ---- checkout.session.completed checkout.session.async_payment_succeeded checkout.session.async_payment_failed checkout.session.expired charge.refunded charge.dispute.created ---- The first marks an order paid. The rest cover slow payments, abandoned carts, refunds and disputes. Save it, then reveal the **signing secret**. It starts `whsec_`. NOTE: Your service is on the public internet, so Stripe reaches it directly. You do not need the Stripe CLI or `stripe listen` — those are for a webhook arriving at your laptop. == 3. Set it up With the two values in hand: [source,bash] ---- npm install -g @restheart-cloud/cli export STRIPE_SECRET_KEY=sk_test_... export STRIPE_WEBHOOK_SECRET=whsec_... rhc login rhc setup --srv ---- `rhc login` asks for a personal access token — create one at https://cloud.restheart.com[cloud.restheart.com], under your profile. It is not your account password. Or do it from the console: your service → **Plugins** → install **stripe**, then fill in the two secrets. == 4. Check it worked Buy something with the test card, then look at your `orders` collection. The order should read `status: "paid"`. Still `pending_payment`? The webhook is not arriving. In Stripe, open your event destination and look at the recent deliveries: * `404` or `401` — the URL is wrong; it must end in `/stripe/webhook` * `400` — the signing secret does not match the one you configured * nothing at all — `checkout.session.completed` is not in your selected events == Keeping the setup in git `rhc setup` runs a file you commit alongside your app, so a new service is one command rather than a checklist. This is what that file looks like: [source,typescript] ---- import { defineSetup, step, fromEnv, isRedacted } from '@restheart-cloud/cli'; /** A stored secret reads back as bullets; one never set reads back blank. */ const configured = (v: unknown) => isRedacted(v) || (typeof v === 'string' && v.length > 0); export default defineSetup('Shop', [ step('stripe plugin installed', { check: ({ admin, srvId }) => admin.isPluginInstalled(srvId, 'stripe'), apply: ({ admin, srvId }) => admin.installPlugin(srvId, 'stripe'), }), step('stripe configured', { async check({ admin, srvId }) { const c = await admin.getPluginConfig(srvId, 'stripe'); return configured(c['secret-key']) && configured(c['webhook-secret']); }, async apply({ admin, srvId }) { const current = await admin.getPluginConfig(srvId, 'stripe'); await admin.updatePluginConfig(srvId, 'stripe', { ...current, 'secret-key': configured(current['secret-key']) ? current['secret-key'] : fromEnv('STRIPE_SECRET_KEY'), 'webhook-secret': configured(current['webhook-secret']) ? current['webhook-secret'] : fromEnv('STRIPE_WEBHOOK_SECRET'), }); }, }), step('collections and indexes initialised', { check: ({ service }) => service.collectionExists('transactions'), apply: ({ admin, srvId }) => admin.initPlugin(srvId, 'stripe', 'products'), }), ]); ---- Two things about it: **Run it as often as you like.** Every step checks before it writes, so a second run against a configured service changes nothing. `--dry-run` tells you what is missing without touching anything. **Secrets are only needed the first time.** A stored key reads back as bullets, and `configured()` passes those straight through — so a re-run needs nothing in your environment. == Guests must be allowed to shop The plugin moves the money; your service's ACL decides who may reach the collections. For a shop that sells to people without accounts, three permissions have to exist, and each fails in a way that does not look like a permissions problem: [cols="1,2"] |=== | Permission | Symptom when missing | `GET /catalog` anonymously | The shop is empty. No error. | `POST /orders` anonymously | Guest checkout answers `401` at the last click. | `GET /orders/{id}` anonymously, filtered on `?secret=` | The buyer pays, then the return page answers `401` — the one page whose job is to reassure them. |=== TIP: Scope the catalog rule with a `readFilter`. A catalog document is public the moment the rule exists, so a draft product must not become readable by omission. Filtering on `{ purchasable: true }` alone is not enough once you sell variants — see below. [source,javascript] ---- mongo: { readFilter: { $or: [ { purchasable: true }, { variants: { $elemMatch: { purchasable: { $ne: false } } } } ] } } ---- The link:https://github.com/SoftInstigate/restheart-cloud-starter-ecommerce[ecommerce starter] has all three written out, along with the success URL that carries the order reference. == Variants, and what is on the shelf A product with options — colours, sizes — carries them **inside its own document**, as `variants`. There is no second collection and no product-per-combination: [source,json] ---- { "_id": "tee-classic", "name": "Classic T-shirt", "description": "Heavyweight cotton", "images": ["https://…/tee.jpg"], "currency": "eur", "variants": [ { "id": "yellow-l", "unit_amount": 2500, "purchasable": true, "in_stock": 12, "images": ["https://…/tee-yellow.jpg"], "metadata": { "colour": "yellow", "size": "L" } } ] } ---- Five rules, and that is the whole model: . A document **without** `variants` is bought as itself. Most products have no variants, and making them declare a list of one would be ceremony. . A document **with** `variants` is not buyable; its variants are. The document carries what they share — name, description, images. . A variant is referenced as **`/`**: `tee-classic/yellow-l`. That is a `productId` like any other in `POST /orders`, so a client never needs to know there are two shapes. . Variant ids are unique **inside their document** and nowhere else. Nothing to keep straight across the catalog. . A variant inherits every field it does not declare. Price and stock sit in fields beside the metadata, never inside it. `metadata` is labels: it describes, it does not define. Whatever keys you choose reach the order line, the Stripe dashboard, the receipt and the invoice, and your email templates can read them by name — `{{colour}}` if that is what you called it. At most 50 keys, keys under 40 characters, values under 500, which are Stripe's limits. === `in_stock` is optional, and nothing is reserved `in_stock` goes on the variant, or on the product when it has none. **Leave it out and the item is uncounted**, which is the right default for most of what a small shop sells. A cart holds no claim on anything. The units come off when the payment lands, in one atomic update per line — so two people *can* buy the last one, and both payments succeed. The order that pushed the count below zero is marked `oversold: true`, and you refund it from the Stripe dashboard, which arrives back as `charge.refunded` and is already handled. That is a trade, made deliberately. Reserving would mean an endpoint, a server-issued token, a per-cart cap, a rate limit, expiry arithmetic and a cart that stops being client-side — to prevent two people wanting the last unit within the same half hour. Overselling costs a fee and an apologetic email and is paid rarely; the machinery would be paid always. What the atomic decrement buys is that the case is **visible**: nobody refunds what nobody noticed. Checkout still refuses what already reads zero, so this only leaves the window between that check and the payment. NOTE: Nothing puts stock back. A refund happens outside the system — the goods may return, return broken, or never return — so restocking and correcting after a refund are both a person's job, from the console. == Common mistakes **Filtering the catalog on `purchasable` alone.** A product with variants carries no top-level `purchasable` — the flag is on each variant, because that is where the decision belongs. A missing field does not match, so `{ purchasable: true }` hides every product that has options, and the shop simply renders without them. Nothing errors. **Pointing the app at the admin node.** `cloud-api.restheart.com` is RESTHeart Cloud's own control plane and serves no collection of yours, so every `GET /catalog` answers `401` — which reads exactly like a missing permission. Your service's URL is the one on its Connect page. **A `success-url` that does not match a route in the app.** The buyer pays and lands on a 404. Stripe substitutes `{CHECKOUT_SESSION_ID}`; RESTHeart's plugin also interpolates `{ORDER_ID}` and `{ORDER_SECRET}` — put those in the URL **fragment**, so the secret never reaches a server log or a `Referer` header. **Renamed collections the client does not know about.** `products.catalog-collection` and `products.orders-collection` are configurable, and the client takes them as parameters rather than assuming. Rename one, tell the client. == Order emails Order confirmations and refund notices are sent by the plugin. They come from `noreply@restheart.com` unless you set up your own mail server — see xref:emails.adoc[Email], which also covers the daily limit they count against. == Related pages * link:{restheart-docs}/stripe/overview[restheart-stripe] — the plugin in depth * link:{restheart-docs}/stripe/webhooks[Webhooks] — the endpoint to register with Stripe * link:{restheart-docs}/stripe/plan-gates[Plan Gates & ACL] — gating your API on a subscription * xref:cli.adoc[The `rhc` command line] — keeping the configuration above in git