Stripe Checkout revenue attribution

Pass XPmetric visitor IDs into Stripe Checkout metadata to attribute revenue to traffic sources.

Connect Stripe in Settings → Integrations, then pass XPmetric visitor IDs from the browser to your checkout route so payments tie back to a traffic source, referrer, and landing page.

Prerequisites

  1. Install p.js on your site.
  2. Connect Stripe with a restricted read key in Settings → Integrations → Stripe. The sync reads Charges, Invoice Payments, Invoices, PaymentIntents, and Refunds.
  3. Create Checkout sessions server-side (Stripe API) so you can set metadata.

Static Payment Links cannot carry per-visitor metadata.

With the default cookieless p.js, first-touch attribution is scoped to the UTC day of the visitor ID sent at checkout. A delayed payment can still match that day's visit when it carries the ID; a later visit gets a new ID. For cross-day first-touch, choose cookie mode in Settings → Sites: its p.cookie.js script uses the first-party _xpm_id cookie. Compare tracking modes.

Pass visitor IDs

After its first pageview response, p.js exposes these values in the browser:

  • window.xpmetric.v — visitor hash
  • window.xpmetric.s — session id

Start checkout after both IDs are available. Send them in a request to your server; keep the Stripe secret key and stripe.checkout.sessions.create() on the server. Adapt the routes below to your authentication and price selection.

Subscriptions

Use mode: "subscription". Put both keys in the top-level Checkout Session metadata and subscription_data.metadata. The latter is stored on the Subscription and copied into invoice.parent.subscription_details.metadata when each invoice is finalized, including initial and recurring paid invoices. The top-level copy lets you inspect the Checkout Session itself; the sync reads the invoice snapshot.

Browser checkout call:

// Browser — call startSubscriptionCheckout from your checkout button
async function startSubscriptionCheckout() {
  const { v, s } = await window.xpmetric?.ready?.() ?? {};
  if (!v || !s) throw new Error("XPmetric IDs are unavailable; retry checkout");

  const res = await fetch("/api/checkout/subscription", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ xpm_visitor_id: v, xpm_session_id: s }),
  });
  if (!res.ok) throw new Error("Checkout could not start");
  const { url } = await res.json();
  window.location.assign(url);
}

Server route (Next.js App Router):

// app/api/checkout/subscription/route.ts — server only
import Stripe from "stripe";
import { NextResponse } from "next/server";

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

export async function POST(req: Request) {
  const { xpm_visitor_id, xpm_session_id } = await req.json();
  if (typeof xpm_visitor_id !== "string" || !xpm_visitor_id.trim() ||
      typeof xpm_session_id !== "string" || !xpm_session_id.trim()) {
    return NextResponse.json({ error: "XPmetric IDs are required" }, { status: 400 });
  }
  const metadata = {
    xpm_visitor_id: xpm_visitor_id.trim(),
    xpm_session_id: xpm_session_id.trim(),
  };
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: process.env.STRIPE_SUBSCRIPTION_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.APP_URL}/success`,
    cancel_url: `${process.env.APP_URL}/checkout`,
    metadata,
    subscription_data: { metadata },
  });
  return NextResponse.json({ url: session.url });
}

One-time payments

Use mode: "payment". Put both keys in the top-level Checkout Session metadata and payment_intent_data.metadata. XPmetric reads the PaymentIntent metadata for the successful Charge. payment_intent_data is for this mode only.

Browser checkout call:

// Browser — call startOneTimeCheckout from your checkout button
async function startOneTimeCheckout() {
  const { v, s } = await window.xpmetric?.ready?.() ?? {};
  if (!v || !s) throw new Error("XPmetric IDs are unavailable; retry checkout");

  const res = await fetch("/api/checkout/payment", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ xpm_visitor_id: v, xpm_session_id: s }),
  });
  if (!res.ok) throw new Error("Checkout could not start");
  const { url } = await res.json();
  window.location.assign(url);
}

Server route (Next.js App Router):

// app/api/checkout/payment/route.ts — server only
import Stripe from "stripe";
import { NextResponse } from "next/server";

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

export async function POST(req: Request) {
  const { xpm_visitor_id, xpm_session_id } = await req.json();
  if (typeof xpm_visitor_id !== "string" || !xpm_visitor_id.trim() ||
      typeof xpm_session_id !== "string" || !xpm_session_id.trim()) {
    return NextResponse.json({ error: "XPmetric IDs are required" }, { status: 400 });
  }
  const metadata = {
    xpm_visitor_id: xpm_visitor_id.trim(),
    xpm_session_id: xpm_session_id.trim(),
  };
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: process.env.STRIPE_ONE_TIME_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.APP_URL}/success`,
    cancel_url: `${process.env.APP_URL}/checkout`,
    metadata,
    payment_intent_data: { metadata },
  });
  return NextResponse.json({ url: session.url });
}

