Bright ideas and techniques for building with Convex.
Profile image
Michal Srb
9 months ago

Wake up, you need to make money! (Add Stripe to your product)

Convex starts a checkout with Stripe, then handles the success webhook

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:

  1. The user types a message and hits the “Pay $1 and send” button.
  2. They are redirected to a checkout site hosted by Stripe, where they fill out their payment details and hit the Pay button.
  3. 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:

  1. It creates a document in our “payments” table so that we can track the progress of the checkout flow.
  2. It calls Stripe’s SDK to get the checkout page URL the client should redirect to.
  3. It includes the ID of our “payments” document in the success_url that Stripe will redirect to after the user has finished paying.
  4. 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 🤑

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started