← Back to dashboard
stripepaymentsfreshReader view (for NotebookLM)

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 the default rail (US-first); M-Pesa/Daraja applies only to Kenya-targeted projects. Pin `apiVersion` in code — the current release is `2026-07-29.dahlia` — and never pass `payment_method_types`, which would disable dynamic payment methods. 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_failed webhook still arrives. Your server learns the outcome from Stripe, not from the customer's browser redirect.

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

Stripe Integration Guide

Focus: Cards, wallets, subscriptions, and billing — codeAmani's default payment rail for its US-first customer base.

Overview

Stripe handles card and wallet payments globally: one-time charges, subscription billing, invoicing, and hosted Stripe Checkout. Per CLAUDE.md, Stripe is the default payment rail for every codeAmani project. M-Pesa via the Daraja API is a separate rail used only when a project's primary users are in Kenya — see MPESA_PATTERNS.md. The two are alternatives selected per project, not a primary/secondary pair.

Here is the big picture — the payment lifecycle is a clean, predictable loop:

Official Documentation


API versioning — pin it explicitly

Stripe uses a flora-named release model: YYYY-MM-DD.codename. A new codename means breaking changes; monthly releases inside a codename are additive-only and safe to adopt.

ReleaseFirst versionLatest monthly (2026-08-23)
Acacia2024-09-30.acacia—
Basil2025-03-31.basil2025-08-27.basil
Clover2025-09-30.clover2026-02-25.clover
Dahlia (current)2026-03-25.dahlia2026-07-29.dahlia

stripe-node v22.5.0 ships ApiVersion = '2026-07-29.dahlia'. Always pin the version in code rather than relying on your account default — otherwise a dashboard upgrade silently changes the response shapes your code parses.

TypeScript
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-07-29.dahlia",
});

A version string must include the codename suffix. "2025-04-30" on its own is not a valid Stripe API version.

Breaking changes that affect existing code

ChangeReleaseWhat to do
latest_invoice.payment_intent replaced by latest_invoice.confirmation_secretBasil+Expand latest_invoice.confirmation_secret when creating incomplete subscriptions
Flexible billing mode is the default for new subscriptionsClover 2025-09-30Set billing_mode: { type: "flexible" } explicitly
Checkout postpones subscription creation until after paymentBasil 2025-03-31Don't assume the subscription exists before checkout.session.completed
Legacy Stripe.js methods removedDahlia 2026-03-25Replace handleCardPayment, confirmPaymentIntent, handleFpxPayment, handleCardSetup, confirmSetupIntent, createSource, retrieveSource
initCheckout → initCheckoutElementsDahlia 2026-03-25Rename the call
Checkout collected_information.tax_ids → tax_idDahlia 2026-07-29Rename the field read
total_count expansion removed on list responsesBasil 2025-03-31Count separately

stripe.confirmPayment({ elements, confirmParams }) was not removed — it is still the current method. Only the older intent-specific helpers listed above were.


Environments — Sandbox vs Live

Stripe has two kinds of environment: sandboxes (isolated test environments) and live mode (real money). Payments made in a sandbox are never processed by card networks.

Sandboxes

A sandbox is an isolated environment inside your account. Each sandbox has its own data and its own API keys, so teammates can test without colliding.

Bash
# Provision a sandbox from the CLI (no account registration required)
stripe sandbox create --help

Access sandboxes from Dashboard → account picker → Sandboxes. You can invite an external collaborator into a single sandbox without granting any live-mode access.

Sandbox limitations that matter to us:

LimitationImpact
Can't test IC+ pricingCost models must use published rates
Can't connect a platform sandbox to connected-account sandboxesVertiq's Connect flows can't be fully end-to-end tested across sandboxes — plan a live-mode pilot with a real test seller

Live mode

Live mode requires a fully activated account (business details, bank account, tax info). Before flipping any project to live:

  1. Swap sk_test_ / pk_test_ for sk_live_ / pk_live_.
  2. Register the production webhook endpoint in the Dashboard and use its whsec_... — the one from stripe listen is sandbox-only.
  3. Re-verify apiVersion is pinned in code.
  4. Run one real low-value transaction and refund it.

