In this lesson we’re going to add payment integration using Stripe.
Stripe has a product called Connect that allows to create a marketplace where people get money directly from customers.
That looks something worth exploring, but not in our context. In our app we’ll do like Airbnb does: we collect the payment ourselves, and we’ll distribute the earning in the backend, once every month or so.
This is not something we’ll implement in the course.
What we’ll do is, we’ll collect the payment for the booking. That’s it.
We do this using Stripe Checkout.
Sign up to Stripe if you don’t have an account yet.
We have 2 different types of Checkout.
One is the client-only integration, the other is the client & server integration.
From the Stripe docs:
With the client-only integration, you define your products directly in the Stripe Dashboard and reference them by ID on the client side. This approach makes it possible to integrate Checkout into your website without needing any server-side code. It is best suited for simple integrations that don’t need dynamic pricing.
It’s clear that this is not enough. We must define each product separately, as a “booking”, because our prices vary depending on the house, and on the duration of the stay.
Also client-only integration does not support placing a hold on a card before charging it. This could be interesting: you don’t charge a card immediately, but just after the person stayed at the house. Or checks in.
But in our case, to simplify the workflow, we’ll bill directly at the booking time.
So, the work on the following workflow:
- we create a checkout session server-side, when the user clicks “Reserve now”, and we provide a success URL that will be where people are sent, on our site, after the payment was successful
- we store the booking, set as paid=false
- we redirect to checkout on Stripe’s webpage
- as soon as the payment is done, Stripe redirects to the success URL, where we’ll later set up the lists of booked places of the user, along with the dates
- meanwhile, Stripe will send us a confirmation via Webhook to set the booking as paid
As soon as a person clicks “Reserve now”, we’ll add the reservation into the database, along with the Stripe session id.
This clears the possibility that a person books meanwhile another person books, so the dates are immediately marked as unavailable on the calendar.
We’ll store it with a new paid
field set to false.
As soon as Stripe calls our Webhook to inform of the payment, we’ll set the reservation to paid
= true
.
Let’s do it!
I start by adding this new paid
field to the Booking model, and a new sessionId
string, too:
Booking.init({
//...
paid: { type: Sequelize.DataTypes.BOOLEAN, defaultValue: false, allowNull: false },
sessionId: { type: Sequelize.DataTypes.STRING }
}, {
//...
})
Full code:
models/booking.js
const Sequelize = require('sequelize')
const sequelize = require('../database.js')
class Booking extends Sequelize.Model {}
Booking.init(
{
id: {
type: Sequelize.DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true
},
houseId: { type: Sequelize.DataTypes.INTEGER, allowNull: false },
userId: { type: Sequelize.DataTypes.INTEGER, allowNull: false },
startDate: { type: Sequelize.DataTypes.DATEONLY, allowNull: false },
endDate: { type: Sequelize.DataTypes.DATEONLY, allowNull: false },
paid: { type: Sequelize.DataTypes.BOOLEAN, defaultValue: false, allowNull: false },
sessionId: { type: Sequelize.DataTypes.STRING }
},
{
sequelize,
modelName: 'booking',
timestamps: true
}
)
module.exports = Booking
Next we install the stripe
npm package for server-side usage:
npm install stripe
and we need to add the Stripe frontend code:
<script src="https://js.stripe.com/v3/"></script>
How? In the components/Layout.js
file, which is what every page component includes, we’re going to add this to the top:
import Head from 'next/head'
and then, when we return the JSX:
return (
<div>
<Head>
<script src='https://js.stripe.com/v3/'></script>
</Head>
This will put this script tag in the page <head>
tag.
Now I’m going to modify the process we use to reserve the house, a little bit.
Before actually POSTing to /api/houses/reserve
, I’m going to POST to a new endpoint we’ll create that listens on /api/stripe/session
and wait for the end result.
In that endpoint we’ll set up the payment, with the amount and details, and Stripe will give us a sessionId
for the payment, which we’ll use in the frontend.
Before going on, we must go on the Stripe dashboard and gather the API secret key and the public key. The first must never be exposed to the frontend, while the second will be used in code that can be seen by users (hence the name public).
In my case they look like sk_SOMETHING
and pk_SOMETHING
.
We add them to a file in the root folder of the project, outside of src
. The folder that hosts the package.json
file, among others.
In there, create a file called .env
and write there the keys, like this:
STRIPE_SECRET_KEY=sk_SOMETHING
STRIPE_PUBLIC_KEY=pk_SOMETHING
The syntax is a bit strange, because it’s not JavaScript. It’s a shell script.
Now we install this special package:
npm install dotenv
which allows us to gather those variables in the Node.js code like this:
import dotenv from 'dotenv'
dotenv.config()
console.log(process.env.STRIPE_SECRET_KEY) //prints the secret key
console.log(process.env.STRIPE_PUBLIC_KEY) //prints the public key
It’s very handy to keep all keys OUTSIDE of the code. You can for example publish your project on GitHub (like I do with the example) and use .gitignore
to ignore the content of the .env
file, so it’s never made public.
Let’s do it. Open server.js
, and at the top, add:
import dotenv from 'dotenv'
dotenv.config()
Now let’s define the /api/stripe/session
endpoint:
server.post('/api/stripe/session', async (req, res) => {
const amount = req.body.amount
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
name: 'Booking house on Airbnb clone',
amount: amount * 100,
currency: 'usd',
quantity: 1
}
],
success_url: process.env.BASE_URL + '/bookings',
cancel_url: process.env.BASE_URL + '/bookings'
})
res.writeHead(200, {
'Content-Type': 'application/json'
})
res.end(
JSON.stringify({
status: 'success',
sessionId: session.id,
stripePublicKey: process.env.STRIPE_PUBLIC_KEY
})
)
})
We get the amount
value from the POST request body,and once we have that, we can require the stripe
library and create a session. We pass an object that defines the payment, which includes the payment accepted, the item purchased and 2 lines that set the URLs of the pages to redirect to, after the purchase is done or cancelled.
Finally, we return the session id
value, and also the process.env.STRIPE_PUBLIC_KEY
, because the frontend can’t access it directly and we’ll need it later to invoke the Stripe checkout.
Now we can call this endpoint in pages/houses/[id].js
before we call /api/houses/reserve
:
const sessionResponse = await axios.post('/api/stripe/session', { amount: props.house.price * numberOfNightsBetweenDates })
if (sessionResponse.data.status === 'error') {
alert(sessionResponse.data.message)
return
}
const sessionId = sessionResponse.data.sessionId
const stripePublicKey = sessionResponse.data.stripePublicKey
Once this is done, we pass sessionId
to the houses/reserve
call, because we want to store it in the bookings table.
Why? Because when the Stripe payment confirmation webhook will be sent to us, that’s the way we can link the payment with the booking.
const reserveResponse = await axios.post(
'/api/houses/reserve',
{
houseId: props.house.id,
startDate,
endDate,
sessionId
}
)
In the server.js
, in the reserve endpoint, we now need to gather this new sessionId
field from the body:
const sessionId = req.body.sessionId
and then we pass it inside Booking.create()
:
server.post('/api/houses/reserve', (req, res) => {
const userEmail = req.session.passport.user
User.findOne({ where: { email: userEmail } }).then(user => {
Booking.create({
houseId: req.body.houseId,
userId: user.id,
startDate: req.body.startDate,
endDate: req.body.endDate,
sessionId: req.body.sessionId
}).then(() => {
res.writeHead(200, {
'Content-Type': 'application/json'
})
res.end(JSON.stringify({ status: 'success', message: 'ok' }))
})
})
})
Finally in pages/houses/[id].js
, I can redirect the user to Stripe checkout, with this code:
pages/houses/[id].js
const stripe = Stripe(stripePublicKey)
const { error } = await stripe.redirectToCheckout({
sessionId
})
This all happens transparently to the user. They are immediately sent to the Stripe checkout:
Stripe has this great testing tool that lets you add a credit card numbered 4242 4242 4242 4242
, you add 4
or 2
to the expiration date and code, and it’s considered valid.
The success URL route
Remember? In the /api/stripe/session
API endpoint, we set
success_url: process.env.BASE_URL + '/bookings',
This is a page on our site, where people will be redirected to when the Stripe payment is successful.
Let’s create this page. Create a pages/bookings.js
file, and add this content to it:
import Layout from '../components/Layout'
const Bookings = () => {
return <Layout content={<p>TODO</p>} />
}
export default Bookings
The app should respond to http://localhost:3000/bookings
with:
The stripe webhook handler
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
The Webhook handler
Now let’s create the Webhook POST handler.
Open server.js
and create a POST api/stripe/webhook
endpoint.
We return a { received: true }
JSON response to Stripe, to tell them “we got it!”:
server.post('/api/stripe/webhook', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'application/json'
})
res.end(JSON.stringify({ received: true }))
})
We now 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(req.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 req.rawBody
. This is not a property available by default to us, using Polka or Express. I use this to solve a problem. stripe.webhooks.constructEvent()
wants the raw request body passed to it, to verify for security purposes.
Now, in src/server.js
we pass the bodyParser.json()
middleware that parses the request body as JSON, and from that point the raw body is not available any more.
So I changed the line bodyParser.json()
in src/server.js
to:
bodyParser.json({
verify: (req, res, buf) => { //make rawBody available
req.rawBody = buf
}
})
so that we can access the raw body using req.rawBody
in the POST endpoint.
This thing required quite a bit of research, it’s not a problem commonly found - and sometimes the little things can take a relatively long time to solve
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:
server.post('/api/stripe/webhook', async (req, res) => {
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(req.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 } })
} 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!
Clearing unpaid bookings
Now we have a little issue. Since we book dates before paying, some people might not pay (it will happen frequently!) and this leaves us with booked dates that are not really confirmed.
To solve this, we need to clear out bookings from time to time.
I made a POST endpoint to do this, in server.js
:
server.js
server.post('/api/bookings/clean', (req, res) => {
Booking.destroy({
where: {
paid: false
}
})
res.writeHead(200, {
'Content-Type': 'application/json'
})
res.end(
JSON.stringify({
status: 'success',
message: 'ok'
})
)
})
It calls Booking.destroy
to remove all unpaid bookings.
This needs some refinement, but it’s good for us now. I added an Insomnia POST request to help me clean the database:
Ideally you’d remove all bookings that have been created, say, more than 1 hour ago, and are still unpaid.
The code for this lesson is available at https://github.com/flaviocopes/airbnb-clone-react-nextjs/commit/da709b277654023520a244926344f2d5f4795727