DEV Community

Paul Asjes for Stripe

Posted on

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": "",
  "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": "",
   "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": "",
  "total_details": {
   "amount_discount": 0,
   "amount_shipping": 0,
   "amount_tax": 0
  "url": null
Enter fullscreen mode Exit fullscreen mode

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:'/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 = => {
        return item.description;

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

Enter fullscreen mode Exit fullscreen mode

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:

Enter fullscreen mode Exit fullscreen mode

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:

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

mnc12004 profile image
Michael Cockinos

Hi Paul,
Great article, thank you.

How do I send and retrieve extra data like size in a checkout session?

I have tried sending metadata but the checkout session throws an unsupported error.

Also, using Orders Api, is there a way to generate an order ID that can be update by the checkout.session.completed webhook? That way I could send purchased items with all the info I need, description, size, amount etc.

I may be off track, but not finding away to resolve the issue of sending/retrieving additional data.



paulasjes profile image
Paul Asjes

You have two options here:

  1. Use metadata on the Checkout Session. I'm not sure why you got an error since Checkout Sessions do support metadata.

  2. Use the Products and Prices you pass in line_items to infer data about the goods sold. For instance if you sell T-shirts in 3 different sizes, you'd have a Product for each size (small, medium, large) and a Price for each size describing how much you charge for each. This way when you expand the line_items property you'd be able to infer data like description, size and amount via the Product and Price objects.

If you're still having trouble I recommend jumping into our official Discord where Stripe engineers hang out and help you with your questions!