Stripe Payment Links Don't Do Carts. Here's a Cart Anyway.

Payment Links are the fastest way to take money and the worst way to sell two things at once. A thin endpoint bridges the gap — one checkout, many items, no storefront — plus the three bugs that pattern hides.

Stripe Payment Links are the fastest way to get paid: one URL, one price, no backend, nothing to deploy. They stay perfect right up until a buyer wants two things at once. Sell an audit and a build together and your options both hurt — send two links, which means two card entries, two receipts, and a real chance the second link never gets clicked; or stand up a full storefront to sell what is really three or four fixed offers. There's a middle path that keeps the Payment Link for the common case and only reaches for more when it has to.

The whole design is one branch

A cart with a single item redirects straight to that item's Payment Link. Nothing changes, no backend runs, you keep everything that made Payment Links attractive. A cart with two or more items goes to a thin endpoint that builds one Stripe Checkout Session containing every line item, so the buyer enters their details once and pays once. Most purchases are single-item and stay on the fast path; only the multi-item minority pays the small cost of a server call.

The front end stays dumb on purpose. The page doesn't know prices, doesn't do math, and holds no state — it collects the Stripe price IDs the shopper picked and hands that list to the endpoint. Every number that matters lives in Stripe, where it belongs; the cart is just a comma-separated list of IDs riding in the URL. That keeps the surface you have to trust small: a static page can't leak a price it never knew.

How it's built

The endpoint is a Google Apps Script web app — a doGet that takes the cart as a list of Stripe price IDs, validates each one, and either redirects to a Payment Link or calls the Stripe API to create a Checkout Session and redirects to its hosted URL. It lives in the same script that already receives Stripe webhooks, so there's no new service to run and nothing new to pay for.

Reaching for a Checkout Session rather than tallying the prices yourself is the point, not a shortcut. Stripe hosts the payment page, shows the right currency and tax, offers the wallets your buyers already have saved, and emits the same checkout.session.completed event your existing webhook handles — so a multi-item order lands in your books through the exact path a single Payment Link already does. You compose the line items; Stripe does everything downstream of "pay," and your code never touches a card number.

Two rules keep it honest. The first is a server-side allowlist of price IDs. Never build a session from whatever price ID a URL hands you — that's how someone checks out a price you never meant to sell. Validate every incoming ID against a list you control:

const ALLOWED = new Set([   // keep in lockstep with your LIVE prices
  PRICE_AUDIT, PRICE_BUILD, PRICE_CARE, PRICE_BUNDLE
]);

function doGet(e) {
  const ids = (e.parameter.items || '').split(',').filter(Boolean);
  for (const id of ids) {
    if (!ALLOWED.has(id)) throw new Error('unknown price: ' + id);  // fail LOUD
  }
  if (ids.length === 1) {
    return redirect(LINK[ids[0]]);            // single item: keep the Payment Link
  }
  const hasBundle = ids.some(id => PREDISCOUNTED.has(id));
  const session = stripe('checkout/sessions', {
    mode: 'payment',
    line_items: ids.map(id => ({ price: id, quantity: 1 })),
    allow_promotion_codes: !hasBundle          // no coupon stacking on a bundle
  });
  return redirect(session.url);
}

The bug that actually bit us

That allowlist has to move in lockstep with your live prices, and here is what happens when it doesn't. We changed pricing and retired an old bundle price ID — but the allowlist still named the dead one. The failure wasn't an error you could see. It was quieter and worse: the retired item dropped silently out of the combined session, the buyer was charged for less than they picked, and nothing logged a complaint. A whitelist that fails silent is a whitelist that costs you money on every affected order. Make an unknown or missing price ID a loud exception, not a quiet skip — the throw in that loop is the whole point.

The discount trap

If any item in the cart is already discounted — a bundle priced below the sum of its parts — turn promo codes off for that session. Leave them on and a customer stacks a coupon on top of an already-cut price, and you've shipped a double discount you'll only discover weeks later in the numbers. The rule is one line: a pre-discounted item in the cart means allow_promotion_codes: false.

Don't break the webhook on the way in

The same script's doPost is a live Stripe webhook — real orders flow through it. When you add the doGet, redeploy as a new version of the existing web app, not a new deployment. New deployments mint a brand-new /exec URL; the old one — the one Stripe is calling — keeps pointing at the old code, and your carefully tested change silently does nothing in production. Same-URL redeploys are the difference between shipping and believing you shipped.

Costs and failure modes

Be honest about what this is: a checkout composer, not a store. It handles a handful of fixed offers with none of the machinery a real cart needs — no inventory, no per-SKU tax logic, no saved baskets. That's the trade on purpose. For three to five fixed products, forty lines beat a storefront you'd have to maintain forever; the day you need inventory or line-item tax rules, graduate to full Stripe Checkout and don't look back.

Notice what the three failure modes share: none of them throws in a place you'd look. The silent-drop allowlist, the stacked discount, and the phantom redeploy all fail by quietly costing money or quietly doing nothing. So test the actual money path — a real session, the real redeployed URL — because a stack trace will never show you any of them.

If your checkout is a pile of single-purpose links and you're losing the buyers who wanted two things, closing that gap is a weekend, not a rebuild — as long as you make the quiet failures loud.

Checkout leaking the buyers who wanted more than one thing?

Iluxxian builds payment and automation flows on the tools you already use — Stripe, Apps Script, webhooks — with the silent failure modes made loud. Fixed scope, documented handoff.

Start a build