Why the Stripe customer portal doesn't show one-time payments

Stripe's customer portal lists invoices, subscriptions, payment methods, and billing details. A one-time Checkout payment creates no invoice by default, and usually no Customer, so it has nothing to appear under. To show past purchases, turn on post-payment invoices for new sessions, or build an order history page from the buyer's Checkout Sessions. On Flint, every paid order, one-time or renewal, is already in the buyer's account.
Verified against official documentationReviewed Send a correction (opens in a new tab)

Stripe's own feature list for the portal is short. Customers can update billing information and tax IDs, update payment methods, update and cancel subscriptions, and "pay, download, and view current and past invoices." A payment, a charge, and a Checkout Session are not on that list. A purchase reaches the portal only by becoming an invoice on the Customer the buyer signs in as.

Which path applies to you

What you seeYour path
Buyers paid through Checkout or a Payment Link and their portal is emptyWhy the portal shows nothing
You want future one-time purchases listed in the portalTurn on post-payment invoices
A buyer signs in and sees someone else's history, or only part of theirsOne Customer per buyer
You want an order history with items, including purchases already madeBuild your own history from Checkout Sessions
You would rather not build an account at allA buyer account with orders, on Flint

What the portal shows, and why one-time payments miss it

Two defaults in Checkout keep a one-time purchase out of the portal:

  • No invoice. Stripe generates invoices automatically for subscriptions, "but you need to enable them for one-time payments." A payment-mode session without invoice_creation produces a PaymentIntent and nothing the invoice history can list.
  • No Customer. Unless you pass customer or set customer_creation=always, Checkout creates a Customer only when the session requires one, and Stripe lists two cases that do: subscription mode, and payment mode with post-purchase invoices enabled. Every other session is filed under a guest customer, which Stripe calls "a read-only grouping for completed transactions." The no-code portal login emails a link only to an address that matches an existing customer, so a guest has no portal to open.

The portal's invoice history is a single setting, "Invoice history visible", on by default. There is no setting that adds payments without an invoice. Making a one-time purchase visible means giving it an invoice and a Customer, or showing it on a page you build.

Fix: create a paid invoice for every Checkout payment

Set invoice_creation[enabled] when you create the session, and pass the buyer's existing Customer so the invoice lands where they sign in:

cURL
curl https://api.stripe.com/v1/checkout/sessions \
  -u "sk_test_...:" \
  -d mode=payment \
  -d customer=cus_... \
  -d "invoice_creation[enabled]=true" \
  -d "line_items[0][price]=price_..." \
  -d "line_items[0][quantity]=1" \
  --data-urlencode "success_url=https://example.com/thanks"

After the payment succeeds, Stripe creates a paid invoice on that Customer and, with automatic receipts on, emails the buyer a summary with links to download PDFs of the invoice and the receipt. The invoice is what the portal lists. Payment Links have the same option.

Stripe prices this separately from Invoicing. Its support article puts post-payment invoices for one-time purchases through Checkout and Payment Links at 0.4% of the transaction total, up to $2 USD (or the local currency equivalent) per invoice; the same invoices for subscription payments are included in Stripe Billing pricing. The parameter only affects sessions created with it, so purchases already taken stay out of the portal.

Fix: pass the same Customer to every session

Invoices only help if they all land on one Customer. A session without customer creates a new Customer from what the buyer typed in three cases: subscription mode, customer_creation=always, and payment mode with invoice_creation enabled. That includes the invoice fix above. Only a customer you pass reuses an existing record. A repeat buyer ends up with several records sharing one email, and a portal session shows one Customer's invoices and subscriptions. For the no-code login, Stripe says: "If multiple customers have the same email address, Stripe selects the most recently created customer that has both that email and an active subscription." Its docs don't say which record opens when none of them has an active subscription, which is the usual case for one-time buyers.

Resolve the Customer once per buyer, store it, and pass it every time:

JavaScript
// Before every Checkout Session: one Customer per signed-in buyer.
async function customerFor(user) {
  if (user.stripeCustomerId) return user.stripeCustomerId;

  // The list filter is case-sensitive: it finds only Customers stored
  // in exactly this form. Create every Customer with it, and backfill
  // older ones (see below) before relying on this lookup.
  const email = user.email.trim().toLowerCase();
  const existing = await stripe.customers.list({ email, limit: 1 });
  const customer =
    existing.data[0] ?? (await stripe.customers.create({ email }));

  // Save it in the same transaction that locks the user row, so two
  // tabs checking out at once cannot each create a Customer.
  await db.users.setStripeCustomerId(user.id, customer.id);
  return customer.id;
}

const session = await stripe.checkout.sessions.create({
  mode: "payment",
  customer: await customerFor(user),
  invoice_creation: { enabled: true },
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: "https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
});

