Skip to main content

Stripe

PaymentIntent API

A custom payment form on your own page — the ids go on the PaymentIntent.

Use this when you build the payment form yourself with Stripe Elements and never send the customer to a Stripe-hosted page.

Your server creates the PaymentIntent, so the ids go in as metadata there — the same place and the same keys as a Checkout Session, on a different object.

Next.js
// app/api/payment-intent/route.js
import Stripe from "stripe";
import { cookies } from "next/headers";

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

export async function POST() {
  // `cookies()` is only readable on the server, which is why the intent is
  // created here and not in the browser.
  //
  // It is asynchronous from Next.js 15 on. Version 14 returns it directly,
  // so drop the `await` there.
  const cookieStore = await cookies();

  const ts_vid = cookieStore.get("ts_vid")?.value;
  const ts_vs = cookieStore.get("ts_vs")?.value;

  // Left out entirely when absent: Stripe rejects a metadata value of
  // `undefined`, and a visitor with cookies blocked simply has none.
  const metadata = {};
  if (ts_vid) metadata.ts_vid = ts_vid;
  if (ts_vs) metadata.ts_vs = ts_vs;

  const intent = await stripe.paymentIntents.create({
    amount: 4900,
    currency: "usd",
    automatic_payment_methods: { enabled: true },
    metadata,
  });

  return Response.json({ clientSecret: intent.client_secret });
}

Subscriptions need it in a second place

A PaymentIntent covers one payment. If you are creating a subscription, put the same metadata on the subscription too — otherwise the first charge is attributed and every renewal after it is not.

const subscription = await stripe.subscriptions.create({
  customer: customerId,
  items: [{ price: "price_123" }],

  // The first invoice's PaymentIntent gets this through `payment_settings`,
  // but the SUBSCRIPTION needs its own copy: Stripe does not copy metadata
  // between related objects, and renewals are billed from the subscription.
  metadata,
});

Stripe never copies metadata between objects. A PaymentIntent, a Checkout Session, a Subscription and an Invoice each carry their own. This is the single most common reason a custom integration attributes the first payment and nothing after it.

Why this is as good as Checkout

The ids are read on your server from a cookie the browser cannot forge, and recorded at the moment of purchase. Nothing depends on the customer reaching a particular page afterwards.

The only extra work compared with a Checkout Session is remembering the subscription's own metadata, above.

Was this page helpful?

Last updated September 9, 2026