Skip to content
Gaurav TiwariDocs

Cart and Checkout

Version 0.2.0 Updated November 15, 2026

How the cart works: ?add= links, the cart components, the checkout page in cart mode, subscriptions in a cart, what each provider shows, and the API. The cart is part of Licensing and Storefront, so there is no module to turn on.

Since 0.2.0 a buyer can put several products and plans, each with a quantity, into one checkout. They pay once and get one order with an item for each line, plus one license per unit of a licensed product.

A checkout of one product at quantity 1 is still the 0.1.0 checkout. It has the same quote, terms, provider request, order and receipt.

Linking to a cart

Use an ?add= link to open the checkout with items already in the cart:

/checkout/?add=speed-kit:personal
/checkout/?add=speed-kit:personal:2,theme-pack:lifetime
  • Each piece is product:plan or product:plan:quantity. Products and plans are slugs, and the quantity runs from 1 to 100.
  • Pieces are separated by commas, and the parameter can repeat. If the same product and plan appear twice, their quantities are added together.
  • Malformed pieces are skipped, never guessed. A quantity outside 1 to 100 skips its piece.
  • The server renders the cart from the query string, so these links work without JavaScript.

Every plan in the storefront API (storefront/products, storefront/product) now has an add_to_cart_url beside checkout_url. It always points at the native checkout, even for a product whose checkout link goes to a form.

Components

---
import { BuyButton, CartButton, CartDrawer } from "@gatilab/emdash-vendwick/astro";
---
<CartButton />           <!-- in the header: a count badge that opens the drawer -->
<CartDrawer />           <!-- once per layout -->
<BuyButton product="speed-kit" plan="personal" cart />   <!-- "Add to cart" -->
  • <BuyButton cart> renders an "Add to cart" link to ?add=product:plan. With the client script loaded, it adds the plan to the browser cart, announces it in a live region and opens the drawer. If the page has no drawer, it goes to the checkout with the whole cart.
  • <CartButton> is a link to the checkout page. With JavaScript it opens the drawer and shows how many units the cart holds.
  • <CartDrawer> is a modal <dialog> side sheet. It lists the browser cart with quantity steppers (capped by the stock left), a price field for pay-what-you-want lines, and remove with undo; gets a live subtotal from checkout/quote; says what is calculated at checkout (shipping, tax); and links to the checkout with the whole cart. A cart that needs a split shows the reason and a "Check out … first" link. Escape or a click outside closes it, focus goes back to the button that opened it, and the page behind does not scroll.
  • The product page’s plan picker has "Add to cart" too: with JS it adds the chosen plan and quantity to the browser cart and opens the drawer; without JS it opens the checkout with ?cf_product=&cf_plan=&qty=&cart=1, which the checkout reads as a one-line cart (a quantity above 1 does the same).
  • The vendwick-cart Portable Text block renders a cart button and its drawer. In the "Buy button" block, the "Add to cart" toggle turns on the cart variant.

The browser keeps the cart in localStorage (vw-cart) as slugs and quantities, plus any pay-what-you-want amounts typed in the drawer (amounts, minor units by product:plan; the checkout page fills its price fields from them). A record written before these amounts existed reads the same. Prices always come from the server. When a direct ?add= link opens the checkout, its lines are added once to whatever was already in the cart. From then on, the checkout page is the cart. After a completed order, the cart is emptied.

The checkout page

In cart mode, a cart section sits at the top of the checkout. It lists every line with its unit price, a quantity field between "One fewer" and "One more" buttons, the line total and a Remove button. All of these are submit buttons tied to the checkout form, so they work without JavaScript: the server prices the page again. The client script changes quantities in place and updates the totals live in the existing aria-live totals region. With JS, "Remove" takes the line out in place and offers "Undo" (the last line still goes to the server, which shows the empty cart). A quantity above the stock left shows as a problem on its line, and a submit is refused with an error that links to that quantity.

Each line’s checkout questions appear in their own fieldset ("About Speed Kit Personal"). Their fields are posted as lq[<line>][<key>].

When the chosen payment method cannot take the cart, the cart section explains why and links to a separate checkout for each group of items. If no available payment method can take the cart, that explanation replaces the payment step.

