Stripe Checkout API
Attribute revenue from the Stripe Checkout API.
Attribute payments from Checkout Sessions that your own server creates. This takes about ten minutes.
Before you start
Connect Stripe first. Install the web SDK on your pricing page and the Node SDK on your server.
Send the visitor IDs to your server
The browser holds the two IDs that identify the visitor. Read them with
checkoutMetadata() and post them with the rest of the checkout request.
// app/pricing/checkout-button.tsx
"use client";
import { founderhq } from "@founderhq/events";
export function CheckoutButton({ priceId }: { priceId: string }) {
async function checkout() {
const identity = founderhq.checkoutMetadata();
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ priceId, identity }),
});
const { url } = await response.json();
window.location.href = url;
}
return <button onClick={checkout}>Start the growth plan</button>;
}Place the metadata on the session and on the renewal object
Put the same metadata in two places. Session metadata alone does not survive renewals, so the second placement is what keeps a subscription attributed.
// app/api/checkout/route.ts
import Stripe from "stripe";
import { checkoutMetadata } from "@founderhq/events-node";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { priceId, identity } = await request.json();
const attribution = checkoutMetadata({
anonymousId: identity.fhq_anonymous_id,
sessionId: identity.fhq_session_id,
});
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: "https://acme.com/thanks",
cancel_url: "https://acme.com/pricing",
metadata: attribution,
subscription_data: { metadata: attribution },
});
return Response.json({ url: session.url });
}Selling a one-time product? Use payment_intent_data: { metadata: attribution }
in place of subscription_data.
In subscription mode the session has no PaymentIntent, and Stripe charges
the first invoice of the subscription. FounderHQ gives that first payment the
session's metadata, or the metadata that Stripe copies onto the subscription.
Stripe sends the checkout, the invoice, and the two money events in any
order, often in the same second. FounderHQ gives the payment and the
subscription the same owner in every order. Keep these events enabled, with
the rest of the
list of events:
checkout.session.completed, customer.subscription.created, invoice.paid,
and the two money events payment_intent.succeeded and
invoice_payment.paid. In API version 2025-05-28.basil, only
invoice_payment.paid connects a payment to its invoice and subscription.
Billing for an account? Add the server-issued fhq_account_context to
attribution before you create the session, on both metadata and
subscription_data.metadata. See
Which account gets a payment.
checkoutMetadata() throws when either ID is empty. Catch that and create the
session without the metadata rather than blocking the sale.
Let renewals resolve themselves
FounderHQ binds the Stripe customer and subscription on the first payment. Later renewals arrive with no browser session, and FounderHQ resolves them through that binding.
Verify
- Start a checkout on your pricing page.
- In Stripe, open the Checkout Session. The metadata shows
fhq_anonymous_idandfhq_session_id. - Open the created subscription. It shows the same two keys.
- Pay with a test card. In FounderHQ, the payment shows the channel that brought the buyer.
Troubleshoot
The session has metadata but the subscription does not
You set metadata only. Add subscription_data.metadata with the same object
and create a new session.
The payment arrives with no channel
The browser sent empty IDs. checkoutMetadata() in the browser returns an
empty object when the visitor denied consent. Check consent before you blame
the server.