Keys — test vs live

Stripe keys are environment-scoped and namespaced. A test key can never touch live objects, and vice versa — this is the usual cause of No such payment_intent.

KeyPrefixWhere it livesNotes
Secret keysk_test_ / sk_live_Server onlyFull account access. Never in a client bundle
Publishable keypk_test_ / pk_live_Browser (safe)Only identifies your account to Stripe.js
Restricted keyrk_test_ / rk_live_Server / agentsScoped permissions — use for MCP + agents
Webhook signing secretwhsec_Server onlyPer-endpoint; stripe listen prints a sandbox-only one

Rules we follow:

  • Never prefix a secret with NEXT_PUBLIC_ — that embeds it in the browser bundle. Only the publishable key is NEXT_PUBLIC_.
  • Use restricted keys (rk_...) for anything automated — agents, MCP clients, CI. Grant the minimum resource permissions and nothing more.
  • Rotate immediately on suspected exposure; Stripe supports rolling a key with a grace window from Dashboard → Developers → API keys.
  • Never commit any key. See SECURITY.md and the Hazina section below.

Secrets management with Hazina

codeAmani stores Stripe credentials in Hazina, the local encrypted vault (packages/hazina), not in loose .env files. The agent workflow is zero-exposure: Claude handles references and env-var names only, never values.

Catalog the Stripe secrets once (the user runs these — hidden prompt, values never enter chat):

Bash
hazina add stripe/secret_key       --type apikey
hazina add stripe/publishable_key  --type apikey
hazina add stripe/webhook_secret   --type apikey
hazina add stripe/restricted_key   --type apikey   # for MCP / agents

Bind a project's env vars to those references, then inject:

Bash
hazina bind <project> --path <projectDir> \
  --add STRIPE_SECRET_KEY=stripe/secret_key \
  --add NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=stripe/publishable_key \
  --add STRIPE_WEBHOOK_SECRET=stripe/webhook_secret

hazina inject <project>          # writes gitignored .env.local; reports key NAMES only
hazina push <project> --provider vercel --target production

Rules:

  • Use the hazina-wire skill for this workflow — it encodes the zero-exposure rules.
  • Never cat or read .env.local back into context; never use hazina get --reveal.
  • Keep separate references for test and live keys (e.g. stripe/secret_key vs stripe/secret_key_live) so a bad inject can't put live keys in a dev environment.
  • hazina audit flags stale keys past their rotation window — rotate with hazina rotate stripe/secret_key, then re-inject and re-push.

MCP Server Setup

Stripe's official MCP server is now a remote, OAuth-authenticated server at https://mcp.stripe.com. This replaces the older local @stripe/mcp stdio package as the documented default.

Bash
claude mcp add --transport http stripe https://mcp.stripe.com/

Then authenticate — this opens a Stripe OAuth consent screen:

Bash
claude /mcp

OAuth is preferred over a secret key because it grants granular, user-scoped, revocable access. Review authorized sessions under Dashboard → user settings → OAuth sessions.

For headless/agent contexts that cannot do OAuth, pass a restricted API key (rk_..., never a full sk_...) as a bearer token:

JSON
// .mcp.json — prefer OAuth; use this only for non-interactive agents
{
  "mcpServers": {
    "stripe": {
      "url": "https://mcp.stripe.com",
      "headers": { "Authorization": "Bearer ${STRIPE_RESTRICTED_KEY}" }
    }
  }
}

Key MCP Tools

The server exposes generic API tools rather than one tool per endpoint, which keeps the context window small:

ToolDescription
stripe_api_searchFind Stripe API methods by keyword
stripe_api_detailsGet parameter detail for a specific method
stripe_api_readCall any Stripe GET method
stripe_api_writeCall any POST / PATCH / PUT / DELETE method
search_stripe_documentationSearch docs and support articles
stripe_implementation_plannerGuided planning for a Stripe integration
get_stripe_account_infoRetrieve account details
create_refundIssue a refund
stripe_reportSearch, retrieve, and create reports

