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.
| Credential | Where | Prefix | Used by |
|---|---|---|---|
| Secret key | Developers → API keys → Secret key | sk_test_… / sk_live_… | server adapter (secretKey) |
| Publishable key | Developers → API keys → Publishable key | pk_test_… / pk_live_… | client adapter (publishableKey) |
| Webhook signing secret | Developers → Webhooks → (your endpoint) → Signing secret | whsec_… | 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
# server
pnpm add @payfanout/server @payfanout/adapter-stripe-server
# client (React)
pnpm add @payfanout/react @payfanout/adapter-stripe react react-domThe 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
# .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
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] });| Field | Required | Default | Notes |
|---|---|---|---|
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 | - | 300 | Replay-protection window for the webhook timestamp. |
maxNetworkRetries | - | 2 | Network-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
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
publishableKeyandenvironmentare required.returnUrlmatters only for genuinely redirect methods; card payments and 3DS stay inline (Stripe'sredirect: "if_required") and never navigate away. - Confirm-on-client:
<PayButton>callsconfirm()in the browser and resolves the outcome. Stripe never usesonServerCompletion, 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.comStripe'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.*, andcharge.dispute.*families (or all events). PayFanout normalizes the ones it knows and marks the resttype: "unknown", subscribing to extras is harmless. - Copy the endpoint's Signing secret (
whsec_…) intoSTRIPE_WEBHOOK_SECRET.
Mount the handler with the raw body, signature verification hashes the exact bytes, so register the raw parser before express.json():
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 routeSee 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 number | Outcome |
|---|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 0002 | Declined (card_declined) |
4000 0000 0000 9995 | Declined (insufficient_funds) |
4000 0025 0000 3155 | Requires 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
statementDescriptoron 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
apiVersionpinned; upgrade it deliberately, not implicitly.
Then continue with Server usage, React usage, and Webhooks.