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 see | Your path |
|---|---|
| Buyers paid through Checkout or a Payment Link and their portal is empty | Why the portal shows nothing |
| You want future one-time purchases listed in the portal | Turn on post-payment invoices |
| A buyer signs in and sees someone else's history, or only part of theirs | One Customer per buyer |
| You want an order history with items, including purchases already made | Build your own history from Checkout Sessions |
| You would rather not build an account at all | A 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_creationproduces a PaymentIntent and nothing the invoice history can list. - No Customer. Unless you pass
customeror setcustomer_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 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:
// 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:
# 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:
// 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.