The customer list returns the most recent match first, and its email filter is exact and case-sensitive, so the lookup only finds Customers stored in the normalized form. Normalize at write time for every Customer you create from now on. For Customers created earlier with other capitalization, run a one-time backfill with the search API, whose email match ignores case, and save each user's Customer ID. Don't use search inside the checkout path: it lags writes, typically by under a minute, and Stripe says not to use it for read-after-write flows.

Fix: build the order history page yourself

The portal cannot show a purchase that has no invoice. A page of your own can, including every purchase made before you changed anything, because every Checkout Session is still there to list:

Shell
# 1. The buyer's completed Checkout Sessions
curl -G https://api.stripe.com/v1/checkout/sessions \
  -u "sk_test_...:" \
  -d customer=cus_... \
  -d status=complete \
  -d limit=20

# Guests have no Customer: filter by the email they typed instead
curl -G https://api.stripe.com/v1/checkout/sessions \
  -u "sk_test_...:" \
  --data-urlencode "customer_details[email]=ana@example.com" \
  -d status=complete

# 2. What one session sold (line items are not returned by default)
curl https://api.stripe.com/v1/checkout/sessions/cs_.../line_items \
  -u "sk_test_...:"

The customer_details[email] filter is what reaches guest purchases and purchases split across duplicate Customers. Behind your login, your page assembles an order from each session:

JavaScript
// GET /account/orders, behind your own login
const { data: sessions } = await stripe.checkout.sessions.list({
  customer: user.stripeCustomerId,
  status: "complete",
  limit: 20, // one page; pass starting_after for the next
});

const orders = [];
for (const s of sessions) {
  // complete can still mean "processing" for delayed payment methods
  if (s.payment_status !== "paid") continue;
  // Both lists return 10 entries unless you ask for more; 100 is the
  // maximum per page, so page with starting_after beyond that.
  const items = await stripe.checkout.sessions.listLineItems(s.id, {
    limit: 100,
  });
  const refunds = s.payment_intent
    ? await stripe.refunds.list({
        payment_intent: s.payment_intent,
        limit: 100,
      })
    : { data: [] };
  orders.push({
    placedAt: new Date(s.created * 1000),
    total: s.amount_total,
    currency: s.currency,
    items: items.data.map((li) => ({
      name: li.description,
      quantity: li.quantity,
      total: li.amount_total,
    })),
    // Count money that has gone back. pending refunds are still in
    // flight; failed and canceled ones returned nothing.
    refunded: refunds.data
      .filter((r) => r.status === "succeeded")
      .reduce((sum, r) => sum + r.amount, 0),
  });
}
// Subscription renewals are invoices, not sessions: list the
// customer's paid invoices separately and merge by date.

status=complete means the session finished, and Stripe notes that payment processing may still be in progress, so filter on payment_status before you call something an order. For a receipt link, each Charge has a receipt_url; the receipt itself does not expire, but the link does after 30 days, after which Stripe asks for the buyer's email and sends a fresh one. Turn on automatic receipts for successful payments in the Dashboard if buyers should get one by email.

Keep the portal for what it does: link to it from the same page for cards, subscriptions, and invoices, by creating a portal session for the Customer on your server. The portal cannot be embedded in an iframe, and a new portal session expires if it is not used within 5 minutes, so create it when the buyer clicks.

