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.
// convex/stripe.ts
// This pragma is important because Stripe's SDK currently
// only works in the Node Runtime
"use node";
import { v } from "convex/values";
import { action } from "./_generated/server";
import Stripe from "stripe";
import { internal } from "./_generated/api";
export const pay = action({
// The action takes the message the user composed
args: { text: v.string() },
handler: async (ctx, { text }) => {
// We need to tell Stripe where to redirect to
const domain = process.env.HOSTING_URL ?? "http://localhost:5173";
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: "2022-11-15",
});
// Here we create a document in the "payments" table
const paymentId = await ctx.runMutation(internal.payments.create, { text });
// This is where the Stripe checkout is configured
const session = await stripe.checkout.sessions.create({
line_items: [
{
// For this example we use dynamic `price_data`,
// but you could use a predefined `price` ID instead
price_data: {
currency: "USD",
unit_amount: 100,
tax_behavior: "exclusive",
product_data: {
name: "One message of your choosing",
},
},
quantity: 1,
},
],
mode: "payment",
// This is how our web page will know which message we paid for
success_url: `${domain}?paymentId=${paymentId}`,
cancel_url: `${domain}`,
automatic_tax: { enabled: true },
});
// Keep track of the checkout session ID for fulfillment
await ctx.runMutation(internal.payments.markPending, {
paymentId,
stripeId: session.id,
});
// Let the client know the Stripe URL to redirect to
return session.url;
},
});
This action uses 2 internal mutations that perform writes to our database:
// convex/payments.ts
import { v } from "convex/values";
import { internalMutation } from "./_generated/server";
export const create = internalMutation({
args: { text: v.string() },
handler: async (ctx, { text }) => {
return await ctx.db.insert("payments", { text });
},
});
export const markPending = internalMutation({
args: {
paymentId: v.id("payments"),
stripeId: v.string(),
},
handler: async (ctx, { paymentId, stripeId }) => {
await ctx.db.patch(paymentId, { stripeId });
},
});
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:
// src/App.tsx
import { FormEvent, useState } from "react";
import { useAction } from "convex/react";
import { api } from "../convex/_generated/api";
export default function App() {
// State storing the message text
const [newMessageText, setNewMessageText] = useState("");
// Reference to our action
const payAndSendMessage = useAction(api.stripe.pay);
// We pass this handler to a `form` `onSubmit` in our UI code
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
// Call to our action
const paymentUrl = await payAndSendMessage({ text: newMessageText });
// Redirect to Stripe's checkout website
window.location.href = paymentUrl!;
}
// the rest of our UI...
}
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:
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/stripe",
method: "POST",
handler: httpAction(async (ctx, request) => {
// Getting the stripe-signature header
const signature: string = request.headers.get("stripe-signature") as string;
// Calling the action that will perform our fulfillment
const result = await ctx.runAction(internal.stripe.fulfill, {
signature,
payload: await request.text(),
});
if (result.success) {
// We make sure to confirm the successful processing
// so that Stripe can stop sending us the confirmation
// of this payment.
return new Response(null, {
status: 200,
});
} else {
// If something goes wrong Stripe will continue repeating
// the same webhook request until we confirm it.
return new Response("Webhook Error", {
status: 400,
});
}
}),
});
export default http;
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:
// convex/stripe.ts (continued)
export const fulfill = internalAction({
args: { signature: v.string(), payload: v.string() },
handler: async (ctx, { signature, payload }) => {
const stripe = new Stripe(process.env.STRIPE_KEY!, {
apiVersion: "2022-11-15",
});
const webhookSecret = process.env.STRIPE_WEBHOOKS_SECRET as string;
try {
// This call verifies the request
const event = stripe.webhooks.constructEvent(
payload,
signature,
webhookSecret
);
if (event.type === "checkout.session.completed") {
const stripeId = (event.data.object as { id: string }).id;
// Send the message and mark the payment as fulfilled
await ctx.runMutation(internal.payments.fulfill, { stripeId });
}
return { success: true };
} catch (err) {
console.error(err);
return { success: false };
}
},
});
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).
// convex/payments.ts (continued)
export const fulfill = internalMutation({
args: { stripeId: v.string() },
handler: async ({ db }, { stripeId }) => {
const { _id: paymentId, text } = (await db
.query("payments")
.withIndex("stripeId", (q) => q.eq("stripeId", stripeId))
.unique())!;
const messageId = await db.insert("messages", { text });
await db.patch(paymentId, { messageId });
},
});
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:
// src/App.tsx
// A simple React hook that grabs a URL query parameter and erases
// it from the URL.
function useConsumeQueryParam(name: string) {
const [value] = useState(
// get the param value
new URLSearchParams(window.location.search).get(name)
);
useEffect(() => {
const currentUrl = new URL(window.location.href);
const searchParams = currentUrl.searchParams;
searchParams.delete(name);
const consumedUrl =
currentUrl.origin + currentUrl.pathname + searchParams.toString();
// reset the URL
window.history.replaceState(null, "", consumedUrl);
}, []);
return value;
}
export default function App() {
// use the hook to get the "payments" document ID
const paymentId = useConsumeQueryParam("paymentId");
// Call a query to exchange the "payments" document ID
// for the corresponding "messages" document ID
const sentMessageId = useQuery(api.payments.getMessageId, {
paymentId: (paymentId ?? undefined) as Id<"payments"> | undefined,
});
const messages = useQuery(api.messages.list) || [];
// render a list of messages
return (
<>
<ul>
{messages.map((message) => (
<li
key={message._id}
// highlight the message the user just paid for, if any
className={sentMessageId === message._id ? "sent" : ""}
>
<span>{message.text}</span>
</li>
))}
</ul>
{* ... *}
</>
);
}
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.