Prompt-injection caution: stripe_api_write can move money. Keep human confirmation enabled for write tools, and be careful combining the Stripe MCP with untrusted content sources.


Stripe CLI Setup

The CLI is distributed on npm as @stripe/cli (v1.50.4). There is no @stripe/stripe-cli package.

Bash
npm install -g @stripe/cli

stripe login

# Forward webhooks to your local dev server (prints a test-mode whsec_...)
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger checkout.session.completed

Agent skills

Stripe ships first-party skills that keep an agent's Stripe knowledge current (requires CLI v1.43.3+):

Bash
stripe agent setup      # installs stripe-docs, stripe-best-practices, upgrade-stripe
stripe docs /payments   # read any docs page as Markdown in the terminal
stripe docs search "payment intents"
stripe docs api GET /v1/products

Prefer stripe docs over scraping docs.stripe.com — it returns agent-ready Markdown. Note stripe docs requires a valid login; re-run stripe login if you see "The API key provided has expired."


SDK Setup

Bash
npm install stripe                                  # server — v22.5.0
npm install @stripe/stripe-js @stripe/react-stripe-js  # client — v9.14.0 / v6.8.2
TypeScript
// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-07-29.dahlia",
});

Core Patterns

Payment Intent (one-time charge)

TypeScript
// app/api/payments/create-intent/route.ts
import { NextRequest } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { stripe } from "@/lib/stripe";

export async function POST(req: NextRequest) {
  const { userId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const { amount, currency = "usd", orderId } = await req.json();

  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount,          // smallest currency unit — 2000 = $20.00
      currency,
      metadata: { userId, orderId },
      automatic_payment_methods: { enabled: true },
    },
    { idempotencyKey: `pi_${orderId}` }, // RequestOptions — last arg
  );

  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

Expand latest_invoice.confirmation_secret — the old latest_invoice.payment_intent expansion no longer exists, and set billing_mode explicitly so a future default change cannot move under you.

TypeScript
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",
  payment_settings: { save_default_payment_method: "on_subscription" },
  billing_mode: { type: "flexible" },
  expand: ["latest_invoice.confirmation_secret"],
});

// Hand this to the browser to confirm with the Payment Element
const clientSecret =
  (subscription.latest_invoice as Stripe.Invoice).confirmation_secret?.client_secret;

Webhook Handler

This is the trustworthy core of fulfillment — verify the signature first, then act:

TypeScript
// app/api/webhooks/stripe/route.ts
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";

export const runtime = "nodejs"; // signature verification needs Node crypto

export async function POST(req: NextRequest) {
  const body = await req.text();                       // RAW body — never req.json()
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("Webhook signature verification failed", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      // Provision access for session.metadata?.userId
      break;
    }
    case "payment_intent.succeeded": {
      const pi = event.data.object as Stripe.PaymentIntent;
      // Mark payment 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: 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. tied to a cart or order), not per HTTP attempt.

The key goes in the options object — the last argument to any method:

TypeScript
const idempotencyKey = `pi_${orderId}`; // stable across retries

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount,
    currency,
    metadata: { userId, orderId },
    automatic_payment_methods: { enabled: true },
  },
  { idempotencyKey }, // <-- RequestOptions, last arg
);

Fulfillment must be idempotent too. Stripe can deliver the same event more than once, so record each event.id and skip anything already processed before you fulfill:

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: Idempotent requests. Keys are stored by Stripe and expire after 24 hours — they protect against retries, not against a deliberate re-charge a day later.


