Packages@lunora/payment@lunora/payment — Stripe

@lunora/payment — Stripe

End-to-end tutorial for wiring the Stripe adapter into ctx.payments — checkout, subscriptions, entitlements, metered usage, refunds, and verified webhooks.

Stripe is the default provider for @lunora/payment. This page is a standalone, copy-pasteable walkthrough: install the adapter, wire it onto ctx.payments, run your first checkout, gate features and meter usage, and verify inbound webhooks. Every API detail below maps to a real method on the adapter — nothing is invented. For the provider-agnostic concepts (ctx.payments surface, the state machine, the store, reconcile) read the package overview first.

Stripe is a PSP — you are the merchant of record

Stripe is a payment service provider, not a Merchant-of-Record. That is the one fact that shapes everything else: you are the merchant of record, so you own tax calculation, invoicing, and remittance (Stripe Tax and Invoicing are add-on products you enable on your own account — the adapter does not do this for you). This is the opposite of Polar, Dodo Payments, and Creem, where the provider is the legal seller and owns tax/invoices.

The adapter advertises this in its capabilities:

Prop

Type

Choose Stripe when you want the broadest payment-method and pricing coverage, direct control of the money flow (including manual authorize/capture), and you are prepared to handle tax/invoicing yourself. If you would rather offload tax and sales-of-record liability, reach for a Merchant-of-Record provider (Polar / Dodo Payments / Creem) — same ctx.payments API, different adapter.

Because Stripe is a PSP, manual authorize/capture is available: a checkout in mode: "payment" can hold funds (requires_capture) and you capture later. The Merchant-of-Record providers don't expose this.

Prerequisites

A Stripe account. Create one at dashboard.stripe.com. Keep it in Test mode while you build (the toggle is in the dashboard) — test keys start sk_test_…, live keys sk_live_….

Your secret key. Dashboard → Developers → API keys (dashboard.stripe.com/apikeys). Copy the Secret key. This is the value for STRIPE_SECRET_KEY.

A webhook signing secret. Dashboard → Developers → Webhooks (dashboard.stripe.com/webhooks) → add an endpoint (see Webhooks below), then copy its Signing secret (whsec_…). This is the value for STRIPE_WEBHOOK_SECRET. For local dev you can instead run stripe listen (the Stripe CLI) — it prints a whsec_… to use.

At least one Price. Dashboard → Product catalog → create a product with a recurring or one-time price; copy its price id (price_…). You'll pass this to createCheckout / attach.

Lunora scaffolds the two secrets into your .dev.vars automatically when it detects @lunora/payment:

# .dev.vars
STRIPE_SECRET_KEY=sk_test_...       # Developers → API keys
STRIPE_WEBHOOK_SECRET=whsec_...     # Developers → Webhooks (endpoint signing secret)

These are the exact env-var names Lunora's .dev.vars scaffolder writes for the Stripe adapter. Add them to your production secrets with wrangler secret put STRIPE_SECRET_KEY / wrangler secret put STRIPE_WEBHOOK_SECRET before deploying.

Install

The Stripe adapter uses the real stripe SDK — the adapter types its client as a Stripe instance and verifies webhooks with the SDK's own verifier. stripe is an optional peer dependency (the adapter tree-shakes away when unused), so install it alongside the package:

pnpm add @lunora/payment stripe
npm install @lunora/payment stripe
yarn add @lunora/payment stripe
bun add @lunora/payment stripe

Configure

ctx.payments is wired by codegen onto ActionCtx whenever a lunora/ source imports @lunora/payment or reads ctx.payments. The Stripe adapter — which carries your secret key — comes from a config.payment(env) thunk you pass to createShardDO().

Construct a Stripe client from env.STRIPE_SECRET_KEY. On Workers, use the fetch-based HTTP client so the SDK runs on workerd:

import Stripe from "stripe";

const stripe = new Stripe(env.STRIPE_SECRET_KEY, { httpClient: Stripe.createFetchHttpClient() });

