Stripe Integration Guide
How a Stripe payment works
Async PaymentIntent → idempotent webhook, signed by Stripe.
Never trust the client's `redirect` callback alone — webhooks are the source of truth. Verify the `Stripe-Signature` header with your webhook secret on every POST; idempotency is on `event.id` (Stripe retries failed deliveries for up to 3 days). 3DS is mandatory under PSD2 for many EU/UK cards — you don't choose, the bank does, and Stripe surfaces it via `next_action.type === "use_stripe_sdk"`. For codeAmani, Stripe is your card+international rail; M-Pesa stays on Daraja. Use Payment Element on the client (covers cards, wallets, BNPL automatically) and store the `PaymentIntent.id` against your order before the webhook can race you. ACK 2xx fast; do slow work in a queue.
The five-step lifecycle
Each step has its own failure mode. The 3DS step is the most common silent killer of conversion.
Branch the card response
Real payments don't fail or succeed — they take one of several specific paths, each with its own webhook event and recovery flow. Walk through each and read the terminal note for what your server should do.
- PaymentIntentPOST /v1/payment_intents
- Confirm cardstripe.confirmPayment(client_secret)
- 3DS challengenext_action → use_stripe_sdk
- Webhookpayment_intent.succeeded / .payment_failed
- ReconcileVerify signature · dedupe event.id · ACK 200
The hidden lesson: even when 3DS fails, a payment_failedwebhook still arrives. Your server learns the outcome from Stripe, not from the customer's browser redirect.
███████╗████████╗██████╗ ██╗██████╗ ███████╗
██╔════╝╚══██╔══╝██╔══██╗██║██╔══██╗██╔════╝
███████╗ ██║ ██████╔╝██║██████╔╝█████╗
╚════██║ ██║ ██╔══██╗██║██╔═══╝ ██╔══╝
███████║ ██║ ██║ ██║██║██║ ███████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚══════╝Stripe Integration Guide
Focus: Global card payments, subscriptions, and billing — the complement to M-Pesa for international and card-paying users in codeAmani products.
Overview
Stripe handles card-based payments globally: one-time charges, subscription billing, invoicing, and Stripe Checkout. In codeAmani products, Stripe is the secondary payment rail alongside M-Pesa — used when customers prefer card or when serving markets outside Kenya. The official MCP server provides SDK patterns and Stripe API reference directly in Claude Code.
Here is the big picture — the payment lifecycle is a clean, predictable loop you will get comfortable with quickly:
Official Documentation
| Resource | URL |
|---|---|
| Stripe Docs | https://stripe.com/docs |
| Node.js SDK | https://github.com/stripe/stripe-node |
| API Reference | https://stripe.com/docs/api |
| Stripe CLI | https://stripe.com/docs/stripe-cli |
| Webhooks | https://stripe.com/docs/webhooks |
| Testing | https://stripe.com/docs/testing |
MCP Server Setup
Stripe provides an official local MCP server.
# Add the Stripe MCP server
claude mcp add stripe -- npx -y @stripe/mcp --tools=allWith API key in env:
// .mcp.json
{
"mcpServers": {
"stripe": {
"command": "npx",
"args": ["-y", "@stripe/mcp", "--tools=all"],
"env": {
"STRIPE_SECRET_KEY": "${STRIPE_SECRET_KEY}"
}
}
}
}Key MCP Tools
| Tool | Description |
|---|---|
create_payment_intent | Create a PaymentIntent for one-time payments |
create_checkout_session | Generate a Stripe Checkout URL |
list_customers | Browse Stripe customers |
create_subscription | Set up recurring billing |
retrieve_balance | Check account balance |
Stripe CLI Setup
# Install Stripe CLI
npm install -g @stripe/stripe-cli
# Login
stripe login
# Forward webhooks to local dev server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.createdSDK Setup
npm install stripeServer-side (API Routes)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-04-30",
});Core Patterns
Payment Intent (One-time Charge)
// app/api/payments/create-intent/route.ts
import Stripe from "stripe";
import { auth } from "@clerk/nextjs/server";
import { NextRequest } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
const { amount, currency = "usd" } = await req.json();
const paymentIntent = await stripe.paymentIntents.create({
amount, // amount in smallest currency unit (cents)
currency,
metadata: { userId },
automatic_payment_methods: { enabled: true },
});
return Response.json({ clientSecret: paymentIntent.client_secret });
}Stripe Checkout Session
// app/api/checkout/route.ts
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "usd",
unit_amount: 2000, // $20.00
product_data: { name: "codeAmani Pro Plan" },
},
quantity: 1,
},
],
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId },
});
return Response.json({ url: session.url });Subscription
// Create customer + subscription
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
});
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: process.env.STRIPE_PRICE_ID! }],
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
});Webhook Handler
This is the trustworthy core of fulfillment — verify the signature first, then act. Here is how the pieces talk to each other:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch {
return new Response("Webhook signature verification failed", { status: 400 });
}
switch (event.type) {
case "payment_intent.succeeded": {
const pi = event.data.object as Stripe.PaymentIntent;
// Update your DB: mark payment as succeeded
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
// Downgrade user access
break;
}
}
return new Response("OK");
}Idempotency & reconciliation
Network blips, double-clicks, and serverless retries happen — and each one risks charging a customer twice. The fix is the same discipline you already use with Daraja: pass an idempotency key on every state-changing create call, and dedupe webhook events on their id. Stripe stores the result of the first request under that key, so any retry with the same key returns the original PaymentIntent instead of creating a new charge. Generate one stable key per logical operation (e.g. a UUID tied to a cart or order), not per HTTP attempt.
The key is passed as the idempotencyKey field in the options object — the last argument to any method (verified against stripe-node v19 RequestOptions):
// app/api/payments/create-intent/route.ts
import { randomUUID } from "node:crypto";
// orderId is your own stable identifier for this purchase attempt.
// Reuse the SAME key across retries so Stripe returns the original PaymentIntent.
const idempotencyKey = `pi_${orderId}`; // or persist randomUUID() with the order
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency,
metadata: { userId, orderId },
automatic_payment_methods: { enabled: true },
},
{ idempotencyKey }, // <-- RequestOptions, last arg
);On the receiving side, fulfillment must also be idempotent. Stripe can deliver the same event more than once, so record each event.id and skip anything you have already processed before you fulfill — this is what prevents double-shipping or granting access twice:
// inside the webhook handler, after constructEvent succeeds
const alreadyProcessed = await db.webhookEvents.exists(event.id);
if (alreadyProcessed) return new Response("OK"); // dedupe — no-op replay
await db.webhookEvents.insert({ id: event.id, type: event.type });
// ...now safe to fulfill exactly onceSource: Stripe idempotent requests. Idempotency keys are stored by Stripe and expire after 24 hours, so they protect against retries — not against a deliberate re-charge a day later.
Client-Side (Stripe Elements)
npm install @stripe/stripe-js @stripe/react-stripe-js// components/CheckoutForm.tsx
"use client";
import { PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
export function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment-success`,
},
});
if (error) console.error(error.message);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit" disabled={!stripe}>Pay</button>
</form>
);
}// app/checkout/page.tsx
import { loadStripe } from "@stripe/stripe-js";
import { Elements } from "@stripe/react-stripe-js";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export default function CheckoutPage({ clientSecret }: { clientSecret: string }) {
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm />
</Elements>
);
}Test Cards
| Card Number | Behavior |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 9995 | Declined (insufficient funds) |
4000 0025 0000 3155 | 3D Secure authentication required |
4000 0000 0000 0002 | Generic decline |
Use any future expiry date and any 3-digit CVV.
Environment Variables
# Required
STRIPE_SECRET_KEY=sk_live_... # Never expose — server only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Optional
STRIPE_PRICE_ID=price_... # Default subscription price IDFor local dev use
sk_test_...andpk_test_...keys.
Common Use Cases
| Use Case | Approach |
|---|---|
| One-time payment | PaymentIntent + Stripe Elements |
| Subscription billing | subscriptions.create + billing portal |
| Hosted checkout | checkout.sessions.create → redirect |
| Card saves | Payment Methods API + setup_intents |
| Invoicing | invoices.create + invoices.sendInvoice |
Troubleshooting
| Issue | Fix |
|---|---|
No such payment_intent | Check you're using the correct API key mode (test vs live) |
| Webhook 400 — signature mismatch | Use raw body (req.text()), not parsed JSON |
| Stripe CLI not receiving events | Ensure stripe listen is running and the port matches |
publishableKey is not set | Verify NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is in .env.local |
| 3DS test failing | Use card 4000 0025 0000 3155 and complete the auth dialog |