Fulfill Orders Fresh
When you receive a payment with the Checkout Sessions API (including Payment Links), you need to fulfill what the customer paid for — granting service access, shipping goods, etc.
Two approaches
- Manual — Monitor the Dashboard or payment emails and fulfill manually. Works for low-volume experiments.
- Automatic (Recommended) — Build an automated fulfillment system using webhooks + redirect.
This guide covers automatic fulfillment.
How automatic fulfillment works
- Customer completes checkout
- Stripe sends
checkout.session.completedwebhook to your server - Your server calls the fulfill function
- Customer is redirected to your success page
- Your success page also calls the fulfill function (for immediate UX)
The fulfill function is idempotent — calling it multiple times is safe.
Create a fulfillment function
javascript
async function fulfillCheckout(sessionId) {
console.log('Fulfilling Checkout Session:', sessionId);
// Retrieve the Checkout Session with line_items expanded
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});
// Check payment status before fulfilling
if (session.payment_status !== 'unpaid') {
// Check if already fulfilled (query your database)
const alreadyFulfilled = await db.orders.findOne({ sessionId });
if (alreadyFulfilled) return;
// Perform fulfillment
for (const item of session.line_items.data) {
// Grant access, trigger shipment, etc.
console.log('Fulfill:', item.description, item.quantity);
}
// Record fulfillment in your database
await db.orders.create({ sessionId, fulfilled: true, fulfilledAt: new Date() });
}
}Set up the webhook handler
javascript
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// Check if payment succeeded or if it's a delayed payment method
if (session.payment_status === 'paid') {
await fulfillCheckout(session.id);
}
break;
}
case 'checkout.session.async_payment_succeeded': {
// For async payment methods (bank transfers, ACH)
await fulfillCheckout(event.data.object.id);
break;
}
case 'checkout.session.async_payment_failed': {
const session = event.data.object;
// Notify customer their payment failed
await sendPaymentFailedEmail(session.customer_details.email);
break;
}
}
res.json({ received: true });
});Handle the success redirect
When customers are redirected to your success page, also trigger fulfillment for immediate UX:
javascript
app.get('/success', async (req, res) => {
const { session_id } = req.query;
// Trigger fulfillment (idempotent — safe to call multiple times)
await fulfillCheckout(session_id);
// Retrieve session for display
const session = await stripe.checkout.sessions.retrieve(session_id);
res.send(`
<h1>Thanks for your purchase!</h1>
<p>Order confirmed. Check your email for details.</p>
<p>Customer: ${session.customer_details.email}</p>
`);
});Handle multiple line items
For sessions with many line items, use pagination:
javascript
// If session has more than 10 line items, paginate
let lineItems = [];
let hasMore = true;
let page = await stripe.checkout.sessions.listLineItems(session.id);
while (true) {
lineItems = lineItems.concat(page.data);
if (!page.has_more) break;
page = await stripe.checkout.sessions.listLineItems(session.id, {
starting_after: page.data[page.data.length - 1].id,
});
}Best practices
- Idempotency — your fulfill function must be safe to call multiple times with the same session ID
- Database check — before fulfilling, check if you've already fulfilled this session
- Listen for async webhooks — bank transfers and ACH don't complete immediately; always handle
checkout.session.async_payment_succeeded - Return 200 quickly — acknowledge the webhook immediately; do heavy work asynchronously if needed
- Retry on failure — Stripe retries webhooks for up to 3 days if your endpoint returns a non-2xx response
Testing webhooks
bash
stripe listen --forward-to localhost:4242/webhook
stripe trigger checkout.session.completed