← Back to dashboard
stripepaymentsfresh

Stripe Integration Guide

How a Stripe payment works

The real model

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.

PaymentIntent simulatorphase: idle
  1. PaymentIntent
    POST /v1/payment_intents
  2. Confirm card
    stripe.confirmPayment(client_secret)
  3. 3DS challenge
    next_action → use_stripe_sdk
  4. Webhook
    payment_intent.succeeded / .payment_failed
  5. Reconcile
    Verify 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.

Text
███████╗████████╗██████╗ ██╗██████╗ ███████╗
██╔════╝╚══██╔══╝██╔══██╗██║██╔══██╗██╔════╝
███████╗   ██║   ██████╔╝██║██████╔╝█████╗
╚════██║   ██║   ██╔══██╗██║██╔═══╝ ██╔══╝
███████║   ██║   ██║  ██║██║██║     ███████╗
╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝╚═╝     ╚══════╝

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


MCP Server Setup

Stripe provides an official local MCP server.

Bash
# Add the Stripe MCP server
claude mcp add stripe -- npx -y @stripe/mcp --tools=all

With API key in env:

JSON
// .mcp.json
{
  "mcpServers": {
    "stripe": {
      "command": "npx",
      "args": ["-y", "@stripe/mcp", "--tools=all"],
      "env": {
        "STRIPE_SECRET_KEY": "${STRIPE_SECRET_KEY}"
      }
    }
  }
}

Key MCP Tools

ToolDescription
create_payment_intentCreate a PaymentIntent for one-time payments
create_checkout_sessionGenerate a Stripe Checkout URL
list_customersBrowse Stripe customers
create_subscriptionSet up recurring billing
retrieve_balanceCheck account balance

Stripe CLI Setup

Bash
# 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.created

SDK Setup

Bash
npm install stripe

Server-side (API Routes)

TypeScript
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-04-30",
});

Core Patterns

Payment Intent (One-time Charge)

TypeScript
// 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

TypeScript
// 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

TypeScript
// 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:

TypeScript
// 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):

TypeScript
// 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:

TypeScript
// 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 once

Source: 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)

Bash
npm install @stripe/stripe-js @stripe/react-stripe-js
TypeScript
// 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>
  );
}
TypeScript
// 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 NumberBehavior
4242 4242 4242 4242Successful payment
4000 0000 0000 9995Declined (insufficient funds)
4000 0025 0000 31553D Secure authentication required
4000 0000 0000 0002Generic decline

Use any future expiry date and any 3-digit CVV.


Environment Variables

Bash
# 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 ID

For local dev use sk_test_... and pk_test_... keys.


Common Use Cases

Use CaseApproach
One-time paymentPaymentIntent + Stripe Elements
Subscription billingsubscriptions.create + billing portal
Hosted checkoutcheckout.sessions.create → redirect
Card savesPayment Methods API + setup_intents
Invoicinginvoices.create + invoices.sendInvoice

Troubleshooting

IssueFix
No such payment_intentCheck you're using the correct API key mode (test vs live)
Webhook 400 — signature mismatchUse raw body (req.text()), not parsed JSON
Stripe CLI not receiving eventsEnsure stripe listen is running and the port matches
publishableKey is not setVerify NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is in .env.local
3DS test failingUse card 4000 0025 0000 3155 and complete the auth dialog