Subscriptions in a cart

  • A cart can hold one subscription line, at quantity 1, with any number of one-time lines.
  • Stripe takes one-time lines with a subscription. The subscription renews at its own line’s price, minus its own share of a forever coupon, plus tax. The one-time lines are charged once, on the first invoice.
  • PayPal, Razorpay and Polar cannot add one-time lines to a subscription. If the gateway would renew the subscription automatically, the cart is refused with CART_SPLIT_REQUIRED, and the answer suggests a split. If the gateway would not renew it automatically anyway (renewals off for that gateway, for example), the cart is sold as is and the subscription renews by hand.
  • A cart with two subscriptions is refused on every gateway with CART_TWO_SUBSCRIPTIONS. Two units of one subscription plan count as two subscriptions.
  • The checkout records how its subscription line renews (renewalMode: auto, manual or none) when it starts. If the checkout promised automatic renewal (auto, or renewal: "auto" sent by the page) and the provider subscription can no longer be set up, the start fails with AUTO_RENEW_UNAVAILABLE and nothing is charged. The checkout never falls back to a one-off payment.
  • Only the subscription line’s order item becomes the provider subscription. Term plans on the other lines keep their own local terms and renew by hand.

What the provider shows

  • Stripe: one line_items entry per cart line. Each line’s discount and tax are folded into its unit price, so the lines add up to the charged amount exactly. A line whose total does not divide by its quantity becomes one item named "name x n". If the lines do not add up, or Stripe cannot express a unit (three-decimal currencies), the provider gets the single summary line from 0.1.0. A subscription checkout adds the one-time lines as price_data items on the first invoice.
  • PayPal: purchase_units[0].items, with amount.breakdown.item_total equal to the amount and the same folding and fallback. HUF, JPY and TWD units must be whole.
  • Razorpay: the payment link description lists every line after the order description, and a vw_items note carries the same list (capped at 256 characters).
  • Polar: this works as before: one private product priced at the total. For a cart, the label is "Order {amount}", so Polar reuses one product per amount and currency instead of creating one per combination of items. The merchant-of-record block (no tax, no coupon) still applies.

For a cart, the description is "{first item} and N more for {email}".

Emails, invoices and the portal

  • The receipt lists every line with its quantity and line total ("2 x Speed Kit Personal: $98.00") and the total paid. When the emails module is on, the branded order table shows the quantity instead ("Speed Kit ยท Personal x 2"). A one-item receipt is unchanged.
  • The abandoned-checkout email names the cart’s products, lists every line with its quantity and total, and links back to the whole cart (?add=). Buying any of the cart’s products after the checkout started stops the email.
  • The order invoice, the portal order view and the admin order detail list every item with its quantity and amount.

Limits

  • At most 20 lines, and a quantity of 1 to 100 per line. Requests outside these limits are refused with VALIDATION_ERROR, never clamped.
  • Every line must be priced in the same currency, or the request is refused with CART_MIXED_CURRENCY.
  • The rate limits of checkout/quote (60 a minute per IP) and the checkout start (10 per IP) still apply.

API reference

checkout/quote (public)

Request: product and plan (with an optional quantity), or lines: [{ product, plan, quantity?, answers? }]. The top-level answers belong to the first line. Other fields are unchanged.

The response keeps every 0.1.0 field. product, plan and questions describe the first line. It adds:

  • items[]: lineId, product { id, slug, name, type }, plan { id, slug, name, priceMinor, currency, billingPeriod, billingInterval, activationLimit, recurring }, quantity, unitAmountMinor, amountMinor, discountMinor, taxMinor, totalMinor, subscription, questions, extras[], and display { unit, amount, total }.
  • cart: lines, units, block (refused with every gateway: { code, message, split }), subscriptionLineId, and renewals: [{ gateway, renewal: "auto" | "manual" | "none", block }] for each available gateway.
  • gatewayError also carries the cart block for the chosen gateway.

internal/checkout/page (companion)

The request takes the same lines as the quote, and quantity for the shorthand. Each entry in gateways[] adds renewal, and blocked also carries a cart block. The response adds items and cart as in the quote. extras[] entries carry lineId in a cart.

internal/checkout/start (companion)

The request takes the same lines, quantity for the shorthand, and renewal ("auto", "manual" or "none"): what the page told the buyer about renewal. A promised auto is never downgraded. New error codes: CART_MIXED_CURRENCY, CART_SPLIT_REQUIRED, CART_TWO_SUBSCRIPTIONS and AUTO_RENEW_UNAVAILABLE (409).

Records

  • CheckoutAnswer.lineId is the line an answer belongs to (set only in a cart).
  • CheckoutRecord.renewalMode is set at start when the checkout has a subscription line.
  • Order items carry lineId as in 0.2.0. Priced answers are stored on their own line’s item (fulfillmentData.extras).
Share

Was this helpful?

Still stuck? Open a support ticket