I have set up Stripe far too many times. I've never enjoyed it. I've talked to the Stripe team about the shortcomings and they say they'll fix them...eventually.
Until then, this is how I recommend setting up Stripe. I don't cover everything - check out things that are still your problem for clarity on what I'm NOT helping with.
If you want to stay sane implementing file uploads, check out my product UploadThing.
- TypeScript
- Some type of JS backend
- Working auth (that is verified on your JS backend)
- A KV store (I use Redis, usually Upstash, but any KV will work)
IMO, the biggest issue with Stripe is the "split brain" it inherently introduces to your code base. When a customer checks out, the "state of the purchase" is in Stripe. You're then expected to track the purchase in your own database via webhooks.
There are over 258 event types. They all have different amounts of data. The order you get them is not guaranteed. None of them should be trusted. It's far too easy to have a payment be failed in stripe and "subscribed" in your app.
These partial updates and race conditions are obnoxious. I recommend avoiding them entirely. My solution is simple: a single syncStripeDataToKV(customerId: string)
function that syncs all of the data for a given Stripe customer to your KV.
The following is how I (mostly) avoid getting Stripe into these awful split states.
This is a quick overview of the "flow" I recommend. More detail below. Even if you don't copy my specific implementation, you should read this. I promise all of these steps are necessary. Skipping any of them will make life unnecessarily hard
- FRONTEND: "Subscribe" button should call a
"generate-stripe-checkout"
endpoint onClick - USER: Clicks "subscribe" button on your app
- BACKEND: Create a Stripe customer
- BACKEND: Store binding between Stripe's
customerId
and your app'suserId
- BACKEND: Create a "checkout session" for the user
- With the return URL set to a dedicated
/success
route in your app
- With the return URL set to a dedicated
- USER: Makes payment, subscribes, redirects back to
/success
- FRONTEND: On load, triggers a
syncAfterSuccess
function on backend (hit an API, server action, rsc on load, whatever) - BACKEND: Uses
userId
to get StripecustomerId
from KV - BACKEND: Calls
syncStripeDataToKV
withcustomerId
- FRONTEND: After sync succeeds, redirects user to wherever you want them to be :)
- BACKEND: On all relevant events, calls
syncStripeDataToKV
withcustomerId
This might seem like a lot. That's because it is. But it's also the simplest Stripe setup I've ever seen work.
Let's go into the details on the important parts here.
The key is to make sure you always have the customer defined BEFORE YOU START CHECKOUT. The ephemerality of "customer" is a straight up design flaw and I have no idea why they built Stripe like this.
Here's an adapted example from how we're doing it in T3 Chat.
export async function GET(req: Request) {
const user = auth(req);
// Get the stripeCustomerId from your KV store
let stripeCustomerId = await kv.get(`stripe:user:${user.id}`);
// Create a new Stripe customer if this user doesn't have one
if (!stripeCustomerId) {
const newCustomer = await stripe.customers.create({
email: user.email,
metadata: {
userId: user.id, // DO NOT FORGET THIS
},
});
// Store the relation between userId and stripeCustomerId in your KV
await kv.set(`stripe:user:${user.id}`, newCustomer.id);
stripeCustomerId = newCustomer.id;
}
// ALWAYS create a checkout with a stripeCustomerId. They should enforce this.
const checkout = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
success_url: "https://t3.chat/success",
...
});
This is the function that syncs all of the data for a given Stripe customer to your KV. It will be used in both your /success
endpoint and in your /api/stripe
webhook handler.
The Stripe api returns a ton of data, much of which can not be serialized to JSON. I've selected the "most likely to be needed" chunk here for you to use, and there's a type definition later in the file.
Your implementation will vary based on if you're doing subscriptions or one-time purchases. The example below is with subcriptions (again from T3 Chat).
// The contents of this function should probably be wrapped in a try/catch
export async function syncStripeDataToKV(customerId: string) {
// Fetch latest subscription data from Stripe
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
limit: 1,
status: "all",
expand: ["data.default_payment_method"],
});
if (subscriptions.data.length === 0) {
const subData = { status: "none" };
await kv.set(`stripe:customer:${customerId}`, subData);
return subData;
}
// If a user can have multiple subscriptions, that's your problem
const subscription = subscriptions.data[0];
// Store complete subscription state
const subData = {
subscriptionId: subscription.id,
status: subscription.status,
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: subscription.current_period_end,
currentPeriodStart: subscription.current_period_start,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
paymentMethod:
subscription.default_payment_method &&
typeof subscription.default_payment_method !== "string"
? {
brand: subscription.default_payment_method.card?.brand ?? null,
last4: subscription.default_payment_method.card?.last4 ?? null,
}
: null,
};
// Store the data in your KV
await kv.set(`stripe:customer:${customerId}`, subData);
return subData;
}
Note
While this isn't 'necessary', there's a good chance your user will make it back to your site before the webhooks do. It's a nasty race condition to handle. Eagerly calling syncStripeDataToKV will prevent any weird states you might otherwise end up in
This is the page that the user is redirected to after they complete their checkout. For the sake of simplicity, I'm going to implement it as a get
route that redirects them. In my apps, I do this with a server component and Suspense, but I'm not going to spend the time explaining all that here.
export async function GET(req: Request) {
const user = auth(req);
const stripeCustomerId = await kv.get(`stripe:user:${user.id}`);
if (!stripeCustomerId) {
return redirect("/");
}
await syncStripeDataToKV(stripeCustomerId);
return redirect("/");
}
Notice how I'm not using any of the CHECKOUT_SESSION_ID
stuff? That's because it sucks and it encourages you to implement 12 different ways to get the Stripe state. Ignore the siren calls. Have a SINGLE syncStripeDataToKV
function. It will make your life easier.
This is the part everyone hates the most. I'm just gonna dump the code and justify myself later.
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get("Stripe-Signature");
if (!signature) return NextResponse.json({}, { status: 400 });
async function doEventProcessing() {
if (typeof signature !== "string") {
throw new Error("[STRIPE HOOK] Header isn't a string???");
}
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
waitUntil(processEvent(event));
}
const { error } = await tryCatch(doEventProcessing());
if (error) {
console.error("[STRIPE HOOK] Error processing event", error);
}
return NextResponse.json({ received: true });
}
Note
If you are using Next.js Pages Router, make sure you turn this on. Stripe expects the body to be "untouched" so it can verify the signature.
export const config = {
api: {
bodyParser: false,
},
};
This is the function called in the endpoint that actually takes the Stripe event and updates the KV.
async function processEvent(event: Stripe.Event) {
// Skip processing if the event isn't one I'm tracking (list of all events below)
if (!allowedEvents.includes(event.type)) return;
// All the events I track have a customerId
const { customer: customerId } = event?.data?.object as {
customer: string; // Sadly TypeScript does not know this
};
// This helps make it typesafe and also lets me know if my assumption is wrong
if (typeof customerId !== "string") {
throw new Error(
`[STRIPE HOOK][CANCER] ID isn't string.\nEvent type: ${event.type}`
);
}
return await syncStripeDataToKV(customerId);
}
If there are more I should be tracking for updates, please file a PR. If they don't affect subscription state, I do not care.
const allowedEvents: Stripe.Event.Type[] = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"customer.subscription.pending_update_applied",
"customer.subscription.pending_update_expired",
"customer.subscription.trial_will_end",
"invoice.paid",
"invoice.payment_failed",
"invoice.payment_action_required",
"invoice.upcoming",
"invoice.marked_uncollectible",
"invoice.payment_succeeded",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"payment_intent.canceled",
];
export type STRIPE_SUB_CACHE =
| {
subscriptionId: string | null;
status: Stripe.Subscription.Status;
priceId: string | null;
currentPeriodStart: number | null;
currentPeriodEnd: number | null;
cancelAtPeriodEnd: boolean;
paymentMethod: {
brand: string | null; // e.g., "visa", "mastercard"
last4: string | null; // e.g., "4242"
} | null;
}
| {
status: "none";
};
Gonna slowly drop more things here as I remember them.
I'm convinced this is literally just used by scammers. over 90% of my cancelled transactions are Cash App Pay.
This is a really useful hidden setting that has saved me a lot of headaches and race conditions. Fun fact: this is the ONLY way to prevent someone from being able to check out twice if they open up two checkout sessions 🙃 More info in Stripe's docs here
While I have solved a lot of stuff here, in particular the "subscription" flows, there are a few things that are still your problem. Those include...
- Managing
STRIPE_SECRET_KEY
andSTRIPE_PUBLISHABLE_KEY
env vars for both testing and production - Managing
STRIPE_PRICE_ID
s for all subscription tiers for dev and prod (I can't believe this is still a thing) - Exposing sub data from your KV to your user (a dumb endpoint is probably fine)
- Tracking "usage" (i.e. a user gets 100 messages per month)
- Managing "free trials" ...the list goes on
Regardless, I hope you found some value in this doc.