The six places the workarounds go wrong

  1. Guest buyers have no Customer to sign in as#

    A buyer enters their email on the portal login page and no login link arrives.

    Unless you pass customer or set customer_creation=always, Checkout creates a Customer only when the session requires one, and a one-time payment requires one only when post-payment invoices are on. Without one, the session is filed under a guest customer, which Stripe describes as "a read-only grouping for completed transactions". The no-code login matches the email against Customer objects, so a guest has nothing to log in to.

    Confirm it

    List your recent complete sessions and count how many have customer: null. Each one is a buyer the portal cannot show.

    Fix

    Pass customer on every session for signed-in buyers, or set customer_creation=always for everyone else. Neither recovers sessions already completed as a guest; list those by customer_details[email] in your own order history.

  2. Sessions that create a Customer make a new one each time#

    A repeat buyer has three Customer objects with the same email, and the portal shows a different slice of their history depending on how they got in.

    A session without customer creates a new Customer from what the buyer typed in three cases: subscription mode, customer_creation=always, and payment mode with invoice_creation enabled. Only a customer you pass reuses an existing record. A portal session belongs to one Customer, so each record shows only its own invoices and subscriptions. For the no-code login, Stripe selects "the most recently created customer that has both that email and an active subscription", and its docs don't say which record opens when none has one, which is the usual case for one-time buyers.

    Confirm it

    List customers with the buyer's email. More than one result means their history is split.

    Fix

    Store one Customer ID on your user record and pass it to every session from then on. For buyers who already have duplicates, an order history page of your own can list sessions for every Customer with their email, which the portal cannot do.

  3. Post-payment invoices start with the next session#

    You turned on invoice creation and last month's purchases are still missing.

    invoice_creation is a parameter on the session you create. It produces an invoice for that session's payment and does nothing for sessions already completed without it.

    Confirm it

    Compare the date you enabled it with the oldest invoice in a repeat buyer's portal.

    Fix

    Show older purchases from your own order history page, built from the sessions you already have.

  4. Email lookups miss on case and on timing#

    Your dedupe step created a second Customer for a buyer who already had one.

    The email filter on the customer list is case-sensitive, so Ana@example.com and ana@example.com are different buyers to it. The search API matches without case but is not meant for read-after-write: data is usually searchable in under a minute, and Stripe says not to rely on it right after a write.

    Confirm it

    Search customers by email with customers/search and compare the count with what your list-based lookup found.

    Fix

    Normalize email when you create a Customer, backfill Customers created with other capitalization once using the search API, save each user's Customer ID and look up by it rather than by email, and serialize Customer creation per user in your own database.

  5. Subscription renewals are not Checkout Sessions#

    Your order history shows the first month of a subscription and none of the renewals.

    A subscription-mode session records the signup. Every renewal after it is an invoice on the subscription, not a new session, so a page built only from sessions stops at the first payment.

    Confirm it

    Pick a buyer with a year-old monthly subscription and count rows on their history page.

    Fix

    Merge two sources: completed payment-mode sessions for one-time purchases, and the Customer's paid invoices for subscription payments. Sort the merged list by date yourself.

  6. A refund doesn't show on the session#

    A buyer's history shows a full total for an order you refunded half of.

    Refunds are separate objects that point at the PaymentIntent or Charge. The session that sold the items records the sale and nothing that happened to the money afterward.

    Confirm it

    Refund a test payment and read its session again.

    Fix

    List refunds by payment_intent for each session and show them on the order. To know which items were refunded, record that in metadata or in your own database when you issue the refund.

On Flint, the buyer account lists orders

Flint is a payments API with orders built in, and every Flint merchant has a customer account for their buyers at account.withflintpay.com. A purchase is an order, whether it came from hosted checkout, a payment link, your API, or a subscription renewal, so the account lists every paid order with its line items, delivery tracking, and return requests, next to invoices, saved cards, and subscriptions. There is no setting to turn on per purchase. Buyers sign in with a six-digit code Flint emails them, and a link in a Flint receipt, shipping notice, or return update opens the order, return, or subscription it is about.

Guest checkout does not split a buyer's history. When a buyer pays as a guest with an email, Flint links the order to your customer with that email once the payment settles, and creates a customer only when none exists. Orders a guest placed before they had an account can be attached later with POST /v1/customers/{customer_id}/link-guest-purchases, after Flint confirms by emailed code that the buyer controls the address.

The account takes your colors, fonts, and name from branding in PATCH /v1/settings, and serves from a hostname of yours with customer_account.presentation.custom_domain and the custom domain add-on. If your site already signs buyers in, create a customer session and redirect to the one-time account_url it returns, so the buyer lands in their account without a second sign-in:

cURL
curl -X POST https://api.withflintpay.com/v1/customer-sessions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: session-cus_1kmn0aExample-001" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "cus_1kmn0aExample" }'

Building the account into your own site

To render order history on your own pages, set customer_account.mode to merchant_hosted and read the buyer's data through /v1/me. Your backend mints a customer session for the buyer it has signed in, with the same request as above, and sends the session's secret instead of your API key:

cURL
curl -G https://api.withflintpay.com/v1/me/orders \
  -H "Authorization: Bearer flint_cses_example" \
  -d payment_status=paid
