Skip to content

7 min read

Stripe Checkout metadata: put your user ID where it lasts

Stripe Checkout metadata stays on the session. How to put your user ID on the subscription and customer, client_reference_id vs metadata, and TypeScript webhooks.

A customer.subscription.updated webhook arrives. It has a subscription ID, a customer ID, a price and a status. What it doesn't have, unless you put it there, is the ID of the user in your own database who is paying.

Stripe checkout metadata is how you put it there. Metadata is a set of string key-value pairs you can attach to most Stripe objects. Stripe stores it, returns it in API responses and includes it in webhook payloads, and otherwise ignores it. The catch is that metadata belongs to one object. Setting it on a Checkout Session doesn't put it anywhere else.

Where does Checkout Session metadata end up?

On the Checkout Session, and nowhere else. When the session creates a subscription and a customer, neither of them inherits the session's metadata.

To get your user ID onto the subscription, use subscription_data.metadata. For one-time payments, the equivalent is payment_intent_data.metadata. Here is a subscription checkout that sets the ID in three places:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

export async function createCheckout(user: { id: string; stripeCustomerId: string }, priceId: string) {
  return stripe.checkout.sessions.create({
    mode: "subscription",
    customer: user.stripeCustomerId,
    line_items: [{ price: priceId, quantity: 1 }],
    client_reference_id: user.id,
    metadata: { user_id: user.id },
    subscription_data: { metadata: { user_id: user.id } },
    success_url: "https://example.com/welcome",
    cancel_url: "https://example.com/pricing",
  });
}

The session gets client_reference_id and metadata, the subscription gets its own copy through subscription_data, and the customer was created earlier with the ID already on it. That last part is the next section.

Why put the user ID on the subscription?

The session is a one-time object. You'll receive checkout.session.completed once, and then never hear about that session again.

The subscription is what you'll hear about for months. Renewals, plan changes, payment failures and cancellations all arrive as subscription or invoice events. If the user ID is in the subscription's metadata, every customer.subscription.* event carries it in the payload. No lookup table, no extra API call, no race between your database and the webhook.

It also makes the Stripe dashboard more useful. When you open a subscription while debugging a support ticket, the user ID is right there.

Why put it on the customer too?

The customer outlives any single subscription. Someone cancels, comes back four months later and subscribes again: new subscription, same customer. Invoices, charges and refunds all reference the customer.

The cleanest pattern is to create the customer yourself, before the first checkout, and store its ID on your user row:

export async function getOrCreateCustomer(user: { id: string; email: string; stripeCustomerId: string | null }) {
  if (user.stripeCustomerId) return user.stripeCustomerId;

  const customer = await stripe.customers.create({
    email: user.email,
    metadata: { user_id: user.id },
  });
  await saveStripeCustomerId(user.id, customer.id);
  return customer.id;
}

Then pass that ID as customer on every Checkout Session. If you don't pass one, Checkout in subscription mode creates a new customer each time, and a returning user becomes two customers with no link between them. Note that customer and customer_email can't both be set on the same session; the customer already has the email.

client_reference_id vs metadata

Both can hold your user ID. They behave differently.

client_reference_id is a single string field on the Checkout Session, meant for exactly this: a reference to the session in your own system. It appears on the session and in the checkout.session.completed event. Like session metadata, it is not copied to the subscription or the customer.

metadata exists on almost every object, holds several keys, and you choose which object it lives on. That's what makes it the right tool for the subscription and the customer.

So client_reference_id alone isn't enough for anything long-lived. Its real advantage is that it can be set in places where you don't control the API call, which is what makes it useful for Payment Links.

What Payment Links can and can't carry

A Payment Link is one URL shared by everyone who uses it. Metadata configured on the link is the same for every buyer, so it can't carry a per-user ID.

What you can do per user is append client_reference_id as a query parameter to the link:

https://buy.stripe.com/your-link?client_reference_id=user_8f3a2c

That value lands on the Checkout Session the link creates. Keep it to letters, digits, dashes and underscores; a UUID or a numeric ID works. You can also prefill the email with prefilled_email, which helps the customer and gives you a second way to match.

Because the value only reaches the session, you have to copy it to the subscription and customer yourself in a webhook. And if someone opens the bare link without the parameter, because it was shared or bookmarked, there's no ID at all. Plan a fallback, usually matching on email.

If you're logged in on your own site when the user clicks upgrade, creating the Checkout Session through the API is simpler and avoids all of this.

Reading the user ID back in webhooks

The handler below verifies the signature, copies the ID from the session to the subscription when it's missing, and reads it back on subscription events with the customer as a fallback.

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string;

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature") ?? "";

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object;
      const userId = session.client_reference_id ?? session.metadata?.user_id;
      if (userId && typeof session.subscription === "string") {
        // Needed for Payment Links, harmless when subscription_data already set it.
        await stripe.subscriptions.update(session.subscription, {
          metadata: { user_id: userId },
        });
      }
      break;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const subscription = event.data.object;
      const userId = subscription.metadata.user_id ?? (await userIdFromCustomer(subscription.customer));
      if (userId) await syncSubscription(userId, subscription);
      break;
    }
  }

  return new Response(null, { status: 200 });
}

async function userIdFromCustomer(customer: string | Stripe.Customer | Stripe.DeletedCustomer) {
  const c = typeof customer === "string" ? await stripe.customers.retrieve(customer) : customer;
  return c.deleted ? null : (c.metadata.user_id ?? null);
}

Three things to keep in mind.

Events can arrive in any order. Stripe doesn't guarantee ordering, so a subscription event can show up before checkout.session.completed. That's the strongest argument for setting subscription_data.metadata when you create the session: the ID is on the subscription from the moment it exists.

Your own update fires an event. Updating the subscription's metadata produces a customer.subscription.updated event. Make the handler idempotent so processing it twice changes nothing.

Metadata is visible. Anyone with access to your Stripe dashboard can read it. An opaque user ID is fine. Tokens, passwords and anything sensitive don't belong there.

Backfilling existing subscriptions

If you have paying customers from before you added this, write a one-off script: list your subscriptions, match each customer to a user by email, and update the subscription and customer metadata. Review the matches it couldn't make by hand rather than guessing.

Why it matters beyond billing

Once the user ID is on the subscription, revenue can be joined to anything else you know about that user: their plan history, their mobile purchases, or the page they first landed on. That last join is what attribution is built on, as described in how to see which blog posts bring paying customers, and the same ID is what lets a web and app subscriber count as one person in combined MRR for Stripe and RevenueCat.

Joinsight's Stripe connection is read-only and relies on exactly this: the user ID you pass to identify() at signup, carried on the Stripe customer or subscription.

See what makes you money.

Connect Stripe or RevenueCat and Search Console, and you see combined MRR and 16 months of search history on day one. Page and keyword revenue follow as the snippet collects first touches.

No card required. The trial clock starts when your first revenue source connects.