Stripe Integration Guide
Technology: stripe · Category: payments · Last reviewed: 2026-08-23
Source: https://tech-stack.codeamanilabs.org/guide/stripe
Insight:
Stripe is codeAmani's default payment rail — the US-first customer base pays by card and wallet, and Stripe Checkout is the shortest path to production. M-Pesa (Daraja) is a separate rail used only for Kenya-targeted projects. Pin your
apiVersionexplicitly, verify every webhook signature, and pass an idempotency key on every state-changing create call.
███████╗████████╗██████╗ ██╗██████╗ ███████╗
██╔════╝╚══██╔══╝██╔══██╗██║██╔══██╗██╔════╝
███████╗ ██║ ██████╔╝██║██████╔╝█████╗
╚════██║ ██║ ██╔══██╗██║██╔═══╝ ██╔══╝
███████║ ██║ ██║ ██║██║██║ ███████╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚══════╝
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:
flowchart LR
A["Customer pays<br/>card or checkout"] --> B["Your server<br/>creates PaymentIntent"]
B --> C["Payment Element<br/>confirms payment"]
C --> D["Stripe sends<br/>signed webhook"]
D --> E["Your server<br/>verifies signature"]
E --> F["Update DB<br/>and fulfill"]
Official Documentation
| Resource | URL |
|---|---|
| Stripe Docs | https://docs.stripe.com |
| API Reference | https://docs.stripe.com/api |
| Developer Changelog | https://docs.stripe.com/changelog |
| Stripe CLI | https://docs.stripe.com/stripe-cli |
| Webhooks | https://docs.stripe.com/webhooks |
| MCP server | https://docs.stripe.com/mcp |
| Agent skills | https://docs.stripe.com/skills |
| Testing | https://docs.stripe.com/testing |
| Node.js SDK | https://github.com/stripe/stripe-node |
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.
| Release | First version | Latest monthly (2026-08-23) |
|---|---|---|
| Acacia | 2024-09-30.acacia |
— |
| Basil | 2025-03-31.basil |
2025-08-27.basil |
| Clover | 2025-09-30.clover |
2026-02-25.clover |
| Dahlia (current) | 2026-03-25.dahlia |
2026-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.
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
| Change | Release | What to do |
|---|---|---|
latest_invoice.payment_intent replaced by latest_invoice.confirmation_secret |
Basil+ | Expand latest_invoice.confirmation_secret when creating incomplete subscriptions |
| Flexible billing mode is the default for new subscriptions | Clover 2025-09-30 |
Set billing_mode: { type: "flexible" } explicitly |
| Checkout postpones subscription creation until after payment | Basil 2025-03-31 |
Don't assume the subscription exists before checkout.session.completed |
| Legacy Stripe.js methods removed | Dahlia 2026-03-25 |
Replace handleCardPayment, confirmPaymentIntent, handleFpxPayment, handleCardSetup, confirmSetupIntent, createSource, retrieveSource |
initCheckout → initCheckoutElements |
Dahlia 2026-03-25 |
Rename the call |
Checkout collected_information.tax_ids → tax_id |
Dahlia 2026-07-29 |
Rename the field read |
total_count expansion removed on list responses |
Basil 2025-03-31 |
Count 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.
flowchart TB
subgraph SB["Sandbox · no real money"]
S1["sk_test_… / pk_test_…"]
S2["Test cards 4242…"]
S3["stripe listen → whsec_… (test)"]
end
subgraph LV["Live · real money"]
L1["sk_live_… / pk_live_…"]
L2["Real cards"]
L3["Dashboard endpoint → whsec_… (live)"]
end
SB -.->|"promote after testing"| LV
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.
# 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:
| Limitation | Impact |
|---|---|
| Can't test IC+ pricing | Cost models must use published rates |
| Can't connect a platform sandbox to connected-account sandboxes | Vertiq'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:
- Swap
sk_test_/pk_test_forsk_live_/pk_live_. - Register the production webhook endpoint in the Dashboard and use its
whsec_...— the one fromstripe listenis sandbox-only. - Re-verify
apiVersionis pinned in code. - 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.
| Key | Prefix | Where it lives | Notes |
|---|---|---|---|
| Secret key | sk_test_ / sk_live_ |
Server only | Full account access. Never in a client bundle |
| Publishable key | pk_test_ / pk_live_ |
Browser (safe) | Only identifies your account to Stripe.js |
| Restricted key | rk_test_ / rk_live_ |
Server / agents | Scoped permissions — use for MCP + agents |
| Webhook signing secret | whsec_ |
Server only | Per-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 isNEXT_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.mdand 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):
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:
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-wireskill for this workflow — it encodes the zero-exposure rules. - Never
cator read.env.localback into context; never usehazina get --reveal. - Keep separate references for test and live keys (e.g.
stripe/secret_keyvsstripe/secret_key_live) so a bad inject can't put live keys in a dev environment. hazina auditflags stale keys past their rotation window — rotate withhazina rotate stripe/secret_key, then re-injectand 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.
claude mcp add --transport http stripe https://mcp.stripe.com/
Then authenticate — this opens a Stripe OAuth consent screen:
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:
// .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:
| Tool | Description |
|---|---|
stripe_api_search |
Find Stripe API methods by keyword |
stripe_api_details |
Get parameter detail for a specific method |
stripe_api_read |
Call any Stripe GET method |
stripe_api_write |
Call any POST / PATCH / PUT / DELETE method |
search_stripe_documentation |
Search docs and support articles |
stripe_implementation_planner |
Guided planning for a Stripe integration |
get_stripe_account_info |
Retrieve account details |
create_refund |
Issue a refund |
stripe_report |
Search, retrieve, and create reports |
Prompt-injection caution:
stripe_api_writecan 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.
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+):
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
npm install stripe # server — v22.5.0
npm install @stripe/stripe-js @stripe/react-stripe-js # client — v9.14.0 / v6.8.2
// 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)
// 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
// 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.
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:
sequenceDiagram
participant C as "Customer"
participant S as "Your server"
participant ST as "Stripe"
participant DB as "Database"
C->>S: Request PaymentIntent
S->>ST: paymentIntents.create
ST-->>S: client_secret
S-->>C: client_secret
C->>ST: confirmPayment via Payment Element
ST->>S: Webhook payment_intent.succeeded
S->>ST: constructEvent verify signature
S->>DB: Mark payment succeeded
S-->>ST: Respond 2xx
// 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:
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:
// 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
flowchart TD
A["Create PaymentIntent<br/>with idempotencyKey"] --> B{"Key seen<br/>before by Stripe"}
B -->|"yes"| C["Return original<br/>PaymentIntent · no new charge"]
B -->|"no"| D["Create new<br/>PaymentIntent"]
D --> E["Webhook arrives"]
C --> E
E --> F{"event.id in<br/>processed log"}
F -->|"yes"| G["Skip · already fulfilled"]
F -->|"no"| H["Record id<br/>then fulfill 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)
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;
// 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>
);
}
// 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 CVC. Full matrix: https://docs.stripe.com/testing
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 ID
STRIPE_RESTRICTED_KEY=rk_... # Scoped key for agent/MCP contexts
For local dev use
sk_test_.../pk_test_.... Thewhsec_...printed bystripe listenis test-mode only — production uses the signing secret from the Dashboard endpoint.
Common Use Cases
| Use Case | Approach |
|---|---|
| One-time payment | PaymentIntent + Payment Element |
| Hosted checkout | checkout.sessions.create → redirect |
| Subscription billing | checkout.sessions.create in subscription mode, or subscriptions.create + billing portal |
| Card saves | setupIntents.create + Payment Methods API |
| Invoicing | invoices.create + invoices.sendInvoice |
| Refunds | refunds.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:
- Activate the Stripe account; capture keys into Hazina.
lib/stripe.tswith a pinnedapiVersion.- A server route that creates a Checkout Session or PaymentIntent.
- A signature-verifying webhook route that is the only thing that fulfills.
stripe listenlocally; 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.
flowchart LR
B["Buyer"] -->|"pays"| SA["Seller's Stripe account<br/>(merchant of record)"]
SA -->|"application_fee_amount"| V["Vertiq platform<br/>(commission only)"]
SA -->|"payout"| SB["Seller's bank"]
SA -.->|"refunds · chargebacks<br/>debit THIS balance"| SA
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.
| Property | Direct charges (Vertiq) | Destination charges (rejected) |
|---|---|---|
| Funds land in | Seller's balance | Platform balance |
| Merchant of record | Seller | Platform |
| Refund debits | Seller's balance | Platform balance |
| Chargeback debits | Seller's balance | Platform balance |
| Statement descriptor | Seller's | Platform's |
| Platform revenue | application_fee_amount |
Amount retained |
1. Create seller accounts
New platforms should use the Accounts v2 API. The three settings that assign responsibility away from Vertiq:
| Setting | Value | Effect |
|---|---|---|
defaults.responsibilities.losses_collector |
stripe |
Stripe — not Vertiq — is liable for the seller's negative balances |
defaults.responsibilities.fees_collector |
stripe |
Stripe bills processing fees to the seller directly |
dashboard |
full |
Seller gets the full Stripe Dashboard and self-serves refunds/disputes |
// 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:
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.responsibilitiescannot 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:
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:
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:
// 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:
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
| Issue | Fix |
|---|---|
No such payment_intent |
Test vs live key mismatch — the namespaces are separate |
| Webhook 400 — signature mismatch | Use the raw body (req.text()), not parsed JSON |
confirmation_secret is undefined |
You expanded latest_invoice.payment_intent; it was replaced — expand latest_invoice.confirmation_secret |
stripe.handleCardPayment is not a function |
Removed in Dahlia — use confirmCardPayment, or confirmPayment with Elements |
| Stripe CLI not receiving events | Ensure stripe listen is running and the port matches |
npm i -g @stripe/stripe-cli 404s |
Wrong package — it is @stripe/cli |
stripe docs says key expired |
Run stripe login again; stripe docs needs CLI v1.43.3+ |
publishableKey is not set |
Verify NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is in .env.local |
Stripe developer resources
| Resource | URL | Use it for |
|---|---|---|
| Docs home | https://docs.stripe.com | Everything; .md suffix returns agent-ready Markdown |
| API reference | https://docs.stripe.com/api | Exact parameters and response shapes |
| Developer changelog | https://docs.stripe.com/changelog | Every API change, filterable by release |
| Release notes | https://docs.stripe.com/changelog/dahlia | Current release's breaking changes |
| API upgrades guide | https://docs.stripe.com/upgrades | How to move between versions safely |
| Go-live checklist | https://docs.stripe.com/get-started/checklist/go-live | Pre-launch review |
| Testing + test cards | https://docs.stripe.com/testing | Full card/scenario matrix |
| Sandboxes | https://docs.stripe.com/sandboxes | Isolated test environments |
| Keys & best practices | https://docs.stripe.com/keys-best-practices | Key hygiene, rotation |
| Restricted API keys | https://docs.stripe.com/keys/restricted-api-keys | Scoped keys for agents/CI |
| Connect | https://docs.stripe.com/connect | Marketplaces (Vertiq) |
| Accounts v2 | https://docs.stripe.com/connect/accounts-v2 | Current Connect account model |
| Connect pricing | https://stripe.com/connect/pricing | Platform fee structure |
| MCP server | https://docs.stripe.com/mcp | Agent tool access |
| Agent skills | https://docs.stripe.com/skills | stripe agent setup |
| Stripe CLI reference | https://docs.stripe.com/cli | Every CLI command |
| Workbench | https://dashboard.stripe.com/workbench | Live request logs, version upgrades |
| Status page | https://status.stripe.com | Incidents |
| stripe-node | https://github.com/stripe/stripe-node | Source, changelog, types |
| Stripe Apps marketplace | https://marketplace.stripe.com | Prebuilt 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:
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
- Never pass
payment_method_types. The one exception is Terminal, which requirespayment_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, usepayment_method_configurationsorexcluded_payment_method_types— neverpayment_method_types. - Default to a restricted key (
rk_), not a secret key (sk_). Any agent, MCP client, or CI job gets the narrowest scope that works. - 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. - Pin
apiVersionin code; don't inherit the account default. - Tag Checkout Sessions on
2026-03-25.dahlia+ withintegration_identifier(label plus an 8-random-letter suffix) so flows are comparable in the Dashboard. - Fetch before you write. Use
stripe docs/ the MCPsearch_stripe_documentationtool instead of recalling an API shape — Stripe's surface moves every month. - Keep human confirmation on MCP write tools.
stripe_api_writecan move money; treat any untrusted content in the loop as a prompt-injection risk.
Integration routing
| Building… | Use |
|---|---|
| One-time payments | Checkout Sessions |
| Custom embedded payment form | Checkout Sessions + Payment Element |
| Saving a card for later | Setup Intents |
| Marketplace / platform (Vertiq) | Accounts v2 (/v2/core/accounts) |
| Subscriptions / recurring | Billing APIs + Checkout Sessions |
| Usage-based billing (new build) | Metronome |
| Sales tax / VAT / GST | Stripe 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:
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_KEYandSTRIPE_WEBHOOK_SECRETmust never be prefixedNEXT_PUBLIC_. Only the publishable key reaches the browser. Store secrets in.env.local/ Vercel env vars — seeENV_MASTER.mdandSECURITY.md. - Verify every webhook with
constructEventbefore 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
apiVersionin 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.
Official docs: