Many sellers rely on marketplace and payment processing platforms like Stripe in order to sell their goods. If you are creating a marketplace for sellers to sell products using your platform, and if you want to earn 15 percent on every transaction on your platform, you’ll need to use Stripe Connect to connect your users’ Stripe accounts to your Stripe account.
In this tutorial, you will learn how to connect your users’ Stripe accounts to your platform using Next.js and Stripe Connect. You will also learn how Stripe Connect webhooks work.
The Stripe Connect Payment Onboarding flow helps developers get access to their users’ Stripe account to create products, edit products, sell them, and more — while providing an application fee to the platform.
There are two main types of Stripe Connect accounts, Standard or Express.
Stripe Express is the premier tier because it gives the platform more control over the money flow and is more expensive for the platform, given their monthly fee of $2 per active connected account. This Stripe Connect implementation is used by Medium, Quora, and other advanced marketplaces.
Stripe Standard connects a Stripe account to the platform Stripe account but provides the platform with a limited amount of control. It’s free to use and you don’t have to pay Stripe fees on the application fees earned. It’s used by Substack, for example.
There is another type of Stripe Connect implementation called Stripe Custom Connected Accounts, which is used by companies that need to implement sophisticated and customized payment systems. Companies like Shopify and Lyft use it to pay their users and merchants. Stripe Custom Connected Accounts provides granular control over the payment onboarding experience. For the sake of this article, we won’t cover custom accounts.
Learn more about the difference between these accounts here.
In this tutorial, we will focus on using Stripe Standard Connected Accounts, but this process is almost identical for Stripe Express accounts as well.
Here’s a quick demo of the project we’re building using OAuth flow.
When you go to the demo and click Connect with Stripe, you’ll route to a Stripe login page. After logging in and connecting with a valid account, you will see a complete analysis of your Stripe account, like this. 👇
Whenever you provide users an option to connect to your Stripe account, and if you want to pay them via your platform, or if you want to collect application fees, you will need to consider the following:
To work with a Connected Account using Connect Webhooks (tutorial on it later in the article), to perform operations on the connected account, you might want to check the above four things mentioned.
First, we need to get your Stripe account up and running. (You can check out the complete GitHub source code here.)
From your Stripe dashboard, get a Stripe publishable key and a secret key. If you don’t have a valid Stripe account just yet, you can try it in test mode. Make sure you save those keys — you will need them in the following steps.
Now, go to Stripe Connect Settings, scroll to the very bottom, and get your OAuth Client ID.
Make sure you add https://localhost:3001
as a redirect URL (you don’t need to make it default), or, if you are using some other URL for development and testing, add that instead. The repository I provided you uses development port 3001
.
Note: Make sure all these keys are either all from the test mode or all from the live mode; don’t mix them up!
Now, clone or download this repository, then duplicate and rename the .env.sample
to .env.development
and fill in the credentials as such (replace the keys).👇
NEXT_PUBLIC_BASE_URL=http://localhost:3001 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_*** STRIPE_SECRET_KEY=sk_test_*** NEXT_PUBLIC_STRIPE_OAUTH_CLIENT_ID=ca_***
Next, run npm install
and then npm run dev
. There you go, you now have it working! Make sure to try it out with your Stripe account.
Next, we need to create, connect, and verify the account ID.
When you click the “Connect to Stripe” button, you get redirected to this link:
https://dashboard.stripe.com/oauth/authorize?response_type=code&client_id=<OAUTH_CLIENT_ID>&scope=read_write&redirect_uri=http://localhost:3001
You may visit the link yourself, just replace <OAUTH_CLIENT_ID>
with your OAuth Client ID.
Tip: If you want to specify another redirect URL (from the one you put in Stripe Connect settings), you may do that as well.
Note that when a user signs in with their Stripe Account on that specific link, it means they gave you consent to connect their Stripe to your platform.
Now, you will need to verify that consent on the server-side.
When the user logged in with Stripe via your Stripe OAuth link, they return to the specified redirect_uri
with some properties in the URL, like this:
https://localhost:3001/?scope=read_write&code=ac_***
So, when a user consents to connect their Stripe account to your OAuth link, Stripe sends you to the redirect URL with the code
that you will now need to use in the backend to verify the consent and connect the account successfully.
Verifying this is easy. You just need to make this small request — stripe.oauth.token
— on the backend. If the request gives you an Account ID, the connection was successful.
Now that you have the Account ID, you can store it anywhere you want to access information about it later, and to perform all kinds of Stripe operations on that account. Now, let’s lay the groundwork.
Next, create the classic Stripe style “Stripe Connect” button.
We are using styled-components
. Here’s the button style.
Now, redirect people to your Stripe OAuth URL on the onClick
event of that button, like so:
<button type="button" className="stripe-connect" onClick={() => { if (window) { const url = `https://dashboard.stripe.com/oauth/authorize?response_type=code&client_id=${ process.env.NEXT_PUBLIC_STRIPE_OAUTH_CLIENT_ID }&scope=read_write&redirect_uri=${ process.env.NEXT_PUBLIC_BASE_URL }`; window.document.location.href = url; } }} > <span>Connect with Stripe</span> </button>
For this to work, make sure you check your .env.development
file again, ensure all the credentials are correct and re-run npm run dev
if you face trouble.
The scope=read_write
clause gives you the consent to perform both write and read operations on your user’s Stripe account.
Now, let’s make an API on the backend that validates the code sent by Stripe in the URL bar after the user returns from your OAuth page. You can refer to this file’s final version in the repository, which is responsible for validating the code
it receives in the body of the request.
Note: There are some other utility functions that the main logic uses, so make you check all that in the repository.
Let’s start with this code in /pages/api/verifyStripe.ts
.
import { NextApiHandler } from 'next'; import handleErrors from '@/api/middlewares/handleErrors'; import createError from '@/api/utils/createError'; const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const handler: NextApiHandler = async (req, res) => { const body = req.body; switch (req.method) { case 'POST': const result = await stripe.oauth .token({ grant_type: 'authorization_code', code: body?.code, }) .catch((err: unknown) => { throw createError(400, `${(err as any)?.message}`); }); res .status(200) .json({ oauth: result }); break; default: throw createError(405, 'Method Not Allowed'); } }; export const config = { api: { bodyParser: { sizeLimit: '1mb', }, }, }; export default handleErrors(handler);
Here, if the correct code
is supplied in the request…
const result = await stripe.oauth .token({ grant_type: 'authorization_code', code: body?.code, }) .catch((err: unknown) => { throw createError(400, `${(err as any)?.message}`); });
…you get this result from Stripe.
{ "token_type": "bearer", "stripe_publishable_key": "{PUBLISHABLE_KEY}", "scope": "read_write", "livemode": false, "stripe_user_id": "{ACCOUNT_ID}", "refresh_token": "{REFRESH_TOKEN}", "access_token": "{ACCESS_TOKEN}" }
It also gives back errors if incorrect or expired code is provided to the API.
Remember,
code
expires in a matter of seconds, so, when the client receives this code, verify it instantly on load.
Now that you have a basic API endpoint, that is ready to be pinged by the Next.js client!
Let’s create a server-side request from the Next.js homepage if it receives code
parameter in the URL.
It should look like this in /pages/index.ts
.
import React from 'react'; import fetch from 'isomorphic-unfetch'; import HomeView from '@/views/Home'; const Home = (props) => { return <HomeView data={props} />; }; export const getServerSideProps = async ({ req, }) => { const body = req?.__NEXT_INIT_QUERY; if (!body?.code) { return { props: { data: null, req: body } }; } let response; try { response = await fetch( process.env.NEXT_PUBLIC_BASE_URL + '/api/verifyStripe', { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', }, }, ).then((res) => res.json()); } catch (error) { return { props: { data: { error }, req: body } }; } return { props: { data: response, req: body } }; }; export default Home;
When you export a getServerSideProps
function from the Next.js page file, Next.js runs that function on the server. You can return the fetched data in getServerSideProps
function so that the React component receives the fetched data in form of props.
You can learn more about how Next.js SSR works here.
Note: You can also make requests in
useEffect()
and then update the client state once data is fetched.
If your URL has ?code=ac_***
, you’ll want to get that parameter from the URL. Next.js provides you with the body parameters in the req?.__NEXT_INIT_QUERY
object.
{ code: "ac_***", scope: "read_write" }
Using isomorphic-unfetch
, we pull the data from our API by supplying it the code
in the request:
await fetch( process.env.NEXT_PUBLIC_BASE_URL + '/api/verifyStripe', // our API endpoint { method: 'POST', body: JSON.stringify(body), // '{"code": "ac_***", "scope": "read_write"}' headers: { 'Content-Type': 'application/json', }, }, ).then((res) => res.json());
If you check /pages/api/verifyStripe.ts
, you will see that the API returns { oauth: result }
.
That result contains the Stripe Account ID, which we can now use in the props
of the React component, by returning this response in getServerSideProps
in /pages/index.ts
.
return { props: { data: response, req: body } };
In the above line of code, we are dumping all the fetched data from the API into the data
prop. The req
prop provides the URL parameter data, which you can now access in the React component.
Now, let’s use the fetched data using data
prop passed through the React component.
const Home = (props) => { return <HomeView data={props} />; }; const HomeView = ({ data }) => { return ( <> <button type="button" className="stripe-connect" onClick={() => { if (window) { const url = `https://dashboard.stripe.com/oauth/authorize?response_type=code&client_id=${ process.env.NEXT_PUBLIC_STRIPE_OAUTH_CLIENT_ID }&scope=read_write&redirect_uri=${ process.env.NEXT_PUBLIC_BASE_URL }`; window.document.location.href = url; } }} > <span>Connect with Stripe</span> </button> {data?.req?.code?.startsWith('ac_') && ( <> <div className="fetchedData"> <h3>Fetched data</h3> <pre>{JSON.stringify(data, null, 2)}</pre> </div> </> )} </> ); };
Notice that the frontend will show the data fetched from the backend in a <pre>
element if the URL bar has the code
parameter string starting with ac_
.
Now, try clicking the Stripe button and signing up. When you get redirected back to the homepage from the Stripe OAuth page, you should see a success message printed, something like this:
{ "data": { "oauth": { "access_token": "sk*****4m", "livemode": false, "refresh_token": "r*****Oib6W", "token_type": "bearer", "stripe_publishable_key": "pk_test_51***tfPe", "stripe_user_id": "acct_1******YHsmb", "scope": "read_write" } }, "req": { "scope": "read_write", "state": "13.036056350529645", "code": "ac_JP8TFZTmFg1GUnPnJmTII2PTOJYaeBCD" } }
Cool! Now you have the data on the frontend being fetched from the /api/verifyStripe
endpoint.
You may have noticed that there’s something missing. You have the Account ID, but you don’t have the account information.
Let’s fetch that, too, from /pages/api/verifyStripe.ts
.
import { NextApiHandler } from 'next'; import handleErrors from '@/api/middlewares/handleErrors'; import createError from '@/api/utils/createError'; const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const handler: NextApiHandler = async (req, res) => { const body = req.body; switch (req.method) { case 'POST': const result = await stripe.oauth .token({ grant_type: 'authorization_code', code: body?.code, }) .catch((err) => { throw createError(400, `${err?.message}`); }); // We get the Account ID from `result.stripe_user_id`, // let's fetch more account details using the ID. const account = await stripe.accounts ?.retrieve(result?.stripe_user_id) ?.catch((err) => { throw createError(400, `${err?.message}`); }); // Here we get the important details of the account. const accountAnalysis = { hasConnectedAccount: !!account?.id, // Check if account ID received is actually connected or exists. accountId: account?.id, hasCompletedProcess: account?.details_submitted, isValid: account?.charges_enabled && account?.payouts_enabled, displayName: account?.settings?.dashboard?.display_name || account?.display_name || null, country: account?.country, currency: account?.default_currency, }; // boolean - Once the account is connected, should we let it unlink? const shouldAllowUnlink = accountAnalysis?.hasConnectedAccount && (!accountAnalysis?.isValid || !accountAnalysis?.hasCompletedProcess || !accountAnalysis?.displayName); res .status(200) .json({ account, oauth: result, accountAnalysis, shouldAllowUnlink }); break; default: throw createError(405, 'Method Not Allowed'); } }; export const config = { api: { bodyParser: { sizeLimit: '1mb', }, }, }; export default handleErrors(handler);
Now, refresh the homepage, log into Stripe again, and you’ll see that you now have a lot more important information than you had before.
Let’s use the fetched data on the frontend and update your Stripe Connect button with dynamic text.
<button type="button" className="stripe-connect" disabled={!data?.data?.shouldAllowUnlink} onClick={() => { if (window) { const url = `https://dashboard.stripe.com/oauth/authorize?response_type=code&client_id=${ process.env.NEXT_PUBLIC_STRIPE_OAUTH_CLIENT_ID }&scope=read_write&state=${Math.random() * 100}&redirect_uri=${ process.env.NEXT_PUBLIC_BASE_URL }`; window.document.location.href = url; } }} > {data?.data?.account?.id ? ( <span>Connected: {data?.data?.account?.display_name}</span> ) : ( <span>Connect with Stripe</span> )} </button>
This checks if you have the Account ID in the data
prop. If you do, it says, Connected: Display Name
, else, Connect with Stripe
.
Here, we also added the disabled={!data?.data?.shouldAllowUnlink}
prop to the button. You will learn about what shouldAllowUnlink
thing is in the next section of this article.
We can also display the account information like this:
const YES = <>✅ Yes.</>; const NO = <>❌ No.</>; const HomeView: React.FC<{ data?; }> = ({ data }) => { return ( <> <h1>Stripe Connect Demo</h1> ... {data?.data?.account?.id && ( <> <div className="accountAnalysis"> <div> <h3>Payouts Enabled?</h3> <h2>{data?.data?.account?.payouts_enabled ? YES : NO}</h2> </div> <div> <h3>Charges Enabled?</h3> <h2>{data?.data?.account?.charges_enabled ? YES : NO}</h2> </div> <div> <h3>Details Submitted?</h3> <h2>{data?.data?.account?.details_submitted ? YES : NO}</h2> </div> </div> <div className="allowUnlink"> <h3>Allow Unlink?</h3> <p> When users connect their Stripe account, and if it is incomplete or invalid, you might want to let them unlink. </p> <h2>{data?.data?.shouldAllowUnlink ? YES : NO}</h2> </div> </> )} .... </> ); }
Here’s the result.
There we go, you implemented basic Stripe OAuth successfully!
These factors are incredibly important to ensure a better KYC process.
Let’s understand what “unlinking the Stripe account” means.
When a user connects their Stripe account, you get the account ID, which you can store in the database for future access.
Now, imagine, with a connected account, you have tons of products, customers, and subscriptions.
You wouldn’t want your user to be able to disconnect their Stripe via your account (from your platform UI) if their account is running smoothly. You also wouldn’t want them to change their Stripe account, else things will break.
However, you do want them to be able to disconnect and reconnect the account if the account is invalid, if payouts are disabled, if charges are disabled, or if the account is from a country or a currency that you do not support.
Remember: The user can always disconnect your platform from their Stripe dashboard anyway. But you would still want to decide if you want to provide users that option on your platform itself.
In our demo example, we check if an account should be able to unlink itself, like this (check verifyStripe.ts
) 👇
const accountAnalysis = { hasConnectedAccount: !!account?.id, accountId: account?.id, hasCompletedProcess: account?.details_submitted, isValid: account?.charges_enabled && account?.payouts_enabled, displayName: account?.settings?.dashboard?.display_name || account?.display_name || null, country: account?.country, currency: account?.default_currency, }; // @type boolean const shouldAllowUnlink = accountAnalysis?.hasConnectedAccount && (!accountAnalysis?.isValid || !accountAnalysis?.hasCompletedProcess || !accountAnalysis?.displayName);
In our example, we should allow unlinking the account if the account is connected and if any of the below are false
or null
:
account.charges_enabled
account.payouts_enabled
account.details_submitted
Note: Whether you allow unlinking or not depends on your platform’s needs. Make sure you adjust the logic accordingly.
Will your Stripe Connected account user be able to withdraw funds from their Stripe balance to their bank accounts?
If you get account?.payouts_enabled
as false
, that means the account doesn’t have a valid bank account connected to the Stripe account.
Sometimes, this shouldn’t affect your platform because you can provide your services even though your user’s connected account doesn’t have payouts enabled.
It depends on your use case. But ideally, you should require users to have it enabled.
This is asking: will your Connected account user be able to create charges?
If you get account?.charges_enabled
as false
, the account cannot create charges.
If the user doesn’t have charges enabled, you won’t be able to create charges on their accounts. If your platform relies on creating charges via its users’ Stripe accounts, you need user charges to be enabled.
Things like subscription, plans, and checkouts, may rely on Charge API. Therefore, it is an absolute necessity in almost all use cases.
This one should be obvious.
When you create a Stripe account, it asks you a lot of information: name, email, address, tax information, EIN code, bank account information, etc.
For a Stripe account to be fully functional, with all the necessary capabilities, you would want your users’ Stripe accounts to be fully verified by Stripe with all of its details correctly submitted.
If you get account?.details_submitted
as false
, that means the account verification process isn’t complete yet, or some details are not yet known to Stripe.
Always avoid doing transactions with such accounts.
When you verify a Stripe Connected account, immediately check the account?.requirements[currently_due]
. If it is null
, that means the Connected account is in great shape.
If there are requirements due, and if the user doesn’t adhere to the requirements in a timely fashion, critical account operations can get blocked later.
Therefore, when you spot a requirement listed in the account?.requirements[currently_due]
, always notify the user and make them take action.
Tip: Keep checking on your Connected Accounts every four weeks, and keep optimizing and collecting data on the validity of the connected Stripe accounts.
You can easily perform operations using Node.js and the stripe
node package.
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); await payment.products .create( { name: "New product", description: "Product created on a Connected Account", metadata: { userId: "<WHATEVER_CUSTOM_PLATFORM_USER_ID_YOU_HAVE>" } }, // Just add the following object as a second parameter on any Stripe operation. { stripeAccount: account?.id } ) .catch(async e => { throw new Error(e.message) })
In the above example, you can create a Stripe product on the Connected Account. To do so, you only need to provide a stripeAccount
ID in the object in the optional second parameter of the Stripe Node API. Note that if you don’t provide a Stripe account ID, it will create the product on your Stripe account instead of the desired Connected Account.
Remember: If a Connected Account disconnected from your platform via Stripe dashboard, or if the account isn’t valid enough, some operations might fail. So, you are advised to carry out operations only on the Connected Accounts that are fully valid.
You can carry out most of the other operations on Connected Stripe Accounts using a similar method.
What is a webhook? It is an endpoint that is solely made to receive events when they happen live, from (and via) other services.
Every time an event takes place on a Stripe account, e.g., when an invoice is created, or when a subscription, charge, or payment method is created or updated, Stripe is able to ping your API endpoint with appropriate information so that you can fulfill the event from your side.
Here’s an example use case.
Your user is on your Stripe checkout page and pays you to buy your software’s license. After the user comes submits from the Stripe checkout page and returns to the homepage, the user will see a “payment successful” message.
But, how would you know if the user paid you? By using Stripe webhooks. When the user pays you via your checkout page, Stripe pings your webhook API endpoint, telling you that the user has paid you the fee for a particular product at a particular moment. Now, you can email them the license, and fulfill the order.
Basically, they are Stripe webhooks that ping your given endpoint whenever chosen events take place on one of the Connected Accounts.
If you have a subscription or any other events running on Connected Accounts, you will use Stripe Connect webhooks.
First of all, install Stripe CLI. Then run stripe listen --forward-connect-to localhost:3001/api/connectWebhook
in terminal.
If you are doing it the first time, it’ll ask you to log in. Log in and run the command again.
Running the command will give you an endpoint secret starting with whsec_
. It is now listening for all the events that may happen in all your Stripe Connected Accounts.
Every time it gets an event, it will ping your webhook at localhost:3001/api/connectWebhook
.
In the .env.development
and the .env.production
files, save your STRIPE_SECRET_KEY
that you received after running the CLI command and restart the server.
To create the webhook, create a file at /pages/api/connectWebhook.ts
.
import { NextApiHandler } from 'next'; import handleErrors from '@/api/middlewares/handleErrors'; import createError from '@/api/utils/createError'; const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const handler: NextApiHandler = async (req, res) => { const body = req.body; switch (req.method) { case 'POST': // Refers to STRIPE_ENDPOINT_SECRET variable in .env.development file const endpointSecret: string = process.env.STRIPE_ENDPOINT_SECRET // This is to verify if the request is coming from Stripe indeed. const sig = req.headers["stripe-signature"] let event try { event = stripe.webhooks.constructEvent(req?.body, sig, endpointSecret) } catch (err) { console.log(err.message) return res.status(401).send(`Webhook Error: ${err.message}`) } // Handle the checkout.session.completed event if (event.type === "checkout.session.completed") { const session = event?.data?.object // Fulfill the purchase if payment_status is "paid". if (session.payment_status === "paid") { try { // Do whatever here to fulful the purchase. // await fulfilThePurchase() // Or just observe the Session object console.log(session) } catch (err) { return res.status(400).send({ received: true, error: `${err}`, event }) } } } // Return a response to acknowledge receipt of the event res .status(200) .json({ received: true, event }); break; default: throw createError(405, 'Method Not Allowed'); } }; export const config = { api: { bodyParser: { sizeLimit: '1mb', }, }, }; export default handleErrors(handler);
In the above example, you were listening for checkout.session.completed
event. You can choose to listen to many more events that occur on your Stripe Accounts or the Stripe Connected Accounts. See the list of all the events here.
You can trigger these webhooks from your dashboard by creating/editing products, or by updating subscriptions, depending on what events you are listening to.
For testing purposes on development mode, use Stripe Test Triggers.
$ stripe trigger checkout.session.completed
You now understood how Stripe Connect Webhooks works, and how you can test it on development mode.
To use this webhook in production for Stripe, go to Developers > Webhooks section in the Stripe dashboard.
Beside Endpoints receiving events from Connect applications, click on Add Endpoint.
Fill in the events you want to listen to and use the endpoint URL that you have hosted online.
For deploying, I would recommend Vercel because it’s free and easy to use — you can just run the vercel
command in your terminal and have your Next.js project deployed.
When you deploy, make sure the .env.production
variables are all correct regarding your Stripe account. You should also add them from your Vercel project dashboard.
Thanks for reading!
LogRocket is like a DVR for web and mobile apps and websites, recording literally everything that happens on your ecommerce app. Instead of guessing why users don’t convert, LogRocket proactively surfaces the root cause of issues that are preventing conversion in your funnel, such as JavaScript errors or dead clicks. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Start proactively monitoring your ecommerce apps — try LogRocket for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowUse CSS to style and manage disclosure widgets, which are the HTML `details` and `summary` elements.
React Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.