Wake up, you need to make money! (Add Stripe to your product)
If you’re building a full-stack app, chances are you’ll want some of your users to pay you for the service you provide. Stripe makes taking payments really easy, and with Convex you can ensure that you deliver when the payment comes in.
We put together a full working app that lets users pay to send a message to a global message board. Check out the GitHub repo or follow along with this article that goes over the details.
Overview of the user experience
Our UX for this example is simple:
- The user types a message and hits the “Pay $1 and send” button.
- They are redirected to a checkout site hosted by Stripe, where they fill out their payment details and hit the Pay button.
- They are then redirected back to our web application, where they can see the message they paid for highlighted. Split view of Convex Paid Chat UI and Stripe checkout
How do we know that the payment has been processed and the message can be sent? And how can we tell which message is the one the user just paid for? Read on to find out.
Step 1: Initiate the checkout flow
To kick off the payment flow, we call a Convex action that does a couple of things for us:
- It creates a document in our “payments” table so that we can track the progress of the checkout flow.
- It calls Stripe’s SDK to get the checkout page URL the client should redirect to.
- It includes the ID of our “payments” document in the
success_url
that Stripe will redirect to after the user has finished paying. - Finally, it writes the Stripe checkout session ID into the “payments” document to ensure we only fulfill the “order” once.
1// convex/stripe.ts
2
3// This pragma is important because Stripe's SDK currently
4// only works in the Node Runtime
5"use node";
6
7import { v } from "convex/values";
8import { action } from "./_generated/server";
9import Stripe from "stripe";
10import { internal } from "./_generated/api";
11
12export const pay = action({
13 // The action takes the message the user composed
14 args: { text: v.string() },
15 handler: async (ctx, { text }) => {
16 // We need to tell Stripe where to redirect to
17 const domain = process.env.HOSTING_URL ?? "http://localhost:5173";
18 const stripe = new Stripe(process.env.STRIPE_KEY!, {
19 apiVersion: "2022-11-15",
20 });
21 // Here we create a document in the "payments" table
22 const paymentId = await ctx.runMutation(internal.payments.create, { text });
23 // This is where the Stripe checkout is configured
24 const session = await stripe.checkout.sessions.create({
25 line_items: [
26 {
27 // For this example we use dynamic `price_data`,
28 // but you could use a predefined `price` ID instead
29 price_data: {
30 currency: "USD",
31 unit_amount: 100,
32 tax_behavior: "exclusive",
33 product_data: {
34 name: "One message of your choosing",
35 },
36 },
37 quantity: 1,
38 },
39 ],
40 mode: "payment",
41 // This is how our web page will know which message we paid for
42 success_url: `${domain}?paymentId=${paymentId}`,
43 cancel_url: `${domain}`,
44 automatic_tax: { enabled: true },
45 });
46
47 // Keep track of the checkout session ID for fulfillment
48 await ctx.runMutation(internal.payments.markPending, {
49 paymentId,
50 stripeId: session.id,
51 });
52 // Let the client know the Stripe URL to redirect to
53 return session.url;
54 },
55});
56
This action uses 2 internal mutations that perform writes to our database:
1// convex/payments.ts
2
3import { v } from "convex/values";
4import { internalMutation } from "./_generated/server";
5
6export const create = internalMutation({
7 args: { text: v.string() },
8 handler: async (ctx, { text }) => {
9 return await ctx.db.insert("payments", { text });
10 },
11});
12
13export const markPending = internalMutation({
14 args: {
15 paymentId: v.id("payments"),
16 stripeId: v.string(),
17 },
18 handler: async (ctx, { paymentId, stripeId }) => {
19 await ctx.db.patch(paymentId, { stripeId });
20 },
21});
22
All that is left is to call this action when the user clicks the “Pay $1 and send” button and redirect to the returned URL:
1// src/App.tsx
2import { FormEvent, useState } from "react";
3import { useAction } from "convex/react";
4import { api } from "../convex/_generated/api";
5
6export default function App() {
7 // State storing the message text
8 const [newMessageText, setNewMessageText] = useState("");
9
10 // Reference to our action
11 const payAndSendMessage = useAction(api.stripe.pay);
12
13 // We pass this handler to a `form` `onSubmit` in our UI code
14 async function handleSendMessage(event: FormEvent) {
15 event.preventDefault();
16 // Call to our action
17 const paymentUrl = await payAndSendMessage({ text: newMessageText });
18 // Redirect to Stripe's checkout website
19 window.location.href = paymentUrl!;
20 }
21 // the rest of our UI...
22}
23
Step 2: The payment checkout flow
Nothing to do here! Stripe did this work for us. 😮💨
Step 3: Receiving the payment confirmation
Stripe will notify our server about the confirmed payment via a webhook request. All we need to do is expose an HTTP endpoint that Stripe’s servers can hit whenever a user finishes a payment. That’s a perfect fit for a Convex HTTP action. The HTTP action will handle the Stripe webhook request, parsing it, and passing its contents to another Convex action that will confirm the request is valid using the Stripe SDK in the Node.js runtime:
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4import { internal } from "./_generated/api";
5
6const http = httpRouter();
7
8http.route({
9 path: "/stripe",
10 method: "POST",
11 handler: httpAction(async (ctx, request) => {
12 // Getting the stripe-signature header
13 const signature: string = request.headers.get("stripe-signature") as string;
14 // Calling the action that will perform our fulfillment
15 const result = await ctx.runAction(internal.stripe.fulfill, {
16 signature,
17 payload: await request.text(),
18 });
19 if (result.success) {
20 // We make sure to confirm the successful processing
21 // so that Stripe can stop sending us the confirmation
22 // of this payment.
23 return new Response(null, {
24 status: 200,
25 });
26 } else {
27 // If something goes wrong Stripe will continue repeating
28 // the same webhook request until we confirm it.
29 return new Response("Webhook Error", {
30 status: 400,
31 });
32 }
33 }),
34});
35
36export default http;
37
Our fulfill
action confirms the webhook request really came from Stripe via its SDK, gets the checkout session ID, and finally “fulfill”s the order by calling a database mutation:
1// convex/stripe.ts (continued)
2export const fulfill = internalAction({
3 args: { signature: v.string(), payload: v.string() },
4 handler: async (ctx, { signature, payload }) => {
5 const stripe = new Stripe(process.env.STRIPE_KEY!, {
6 apiVersion: "2022-11-15",
7 });
8
9 const webhookSecret = process.env.STRIPE_WEBHOOKS_SECRET as string;
10 try {
11 // This call verifies the request
12 const event = stripe.webhooks.constructEvent(
13 payload,
14 signature,
15 webhookSecret
16 );
17 if (event.type === "checkout.session.completed") {
18 const stripeId = (event.data.object as { id: string }).id;
19 // Send the message and mark the payment as fulfilled
20 await ctx.runMutation(internal.payments.fulfill, { stripeId });
21 }
22 return { success: true };
23 } catch (err) {
24 console.error(err);
25 return { success: false };
26 }
27 },
28});
29
The internal mutation makes writes to our database. It’s awesome that this mutation is automatically transactional, so if we made it this far, we are guaranteed to both mark the payment as fulfilled (by adding a messageId
to it) and send the message (by adding it to the “messages” table).
1// convex/payments.ts (continued)
2export const fulfill = internalMutation({
3 args: { stripeId: v.string() },
4 handler: async ({ db }, { stripeId }) => {
5 const { _id: paymentId, text } = (await db
6 .query("payments")
7 .withIndex("stripeId", (q) => q.eq("stripeId", stripeId))
8 .unique())!;
9 const messageId = await db.insert("messages", { text });
10 await db.patch(paymentId, { messageId });
11 },
12});
13
At this point, the payment and fulfillment flow works end-to-end.
Bonus: Tying it all together
Our demo includes one more trick for highlighting the message the user just paid for. This could be used to show a unique order confirmation, even if the user made 2 purchases in parallel (from two different browser tabs).
Back in step 1, we added the “payments” document ID to the URL Stripe redirects to after a successful checkout, the success_url
.
We can use this information from the URL to highlight the relevant message:
1// src/App.tsx
2
3// A simple React hook that grabs a URL query parameter and erases
4// it from the URL.
5function useConsumeQueryParam(name: string) {
6 const [value] = useState(
7 // get the param value
8 new URLSearchParams(window.location.search).get(name)
9 );
10
11 useEffect(() => {
12 const currentUrl = new URL(window.location.href);
13 const searchParams = currentUrl.searchParams;
14 searchParams.delete(name);
15 const consumedUrl =
16 currentUrl.origin + currentUrl.pathname + searchParams.toString();
17 // reset the URL
18 window.history.replaceState(null, "", consumedUrl);
19 }, []);
20 return value;
21}
22
23export default function App() {
24 // use the hook to get the "payments" document ID
25 const paymentId = useConsumeQueryParam("paymentId");
26 // Call a query to exchange the "payments" document ID
27 // for the corresponding "messages" document ID
28 const sentMessageId = useQuery(api.payments.getMessageId, {
29 paymentId: (paymentId ?? undefined) as Id<"payments"> | undefined,
30 });
31 const messages = useQuery(api.messages.list) || [];
32 // render a list of messages
33 return (
34 <>
35 <ul>
36 {messages.map((message) => (
37 <li
38 key={message._id}
39 // highlight the message the user just paid for, if any
40 className={sentMessageId === message._id ? "sent" : ""}
41 >
42 <span>{message.text}</span>
43 </li>
44 ))}
45 </ul>
46 {* ... *}
47 </>
48 );
49}
50
Refer to the the Github repo for the full implementation.
It was ninety-nine cents!
Convex $ Stripe from Macklemore's Thrift Shop Music Video
While most people won’t pay a dollar to send a message to a random message board, our demo app showcases all the necessary pieces for integrating a full-fledged payment flow. Convex and Stripe make this flow feel solid and reliable. Let us know in Discord what your app charges for 🤑
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.