Recently, I've been working on integrating payment with my SaaS product PPResume. I've been researching for a long time, and finally decided to use stripe. I found some caveats during the integration process, and I'd like to share them with you for your reference.
Stripe officially provides [four integration approaches] (https://docs.stripe.com/payments/accept-a-payment/web/compare-integrations):
Next.js has a demo, you can visually compare the difference between payment links, checkout and elements.
My personal recommendation:
Stripe provides a no code pricing table, you can easily embed it in your website. However, Stripe's pricing table has a problem, by default, when clicking the CTA button (in this case it is the 'Subscribe' button) , it will be directed to stripe's checkout page, whereas for general SaaS products, Pricing Table's CTA button will be directed to a different page depending on whether or not the user is already logged in:
Another issue with Stripe's Pricing Table is that the UI is still not as customizable as it should be, with no separate CSS customization available, which could be a big difference from the product's own UI style.
There is a specialized Pricing Table SaaS product that partially solves this problem, you can refer to it. If you can't, you can just write a Pricing Table by your own, it won't be too hard.
The create API for Stripe Checkout Session receives a customer parameter, which is the id of the customer built into stripe. If this customer parameter is not provided when creating a Stripe Checkout Session (stripe.checkout.sessions.create), then Stripe's backend will group all customers with the same email/credit card/phone into a single guest customer, which would be visible only in Stripe's Dashboard, but not in Stripe API, and there is no way for the guest customer to interface with Customer Portal (about Customer Portal, see later).
My personal recommendation:
/**
* Retrieves or creates a Stripe customer
*
* This function first attempts to fetch the user's context usign a customer
* email.
*
* Then checks if a customer already exists in Stripe with that email, if
* existed, return that stripe customer, otherwise a new customer is created in
* Stripe.
*
* [@param](/param) {NextRequest} request - The incoming request object from Next.js.
*
* [@returns](/returns) {Promise<Stripe.Customer | null>} A promise that resolves to the
* Stripe Customer object if found or created, otherwise null.
*/
export async function getOrCreateCustomer(
request: NextRequest
): Promise<StripeServer.Customer | null> {
const stripe = getStripeServer();
try {
// find a way to get a customer email from your auth system
const customerEmail = "[email protected]";
if (!customerEmail) {
return null;
}
const customers = await stripe.customers.list({
email: customerEmail,
limit: 1,
});
if (customers.data.length === 0) {
return await stripe.customers.create({
email: customerEmail,
});
} else {
return customers.data[0];
}
} catch (err) {
return null;
}
}
Stripe provides a no code Customer Portal, which allows users to manage payment related data by themselves, such as payment methods, subscription records, invoices and so on. These things are tedious and boring to implement on your own, so it would be very convenient if the payment system can provide a corresponding solution.
As far as I know, Stripe and LemonSqueezy provide customer portal, while Paddle doesn't (there is another dedicated SaaS product that provides a Customer Portal for Paddle).
There are two ways to integrate with the Stripe Customer Portal:
/**
* Creates a Stripe customer portal session and redirects user to that portal
*
* It first retrieves or creates a Stripe customer based on the incoming
* request, then generates a session for the Stripe Billing Portal, and finally
* redirects the user to the Stripe Billing Portal.
*
* The major benefit of using API to create a customer portal with customer
* attached over plain customer portal link provided from stripe dashboard is,
* user won't need to manually sign in for API created customer portal.
* Basically, when you create a customer portal with API, it will generate a
* short-lived URL for user and this URL do not need user to sign in.
*
* From [Stripe](https://docs.stripe.com/api/customer_portal/sessions):
*
* ```
* A portal session describes the instantiation of the customer portal for a
* particular customer. By visiting the session’s URL, the customer can manage
* their subscriptions and billing details. For security reasons, sessions are
* short-lived and will expire if the customer does not visit the URL. Create
* sessions on-demand when customers intend to manage their subscriptions and
* billing details
* ```
*
* [@param](/param) {NextRequest} request - The incoming request object from Next.js.
*
* [@returns](/returns) {Promise<Response>} A promise that resolves to a redirect response
* to the Stripe Billing Portal, or a JSON response indicating that the customer
* was not found.
*/
export async function GET(request: NextRequest) {
const stripe = getStripeServer();
const customer = await getOrCreateCustomer(request);
if (!customer) {
return Response.json(
{
message: "Customer not found",
},
{ status: 404 }
);
}
const customerPortalSession = await stripe.billingPortal.sessions.create({
customer: customer.id,
return_url: getAbsoluteUrlWithDefaultBase(routes.pages.settings.billing),
});
return Response.redirect(customerPortalSession.url, 303);
}
After the user places an order, the system needs to fulfill the user's order needs, for example, if your SaaS is a e-commerce shop, then the merchant needs to pack and ship the goods, while if it is a virtual service such as SaaS, it is generally necessary to grant the user the appropriate Pro privileges, this process is called [order fulfillment](https://docs.stripe.com/ checkout/fulfillment).
Order fulfillment requires the implementation of a webhook:
Webhooks are required
You can’t rely on triggering fulfilment only from your Checkout landing page,
because your customers aren’t guaranteed to visit that page. For example,
someone can pay successfully in Checkout and then lose their connection to the
internet before your landing page loads.Set up a webhook event handler to get Stripe to send payment events directly
to your server, bypassing the client entirely. Webhooks are the most reliable
way to know when you get paid. If webhook event delivery fails, we retry several
times.
The so-called webhook, in essence, it is a public exposed API endpoint from your system, when the user placed an order successfully, Stripe will send a POST request to this API endpoint, with a request event that includes a detailed order payment information. The webhook will receives this information and then decide how to fulfill this order accordingly.
Webhook implementation has a small caveats, that is, Stripe does not guarantee the timing order of the event, nor does it guarantee the uniqueness of the request—in fact, there is no way to make such guarantee in the public web request. Therefore, in your Webhook implementation, depending on the requirements, if there is a high demand for data consistency, it is best to implement idempotency.
Stripe's development experience is very good, but the product matrix is very large, so newbies tend to get confused easily. There are some hidden caveats as mentioned above.
All in all, even with so much SDKs, platforms, integrating the payment system is still very difficult and boring. I would like to write a “Stripe 101: The Missing Tutorial for Indie Makers” once I finish the payment integration for my own SaaS.
One more thing, my product, PPResume, is about to launch a payment plan recently after polishing for a year. All users that sign up before the payment launch would get 50% off discount in one year. Thank you!
The idempotency point is the one that bites hardest in production. The Stripe-level key isn't enough — you need idempotency enforced on your own DB writes and side effects too. Otherwise a network timeout between your write and your event dispatch still leaves you with inconsistent state.
Great rundown. One caveat worth adding as a #6: failed subscription payment handling.
Once you've integrated Stripe subscriptions, the next silent problem is what happens when a payment fails. Stripe's default behavior: 3-4 automatic retries over a few days, then the subscription cancels. The customer gets a Stripe-branded email (if that), but no proactive outreach from you.
For recurring products, this is where 20-40% of involuntary churn comes from — not customers who chose to leave, but customers whose cards failed silently.
The fix is to listen for the invoice.payment_failed webhook and send a Day 1 / Day 3 / Day 7 recovery email sequence yourself. Day 1 is informational (no pressure), Day 3 adds urgency, Day 7 is the final notice. Recovery rates of 30-60% on failed invoices are realistic.
Worth wiring this up at the same time as your initial Stripe integration — it's a small addition but high ROI.