Purchase fulfilment with Checkout, or “Wait, what was I paid for?”


Imagine you’re in the middle of setting up your payments integration. You’ve implemented Stripe Checkout, got your webhooks up and running, and even installed a Slack app to tell you when you’ve made money.

Next up, you need to actually provide the thing or service you’re selling to your customers. “Not a problem!” you think, unaware that you’re about to be proven wrong. You’ll just add some business logic to your backend when you receive that checkout.session.completed webhook event. You try this out in test mode and get a payload not unlike the following:

 "object": {
  "id": "cs_test_a16Dn1Ja9hTBizgcJ9pWXM5xnRMwivCYDVrT55teciF0mc3vLCUcy6uO99",
  "object": "checkout.session",
  "after_expiration": null,
  "allow_promotion_codes": null,
  "amount_subtotal": 3000,
  "amount_total": 3000,
  "automatic_tax": {
   "enabled": false,
   "status": null
  "billing_address_collection": null,
  "cancel_url": "https://example.com/cancel",
  "client_reference_id": null,
  "consent": null,
  "consent_collection": null,
  "currency": "usd",
  "customer": "cus_M5Q7YRXNqZrFtu",
  "customer_creation": "always",
  "customer_details": {
   "address": {
    "city": null,
    "country": null,
    "line1": null,
    "line2": null,
    "postal_code": null,
    "state": null
   "email": "stripe@example.com",
   "name": null,
   "phone": null,
   "tax_exempt": "none",
   "tax_ids": [
  "customer_email": null,
  "expires_at": 1658319119,
  "livemode": false,
  "locale": null,
  "metadata": {
  "mode": "payment",
  "payment_intent": "pi_3LNFHPGUcADgqoEM2rxLo91k",
  "payment_link": null,
  "payment_method_options": {
  "payment_method_types": [
  "payment_status": "paid",
  "phone_number_collection": {
   "enabled": false
  "recovered_from": null,
  "setup_intent": null,
  "shipping": null,
  "shipping_address_collection": null,
  "shipping_options": [
  "shipping_rate": null,
  "status": "complete",
  "submit_type": null,
  "subscription": null,
  "success_url": "https://example.com/success",
  "total_details": {
   "amount_discount": 0,
   "amount_shipping": 0,
   "amount_tax": 0
  "url": null

From that data you can gather who paid and how much, but what did the user actually buy? How do you know what to ship if you sell physical products or what to provision if your wares are digital?

This is a quirk that trips up a lot of people when they get to this stage. You probably recall providing line_items when creating your Checkout Session, it’s the field where you specify what exactly the user is purchasing by either providing a Price ID or by creating a Price ad-hoc.

This field isn’t included by default when you retrieve a Checkout Session, nor is it in the payload of the webhook event. Instead you need to retrieve the Checkout Session from the Stripe API while expanding the fields that you require. Expanding is the process of requesting additional data or objects from a singular Stripe API call. WIth it you could for example retrieve both a Subscription and the associated Customer object with a single API call rather than two.

⚠️ Hint: properties that are expandable are noted as such in the API reference. You can learn more about expanding in our video series.

Here’s an example using Node and Express on how that would look in your webhook event code:

app.post('/webhook', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const endpointSecret = process.env.WEBHOOK_SECRET;

  let event;

  // Verify the webhook signature
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    console.log(`Webhook error: ${err.message}`);
    return res.status(400).send(`Webhook error: ${err.message}`);

  // Acknowledge that the event was received _before_ doing our business logic
  // That way if something goes wrong we won't get any webhook event retries
  res.json({ received: true });

  switch (event.type) {
    case 'checkout.session.completed':

      // Retrieve the session, expanding the line items
      const session = await stripe.checkout.sessions.retrieve(
          expand: ['line_items'],

      const items = session.line_items.data.map((item) => {
        return item.description;

        Items purchased: ${items.join(' ')}
        Total amount: ${session.amount_total}


The above will retrieve the same Checkout Session, but ask the API to include the full line_items object that you’d otherwise not get. We then print the descriptions of each item purchased and the total amount that the customer paid.

This approach might seem obtuse (why not just include the line_items in the payload?), but there’s actually a good reason for and benefit to this way of doing things.


The truth is that it is computationally expensive to retrieve a full list of line items and return them in your webhook event payload. This is especially the case if you have lots of line items for a single Checkout Session. Coupled with the fact that many Stripe users don’t use the contents of line_items, adding them in every payload would significantly increase the latency of the webhook event. As such, Stripe has opted for this property to be opt-in to keep the API fast for everybody.

Ensure that you always have the latest up-to-date object

Imagine a scenario where your customer creates a new subscription and you’re listening for the following webhook events:


Where in each step you want to perform some business logic before the next step (for example, updating a status in your database).

Then, when you test out your integration you get the events in this order:


Wait what? How can the invoice be created and paid before the subscription is created?

Well, it didn’t. The order of webhooks can unfortunately not be trusted due to how the internet works. While the events might be sent in order from Stripe, there’s no guarantee that they will be received in order (I blame internet gremlins). This is especially true for events that are generated and sent in quick succession, like the events associated with the creation of a new subscription.

If your business logic relies on these events happening in order, with Stripe objects in varying states, bad stuff can happen. You can mitigate this entirely by always fetching the object in question before making any changes. That way you guarantee that you always have the most up-to-date object which reflects what Stripe has on their end. While this does mean making one extra API call, it also means you never have stale data or suffer from the internet gremlin’s ire.

Wrap up

These were some tips on doing purchase reconciliation and some webhook best practices. Did I miss anything or do you have follow-up questions? Let me know in the comments below or on Twitter!

About the author

Profile picture of Paul Asjes

Paul Asjes is a Developer Advocate at Stripe where he writes, codes and hosts a monthly Q&A series talking to developers. Outside of work he enjoys brewing beer, making biltong and losing to his son in Mario Kart.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Migrating a nodejs, webpack project from JavaScript to TypeScript

Next Post

Do we need Axios having the fetch API everywhere?

Related Posts