Airbnb clone, handling Stripe webhooks

Join the 2022 Full-Stack Web Dev Bootcamp!


This post is part of a new series where we build a clone of Airbnb with Next.js. See the first post here.

Now we must implement the Stripe webhook handler. A webhook is an HTTP call in response to something, and in our case that’s sent to us when the payment is successful.

Using Webhooks locally

When the app will be “live” on a real server, you’d go to https://dashboard.stripe.com/account/checkout/settings and click “Configure webhooks” in the “Checkout client & server integration” section to configure the real webhook.

But since we are running the app locally, what can we do? Stripe does not let us use localhost as a valid domain, and for a good reason: they must be able to access your app, and if it’s running on localhost it’s just not possible - unless you set up an ip tunnel using ngrok for example.

Luckily for us, Stripe has this great tool called Stipe CLI that can provide a way to automatically get webhooks to our local server.

See here how to install it. In my case, on macOS, it’s

brew install stripe/stripe-cli/stripe

Once installed I run

stripe login

and follow the instructions to log in.

Then run

stripe listen --forward-to localhost:3000/api/stripe/webhook

This command will return a webhook signing secret code, which you’ll need to put in the .env file:

STRIPE_WEBHOOK_SECRET=whsec_SOMETHING

and it will keep running in the background.

Important: you now need to stop the Next.js npm run dev command, and start it again, to apply the .env setting.

The Webhook handler

Now let’s create the Webhook POST handler.

Create a file pages/api/stripe/webhook.js

We return a { received: true } JSON response to Stripe, to tell them “we got it!”:

export default async (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'application/json'
  })
  res.end(JSON.stringify({ received: true }))
}

Before doing so however, we can use this code (which I found on the Stripe documentation) that we use to analyze the Webhook:

const sig = req.headers['stripe-signature']

let event

try {
  event = stripe.webhooks.constructEvent(rawBody, sig, endpointSecret)
} catch (err) {
  res.writeHead(400, {
    'Content-Type': 'application/json'
  })
  console.error(err.message)
  res.end(
    JSON.stringify({
      status: 'success',
      message: `Webhook Error: ${err.message}`
    })
  )
  return
}

See that I use the rawBody variable. This is not a property available by default to us, but stripe.webhooks.constructEvent() wants the raw request body passed to it, to verify for security purposes.

We must tell Next.js to avoid parsing the body:

export const config = {
  api: {
    bodyParser: false
  }
}

Then we can install the raw-body library:

npm install raw-body

and we have access to the raw body using

const rawBody = await getRawBody(req, {
  encoding: 'utf-8'
})

Since we get lots of various Webhook notifications from Stripe we need to filter out the one we need, which is called checkout.session.completed.

When this happens we get the session id from the event and we use that to update the booking with the same session id assigned (remember? we added it into the table) and it sets the paid column to true:

if (event.type === 'checkout.session.completed') {
  const sessionId = event.data.object.id

  try {
    Booking.update({ paid: true }, { where: { sessionId } })
  } catch (err) {
    console.error(err)
  }
}

This is the complete code:

import { Booking } from '../../../model.js'
import dotenv from 'dotenv'
dotenv.config()
import getRawBody from 'raw-body'

export const config = {
  api: {
    bodyParser: false
  }
}

export default async (req, res) => {
  if (req.method !== 'POST') {
    res.status(405).end() //Method Not Allowed
    return
  }
  const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
  const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET
  const sig = req.headers['stripe-signature']

  const rawBody = await getRawBody(req, {
    encoding: 'utf-8'
  })

  let event

  try {
    event = stripe.webhooks.constructEvent(rawBody, sig, endpointSecret)
  } catch (err) {
    res.writeHead(400, {
      'Content-Type': 'application/json'
    })
    console.error(err.message)
    res.end(
      JSON.stringify({
        status: 'success',
        message: `Webhook Error: ${err.message}`
      })
    )
    return
  }

  if (event.type === 'checkout.session.completed') {
    const sessionId = event.data.object.id

    try {
      Booking.update({ paid: true }, { where: { sessionId } })
      console.log('done')
    } catch (err) {
      console.error(err)
    }
  }

  res.writeHead(200, {
    'Content-Type': 'application/json'
  })
  res.end(JSON.stringify({ received: true }))
}

This is a central part of our application.

As soon as the user is back from the payment from Stripe, if all goes well the booking has already been marked as paid in our database.

Testing Webhooks

As with every entity that’s introduced in your application that you don’t have full control over, testing webhooks is complicated.

Luckily Stripe provides us the stripe CLI tool we already used to allow local webhooks to run.

If you open another window, you can invoke it again like this:

stripe trigger checkout.session.completed

This lets you test the Webhook code without having to follow the payments workflow manually over and over again.

You can now try the whole workflow, and ideally you should get a booking marked as paid in the database!

See the code on GitHub

Next part: Airbnb clone, view bookings

Want to become a better Web Developer? Join the 2022 Web Development Bootcamp!

⭐️⭐️⭐️ Join the 2022 Web Development Bootcamp ⭐️⭐️⭐️