docs

Payments with Stripe (payments-stripe)

Sell subscriptions and one-time purchases with Stripe Checkout, and let customers manage their plan, card and invoices in the Stripe customer portal. The block talks to the Stripe REST API with fetch (no SDK, no dependencies), keeps one Stripe customer per user, syncs subscriptions from signed webhooks processed exactly once, and gives your routes a plan check.

Terminal
npx calm add payments-stripe
Adds
Filesapp/blocks/payments-stripe/index.js (routes, webhook processing, plan checks), stripe.js (the REST client), signature.js (Stripe-Signature verification, no imports besides node:crypto)
EnvironmentSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (both required in production)
Collectionscalm_billing_customers, calm_billing_subscriptions, calm_billing_purchases, calm_billing_events (processed event ids, TTL index)

Setup#

  1. Products and prices. In the Stripe dashboard (test mode first), create a product per plan and a price for it: recurring for subscriptions, one-off for one-time purchases. Copy the price ids (price_...).

  2. List what you sell in calm.config.js. Only these prices can be bought:

    JavaScript
    export default defineConfig({
      blocks: {
        'payments-stripe': {
          prices: {
            price_1PmonthlyPro: 'pro',                                  // subscription (the default mode)
            price_1PyearlyPro: { plan: 'pro', mode: 'subscription' },
            price_1Plifetime: { plan: 'lifetime', mode: 'payment' },     // one-time purchase
          },
        },
      },
    });
  3. API key. Dashboard > Developers > API keys: put the secret key in .env as STRIPE_SECRET_KEY (the test key locally, the live key in production; a restricted key with write access to Customers, Checkout Sessions and Customer portal and read access to Subscriptions works too).

  4. Customer portal. Dashboard > Settings > Billing > Customer portal: turn on what customers may do (update payment method, switch plans between your prices, cancel) and save. The portal refuses to open until this is saved once.

  5. Webhook. Dashboard > Developers > Webhooks > Add endpoint: URL https://<your api>/api/billing/webhook, events checkout.session.completed, checkout.session.async_payment_succeeded, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed. Copy its signing secret into .env as STRIPE_WEBHOOK_SECRET.

  6. Run npx calm doctor: it reports a missing key or webhook secret, a test key in production and a live key in development.

Local testing with the Stripe CLI#

Terminal
stripe login
stripe listen --forward-to localhost:5001/api/billing/webhook
# prints "Your webhook signing secret is whsec_...": put it in .env as STRIPE_WEBHOOK_SECRET

stripe trigger checkout.session.completed      # or customer.subscription.updated, invoice.payment_failed...

Pay in test mode with the card 4242 4242 4242 4242 (any future date, any CVC); 4000 0000 0000 0341 attaches but fails on the first renewal, to try invoice.payment_failed.

Routes#

All under /api/billing. checkout, portal and GET / need a signed-in user (option policy).

GET /api/billing/prices (public)#

JSON
{ "data": [{ "price": "price_1PmonthlyPro", "plan": "pro", "mode": "subscription" }, { "price": "price_1Plifetime", "plan": "lifetime", "mode": "payment" }] }

POST /api/billing/checkout#

JSON
{ "price": "price_1PmonthlyPro", "successUrl": "https://app.example.com/billing/done?s={CHECKOUT_SESSION_ID}", "cancelUrl": "https://app.example.com/pricing" }

Pass price (one of the listed ids) or plan (when one price sells it). successUrl and cancelUrl are optional (defaults: APP_URL/billing/success?session_id={CHECKOUT_SESSION_ID} and APP_URL/billing) and must be on an allowed origin. Answers 201:

JSON
{ "data": { "id": "cs_test_a1b2...", "url": "https://checkout.stripe.com/c/pay/cs_test_a1b2..." } }

Errors: 422 unknown price or redirect on another origin, 409 ALREADY_SUBSCRIBED (a live subscription exists: plans change in the portal; option oneSubscription), 502 PAYMENT_PROVIDER_ERROR (Stripe refused or is unreachable; the details are in your log, not the answer), 503 BILLING_NOT_CONFIGURED.

POST /api/billing/portal#

JSON
{ "returnUrl": "https://app.example.com/settings" }

returnUrl is optional (default APP_URL/billing). Answers { "data": { "url": "https://billing.stripe.com/p/session/..." } }, or 404 when the user never started a checkout.