Client-Side (Payment Element)

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;

    // confirmPayment is current — the legacy handleCardPayment /
    // confirmPaymentIntent helpers were removed in Dahlia (2026-03-25).
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${window.location.origin}/payment-success` },
    });

    if (error) {
      // Surface error.message to the user — do not treat this as authoritative
      // failure; the webhook is the source of truth.
    }
  };

  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 CVC. Full matrix: https://docs.stripe.com/testing


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
STRIPE_RESTRICTED_KEY=rk_...           # Scoped key for agent/MCP contexts

For local dev use sk_test_... / pk_test_.... The whsec_... printed by stripe listen is test-mode only — production uses the signing secret from the Dashboard endpoint.


Common Use Cases

Use CaseApproach
One-time paymentPaymentIntent + Payment Element
Hosted checkoutcheckout.sessions.create → redirect
Subscription billingcheckout.sessions.create in subscription mode, or subscriptions.create + billing portal
Card savessetupIntents.create + Payment Methods API
Invoicinginvoices.create + invoices.sendInvoice
Refundsrefunds.create (also exposed as an MCP tool)

Setting up payments

Single-merchant projects (the default)

Most codeAmani products collect payments for themselves. That is the standard setup covered above:

  1. Activate the Stripe account; capture keys into Hazina.
  2. lib/stripe.ts with a pinned apiVersion.
  3. A server route that creates a Checkout Session or PaymentIntent.
  4. A signature-verifying webhook route that is the only thing that fulfills.
  5. stripe listen locally; a Dashboard endpoint in production.

Marketplaces — Vertiq.market

Vertiq Market is a multi-party marketplace: independent sellers list digital goods, buyers pay, and Vertiq takes a commission. The explicit requirement is that sellers receive the payments and are responsible for them — Vertiq is not the party handling the money.

That maps to exactly one Stripe design: Connect with direct charges onto seller-owned accounts.

Why direct charges, not destination charges: with destination charges the money lands in the platform's balance and refunds/chargebacks debit the platform. That is precisely the liability Vertiq is trying not to hold. With direct charges the funds never touch Vertiq's balance — only the commission does.

PropertyDirect charges (Vertiq)Destination charges (rejected)
Funds land inSeller's balancePlatform balance
Merchant of recordSellerPlatform
Refund debitsSeller's balancePlatform balance
Chargeback debitsSeller's balancePlatform balance
Statement descriptorSeller'sPlatform's
Platform revenueapplication_fee_amountAmount retained

1. Create seller accounts

New platforms should use the Accounts v2 API. The three settings that assign responsibility away from Vertiq:

SettingValueEffect
defaults.responsibilities.losses_collectorstripeStripe — not Vertiq — is liable for the seller's negative balances
defaults.responsibilities.fees_collectorstripeStripe bills processing fees to the seller directly
dashboardfullSeller gets the full Stripe Dashboard and self-serves refunds/disputes
TypeScript
// Accounts v2 — recommended for new platforms
const account = await stripe.v2.core.accounts.create({
  contact_email: sellerEmail,
  dashboard: "full",
  defaults: {
    responsibilities: {
      losses_collector: "stripe",   // Stripe bears seller losses, not Vertiq
      fees_collector: "stripe",     // seller pays Stripe fees directly
    },
  },
  configuration: { merchant: {} },  // enables accepting payments
  include: ["configuration.merchant"],
});

The equivalent on the v1 Accounts API with controller properties:

TypeScript
const account = await stripe.accounts.create({
  email: sellerEmail,
  controller: {
    losses: { payments: "stripe" },      // seller/Stripe bear losses
    fees: { payer: "account" },          // seller pays Stripe fees
    stripe_dashboard: { type: "full" },  // full Dashboard access
    requirement_collection: "stripe",    // Stripe runs KYC
  },
});

defaults.responsibilities cannot be changed after creation, and an account's country is fixed. Get this right on the first seller.

2. Onboard the seller

Stripe runs KYC and identity verification — Vertiq never collects government IDs:

TypeScript
const link = await stripe.accountLinks.create({
  account: account.id,
  type: "account_onboarding",
  refresh_url: `${process.env.APP_URL}/sellers/onboarding/refresh`,
  return_url: `${process.env.APP_URL}/sellers/onboarding/complete`,
});
// redirect the seller to link.url

Before letting a seller list anything, confirm the card_payments capability is active — direct charges require it:

TypeScript
const acct = await stripe.accounts.retrieve(sellerAccountId);
const canSell = acct.capabilities?.card_payments === "active";

3. Take a payment (direct charge + commission)

The seller's account ID goes in the request options, not the params — that is what makes it a direct charge:

TypeScript
// app/api/checkout/route.ts
const COMMISSION_BPS = 1000; // 10.00%

const session = await stripe.checkout.sessions.create(
  {
    mode: "payment",
    line_items: [{ price: listing.stripePriceId, quantity: 1 }],
    payment_intent_data: {
      // Vertiq's cut — transferred to the platform balance
      application_fee_amount: Math.round(listing.amount * COMMISSION_BPS / 10_000),
    },
    success_url: `${process.env.APP_URL}/orders/{CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/listings/${listing.id}`,
    metadata: { listingId: listing.id, buyerId },
  },
  {
    stripeAccount: seller.stripeAccountId, // ← direct charge on the seller
    idempotencyKey: `order_${orderId}`,
  },
);

4. Receive seller webhooks

Connect events arrive at your platform endpoint with an account field naming the connected account. Register a Connect webhook endpoint and branch on it:

TypeScript
event = stripe.webhooks.constructEvent(body, signature, connectWebhookSecret);

const sellerAccountId = event.account; // present on Connect events
switch (event.type) {
  case "checkout.session.completed":
    // release the digital download for this seller's order
    break;
  case "account.updated":
    // capability changed — re-check card_payments before allowing listings
    break;
}

What Vertiq is still responsible for

Being off the payment liability hook is not the same as having no obligations. State these plainly rather than assuming:

  • Vertiq remains liable for negative balances on its own platform account.
  • Vertiq is bound by the Stripe Connect platform agreement, including obligations around prohibited businesses and marketplace conduct.
  • Vertiq must handle its own tax treatment of commission revenue; sellers handle tax on their sales.
  • Sandboxes cannot link a platform sandbox to connected-account sandboxes, so the end-to-end Connect flow needs a live-mode pilot with a cooperating first seller.
  • Sellers need a clear in-product explanation that refunds and chargebacks come out of their balance, since that is a real change from a platform-managed marketplace.

Payment liability and marketplace structure carry legal and tax consequences. Confirm this design with counsel and with Stripe's Connect team before launch — this guide documents the technical mechanism, not legal advice.


Troubleshooting

IssueFix
No such payment_intentTest vs live key mismatch — the namespaces are separate
Webhook 400 — signature mismatchUse the raw body (req.text()), not parsed JSON
confirmation_secret is undefinedYou expanded latest_invoice.payment_intent; it was replaced — expand latest_invoice.confirmation_secret
stripe.handleCardPayment is not a functionRemoved in Dahlia — use confirmCardPayment, or confirmPayment with Elements
Stripe CLI not receiving eventsEnsure stripe listen is running and the port matches
npm i -g @stripe/stripe-cli 404sWrong package — it is @stripe/cli
stripe docs says key expiredRun stripe login again; stripe docs needs CLI v1.43.3+
publishableKey is not setVerify NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is in .env.local

Stripe developer resources

ResourceURLUse it for
Docs homehttps://docs.stripe.comEverything; .md suffix returns agent-ready Markdown
API referencehttps://docs.stripe.com/apiExact parameters and response shapes
Developer changeloghttps://docs.stripe.com/changelogEvery API change, filterable by release
Release noteshttps://docs.stripe.com/changelog/dahliaCurrent release's breaking changes
API upgrades guidehttps://docs.stripe.com/upgradesHow to move between versions safely
Go-live checklisthttps://docs.stripe.com/get-started/checklist/go-livePre-launch review
Testing + test cardshttps://docs.stripe.com/testingFull card/scenario matrix
Sandboxeshttps://docs.stripe.com/sandboxesIsolated test environments
Keys & best practiceshttps://docs.stripe.com/keys-best-practicesKey hygiene, rotation
Restricted API keyshttps://docs.stripe.com/keys/restricted-api-keysScoped keys for agents/CI
Connecthttps://docs.stripe.com/connectMarketplaces (Vertiq)
Accounts v2https://docs.stripe.com/connect/accounts-v2Current Connect account model
Connect pricinghttps://stripe.com/connect/pricingPlatform fee structure
MCP serverhttps://docs.stripe.com/mcpAgent tool access
Agent skillshttps://docs.stripe.com/skillsstripe agent setup
Stripe CLI referencehttps://docs.stripe.com/cliEvery CLI command
Workbenchhttps://dashboard.stripe.com/workbenchLive request logs, version upgrades
Status pagehttps://status.stripe.comIncidents
stripe-nodehttps://github.com/stripe/stripe-nodeSource, changelog, types
Stripe Apps marketplacehttps://marketplace.stripe.comPrebuilt integrations

Dashboard destinations worth bookmarking: API keys (/apikeys), Webhook endpoints (/webhooks), Workbench overview (/workbench/overview), Connect platform profile (/settings/connect/platform-setup), MCP access (/settings/mcp).


Coding agents — rules & best practices

Stripe ships first-party agent skills; install them so an agent's Stripe knowledge comes from Stripe rather than from training data:

Bash
npm i -g @stripe/cli
stripe agent setup     # installs stripe-docs, stripe-best-practices, upgrade-stripe
stripe sandbox create  # working API keys, no account registration needed

Hard rules

  1. Never pass payment_method_types. The one exception is Terminal, which requires payment_method_types: ['card_present']. Omitting it enables dynamic payment methods, so you configure payment methods from the Dashboard and Stripe shows each customer the most relevant eligible options. To restrict, use payment_method_configurations or excluded_payment_method_types — never payment_method_types.
  2. Default to a restricted key (rk_), not a secret key (sk_). Any agent, MCP client, or CI job gets the narrowest scope that works.
  3. Never enable automatic_tax: { enabled: true } without an active tax registration. Without one, Stripe calculates and collects no tax while the integration looks like tax is on — the most common Stripe Tax mistake.
  4. Pin apiVersion in code; don't inherit the account default.
  5. Tag Checkout Sessions on 2026-03-25.dahlia+ with integration_identifier (label plus an 8-random-letter suffix) so flows are comparable in the Dashboard.
  6. Fetch before you write. Use stripe docs / the MCP search_stripe_documentation tool instead of recalling an API shape — Stripe's surface moves every month.
  7. Keep human confirmation on MCP write tools. stripe_api_write can move money; treat any untrusted content in the loop as a prompt-injection risk.

Integration routing

Building…Use
One-time paymentsCheckout Sessions
Custom embedded payment formCheckout Sessions + Payment Element
Saving a card for laterSetup Intents
Marketplace / platform (Vertiq)Accounts v2 (/v2/core/accounts)
Subscriptions / recurringBilling APIs + Checkout Sessions
Usage-based billing (new build)Metronome
Sales tax / VAT / GSTStripe Tax + Registrations API

Version drift warning

The bundled stripe-best-practices skill carries a static version table that can lag the live API. At the 2026-08-23 review the skill (v0.6.3) had caught its API-version line up to 2026-07-29.dahlia, but its Node table still read 22.4.0 while the live stripe-node source was at 22.5.0. (At the prior 2026-08-04 review it lagged on both, reporting 2026-06-24.dahlia / 22.3.0.) When the skill and the live source disagree, the live source wins — verify with:

Bash
npm view stripe version
curl -sL https://raw.githubusercontent.com/stripe/stripe-node/master/src/apiVersion.ts

codeAmani notes

  • Default rail. Stripe is the payment rail for every codeAmani project unless the project's primary users are in Kenya, in which case M-Pesa/Daraja applies instead (MPESA_PATTERNS.md, AFRICAN_MARKET_GUIDE.md). They are per-project alternatives.
  • Secrets stay server-side. STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET must never be prefixed NEXT_PUBLIC_. Only the publishable key reaches the browser. Store secrets in .env.local / Vercel env vars — see ENV_MASTER.md and SECURITY.md.
  • Verify every webhook with constructEvent before acting. An unauthenticated POST to your webhook route is trivial to forge.
  • Restricted keys for agents. When an agent or MCP client needs Stripe access, issue an rk_... restricted key scoped to the minimum permissions — never a full secret key.
  • Pin apiVersion in code, so a Dashboard-side version upgrade cannot silently reshape the objects your handlers parse.
  • Amounts are integers in the smallest currency unit — the same discipline as Daraja's integer-KES rule.