
Stripe Integration
- 55 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
stripe-integration is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- stripe-integration
- AI & Agent Building
- AI-coding skill
Stripe Integration by the numbers
- 55 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #6,781 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill stripe-integrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 55 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Stripe Integration
Overview
Stripe provides a complete payments platform with server-side SDKs (Node.js) and client-side libraries (Stripe.js, React Stripe.js). The Node.js SDK handles server operations like creating Payment Intents, managing Subscriptions, and verifying webhooks. The React Stripe.js library provides pre-built UI components (PaymentElement, Elements provider) for secure client-side payment collection.
When to use: One-time payments, recurring subscriptions, usage-based billing, marketplace payouts, hosted checkout pages, custom payment forms, webhook-driven fulfillment.
When NOT to use: Cryptocurrency payments (not supported), regions where Stripe is unavailable, simple static product sales without payment processing (use a hosted storefront).
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Hosted checkout | stripe.checkout.sessions.create() | Stripe-hosted page, supports payment, subscription, setup modes |
| Payment Intent | stripe.paymentIntents.create() | Server-side, returns client_secret for client confirmation |
| Confirm payment | stripe.confirmPayment({ elements, clientSecret }) | Client-side, requires Elements instance |
| Create subscription | stripe.subscriptions.create() | Use payment_behavior: 'default_incomplete' for SCA |
| Update subscription | stripe.subscriptions.update() | Set proration_behavior explicitly |
| Cancel subscription | stripe.subscriptions.cancel() | Use prorate: true to credit unused time |
| Customer Portal | stripe.billingPortal.sessions.create() | Self-service billing management, returns short-lived URL |
| Webhook verify | stripe.webhooks.constructEvent() | Requires raw body, signature header, and endpoint secret |
| Elements provider | <Elements stripe={stripePromise} options={options}> | Wraps payment components, pass clientSecret or mode |
| PaymentElement | <PaymentElement /> | Renders all supported payment methods automatically |
| Retrieve + expand | stripe.checkout.sessions.retrieve(id, { expand }) | Expand nested objects to reduce API calls |
| Search | stripe.paymentIntents.search({ query }) | Stripe Query Language for filtering |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Parsing webhook body as JSON before verification | Use express.raw({ type: 'application/json' }) to pass raw body to constructEvent |
| Hardcoding payment method types | Use automatic_payment_methods or let Checkout choose based on currency and region |
| Creating PaymentIntent client-side | Create on server, pass only client_secret to client |
Not awaiting elements.submit() before confirm | Call elements.submit() first to trigger validation, then confirmPayment |
Missing return_url in confirmPayment | Always provide return_url for redirect-based payment methods |
| Using test keys in production | Store keys in environment variables, validate STRIPE_SECRET_KEY prefix (sk_live_ vs sk_test_) |
Not handling requires_action status | Check PaymentIntent status after confirmation, handle 3D Secure or other authentication |
| Creating Customer Portal session without settings | Save portal settings in Dashboard first, otherwise API returns an error |
| Not expanding related objects | Use expand parameter to include nested objects like latest_invoice.payment_intent |
| Ignoring webhook idempotency | Use event.id to deduplicate, webhook events can be delivered more than once |
Delegation
- Payment flow architecture: Use
Exploreagent to discover integration patterns - Webhook debugging: Use
Taskagent for end-to-end event tracing - Code review: Delegate to
code-revieweragent for security review of payment handlers
References
- Checkout Sessions, Payment Intents, and one-time payments
- Subscription lifecycle, pricing models, trials, and Customer Portal
- Webhook endpoints, signature verification, and event handling
- React Stripe.js, Elements provider, PaymentElement, and custom forms
Checkout and Payments
Stripe Initialization
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);Checkout Sessions
Checkout Sessions create a Stripe-hosted payment page. Use mode to specify the payment type.
One-Time Payment
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: 'price_123',
quantity: 1,
},
{
price_data: {
currency: 'usd',
unit_amount: 2000,
product_data: {
name: 'T-shirt',
description: 'Comfortable cotton t-shirt',
images: ['https://example.com/tshirt.png'],
},
},
quantity: 2,
},
],
mode: 'payment',
success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://example.com/cancel',
customer_email: 'customer@example.com',
allow_promotion_codes: true,
billing_address_collection: 'required',
phone_number_collection: { enabled: true },
metadata: {
order_id: 'order_12345',
},
});
// Redirect customer to session.urlSubscription Checkout
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_monthly', quantity: 1 }],
mode: 'subscription',
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
customer: 'cus_123',
subscription_data: {
trial_period_days: 14,
metadata: { plan: 'premium' },
},
});Setup Mode (Save Payment Method)
const session = await stripe.checkout.sessions.create({
mode: 'setup',
customer: 'cus_123',
success_url: 'https://example.com/success',
cancel_url: 'https://example.com/cancel',
});Retrieve Session with Expanded Objects
const session = await stripe.checkout.sessions.retrieve('cs_123', {
expand: ['line_items', 'customer', 'payment_intent'],
});Payment Intents
Payment Intents track the lifecycle of a payment from creation to confirmation. Use for custom payment flows (not Checkout).
Create Payment Intent
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000,
currency: 'usd',
customer: 'cus_123',
automatic_payment_methods: { enabled: true },
description: 'Software subscription',
metadata: {
order_id: 'order_12345',
product: 'pro_subscription',
},
receipt_email: 'customer@example.com',
statement_descriptor: 'MYCOMPANY SUB',
});
// Send paymentIntent.client_secret to the clientConfirm Payment Intent (Server-Side)
const confirmed = await stripe.paymentIntents.confirm('pi_123', {
payment_method: 'pm_card_visa',
return_url: 'https://example.com/order/complete',
});Manual Capture (Auth and Capture)
Useful for holding funds before fulfillment:
const paymentIntent = await stripe.paymentIntents.create({
amount: 5000,
currency: 'usd',
capture_method: 'manual',
automatic_payment_methods: { enabled: true },
});
// Later, capture the full or partial amount
const captured = await stripe.paymentIntents.capture('pi_123', {
amount_to_capture: 4500,
});Cancel Payment Intent
const cancelled = await stripe.paymentIntents.cancel('pi_123', {
cancellation_reason: 'requested_by_customer',
});Search Payment Intents
const results = await stripe.paymentIntents.search({
query: "status:'succeeded' AND metadata['order_id']:'12345'",
});Payment Intent Status Flow
requires_payment_method → requires_confirmation → requires_action → processing → succeeded
→ canceled
→ requires_capture (manual)Key statuses:
requires_payment_method-- initial state, waiting for payment detailsrequires_confirmation-- payment method attached, ready to confirmrequires_action-- 3D Secure or additional authentication neededprocessing-- payment is being processedsucceeded-- payment completedrequires_capture-- authorized but not captured (manual capture flow)
Server Endpoint Pattern
A typical Express endpoint for creating a Payment Intent:
import express, { type Request, type Response } from 'express';
const app = express();
app.use(express.json());
app.post('/api/create-payment-intent', async (req: Request, res: Response) => {
const { amount, currency = 'usd' } = req.body;
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: { enabled: true },
});
res.json({ clientSecret: paymentIntent.client_secret });
});Customers
Create Customer
const customer = await stripe.customers.create({
email: 'user@example.com',
name: 'Jane Doe',
metadata: { user_id: 'usr_123' },
});Attach Payment Method to Customer
await stripe.paymentMethods.attach('pm_123', {
customer: 'cus_123',
});
await stripe.customers.update('cus_123', {
invoice_settings: { default_payment_method: 'pm_123' },
});Refunds
const refund = await stripe.refunds.create({
payment_intent: 'pi_123',
amount: 1000,
reason: 'requested_by_customer',
});Omit amount for a full refund.
Idempotency
Use idempotency keys to safely retry requests:
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 2000,
currency: 'usd',
},
{
idempotencyKey: `order_${orderId}`,
},
);Stripe Elements
Installation
npm install @stripe/stripe-js @stripe/react-stripe-jsLoad Stripe
Call loadStripe once at module level, not inside a component:
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
);Elements Provider
The Elements provider gives child components access to Stripe.js. There are two initialization modes.
Deferred Intent Mode (Recommended for Flexibility)
Create the PaymentIntent after the customer fills in payment details:
import { Elements } from '@stripe/react-stripe-js';
function App() {
const options = {
mode: 'payment' as const,
amount: 1099,
currency: 'usd',
appearance: {
theme: 'stripe' as const,
},
};
return (
<Elements stripe={stripePromise} options={options}>
<CheckoutForm />
</Elements>
);
}Client Secret Mode
Pass a clientSecret from an existing PaymentIntent or SetupIntent:
import { Elements } from '@stripe/react-stripe-js';
function App({ clientSecret }: { clientSecret: string }) {
const options = {
clientSecret,
appearance: {
theme: 'stripe' as const,
},
};
return (
<Elements stripe={stripePromise} options={options}>
<CheckoutForm />
</Elements>
);
}Payment Form with PaymentElement
PaymentElement renders a dynamic form that supports cards, wallets, bank transfers, and other payment methods based on your Stripe Dashboard configuration.
Deferred Intent Flow
import {
useStripe,
useElements,
PaymentElement,
} from '@stripe/react-stripe-js';
function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
setProcessing(true);
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMessage(submitError.message ?? 'Validation failed');
setProcessing(false);
return;
}
const res = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 1099, currency: 'usd' }),
});
const { clientSecret } = await res.json();
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}/order/complete`,
},
});
if (error) {
setErrorMessage(error.message ?? 'Payment failed');
}
setProcessing(false);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button disabled={!stripe || processing} type="submit">
{processing ? 'Processing...' : 'Pay $10.99'}
</button>
{errorMessage ? <div>{errorMessage}</div> : null}
</form>
);
}Client Secret Flow
function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
setProcessing(true);
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
confirmParams: {
return_url: `${window.location.origin}/order/complete`,
},
});
if (error) {
setErrorMessage(error.message ?? 'Payment failed');
} else if (paymentIntent?.status === 'succeeded') {
setErrorMessage(null);
}
setProcessing(false);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button disabled={!stripe || processing} type="submit">
{processing ? 'Processing...' : 'Pay'}
</button>
{errorMessage ? <div>{errorMessage}</div> : null}
</form>
);
}PaymentElement Options
<PaymentElement
options={{
layout: {
type: 'tabs',
defaultCollapsed: false,
},
fields: {
billingDetails: {
address: {
country: 'auto',
},
},
},
wallets: {
applePay: 'auto',
googlePay: 'auto',
},
}}
/>Layout options:
tabs-- payment methods shown as tabs (default)accordion-- payment methods shown as collapsible sections
LinkAuthenticationElement
Enables Stripe Link for faster checkout with saved payment details:
import {
LinkAuthenticationElement,
PaymentElement,
} from '@stripe/react-stripe-js';
function CheckoutForm() {
const [email, setEmail] = useState('');
return (
<form onSubmit={handleSubmit}>
<LinkAuthenticationElement
options={{ defaultValues: { email: '' } }}
onChange={(event) => {
if (event.complete) {
setEmail(event.value.email);
}
}}
/>
<PaymentElement options={{ layout: 'tabs' }} />
<button type="submit">Pay</button>
</form>
);
}AddressElement
Collect shipping or billing addresses:
import { AddressElement } from '@stripe/react-stripe-js';
function ShippingForm() {
return (
<AddressElement
options={{
mode: 'shipping',
allowedCountries: ['US', 'CA', 'GB'],
autocomplete: { mode: 'google_maps_api', apiKey: 'YOUR_KEY' },
}}
onChange={(event) => {
if (event.complete) {
const address = event.value;
}
}}
/>
);
}Appearance API
Customize the look of all Elements to match your brand:
const appearance: Stripe.Appearance = {
theme: 'stripe',
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'system-ui, sans-serif',
spacingUnit: '4px',
borderRadius: '4px',
},
rules: {
'.Input': {
border: '1px solid #e0e0e0',
boxShadow: 'none',
},
'.Input:focus': {
border: '1px solid #0570de',
boxShadow: '0 0 0 1px #0570de',
},
'.Label': {
fontWeight: '500',
},
},
};Available themes: stripe, night, flat.
Setup Mode (Save Card for Later)
function App({ clientSecret }: { clientSecret: string }) {
return (
<Elements
stripe={stripePromise}
options={{ clientSecret, appearance: { theme: 'stripe' } }}
>
<SetupForm />
</Elements>
);
}
function SetupForm() {
const stripe = useStripe();
const elements = useElements();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
const { error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: `${window.location.origin}/account`,
},
});
if (error) {
console.error(error.message);
}
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit">Save Payment Method</button>
</form>
);
}Server-Side: Create PaymentIntent Endpoint
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.use(express.json());
app.post('/api/create-payment-intent', async (req, res) => {
const { amount, currency = 'usd' } = req.body;
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: { enabled: true },
});
res.json({ clientSecret: paymentIntent.client_secret });
});Subscriptions
Create Subscription
Use payment_behavior: 'default_incomplete' to handle SCA (Strong Customer Authentication) properly. Expand latest_invoice.payment_intent to get the client_secret for client-side confirmation.
const subscription = await stripe.subscriptions.create({
customer: 'cus_123',
items: [{ price: 'price_monthly' }],
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
});
// Send client_secret to frontend for payment confirmation
const clientSecret = subscription.latest_invoice?.payment_intent?.client_secret;Subscription with Trial
const subscription = await stripe.subscriptions.create({
customer: 'cus_123',
items: [{ price: 'price_monthly' }],
trial_period_days: 14,
payment_settings: {
save_default_payment_method: 'on_subscription',
},
trial_settings: {
end_behavior: { missing_payment_method: 'cancel' },
},
});Multiple Line Items
const subscription = await stripe.subscriptions.create({
customer: 'cus_123',
items: [
{ price: 'price_base_plan', quantity: 1 },
{ price: 'price_per_seat', quantity: 5 },
],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
});Update Subscription (Plan Change)
Always set proration_behavior explicitly to avoid unexpected charges.
const subscription = await stripe.subscriptions.retrieve('sub_123');
const updated = await stripe.subscriptions.update('sub_123', {
items: [
{
id: subscription.items.data[0].id,
price: 'price_new_plan',
},
],
proration_behavior: 'create_prorations',
metadata: { upgraded_at: new Date().toISOString() },
});Proration Behavior Options
create_prorations-- generate proration invoice items (default)none-- no proration, new price applies at next billing cyclealways_invoice-- create prorations and immediately invoice
Cancel Subscription
Cancel at Period End (Recommended)
const subscription = await stripe.subscriptions.update('sub_123', {
cancel_at_period_end: true,
});Cancel Immediately
const cancelled = await stripe.subscriptions.cancel('sub_123', {
prorate: true,
invoice_now: true,
});Resume Cancelled Subscription
Only works if cancel_at_period_end is true and the period has not ended:
const resumed = await stripe.subscriptions.update('sub_123', {
cancel_at_period_end: false,
});Pause Subscription
const paused = await stripe.subscriptions.update('sub_123', {
pause_collection: {
behavior: 'mark_uncollectible',
resumes_at: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
},
});Retrieve Subscription
const subscription = await stripe.subscriptions.retrieve('sub_123', {
expand: ['customer', 'default_payment_method', 'latest_invoice'],
});Subscription Status Flow
incomplete → active → past_due → canceled
→ unpaid → canceled
active → trialing → active
active → paused → activeKey statuses:
incomplete-- initial payment failed or requires actiontrialing-- in free trial periodactive-- payment succeeded, subscription is activepast_due-- latest invoice payment failed, retry in progressunpaid-- all retry attempts exhaustedcanceled-- subscription ended
Pricing Models
Fixed Price
Create a price with a fixed recurring amount:
const price = await stripe.prices.create({
currency: 'usd',
unit_amount: 1500,
recurring: { interval: 'month' },
product: 'prod_123',
});Per-Seat Pricing
Use quantity to represent seats:
const subscription = await stripe.subscriptions.create({
customer: 'cus_123',
items: [{ price: 'price_per_seat', quantity: 10 }],
});
// Update seat count
await stripe.subscriptions.update('sub_123', {
items: [{ id: 'si_123', quantity: 15 }],
proration_behavior: 'create_prorations',
});Metered / Usage-Based Billing
Create a metered price and report usage:
const price = await stripe.prices.create({
currency: 'usd',
unit_amount: 5,
recurring: {
interval: 'month',
usage_type: 'metered',
},
product: 'prod_123',
});
// Report usage for a subscription item
await stripe.subscriptionItems.createUsageRecord('si_123', {
quantity: 100,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
});Customer Portal
The Customer Portal is a Stripe-hosted UI where customers manage subscriptions and billing. Save portal settings in the Dashboard before creating sessions.
Create Portal Session
const portalSession = await stripe.billingPortal.sessions.create({
customer: 'cus_123',
return_url: 'https://example.com/account',
});
// Redirect customer to portalSession.urlPortal Configuration
Create custom portal configurations for different customer segments:
const configuration = await stripe.billingPortal.configurations.create({
business_profile: {
headline: 'Manage your subscription',
},
features: {
subscription_cancel: { enabled: true },
subscription_update: {
enabled: true,
default_allowed_updates: ['price', 'quantity'],
products: [
{
product: 'prod_123',
prices: ['price_basic', 'price_pro', 'price_enterprise'],
},
],
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
});
// Use configuration when creating portal session
const session = await stripe.billingPortal.sessions.create({
customer: 'cus_123',
configuration: configuration.id,
return_url: 'https://example.com/account',
});Express Route for Portal
app.post('/api/create-portal-session', async (req, res) => {
const { customerId } = req.body;
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${req.headers.origin}/account`,
});
res.json({ url: portalSession.url });
});Key Webhook Events for Subscriptions
| Event | When |
|---|---|
customer.subscription.created | New subscription created |
customer.subscription.updated | Status, plan, or quantity changed |
customer.subscription.deleted | Subscription cancelled |
customer.subscription.trial_will_end | Trial ending in 3 days |
invoice.payment_succeeded | Recurring payment succeeded |
invoice.payment_failed | Recurring payment failed |
invoice.finalized | Invoice ready for payment |
Webhooks
Overview
Stripe sends webhook events to notify your server about payment lifecycle changes. Webhook signature verification is critical for security -- it ensures events are genuinely from Stripe and have not been tampered with.
Express Webhook Handler
The raw request body must be passed to constructEvent. Do not parse the body as JSON before verification.
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object;
await fulfillOrder(paymentIntent);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object;
await handleFailedPayment(paymentIntent);
break;
}
case 'customer.subscription.created': {
const subscription = event.data.object;
await provisionSubscription(subscription);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
await updateSubscriptionStatus(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await revokeAccess(subscription);
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object;
await recordPayment(invoice);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
await notifyPaymentFailure(invoice);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
},
);Signature Verification Details
constructEvent accepts an optional tolerance parameter (in seconds) for clock skew:
const event = stripe.webhooks.constructEvent(
rawBody,
signatureHeader,
webhookSecret,
300,
);The default tolerance is 300 seconds (5 minutes). Events older than the tolerance are rejected.
Framework-Specific Raw Body
Different frameworks handle raw bodies differently. The key requirement is passing the exact bytes Stripe sent.
Next.js App Router
import { headers } from 'next/headers';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const sig = headersList.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await fulfillOrder(session);
break;
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
}Next.js Pages Router
Disable body parsing for the webhook route:
import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import Stripe from 'stripe';
export const config = {
api: { bodyParser: false },
};
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const buf = await buffer(req);
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
buf,
sig,
process.env.STRIPE_WEBHOOK_SECRET,
);
// Handle event...
res.json({ received: true });
}Idempotent Event Handling
Stripe may deliver the same event multiple times. Use the event ID to deduplicate:
const processedEvents = new Set<string>();
async function handleWebhookEvent(event: Stripe.Event) {
if (processedEvents.has(event.id)) {
return;
}
// For production, use a database instead of in-memory Set
await db.processedEvents.upsert({
where: { eventId: event.id },
create: { eventId: event.id, processedAt: new Date() },
update: {},
});
// Process the event...
}Stripe Retry Schedule
If your endpoint returns a non-2xx status code, Stripe retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | ~1 hour |
| 2nd retry | ~2 hours |
| 3rd retry | ~4 hours |
| Continues | Up to 3 days |
After all retries are exhausted, the event is marked as failed in the Dashboard.
Key Events by Integration
Checkout Sessions
| Event | When |
|---|---|
checkout.session.completed | Customer completed checkout |
checkout.session.async_payment_succeeded | Async payment (bank transfer) succeeded |
checkout.session.async_payment_failed | Async payment failed |
checkout.session.expired | Session expired (24 hours) |
Payment Intents
| Event | When |
|---|---|
payment_intent.succeeded | Payment completed |
payment_intent.payment_failed | Payment failed |
payment_intent.requires_action | 3D Secure or authentication needed |
payment_intent.canceled | Payment canceled |
Subscriptions
| Event | When |
|---|---|
customer.subscription.created | New subscription |
customer.subscription.updated | Plan or status change |
customer.subscription.deleted | Subscription cancelled |
customer.subscription.trial_will_end | Trial ending in 3 days |
invoice.payment_succeeded | Recurring payment succeeded |
invoice.payment_failed | Recurring payment failed |
Local Development with Stripe CLI
Forward webhook events to your local server:
stripe listen --forward-to localhost:3000/webhookThe CLI outputs a webhook signing secret (whsec_...). Use this as your STRIPE_WEBHOOK_SECRET during development.
Forward specific events only:
stripe listen --forward-to localhost:3000/webhook --events payment_intent.succeeded,customer.subscription.createdTrigger a test event:
stripe trigger payment_intent.succeededTest Webhook Signatures
Generate test webhook signatures for unit tests without connecting to Stripe:
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_fake');
const payload = JSON.stringify({
id: 'evt_test_webhook',
object: 'event',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_test',
amount: 2000,
currency: 'usd',
status: 'succeeded',
},
},
});
const secret = 'whsec_test_secret';
const header = stripe.webhooks.generateTestHeaderString({
payload,
secret,
});
const event = stripe.webhooks.constructEvent(payload, header, secret);Environment Variables
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...Store these securely. Never commit secret keys to version control. Use different keys for test and live modes.