GET /api/billing#

JSON
{
  "data": {
    "customer": true,
    "plan": "pro",
    "plans": ["pro"],
    "status": "active",
    "priceId": "price_1PmonthlyPro",
    "currentPeriodEnd": "2026-11-13T09:00:00.000Z",
    "cancelAtPeriodEnd": false,
    "trialEnd": null,
    "subscriptions": [{ "id": "sub_...", "status": "active", "plan": "pro", "active": true, "currentPeriodEnd": "...", "cancelAtPeriodEnd": false, "latestInvoiceStatus": "paid" }],
    "purchases": []
  }
}

plan is the plan of the current subscription when it is active (activeStatuses, default active and trialing), else a plan bought once, else null.

POST /api/billing/webhook#

For Stripe only. The Stripe-Signature header is checked over the raw body; answers 200 { received: true } (also for duplicates and event types the block does not handle), 400 INVALID_SIGNATURE otherwise.

Requiring a plan#

app/routes.jsJavaScript
import { requirePlan } from './blocks/payments-stripe/index.js';

export default (r) => {
  // 402 PLAN_REQUIRED with a readable message (401 signed out)
  r.get('/reports/advanced', { policy: 'authenticated', meta: { plan: 'pro' } }, buildReport);
  r.get('/exports', { policy: 'authenticated', meta: { plan: ['pro', 'team'] } }, exportAll);

  // As a policy (403 without the plan): a named policy per plan, or the helper
  r.get('/insights', { policy: 'plan-pro | admin' }, insights);
  r.get('/team/seats', { policy: requirePlan('team') }, seats);
};

Named policies (plan-pro, plan-team...) work in resource files too: policy: { create: 'plan-pro' }. In code: await app.ext.billing.hasPlan(userId, 'pro'), await app.ext.billing.plansOf(userId).

Events#

EventPayload
billing.checkout_completed{ user, sessionId, mode, plan, priceId }
billing.subscription_updated{ user, subscription, previousStatus, type } on every change of status, price, period end or cancel flag
billing.subscription_canceledsame payload, once, when a subscription ends
billing.payment_succeeded{ user, invoiceId, subscriptionId, amount, currency }
billing.payment_failed{ user, invoiceId, subscriptionId, amount, currency, attemptCount, nextPaymentAttempt, hostedInvoiceUrl }
JavaScript
calm.on('billing.subscription_canceled', async ({ user }) => {
  await calm.app.ext.notify?.(user, { title: 'Your subscription ended', body: 'Your data is kept for 30 days.' });
});

With the notifications block installed, a failed payment notifies the user (option notifyPaymentFailed).

Options#

OptionDefault
prices{}{ price_id: 'plan' } or { price_id: { plan, mode: 'subscription' | 'payment' } }
successUrl, cancelUrlAPP_URL/billing/success?session_id={CHECKOUT_SESSION_ID}, APP_URL/billingCheckout redirects when the client sends none
portalReturnUrlAPP_URL/billing
allowedRedirectsorigin of APP_URLOrigins clients may redirect to
activeStatuses['active', 'trialing']Add 'past_due' for a grace period while Stripe retries the card
policy'authenticated'Who may check out and open the portal
oneSubscriptiontrueRefuse a second subscription checkout (409)
allowPromotionCodesfalsePromotion code field in Checkout
trialDays0Free trial for subscriptions
toleranceSeconds300Max age of a webhook signature
keepEventsDays30How long processed event ids are kept
notifyPaymentFailedtrueWith the notifications block
apiBasehttps://api.stripe.com/v1Point tests at a fake server
apiVersionnullPin a Stripe API version (both payload shapes before and after 2025-03-31 are read)
timeoutMs15000Per Stripe call

Security notes#

  • Clients never choose what they pay. Only listed price ids can be bought, there is no amount or quantity field, and the customer comes from the session, never the request.
  • Redirects are allowlisted. successUrl, cancelUrl and returnUrl must be on allowedRedirects (default: your APP_URL origin), so the API cannot be used to bounce people to a phishing page.
  • Webhooks are verified on the raw bytes. The route declares rawBody: true, so the HMAC-SHA256 is computed over exactly what Stripe sent, compared in constant time against every v1 value (secret rolls), and refused outside a 5 minute window (replays). Without STRIPE_WEBHOOK_SECRET webhooks are refused, never accepted unsigned.
  • Exactly once. Each event id is claimed with a unique index before anything happens. A duplicate answers 200 and does nothing; a failure drops the claim and answers 500 so Stripe retries.
  • Order does not matter. A subscription is only updated by an event newer than the last applied one, and a canceled subscription is never reactivated by a late event.
  • Plans come from webhooks only. The success page grants nothing: it should read GET /api/billing (the webhook usually arrives first; poll for a few seconds if not).
  • Live and test stay apart. With a live key, test mode events are ignored. calm doctor flags test keys in production and live keys elsewhere.
  • One customer per user even when two checkouts race: Stripe gets the same Idempotency-Key, the database a unique index. Every create sends an Idempotency-Key, so a retried network call never charges twice.
  • Stripe error messages can describe your account; they go to the log, and the client gets 502 PAYMENT_PROVIDER_ERROR.

Frontend#

JavaScript
const api = (path, body) =>
  fetch(`/api/billing${path}`, {
    method: body ? 'POST' : 'GET',
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
    body: body && JSON.stringify(body),
  }).then((r) => r.json());

// Pricing page: "Subscribe" button
const { data } = await api('/checkout', { plan: 'pro' });
location.assign(data.url);

// Settings page
const { data: billing } = await api('');
if (billing.plan) manageButton.onclick = async () => location.assign((await api('/portal', {})).data.url);
if (billing.cancelAtPeriodEnd) notice.textContent = `Ends on ${new Date(billing.currentPeriodEnd).toLocaleDateString()}`;
<!-- block-guide:start (generated from block.json by scripts/sync-block-guides.mjs) -->

Official documentation#

Read these before changing the block; they are also in app/blocks/payments-stripe/AGENTS.md and in the devtools Blocks panel.

For AI agents#

Coding agents read these notes in app/blocks/payments-stripe/AGENTS.md:

Read app/blocks/payments-stripe/index.js (routes, webhook processing, plan checks), stripe.js (the REST client) and signature.js (Stripe-Signature verification) before changing anything. docs.md in the CalmAPI registry has the full setup. Configuration lives in calm.config.js under blocks['payments-stripe'] (prices, successUrl, cancelUrl, portalReturnUrl, allowedRedirects, activeStatuses, oneSubscription, trialDays, allowPromotionCodes). Keys come from .env: STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. Never hardcode or log them. Invariants that must not be broken:

  • Never trust a price, amount, plan or customer id from the client. Checkout only sells price ids listed in options.prices; add a price there, never accept arbitrary ones.
  • Always verify the Stripe-Signature on ctx.rawBody (the route keeps rawBody: true). Never verify against ctx.body or JSON.stringify(ctx.body), never skip verification in any mode.
  • Each event id is processed once (calm_billing_events, unique _id). Keep the claim before the side effects, and delete it on failure so Stripe retries.
  • The webhook is the source of truth for plans. Do not grant a plan from the success page or a client call; read billing state with app.ext.billing.plansOf(userId) / hasPlan().
  • Redirect URLs (successUrl, cancelUrl, returnUrl) must stay restricted to allowedRedirects (default: the APP_URL origin).
  • Events can arrive out of order or twice: keep the lastEventAt guard and never move a canceled subscription back to active. Common tasks: gate a route with meta: { plan: 'pro' } (402 PLAN_REQUIRED), or a policy: 'plan-pro' named policy, or policy: requirePlan('pro') imported from app/blocks/payments-stripe/index.js. React to billing with calm.on('billing.subscription_updated' | 'billing.subscription_canceled' | 'billing.payment_failed' | 'billing.payment_succeeded' | 'billing.checkout_completed', fn). Handle a new Stripe event type by adding it to HANDLED and to handle() in index.js. Pitfalls: unknown event types must still answer 200 (or Stripe retries for days); the webhook route must stay policy: 'public' (Stripe has no session) and outside guards that need a user; test keys in production and live keys in development are reported by calm doctor. Tests: use a fake Stripe server (node:http) and blocks['payments-stripe'].apiBase, sign events with signStripePayload() from signature.js, as test/blocks/payments-stripe.test.js in the CalmAPI repo does. Build test keys at runtime, never commit key-shaped literals.
<!-- block-guide:end -->

Last updated