Skip to content

Set up Stripe

Stripe is a confirm-on-client PSP: your server creates a PaymentIntent, the browser mounts Stripe's Payment Element with the returned clientSecret, and confirm() finalizes the payment inline (including 3DS). The server never touches confirmation. This guide wires Stripe end to end, credentials, server adapter, client adapter, webhooks, against the sandbox (Stripe calls it test mode), then lists what changes to go live.

Two packages: @payfanout/adapter-stripe-server (holds your secret key) and @payfanout/adapter-stripe (browser-safe, holds only the publishable key).

1. Get your Stripe credentials

Everything comes from the Stripe Dashboard. Keep the dashboard's Test mode toggle on while you build, test-mode keys are prefixed sk_test_ / pk_test_ and move no money.

CredentialWherePrefixUsed by
Secret keyDevelopers → API keys → Secret keysk_test_… / sk_live_…server adapter (secretKey)
Publishable keyDevelopers → API keys → Publishable keypk_test_… / pk_live_…client adapter (publishableKey)
Webhook signing secretDevelopers → Webhooks → (your endpoint)Signing secretwhsec_…server adapter (webhookSigningSecret)

The API version (apiVersion, e.g. 2024-06-20) is not a credential, you pin it in code (see below). It is shown at Developers → API version, but never rely on the account default: it can change under you.

Secret key is server-only

sk_… and whsec_… never leave your backend. Only the publishable key (pk_…) is safe in the browser bundle. The scripts/check-boundaries.mjs check fails the build if the server adapter is ever imported into client code.

2. Install

bash
# server
pnpm add @payfanout/server @payfanout/adapter-stripe-server
# client (React)
pnpm add @payfanout/react @payfanout/adapter-stripe react react-dom

The stripe Node SDK is bundled with the server adapter, nothing else to add. Stripe.js is not an npm dependency; the client adapter injects it lazily from Stripe's CDN on first mount.

3. Environment variables

bash
# .env (server), never committed
STRIPE_SECRET_KEY=sk_test_…
STRIPE_WEBHOOK_SECRET=whsec_…

# client bundle, Vite exposes only VITE_-prefixed vars to the browser
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_…

4. Wire the server adapter

ts
import { PaymentService } from "@payfanout/server";
import { StripeServerAdapter } from "@payfanout/adapter-stripe-server";

const stripe = new StripeServerAdapter({
  secretKey: process.env.STRIPE_SECRET_KEY!,               // sk_test_… / sk_live_…
  apiVersion: "2024-06-20",                                 // REQUIRED, pinned, no default
  webhookSigningSecret: process.env.STRIPE_WEBHOOK_SECRET!, // string, or string[] while rotating
  environment: "sandbox",                                   // "sandbox" | "live", never inferred
});

const payments = new PaymentService({ adapters: [stripe] });
FieldRequiredDefaultNotes
secretKey-sk_test_… / sk_live_…. Constructor throws if empty.
apiVersion-Pin it (e.g. "2024-06-20"). No default, the constructor throws without it. Must be a version the bundled stripe SDK supports.
webhookSigningSecret-whsec_…. Pass a string[] to rotate with no cutover, any secret that verifies wins.
environment-Exactly "sandbox" or "live". Never inferred from the sk_test/sk_live prefix.
verifyPaymentMethodStrategy-"setup_intent_detach"Zero-amount verification attaches a PaymentMethod, so the default detaches it on every path to honor no-vaulting. Set "disabled" to turn the capability off entirely.
webhookToleranceSeconds-300Replay-protection window for the webhook timestamp.
maxNetworkRetries-2Network-level retries inside the Stripe SDK, safe because every mutating call carries an idempotency key.

Every mutating call takes an integer minor-unit amount and a required idempotencyKey, see Server usage for the full lifecycle.

When a session restricts paymentMethodTypes, the adapter forwards only the requested rails that can settle the session currency, per the same declared per-method currencies gates that getCapabilities() exposes — Stripe rejects a PaymentIntent whose explicit payment_method_types carries a currency-incompatible entry, so ["sepa_debit", "card"] in GBP becomes a card-only session rather than a failed one (sandbox-verified). If no requested rail can settle the currency the adapter rejects with invalid_request before calling Stripe, naming the rails and the currency. Zero-amount verification sessions are SetupIntents, which carry no currency — they are never narrowed. An overridden config.paymentMethods list carries its own gates: a rail declared without currencies is forwarded unnarrowed.

5. Wire the client adapter