If a confirmed checkout has no visitor ID

Passing visitor IDs in metadata is the preferred path. For Stripe flows that cannot carry them—such as a no-code payment flow or an integration where the browser IDs were unavailable—an owner can enable Email attribution fallback in Settings → Integrations. It is off by default.

After payment is confirmed, call window.xpmetric.identify(email) once from the buyer's browser on your thank-you page. Use the confirmed order's email already available to your app. For a Stripe Checkout Session, you can include {CHECKOUT_SESSION_ID} in the success_url as Stripe documents, retrieve the session on your server, confirm the payment status, and render only the needed email into the success page. Keep the Stripe secret key server-side; do not put the buyer email in the URL, analytics events, browser storage, or logs. A webhook alone cannot capture the browser visitor identity.

// On the confirmed thank-you page, pass an email already available to your app.
function identifyPaidBuyer(buyerEmail) {
  if (typeof buyerEmail !== "string" || !buyerEmail.trim()) return;
  window.xpmetric?.identify?.(buyerEmail);
}

// Replace this example with your app's confirmed order/session email:
// identifyPaidBuyer(order.customerEmail);

The payment's visitor ID remains first choice; email is used only when metadata cannot resolve a first-touch. Email is sent over TLS and XPmetric stores only a site-scoped pseudonymous HMAC link for 30 days after its last capture; disabling the setting deletes those links. Enabling the setting does not obtain visitor consent: update your privacy notice and establish an appropriate legal basis first. In cookieless mode the lookup is limited to the purchase's UTC day, even if payment ingestion is delayed. Cookie mode may match across days only when the link was captured in cookie mode.

Sandbox attribution check

  1. Connect a test-mode Stripe restricted read key to a test XPmetric site, and use the matching sk_test_... key in your checkout route. Use a daily recurring test price with no trial or discount, so the initial invoice creates a successful Charge and a real renewal arrives the next day. Confirm p.js is receiving pageviews on the test site.
  2. Open the test site with a distinctive UTM URL, such as ?utm_source=sandbox_check&utm_medium=campaign. Wait until window.xpmetric.v and .s are both set, then start the subscription checkout from that browser. Complete it with Stripe's test card 4242 4242 4242 4242, any future expiry, and any three-digit CVC.
  3. In Stripe test mode, inspect the Checkout Session metadata and Subscription metadata. Inspect the paid initial Invoice's parent.subscription_details.metadata: both IDs should match the browser values. After the daily renewal, check the new paid Invoice has the same metadata snapshot and a successful Charge. For an immediate Stripe-side metadata check, you can simulate a renewal, but a future-dated simulated payment won't appear in XPmetric's current-date dashboard period.
  4. Repeat with the one-time checkout route. Check its Checkout Session metadata and the resulting PaymentIntent metadata for both IDs. Make a partial test refund of that payment in Stripe.
  5. Click Sync Now in Settings → Integrations, or wait for the scheduled payment sync. In XPmetric, check the test site's revenue view for an attributed new subscription payment, renewal after the daily cycle, and one-time payment. The refund should reduce net revenue under the same traffic source without adding a payment count. Select a period that includes the renewal and refund dates. Running sync again should not duplicate any payment or refund.

If a payment has no source, compare the IDs at each hop: browser → server request → Checkout Session → Subscription and paid Invoice (or PaymentIntent) → XPmetric. A missing browser ID often means checkout started before the first tracking response; a one-time payment can also arrive without a source when the restricted read key lacks PaymentIntents read, because that is where payment_intent_data.metadata is stored. The payment still syncs; reconnect the key with that permission to link it. In cookieless mode, the visitor ID rotates daily, so test the checkout during the tracked visit.