Build the adapter with createStripeAdapter. Pass the Stripe instance straight through — the adapter's client is typed as the real Stripe, so there is no cast:

import { createStripeAdapter } from "@lunora/payment/stripe";

const adapter = createStripeAdapter({
    client: stripe,
    webhookSecret: env.STRIPE_WEBHOOK_SECRET,
    webhookToleranceSeconds: 300, // optional; default 300 (five minutes)
});

Wire it into createShardDO exactly as the overview shows — pass the adapter, your entitlement plans, and an optional authorizer/observer:

import { createStripeAdapter } from "@lunora/payment/stripe";
import { createShardDO } from "./_generated/shard";
import Stripe from "stripe";

export const ShardDO = createShardDO({
    payment: (env) => ({
        adapter: createStripeAdapter({
            client: new Stripe(env.STRIPE_SECRET_KEY, { httpClient: Stripe.createFetchHttpClient() }),
            webhookSecret: env.STRIPE_WEBHOOK_SECRET,
        }),
        // Override the default "caller owns the referenceId" rule for org/workspace keys.
        // authorize: (referenceId) => referenceId === ctx.auth.orgId,
        entitlements: {
            plans: {
                pro: { features: ["export"], limits: { api_calls: 1000 }, priceIds: ["price_123"] },
            },
        },
        observability: (event) => console.log("[payment]", event.type, event),
    }),
});

The adapter options:

Prop

Type

Pin the store's read-heavy tables (e.g. subscriptions) with .global() in your own lunora/schema.ts for cross-region reads — see Data it stores. The provider is stateless; the store owns all state.

First checkout / subscription

Call the facade from an action. createCheckout returns a hosted Stripe Checkout URL to redirect the browser to. Use mode: "subscription" for recurring plans and mode: "payment" for one-time charges. attach is the plan-oriented alias with mode defaulting to "subscription", so a subscription is just { referenceId, priceId, successUrl, cancelUrl }:

import { action, v } from "./_generated/server";

export const subscribe = action.input({ priceId: v.string() }).action(async ({ ctx, args: { priceId } }): Promise<{ url: string }> => {
    const { url } = await ctx.payments.attach({
        referenceId: ctx.auth.userId,
        priceId, // e.g. "price_123"
        successUrl: "https://app.test/done",
        cancelUrl: "https://app.test/cancel",
    });

    return { url };
});

Under the hood the adapter calls Stripe's Create Checkout Session with line_items: [{ price, quantity }], client_reference_id, and the framework-controlled referenceId pinned into both the session and the subscription metadata. The facade reuses a reference's stored Stripe customer — minting a new one via Create Customer only on first checkout — and attaches an outbound idempotency key automatically (override via input.idempotencyKey), so a double-submit never double-charges.

The subscription only becomes active in your store once Stripe confirms payment through a webhook (see below) — createCheckout just starts the flow.

Entitlements and metered usage

Once a subscription is synced, entitlements are derived in-Worker from stored subscription state — no extra call to Stripe. A plan is granted when an active (or genuine trialing) subscription holds one of its configured priceIds.

Gate a feature with check — pass a featureId (feature grant/allowance) or a priceId (active product access):

export const apiCallsRemaining = action.action(async ({ ctx }): Promise<{ allowed: boolean; balance?: number }> => {
    const result = await ctx.payments.check({ referenceId: ctx.auth.userId, featureId: "api_calls" });

    return { allowed: result.allowed, balance: result.balance };
});

Meter usage with track. Because Stripe advertises usageMetering, track writes the durable local ledger and forwards the event to Stripe's Meter Events API (event_name = your featureId, payload.stripe_customer_id = the stored Stripe customer, payload.value = the quantity, identifier = the idempotency key that dedupes within Stripe's aggregation window). Upstream metering is best-effort: a reporting failure is observed, never thrown, and the local ledger check reads is always updated.