tsx
import { PayFanoutProvider, PaymentFields, PayButton } from "@payfanout/react";
import { StripeClientAdapter } from "@payfanout/adapter-stripe";

const stripe = new StripeClientAdapter({
  publishableKey: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY, // pk_test_… / pk_live_…
  environment: "sandbox",                                       // "sandbox" | "live"
  // returnUrl: "https://shop.example/checkout/return",         // only for redirect methods (iDEAL, bank)
});

<PayFanoutProvider adapters={[stripe]} initialPsp="stripe">
  <PaymentFields
    clientSecret={session.clientSecret}                 // from the server's createPaymentSession
    onChange={({ complete }) => setPayEnabled(complete)} // disable Pay until fields are valid
  />
  <PayButton onResult={(result) => showOutcome(result)}>Pay</PayButton>
</PayFanoutProvider>
  • Only publishableKey and environment are required. returnUrl matters only for genuinely redirect methods; card payments and 3DS stay inline (Stripe's redirect: "if_required") and never navigate away.
  • Confirm-on-client: <PayButton> calls confirm() in the browser and resolves the outcome. Stripe never uses onServerCompletion, that callback is for tokenize-first PSPs like Paysafe.
  • SSR-safe: constructing the adapter at module scope is fine; only mounting runs in the browser. Components work as Next.js App Router client components.

Content-Security-Policy

Stripe.js loads from https://js.stripe.com/v3 and renders card fields, 3DS, and redirect challenges in iframes. A CSP-enforcing page needs:

script-src  https://js.stripe.com
frame-src   https://js.stripe.com https://hooks.stripe.com
connect-src https://api.stripe.com

Stripe's fraud signals (Radar) additionally load https://m.stripe.network — allow it under frame-src/script-src if you rely on them. Pin or self-host the script via the sdkUrl config field if you must.

6. Register the webhook endpoint

Webhooks are how the asynchronous truth (async declines, refunds, disputes) reaches you.

In the Stripe Dashboard → Developers → Webhooks → Add endpoint:

  • URL: https://your-api.example/webhooks/stripe
  • Events: subscribe to the payment_intent.*, charge.*, charge.refund.*, and charge.dispute.* families (or all events). PayFanout normalizes the ones it knows and marks the rest type: "unknown", subscribing to extras is harmless.
  • Copy the endpoint's Signing secret (whsec_…) into STRIPE_WEBHOOK_SECRET.

Mount the handler with the raw body, signature verification hashes the exact bytes, so register the raw parser before express.json():

ts
import { createAdapterWebhookHandler } from "@payfanout/server";
const stripeHook = createAdapterWebhookHandler(stripe, {
  onEvent: (event) => enqueue(event), // ack-fast: enqueue, dedupe by event.id; never process inline
});

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  const r = await stripeHook({ rawBody: req.body.toString("utf8"), headers: req.headers });
  res.status(r.status).end();
});
app.use(express.json()); // AFTER the webhook route

See Webhooks for Next.js/Fastify variants, dedupe, and recovery.

Local development

Install the Stripe CLI, then stripe listen --forward-to localhost:4242/webhooks/stripe. It prints a whsec_… signing secret, use that as STRIPE_WEBHOOK_SECRET in dev, and trigger events with stripe trigger payment_intent.succeeded.

7. Test cards

In test mode, use Stripe's test cards with any future expiry, any CVC, and any postal code.

Card numberOutcome
4242 4242 4242 4242Success
4000 0000 0000 0002Declined (card_declined)
4000 0000 0000 9995Declined (insufficient_funds)
4000 0025 0000 3155Requires authentication (3DS challenge, inline)

The full matrix (per-brand, per-decline-code, wallet, and dispute-trigger cards) is at docs.stripe.com/testing.

8. Go live

Nothing in your PayFanout code changes except credentials and one string:

  • [ ] Switch the Dashboard to live mode and swap in the live keys (sk_live_…, pk_live_…) via your production secrets.
  • [ ] Set environment: "live" on both the server and client adapters.
  • [ ] Add a live webhook endpoint in the Dashboard and use its new whsec_… signing secret (test and live endpoints have different secrets).
  • [ ] Set a statementDescriptor on your sessions so the charge is recognizable on the buyer's statement.
  • [ ] Confirm your card fields are still the Stripe-hosted Payment Element (SAQ-A), there is no raw card input anywhere.
  • [ ] Keep the apiVersion pinned; upgrade it deliberately, not implicitly.

Then continue with Server usage, React usage, and Webhooks.