JSON
{
  "data": [
    {
      "order_id": "ord_1kmn0bExample",
      "origin": "subscription",
      "subscription_id": "sub_1kmn0aExample",
      "payment_status": "paid",
      "fulfillment_status": "not_fulfilled",
      "refund_status": "none",
      "line_items": [
        { "order_line_item_id": "li_1kmn0bExample", "name": "Whole bean coffee, 12 oz",
          "quantity": 2, "total_money": { "amount": 3600, "currency": "USD" } }
      ],
      "pricing_amounts": {
        "subtotal_money": { "amount": 3600, "currency": "USD" },
        "tax_money":      { "amount": 0,    "currency": "USD" },
        "total_money":    { "amount": 3600, "currency": "USD" }
      },
      "created_at": "2026-10-01T09:00:04Z"
    },
    {
      "order_id": "ord_1kmn0aExample",
      "origin": "checkout",
      "payment_status": "paid",
      "fulfillment_status": "fulfilled",
      "refund_status": "partially_refunded",
      "line_items": [
        { "order_line_item_id": "li_1kmn0aExample", "name": "Canvas tote bag",
          "quantity": 1, "total_money": { "amount": 2500, "currency": "USD" } },
        { "order_line_item_id": "li_1kmn0cExample", "name": "Enamel pin",
          "quantity": 2, "total_money": { "amount": 1600, "currency": "USD" } }
      ],
      "pricing_amounts": {
        "subtotal_money": { "amount": 4100, "currency": "USD" },
        "tax_money":      { "amount": 328,  "currency": "USD" },
        "total_money":    { "amount": 4428, "currency": "USD" }
      },
      "created_at": "2026-09-14T16:02:11Z"
    }
  ]
}

One-time purchases and subscription renewals come back in one list, told apart by origin, with refunds already reflected in refund_status. There is no customer_id argument anywhere under /v1/me, and sending one fails with ME_CUSTOMER_ID_FORBIDDEN, so a bug in your page cannot show one buyer another buyer's orders. From the same session, GET /v1/me/orders/{order_id} adds the buyer's available actions, including starting a return, GET /v1/me/fulfillment-events?order_id=... gives the tracking timeline, and GET /v1/me/invoices/{invoice_id}/pdf downloads an invoice.

The Stripe portal and Flint's customer account, side by side

Stripe customer portalFlint customer account
A one-time purchaseListed as an invoice when the session had post-payment invoices enabledListed as an order with its line items
Subscription paymentsInvoicesOrders in the same list, with origin set to subscription
Guest buyersA read-only Dashboard grouping, with no portalLinked to your customer with the same email when the payment settles
Two records with one emailThe login opens the newest one with an active subscription; Stripe doesn't document the case where none has oneGuest payments join the existing customer with that email
Delivery tracking and returnsNot in the portal's feature listA tracking timeline and return requests on each order
Sign-inAn emailed login link, or a portal session your server createsAn emailed six-digit code, links in Flint's email, or a one-time account_url from your server
Your own pagesSessions, line items, invoices, and refunds read with your secret key/v1/me with a customer session scoped to one buyer

Questions, answered

Why doesn't the Stripe customer portal show one-time payments?

The portal lists invoices, subscriptions, payment methods, and billing details. A one-time Checkout payment creates no invoice unless you enable post-payment invoices, and by default it creates no Customer either, so there is nothing for the portal to list and often no account for the buyer to sign in to.

How do I show one-time purchases in the Stripe customer portal?

Create each Checkout Session with invoice_creation[enabled]=true and pass the buyer's existing Customer ID. Stripe then creates a paid invoice after the payment succeeds, and that invoice appears in the portal's invoice history. Payment Links have the same option. It applies only to sessions created with it.

Does Stripe charge for invoices on one-time Checkout payments?

Yes. Stripe prices post-payment invoices for one-time purchases separately from Invoicing: 0.4% of the transaction total, up to $2 USD (or the local currency equivalent) per invoice, according to Stripe's support article. Post-payment invoices for subscription payments are included in Stripe Billing pricing.

Can I add past one-time payments to the Stripe customer portal?

Not with invoice_creation, which only affects the sessions you create with it. Purchases made before you turned it on stay out of the portal. Show them on an order history page of your own, built by listing the buyer's completed Checkout Sessions and their line items.

Why does a customer see the wrong history in the Stripe customer portal?

They usually have more than one Customer object with the same email, because sessions that create a Customer without a customer ID make a new one each time. The no-code portal login picks the most recently created customer with that email and an active subscription, and Stripe doesn't document which one opens when none has an active subscription. Either way, the other records' invoices are not shown.

Can guest customers use the Stripe customer portal?

No. A guest customer is a read-only grouping of completed payments in the Dashboard, not a Customer object. The no-code login emails a link only when the address matches an existing customer, and an API portal session is created for a customer ID. Set customer_creation=always or pass a customer if you want those buyers to have a portal.

How do I build an order history page with Stripe?

List the buyer's Checkout Sessions with customer and status=complete, keep the ones whose payment_status is paid, and fetch each session's line items from its line_items endpoint, because line items are not returned by default. Add the Customer's paid invoices for subscription renewals and the refunds for each PaymentIntent.

Does Stripe send receipts for one-time payments?

Yes, when you turn on automatic receipts for successful payments in your Dashboard's customer email settings. Each Charge carries a receipt_url you can link to, and links to receipts expire after 30 days, after which Stripe asks for the buyer's email to send a new one.

Sources