Carts and Checkout
Your store decides what the customer is buying and hands it to PayNow as a checkout session. PayNow handles payment and tax.
Prices
Every product from GET /v1/store/products carries a pricing object next to the base price. Render pricing.price_final, not price.
| Field | Meaning |
|---|---|
price | The base price you set in the dashboard, in minor units of the store currency |
pricing.price_original | The price before any sale, after regional pricing and tax-inclusive adjustment for the customer's location |
pricing.price_final | What the customer will actually pay per unit |
pricing.active_sale | The sale applied, if any, with its type and amount |
pricing.regional_pricing | The regional override in effect, if you have configured one |
Amounts are integers in minor units (1999 is 19.99), and currency is the ISO code they are in. In regions where PayNow shows tax-inclusive prices, price_final is higher than price by the local rate.
This is expected behaviour - it is the number the customer sees at checkout. Forward the customer's IP and Country Code (If available) as described in the Building a Storefront.
The allow_subscription and allow_one_time_purchase flags tell you which billing modes to offer. When both are true, let the customer choose and send the choice as subscription. subscription_interval_value and subscription_interval_scale describe the renewal period.
Two ways to collect lines
A server-side cart lives in PayNow and follows the customer token, so it survives page loads and devices. Use it when customers buy several things at once.
A direct checkout skips the cart - you build the checkout session from a list of lines in one call. Use it for a "Buy now" button, or when your own app already tracks what the customer picked.
Both produce the same checkout session, and you can offer both at once.
The cart
PUT /v1/store/cart/lines adds or updates one line, keyed by query parameters. GET /v1/store/cart returns the lines with their pricing, and DELETE /v1/store/cart empties it.
await storefront.cart.addLine({ product_id: productId, quantity: 1, subscription: "true", increment: "true" });The behaviours to know:
incrementdecides whetherquantityis added to the existing line (true) or replaces it (falseor omitted). Usetruefor an "Add to cart" button andfalsefor a quantity stepper.- Setting
quantityto0withincrement=falseremoves the line. There is no separate delete-line endpoint. - A line is identified by the product plus
subscription, the gift target and the selected game server. The same product added once as a subscription and once as a one-time purchase makes two lines. To change a specific line, send the same identifying parameters again. - Subscription lines always have quantity 1.
- The response is
204with no body. Read the cart afterwards for totals.
The cart holds product IDs and quantities, not prices. Prices are computed when you read the cart and again when you create the checkout, so a sale that starts or ends in between is applied correctly.
Direct checkout
POST /v1/checkouts takes the lines inline. Each line has the same fields as a cart line: product_id, quantity, subscription, selected_gameserver_id, gift_to, custom_variables.
const session = await storefront.checkout.createCheckoutSession({
lines: [{ product_id: productId, quantity: 1, subscription: false }],
return_url: "https://store.example.com/checkout/complete",
cancel_url: "https://store.example.com/cart",
auto_redirect: true,
});For a cart, POST /v1/store/cart/checkout takes the same options minus lines.
The checkout session
Either call returns { id, token, url }.
urlis the hosted checkout page for this session. Sending the browser there is the simplest integration.tokenis what PayNow.js needs to open the same checkout in an overlay on your page.return_urlis where the customer lands after paying, andcancel_urlwhere they land if they back out. Withauto_redirect: truethe hosted checkout sends them toreturn_urlon its own; withfalseit shows a button instead.coupon_id,promo_codesandaffiliate_codepre-apply discounts and attribution. The customer can still enter codes on the checkout page.
Sessions are single-use and short-lived. Create one when the customer clicks pay, not when the page loads.
Opening it - redirect or embed
Redirecting to url works everywhere. Embedding with PayNow.js keeps the customer on your page, which is the better experience on desktop and the reason most custom stores use it.
import PayNowJS from "@paynow-gg/paynow.js";
PayNowJS.checkout.on("completed", ({ orderId }) => {
PayNowJS.checkout.close();
window.location.assign(`/checkout/complete?order=${orderId}`);
});
PayNowJS.checkout.open({ token: session.token, theme: "dark" });Two behaviours to plan for:
- Narrow windows redirect. When the window is narrower than 800px,
open()navigates to the hosted checkout instead of rendering an overlay, unless you passdisableRedirect: true. The overlay is a fixed-size iframe, so the default is what you want on phones. Because the customer leaves your page, setreturn_urlon the session even when you intend to embed. - Completion is an event, not a redirect. In the overlay, listen for
completedand take the customer to your success page yourself.closedfires when they dismiss the overlay without paying, so refresh the cart in case they changed it inside the checkout.
Create the session with auto_redirect: false when you know you will embed, and true when you know you will redirect. If you decide at click time based on window width, pass the decision to your server and set auto_redirect accordingly.
The library is browser-only. In a server-rendered framework, import it inside a client component or with a dynamic import, and only call open() from a click handler.
After payment
Delivery starts as soon as the payment has been completed - the customer does not need to do anything on your site.
Your success page can read the order with GET /v1/store/customer/orders/{orderId} using the customer token to show what they bought.
For anything that must happen on your backend when an order completes, such as granting access in your own database, use the ON_ORDER_COMPLETED webhook rather than the browser event. The browser event can be missed if the tab closes - the webhook cannot.