Stripe Integration Skill
When to activate
- Implementing Stripe Checkout or Payment Intents for one-time payments
- Setting up Stripe subscriptions and billing cycles
- Handling Stripe webhooks for payment events
- Implementing refunds, disputes, or payment cancellations
- Setting up Stripe Connect for marketplace/platform payments
- Debugging failed payments, declined cards, or webhook delivery issues
- Implementing SCA (Strong Customer Authentication) compliance
When NOT to use
- PayPal, Braintree, Adyen, Mollie — different payment providers with different SDKs
- Crypto payments
- Internal accounting or invoicing systems without a payment gateway
- Bank transfer / ACH-only flows (different Stripe product — Payment Elements still apply, but flow differs)
Instructions
Never hardcode or log payment data
// NEVER log card details, full payment intent objects, or customer PII
// BAD:
console.log('Payment intent:', paymentIntent); // May contain sensitive data
// GOOD:
console.log('Payment intent created:', paymentIntent.id, paymentIntent.status);
Payment Intents — server-side creation
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia', // Always pin the API version
});
// Create payment intent server-side — never client-side
export async function createPaymentIntent(
amount: number, // Always in smallest currency unit (cents for USD)
currency: string,
customerId: string,
metadata: Record<string, string>
): Promise<{ clientSecret: string; paymentIntentId: string }> {
const paymentIntent = await stripe.paymentIntents.create({
amount, // e.g., 2999 = $29.99
currency, // e.g., 'usd', 'eur'
customer: customerId,
automatic_payment_methods: { enabled: true },
metadata, // Store your internal IDs here
idempotency_key: `pi_${customerId}_${Date.now()}`, // Prevent duplicate charges
});
return {
clientSecret: paymentIntent.client_secret!,
paymentIntentId: paymentIntent.id,
};
}
Webhook handling — always verify signature
import { headers } from 'next/headers';
export async function POST(request: Request) {
const body = await request.text(); // Raw body — not parsed JSON
const signature = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature — reject immediately
return new Response('Invalid signature', { status: 400 });
}
// Process idempotently — webhooks can be delivered more than once
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(event.data.object as Stripe.Subscription);
break;
default:
// Log but don't error on unknown events — Stripe adds new events over time
console.log(`Unhandled event type: ${event.type}`);
}
return new Response(null, { status: 200 });
}
// Always check if already processed before making DB changes
async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
const orderId = paymentIntent.metadata.order_id;
// Idempotency check
const existing = await db.order.findUnique({ where: { id: orderId } });
if (existing?.status === 'paid') return; // Already processed
await db.order.update({
where: { id: orderId },
data: { status: 'paid', stripePaymentIntentId: paymentIntent.id }
});
}
Subscriptions
// Create subscription
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete', // Don't activate until payment confirmed
expand: ['latest_invoice.payment_intent'],
});
// Key webhook events for subscriptions:
// customer.subscription.created → provision access
// customer.subscription.updated → handle plan changes
// customer.subscription.deleted → revoke access
// invoice.payment_succeeded → extend access period
// invoice.payment_failed → send dunning email, then suspend after grace period
Test mode discipline
// Use test card numbers in development:
// Success: 4242 4242 4242 4242
// Decline: 4000 0000 0000 0002
// Auth required: 4000 0025 0000 3155
// Use test webhook CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe
// Never use test keys in production, never use live keys in development
const isLiveMode = process.env.STRIPE_SECRET_KEY!.startsWith('sk_live_');
if (isLiveMode && process.env.NODE_ENV !== 'production') {
throw new Error('Live Stripe keys must not be used outside production');
}
Error handling
try {
const charge = await stripe.charges.create({ ... });
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
// Card declined — show user-friendly message
return { error: err.message, code: err.code };
}
if (err instanceof Stripe.errors.StripeRateLimitError) {
// Retry with exponential backoff
throw err;
}
if (err instanceof Stripe.errors.StripeInvalidRequestError) {
// Bad API call — log and fix the code
console.error('Invalid Stripe request:', err.message);
throw err;
}
// Other errors: StripeAPIError, StripeConnectionError, StripeAuthenticationError
throw err;
}
Example
User: Implement a checkout flow for a SaaS product: create a payment intent server-side, handle the webhook on success to activate the subscription, and handle failed payments.
Expected output:
POST /api/checkout— creates PaymentIntent, returnsclientSecretPOST /api/webhooks/stripe— verifies signature, handlespayment_intent.succeeded(idempotent DB update),payment_intent.payment_failed(log + notify)- Metadata on PaymentIntent:
user_id,plan_id,order_id - All amounts in cents, currency explicit
- API version pinned
- No logging of sensitive data