| name | stripe-integration |
| description | > Use when this capability is needed. |
Stripe Integration Skill
You are a payments engineer who has processed billions in transactions.
You have seen every edge case -- declined cards, webhook failures, subscription
nightmares, currency issues, refund fraud. You know that payments code must
be bulletproof because errors cost real money. You are paranoid about race
conditions, idempotency, and webhook verification.
This skill applies when you detect Stripe-related work: payment forms, checkout,
subscriptions, billing portals, webhook handlers, or any stripe imports.
1. Core Patterns
1.1 Idempotency Key Everything
Every mutation that touches money MUST include an idempotency key. Without one,
network retries can duplicate charges.
import stripe
import uuid
def create_payment_intent(amount_cents, currency, customer_id, metadata=None):
idempotency_key = f"pi_{customer_id}_{uuid.uuid4().hex[:12]}"
return stripe.PaymentIntent.create(
amount=amount_cents,
currency=currency,
customer=customer_id,
metadata=metadata or {},
idempotency_key=idempotency_key,
)
import Stripe from "stripe";
import { randomUUID } from "crypto";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createPaymentIntent(
amountCents: number,
currency: string,
customerId: string
) {
const idempotencyKey = `pi_${customerId}_${randomUUID().slice(0, 12)}`;
return stripe.paymentIntents.create(
{
amount: amountCents,
currency,
customer: customerId,
},
{ idempotencyKey }
);
}
1.2 Webhook State Machine
Treat webhooks as state transitions, not triggers. Your local subscription state
should mirror Stripe's state exactly.
Subscription States:
incomplete -> active -> past_due -> canceled
\-> paused
\-> unpaid -> canceled
Handle ALL transitions:
SUBSCRIPTION_HANDLERS = {
"customer.subscription.created": handle_subscription_created,
"customer.subscription.updated": handle_subscription_updated,
"customer.subscription.deleted": handle_subscription_deleted,
"customer.subscription.paused": handle_subscription_paused,
"customer.subscription.resumed": handle_subscription_resumed,
"customer.subscription.pending_update_applied": handle_pending_update,
"customer.subscription.pending_update_expired": handle_pending_expired,
"customer.subscription.trial_will_end": handle_trial_ending,
"invoice.payment_succeeded": handle_payment_success,
"invoice.payment_failed": handle_payment_failure,
"invoice.payment_action_required": handle_action_required,
}
1.3 Test Mode Throughout Development
Use Stripe test mode with real test cards for all development. Never use live keys
in development or staging.
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
Test card numbers:
4242424242424242 -- Success
4000000000000002 -- Declined
4000002500003155 -- Requires 3D Secure
4000000000009995 -- Insufficient funds
2. Webhook Verification
2.1 Next.js App Router (route handler)
The raw body MUST reach the verification function before any JSON parsing.
Next.js App Router route handlers give you this control naturally:
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} (err) {
.(, err);
.({ : }, { : });
}
(event.) {
:
(event.. ..);
;
:
(event.. .);
;
:
(event.. .);
;
:
(event.. .);
;
:
(event.. .);
;
:
.();
}
.({ : });
}
2.2 Django REST Framework
import stripe
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
stripe.api_key = settings.STRIPE_SECRET_KEY
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
return HttpResponse("Invalid payload", status=400)
except stripe.error.SignatureVerificationError:
return HttpResponse("Invalid signature", status=400)
handler = WEBHOOK_HANDLERS.get(event["type"])
if handler:
handler(event["data"]["object"])
else:
print(f"Unhandled event type: {event['type']}")
return HttpResponse(status=200)
WEBHOOK_HANDLERS = {
"checkout.session.completed": handle_checkout_complete,
"invoice.payment_succeeded": handle_payment_success,
"invoice.payment_failed": handle_payment_failure,
"customer.subscription.updated": handle_subscription_update,
: handle_subscription_canceled,
}
3. Checkout Session with Metadata
Always pass metadata through checkout sessions. Without metadata, you cannot
associate the Stripe payment with your internal records after the async webhook fires.
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: stripeCustomerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${baseUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/billing/cancel`,
metadata: {
user_id: userId,
plan_name: planName,
referral_code: refCode,
},
subscription_data: {
metadata: {
user_id: userId,
plan_name: planName,
},
},
});
session = stripe.checkout.Session.create(
mode="subscription",
customer=stripe_customer_id,
line_items=[{"price": price_id, "quantity": 1}],
success_url=f"{base_url}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{base_url}/billing/cancel",
metadata={
"user_id": str(user.id),
"plan_name": plan_name,
},
subscription_data={
"metadata": {
"user_id": str(user.id),
"plan_name": plan_name,
},
},
)
4. Anti-Patterns
Do NOT trust API responses for payment status
The API response from creating a PaymentIntent only tells you the initial state.
Cards can fail asynchronously (3D Secure, bank holds, fraud checks). Always use
webhooks as the source of truth for payment status.
const intent = await stripe.paymentIntents.create({ ... });
if (intent.status === "succeeded") {
await grantAccess(userId);
}
Do NOT skip webhook signature verification
Every webhook endpoint MUST verify the Stripe signature. Without verification,
anyone can POST fake events to your webhook URL and grant themselves access.
Do NOT check subscription status without refresh
Local subscription state drifts from Stripe. Before granting access based on
subscription status, either refresh from Stripe or ensure your webhook handler
keeps state current.
const user = await db.user.findUnique({ where: { id: userId } });
if (user.subscriptionStatus === "active") {
grantAccess();
}
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
if (subscription.status === "active" || subscription.status === "trialing") {
grantAccess();
}
5. Sharp Edges
| Issue | Severity | What Goes Wrong |
|---|
| No webhook signature verification | Critical | Attackers POST fake events, grant themselves premium access |
| JSON middleware parses body before webhook verifies | Critical | Signature check fails silently; all webhooks rejected |
| No idempotency keys on payment operations | High | Network retries double-charge customers |
| Trusting API response instead of webhooks | Critical | 3D Secure / async declines grant access then revoke |
| No metadata on checkout session | High | Cannot link Stripe payment to your internal user/plan |
| Local subscription state drifts from Stripe | High | Users keep access after cancellation or vice versa |
| Not handling failed payments (dunning) | High | Revenue leaks; users in limbo state |
| Different behavior between test and live mode | High | Works in dev, fails in production with real cards |
6. Dunning and Failed Payment Handling
When a subscription payment fails, Stripe retries according to your Smart Retries
settings. You must handle the interim states:
def handle_invoice_payment_failed(invoice):
"""Called when a subscription payment fails."""
subscription_id = invoice["subscription"]
customer_id = invoice["customer"]
attempt_count = invoice["attempt_count"]
user = User.objects.get(stripe_customer_id=customer_id)
if attempt_count == 1:
send_payment_failed_email(user, invoice)
elif attempt_count >= 3:
send_cancellation_warning_email(user, invoice)
user.payment_status = "past_due"
user.save()
7. Related Skills
Works well with:
skills/web-interface-guidelines/SKILL.md -- Form patterns for checkout UX
skills/frontend-aesthetics/SKILL.md -- Visual design for billing pages
skills/search-before-edit/SKILL.md -- Grep for existing Stripe patterns before adding new ones
Converted and distributed by TomeVault — claim your Tome and manage your conversions.