export const recordApiCall = action.action(async ({ ctx }): Promise<{ recorded: boolean }> => {
    // `mode: "add"` (default) increments; `"set"` reconciles the period total.
    const result = await ctx.payments.track({ referenceId: ctx.auth.userId, featureId: "api_calls" });

    return { recorded: result.recorded };
});

For track to feed a Stripe meter, create the meter in the dashboard (Product catalog → Meters) with an event_name matching your featureId, and attach it to a usage-based Price. A metered check subtracts usage tracked this period ({ allowed, balance, limit, used }) — the period window comes from the synced subscription's current billing period, so webhook sync (next) must be working for the window to be correct.

Webhooks

Stripe delivers state changes as webhooks. Signature verification needs the raw request body, so the endpoint runs at the Worker edge via httpAction (no ctx.db) and forwards the raw body + signature into the shard, where ctx.payments and its store exist. Note the header is stripe-signature:

// lunora/http.ts
import { httpAction, httpRouter } from "lunorash/server";
import { processWebhook } from "./billing";

export const app = httpRouter();

app.post(
    "/payment/webhook",
    httpAction(async (ctx, request) => {
        const body = await request.text();
        const signature = request.headers.get("stripe-signature") ?? "";

        return Response.json(await ctx.runAction(processWebhook, { body, signature }));
    }),
);
// lunora/billing.ts
import { internalAction, v } from "./_generated/server";

export const processWebhook = internalAction
    .input({ body: v.string(), signature: v.string() })
    .action(async ({ ctx, args: { body, signature } }): Promise<{ applied: boolean; status: number }> => {
        const request = new Request("https://internal/payment/webhook", {
            body,
            headers: { "stripe-signature": signature },
            method: "POST",
        });
        const response = await ctx.payments.handleWebhook(request);
        const result = (await response.json()) as { applied?: boolean };

        return { applied: result.applied ?? false, status: response.status };
    });

Register the endpoint

In the Stripe dashboard → Developers → Webhooks → Add endpoint, set the URL to your deployed route (e.g. https://your-worker.example.com/payment/webhook), then copy the endpoint's Signing secret (whsec_…) into STRIPE_WEBHOOK_SECRET. Locally, stripe listen --forward-to localhost:8787/payment/webhook prints a signing secret and streams events to your dev worker.

Select at least these events when creating the endpoint:

  • checkout.session.completed
  • customer.subscription.created, customer.subscription.updated, customer.subscription.deleted
  • payment_intent.succeeded, payment_intent.payment_failed, payment_intent.amount_capturable_updated
  • charge.refunded

The signature scheme + tolerance

Stripe signs each delivery with the Stripe-Signature header — t=<unix-seconds>,v1=<hex-hmac>. The adapter verifies it with the SDK's own webhooks.constructEventAsync(rawBody, header, secret, tolerance) — no explicit crypto provider: the stripe SDK's platform build selects the right implementation (WebCrypto on workerd, Node's crypto on Node). It rejects any delivery whose signed timestamp is more than webhookToleranceSeconds (default 300s) from now — Stripe's replay-window default — and a failed check surfaces as a WEBHOOK_SIGNATURE_INVALID error (a 400). Verification runs over the raw body; never re-serialize the JSON before verifying.

Events → normalized transitions

handleWebhook verifies, normalizes each event into a WebhookAction, and applies it through the state machine. Once verified it always returns 200 so Stripe stops retrying — a duplicate or no-op is acknowledged, not re-applied (the Stripe event id keys an append-only events log for idempotency and audit).

Stripe eventNormalized transitionNotes
checkout.session.completed (payment mode)payment.capturedamount_total recorded; payment_intent (or session id) is the session key.
checkout.session.completed (subscription)subscription.active only if payment_status is paid/no_payment_required; else subscription.updatedFails closed — an unpaid session never entitles.
customer.subscription.created / .updatedsubscription.active / .past_due / .paused / .canceled / .updatedRouted by the subscription status; updated is a no-op metadata patch when no state changes.
customer.subscription.deletedsubscription.canceled
payment_intent.succeededpayment.capturedUses amount_received (falls back to amount).
payment_intent.payment_failedpayment.failedEmits an alertable payment.failed observer signal.
payment_intent.amount_capturable_updatedpayment.authorizedManual-capture flow: funds held awaiting capture.
charge.refundedpayment.refundedamount_refunded (cumulative) applied as an absolute total, so partials never over-count.
anything elseunhandledVerified and 200-acked, but no state change.

Subscription status changes — including pause/resume, renewals, and moving to past_due after a failed invoice — reach the store through customer.subscription.updated (Stripe emits it alongside the more specific customer.subscription.paused / .resumed / invoice.* events, which the adapter itself treats as unhandled). Keep customer.subscription.updated selected on your endpoint.

Webhooks are eventually-but-not-guaranteed: an endpoint down past Stripe's retry window can drop an event for good. Pair handleWebhook with a @lunora/scheduler sweep that calls reconcile — it re-fetches Stripe's current truth for given payment/subscription ids and overwrites the store when it has drifted.

Refunds, portal, and manual capture

Refunds are first-class. refundPayment (exposed via the adapter) calls Stripe Create Refund against the payment intent — omit amount for a full refund, pass a Money for a partial one — then re-reads the intent to resolve partially_refunded vs refunded. The charge.refunded webhook keeps the store in sync afterward.

Customer portal. capabilities.portal is true, so createPortalSession opens Stripe's hosted Billing Portal where customers manage payment methods, invoices, and cancellations. The customer is derived from the store (never a caller-supplied id — no IDOR):

export const openPortal = action.action(async ({ ctx }): Promise<{ url: string }> => {
    return ctx.payments.createPortalSession(ctx.auth.userId, "https://app.test/account");
});

Enable and configure the portal once in the dashboard under Settings → Billing → Customer portal.

Manual authorize/capture. Because Stripe is a PSP, a mode: "payment" checkout can authorize-only (requires_capture) and you capture later — the adapter exposes capturePayment (optional partial amount) and cancelPayment over Stripe's PaymentIntent capture / cancel endpoints.

Limitations

  • You own tax and invoicing. Stripe Tax and Invoicing are separate products you enable on your own account; the adapter does not compute or file tax.
  • Metering is best-effort upstream. A failed forward to Stripe Meter Events is observed, not thrown — the local ledger stays authoritative for check until reconcile reconciles.
  • Out-of-band cancellations of one-time payments (payment_intent.canceled) are not mapped — they arrive as unhandled. Subscriptions handle cancellation via customer.subscription.deleted.

Troubleshooting

Signature verification fails (400 "no matching signature"). The most common cause is a body that was parsed/re-serialized before verification — always verify the raw request text. Also confirm STRIPE_WEBHOOK_SECRET is the signing secret of the specific endpoint receiving the event (each endpoint, and the stripe listen CLI, has its own whsec_…), and that you read the stripe-signature header (not Stripe-Signature — header lookups are case-insensitive, but the value must be forwarded intact).

"signature timestamp outside tolerance" (WEBHOOK_TIMESTAMP_INVALID). The signed t= timestamp is more than webhookToleranceSeconds (default 300s) from your worker's clock. This is usually a replayed/late delivery — good. If it fires on live traffic, check for a skewed clock; only raise webhookToleranceSeconds deliberately, as a larger window weakens replay protection.

Test vs live keys. A sk_test_… key and its test-mode webhook secret only see test-mode events; sk_live_… needs a separately created live-mode endpoint and its own whsec_…. Mixing test keys with a live endpoint (or vice-versa) yields signature failures or empty results. Keep the two sets of secrets in separate environments.

Subscription shows active but check/metered balance is wrong. Metered check sums usage over the synced billing period, which comes from the subscription's current-period fields. If those are empty, ensure customer.subscription.updated is selected on your endpoint and that your Stripe API version matches your SDK (recent Stripe API versions expose the billing period on subscription items).

See also