Skip to content

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

  1. Customer completes checkout
  2. Stripe sends checkout.session.completed webhook to your server
  3. Your server calls the fulfill function
  4. Customer is redirected to your success page
  5. 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

Stripe API Reference - Self-contained docs reference, refreshed 2026-05-18