docs · build a Blanxer store website with an AI agent

Checkout

Use @blanxer-dev/storefront's Checkout component. This page documents what it does. Restyle it with theme props, CSS variables and wrappers (rule 2). Don't rewrite the payment, delivery, coupon or order logic, and don't call payment endpoints yourself. You need this page to theme the checkout safely, to debug it, and to explain it to the merchant.

The checkout behaves like the Blanxer-hosted storefront's checkout (storefront main at cabd695): the same fields, delivery places and charges, coupons, payment methods, stock holds, flash-sale limits and tracking. Where the package differs from the hosted storefront, this page says so. The hosted storefront also has a few store-specific checkout variants; the package doesn't port them.

The server re-checks the order. Prices, stock, delivery charges, COD and coupons are recomputed on the server; what the browser shows is a preview. Two things are taken from the request as sent: each line's customization_charge and its custom-field values.

The flow at a glance

  1. The page loads the store (check_domain) on the server. The delivery table is loaded on the server when you pass it to Checkout as delivery (the starter does); without it, the package fetches it in the browser.
  2. The shopper fills the form, picks a delivery place and a payment method, and may apply a coupon.
  3. The browser sends POST /order/<store_id> with the cart lines, the form, the payment method, the coupon _id and url = the site's own origin.
  4. The API creates the order. COD orders are Pending and stock is reduced at once. Online orders are Inactive and their units are held while the shopper pays.
  5. COD: the browser goes to /track/<order_id>?from=checkout. Redirect gateways (eSewa, Khalti, FonePay, NPX, Nabil card): the browser starts the payment and leaves for the gateway. The gateway returns to the API, and the API redirects to <url>/payment/success or <url>/payment/failed. In-page QR (Fonepay QR, Blanxer QR, Checkout by Fonepay): a QR modal opens with a hold timer. The browser polls for the payment, then goes to /payment/success.
  6. /payment/success forwards to /track/<order_id>?from=checkout.

Everything in steps 3–6 runs in the browser (rule 5).

What the checkout needs

Input From Notes
Store config GET /store/check_domain?domain=<subdomain> _id, plan, addons, setting, checkout. Load it on the server. (The hosted storefront uses POST /store/check_domain?d=<domain> with body {domain}; both return the same.)
Delivery table GET /public/delivery_charge/<store_id> Load it on the server and pass it as delivery.
Cart lines The browser, localStorage['cart_items'] Only the selected lines are ordered (localStorage['cart_selection']).
url window.location.origin No trailing slash. Never hardcode it (rule 4).

A cart line

ts
interface CartLine {
  id: string;                     // `${product}_${variant}` (see below)
  product: string;                // product _id
  variant: string;                // variant _id, '' for a product without variants
  quantity: number;
  product_name: string;
  variant_name: string;           // the variant's option_name, e.g. "Red/XL"
  price: number;                  // display only; the server uses its own price
  compare_at_price: number;
  weight: number;                 // kg, per unit: the variant's weight if it is a number, else the product's
  image_url: string;
  sku?: string;
  custom_fields: string[];        // serialized, see "Custom fields"
  customization_charge?: number;  // per unit, from paid custom fields
}
  • Line id: ${product}_${variant}. Adding the same product and variant again adds to that line's quantity, even when the custom-field values differ; the line keeps its first values. Only team-order adds (addManyToCart) give each line with custom fields its own id. The hosted storefront also gives print lines their own id (_${printId} appended).
  • Storage keys match the hosted storefront (cart_items, cart_selection, promo_code, checkout_order_id, pixel_purchase_pending), except back-in-stock: the package uses stock_alerts, the hosted storefront bx_stock_alerts.

Fields

Field names are the keys of the order request body. "Server" is the backend's zod schema for POST /order/:store_id.

Field Shown Required Browser check Server rule
customer_full_name Always Yes Not empty 2–120 characters
customer_phone_number Always Yes /^[0-9]{10}$/ Exactly 10 characters: Phone number must be of 10 digit
customer_alt_phone_number Always, labelled optional No Empty, or /^[0-9]{10}$/ Empty or 10 digits: Alternate phone number must be of 10 digit
customer_email Always When checkout.extras.email_required Valid email when required Valid email or ''. Lower-cased; a trailing .con becomes .com. Missing when required: HTTP 401 {"fields":{"customer_email":"Email address is required"}}
customer_address_city Always (the delivery place select) Yes Not empty 2–320 characters, and must equal a place name in the delivery table
customer_address Always Yes Not empty 2–320 characters
customer_address_landmark Always No — ≤ 320 characters
order_note Always No — ≤ 320 characters: Max 320 character is allowed
sender_full_name When checkout.receiver_info Yes, when shown Not empty ≤ 200 characters
sender_phone_number When checkout.receiver_info Yes, when shown /^[0-9]{10}$/ ≤ 15 characters
sender_email When checkout.receiver_info When checkout.extras.sender_email_required Valid email when required Valid email or ''. Missing when required: HTTP 401 {"fields":{"sender_email":"Sender email address is required"}}
company_name When checkout.pan No — ≤ 50 characters
pan When checkout.pan No — ≤ 9 characters
delivery_date When checkout.estimated_delivery No Date input, minimum today Optional string; sent as YYYY-MM-DD

Layout rules the component follows:

  • With checkout.receiver_info on, the form has "1. Sender Information" (the sender_* fields) and "2. Receiver Information" (the customer_* fields plus the delivery place and address). The receiver is the person the parcel goes to.
  • Confirmation email: when sender_full_name is set, it goes only to sender_email, and to nobody when that is empty. Otherwise it goes to customer_email. COD orders are emailed when they are created; online orders only once paid.
  • Without it, the form has "1. General Information" and a separate "Delivery Address" block.
  • The Blanxer storefront shows Company Name and PAN/VAT together when checkout.pan is on.
  • checkout.extras.checkout_note, when not empty, is shown under the total.
  • The delivery place defaults to the first place in the table.

"Checkout Powered by Blanxer"

The package's Checkout always renders this line under the place-order button:

html
<p class="bx-powered-by">Checkout Powered by <a href="https://blanxer.com" target="_blank" rel="noopener noreferrer">Blanxer</a></p>
  • It is part of the checkout (rule 2, owner decision 2026-10-11). There is no prop to remove it.
  • Restyle it through .bx-powered-by and .bx-powered-by a (colour, size, font, spacing, alignment).
  • Never remove, hide, cover or blend it into the background. Your own footer content goes below it.

Delivery places and charges

GET /public/delivery_charge/<store_id> returns:

json
{
  "defaults": [100, 150, 200, 250, 300, 400],
  "places": [
    ["Kathmandu Inside Ring Road", true, 100, -1, -1, -1, -1, -1],
    ["Pokhara", "N", 150, 200, 250, 300, 350, 500],
    ["Ilam", "Y", -1, -1, -1, -1, -1, -1]
  ]
}
  • defaults has 6 numbers, one per weight bracket.
  • Each place is [name, cod, ≤1 kg, ≤2 kg, ≤3 kg, ≤5 kg, ≤10 kg, >10 kg].
  • -1 in a place means "use the default for that bracket".
  • A store that never saved a table gets 83 default places, all with COD on, all -1, and defaults of 0: free delivery everywhere. The merchant edits the list in Blanxer (or you do, with the MCP and their confirmation).

The rules

  • Weight = the sum of weight × quantity over the ordered lines, in kg. The server uses the variant's weight for a variant line (0 when the variant has none) and the product's weight otherwise. The cart line falls back to the product's weight when the variant has none, so the preview can show a different delivery charge than the order.
  • Bracket index: the first of [1, 2, 3, 5, 10] that the weight is <=; above 10 kg it is index 5.
  • Charge = place[index + 2]; if that is below 0, defaults[index].
  • COD is allowed for a place when cod === true || cod === 'Y'. That is the server's rule. The Blanxer storefront uses !!cod, which treats 'N' as yes; don't copy that.
  • The place name must match exactly. An unknown place fails the order with Selected delivery city not found, please try again.
ts
type DeliveryTable = { defaults: number[]; places: [string, boolean | string, ...number[]][] };

const BRACKETS = [1, 2, 3, 5, 10];

export function deliveryFor(table: DeliveryTable, place: string, weightKg: number) {
  let i = BRACKETS.findIndex((max) => weightKg <= max);
  if (i === -1) i = BRACKETS.length; // > 10 kg
  const row = table.places.find((p) => p[0] === place);
  if (!row) return { charge: 0, cod: false, known: false };
  const n = (v: unknown) => (v === '' || v == null || isNaN(Number(v)) ? 0 : Number(v));
  const own = n(row[i + 2]);
  const cod = row[1] === true || row[1] === 'Y';
  return { charge: own < 0 ? n(table.defaults[i]) : own, cod, known: true };
}

Payment methods

The list is built from the store config. Never add or remove a method by hand (rule 9).

  • COD is offered when the chosen place allows it (see above). It comes first.
  • Online methods are offered only when plan is one of basic, premium, plus, platinum. On any other plan (free) the checkout is COD only.

In this order:

paymentMethod Offered when Label in the Blanxer storefront Flow
COD The chosen place allows COD Cash on delivery No payment call
QR_Dynamic addons.fonepay_dynamic && setting.fonepay_dynamic_key Fonepay QR Payment In-page QR
FonepayCheckout addons.fonepay_checkout && setting.fonepay_checkout_key Checkout by Fonepay In-page QR + bank app links
QR addons.qr_request_enabled QR Payment In-page QR (Blanxer QR)
Esewa addons.esewa && setting.esewa_secret Esewa Form post to eSewa
FonePay addons.fonepay FonePay Redirect
Khalti addons.khalti Khalti Redirect
NPX addons.npx Nepal Payment Solution Form post
nbank_card addons.nbank_card Visa/Mastercard Redirect (Nabil Bank)
  • The payment-credential keys in setting arrive masked: 'yes' when set, '' when not (fonepay_checkout_pubkey is always ''). Other setting keys come as stored. Test only the keys in this table, for truthiness.
  • Card (Cybersource) is always off: check_domain always returns addons.cybersource: false.
  • The strings are exact and case-sensitive. The server stores whatever string you send, so a typo creates an order no gateway will ever pay. A missing paymentMethod is saved as COD.
  • The first available method is preselected. If the selected method disappears (for example COD after switching to a no-COD place), the package selects the first available one again. (The hosted storefront doesn't; it keeps the old selection and the server refuses it.)
  • The server also saves the method as the customer's preferred_payment_method, for guests too.
  • The server rejects COD for a place without COD: Cash on delivery is not available for the selected delivery location.

Coupons

Applying a code

http
POST /public/coupon
Content-Type: application/json

{ "store": "<store_id>", "code": "DASHAIN10",
  "products": [{ "product": "<product_id>", "variant": "<variant_id or ''>", "quantity": 2 }] }

Response:

json
{
  "_id": "6705f0c2a1b2c3d4e5f60718",
  "code": "DASHAIN10",
  "coupon_type": 2,
  "coupon_value": 10,
  "min_order_value": 1000,
  "max_discount_amount": 500,
  "total_applicable_amount": 4200,
  "only_for_cod_payment": false,
  "only_for_online_payment": false,
  "categories": [],
  "places": []
}
  • The code lookup is exact (case-sensitive). The package and the hosted storefront upper-case what the shopper types.
  • The promo code box shows only when store.plan is set (not on the free plan).
  • Errors are HTTP 400 {message}: Coupan not found (sic), Coupon is inactive, All the available coupons have been used., Coupon code not available (not started yet), Coupon code is expired, Cart must not be empty, Coupon is not applicable to any products (category-restricted coupon, no matching product).
  • Each successful apply holds one use of the coupon for about 12 seconds. That matters only for a coupon with a usage limit: on its last use, an order placed within ~12 seconds of Apply can fail with All the available coupons have been used. Call it when the shopper presses Apply, never on every keystroke.
  • total_applicable_amount is the sum of price × quantity of the lines the coupon applies to (all lines, or only lines in the coupon's categories).

Checks in the browser

The package re-checks the coupon on every change: payment method, place, delivery charge and cart total. (The hosted storefront re-checks when the coupon, delivery charge, payment method or place changes, but not when the cart total changes.) These give live feedback; the server enforces the same rules when the order is placed.

Check Message
min_order_value !== -1 && subtotal + customization + delivery < min_order_value Coupon is only applicable for order value above <min> (<min> formatted as currency, en-IN)
only_for_cod_payment && paymentMethod !== 'COD' Only cash on delivery can use this coupon
only_for_online_payment && paymentMethod === 'COD' Only online payment method can use this coupon
places.length && place chosen && !places.includes(place) This coupon is not valid for <place>

The discount

ts
// coupon_type: 1 = flat, 2 = percent, 3 = shipping (percent of the delivery charge)
export function couponDiscount(c: Coupon, deliveryCharge: number) {
  const base = c.total_applicable_amount;
  const max = c.max_discount_amount; // -1 = no cap
  let d = 0;
  if (c.coupon_type === 1) d = Math.min(base, c.coupon_value);
  if (c.coupon_type === 2) {
    const p = Math.floor((c.coupon_value / 100) * base);
    d = max === -1 ? Math.min(p, base) : Math.min(p, max);
  }
  if (c.coupon_type === 3) {
    const s = Math.floor((c.coupon_value / 100) * deliveryCharge);
    d = max === -1 ? Math.min(s, deliveryCharge) : Math.min(s, max);
  }
  return Math.max(d, 0);
}

Total shown = subtotal + customization charges + delivery − discount (the discount only while the browser checks pass).

Sending it with the order

  • Send the coupon's _id as coupon, never the code. The server only looks at coupon when it is longer than 20 characters (an id is 24). A code like DASHAIN10 is silently ignored: the order goes through with no discount and no error.
  • Send coupon: '' when there is no coupon or a browser check fails.
  • A coupon longer than 20 characters that isn't a valid id fails the order with HTTP 500.
  • The server re-validates the coupon. If it fails, the whole order fails with one of: Coupon not found (spelled correctly here), You are not allowed to perform this action (another store's coupon), This reward coupon belongs to another account, Coupon is inactive, All the available coupons have been used., Coupon code not available, Coupon code is expired, Only cash on delivery can use this coupon, Only online payment method can use this coupon, This coupon is not valid for <place>, Coupon is only applicable for order value above <min> (the raw number, not formatted), Coupon is not applicable to any products.
  • The server's discount is final. Its base includes customization charges when the coupon has no category restriction, so it can differ slightly from the preview.

?promo=

Any page opened with ?promo=CODE stores CODE in localStorage['promo_code'] (BlanxerProvider does this). The checkout applies it once each time it opens with cart lines. The package also reads ?promo= from the checkout URL itself. Use this for shared promo links.

  • The stored code stays. It is applied again on later checkouts until another ?promo= replaces it. (The hosted storefront clears it only on logout.)
  • It is sent as stored, not upper-cased. Codes are case-sensitive, so ?promo=dashain10 gets Coupan not found for the code DASHAIN10. Write promo links with the code exactly as created.

Custom fields

Some products ask the shopper for extra input (a name to engrave, a photo, a delivery time). The product detail response has them in custom_fields, as arrays:

ts
// [type, label, required, placeholder, options, price]
type RawCustomField = [string, string, boolean, string, (string | { label: string; value: string })[]?, number?];
Type Input
text Text
text_dp Text with a price: when filled, price is added to the line's customization_charge
textarea Long text
image Image upload (below)
date Date, minimum today
time One of 8AM - 10AM, 10AM - 12PM, 12PM - 2PM, 2PM - 4PM, 4PM - 6PM, 6PM - 8PM, 8PM - 10PM
single One option (chips under 8 options, a select otherwise); the first option is preselected
multi, checkbox Several options

Serialization

Each filled field becomes one string, label|||type|||value:

  • arrays are joined with , ;
  • dates are formatted MMM DD, YYYY (for example Oct 24, 2026);
  • an image is its uploaded URL.
json
["Name on cake|||text|||Asha", "Delivery time|||time|||4PM - 6PM", "Photo|||image|||https://.../public_uploads/.../photo-1234.jpg"]
  • A required field left empty shows This field is required and blocks Add to cart.
  • Adding the same product and variant again goes into the existing line, even with different values (see A cart line).
  • customization_charge is per unit. Send it on the line only when it is above 0, and send custom_fields only when at least one string is not empty.

Image upload

http
POST /product/<store_id>/<product_id>/file
Content-Type: multipart/form-data

file=<the image>
csrf_token=<csrf_token from the product detail response>
  • PNG, JPG, JPEG or WebP, else Only png, jpg, jpeg, webp file is allowed!. At most 5,900,000 bytes, else You can only upload at max 5MB file. No file: File wasn't supplied.
  • Response: {"success": true, "file": {"file_url": "https://...", ...}}. Use file.file_url as the field's value.
  • The file is stored as public_uploads/<store_id>/<product_id>_<name>-<4 digits><ext>.
  • The server does not currently check csrf_token or that the product exists; send the real values anyway.
  • Upload from the browser.

The order request

http
POST /order/<store_id>
Content-Type: application/json
ts
interface OrderRequest {
  products: {
    product: string;
    variant: string;                 // '' for a product without variants
    quantity: number;                // ≥ 1
    customization_charge?: number;   // per unit, only when > 0
    custom_fields?: string[];        // only when at least one is not empty
    print_id?: string;               // the chosen print, for a print add-on product (see below)
  }[];
  meta?: {                           // Meta CAPI dedup, see "Meta events" below
    event_id?: string;               // ≤ 100
    fbp?: string;                    // ≤ 200, the _fbp cookie
    fbc?: string;                    // ≤ 400, the _fbc cookie
    event_source_url?: string;       // ≤ 1000, location.href
  };
  customer_full_name: string;
  customer_phone_number: string;
  customer_alt_phone_number: string; // '' when not given
  customer_email: string;            // '' when not given
  customer_address_city: string;     // the delivery place name
  customer_address: string;
  customer_address_landmark: string;
  order_note: string;
  sender_full_name?: string;         // only with checkout.receiver_info
  sender_phone_number?: string;
  sender_email?: string;
  delivery_date?: string;            // YYYY-MM-DD, only with checkout.estimated_delivery
  pan?: string;
  company_name?: string;
  paymentMethod: 'COD' | 'QR_Dynamic' | 'FonepayCheckout' | 'QR' | 'Esewa' | 'FonePay' | 'Khalti' | 'NPX' | 'nbank_card'; // missing = 'COD'
  url: string;                       // window.location.origin
  coupon: string;                    // coupon _id or ''
}

Example:

json
{
  "products": [
    { "product": "66f1c0ffee0000000000aa01", "variant": "66f1c0ffee0000000000bb07", "quantity": 1 },
    { "product": "66f1c0ffee0000000000aa02", "variant": "", "quantity": 2,
      "customization_charge": 100, "custom_fields": ["Name on cake|||text_dp|||Asha"] }
  ],
  "meta": { "event_id": "b6a3a0e2-1c1e-4f5e-9b8e-2f0c7d9e1a11", "fbp": "", "fbc": "",
            "event_source_url": "https://mystore.com/checkout" },
  "customer_full_name": "Asha Gurung",
  "customer_phone_number": "9800000000",
  "customer_alt_phone_number": "",
  "customer_email": "asha@example.com",
  "customer_address_city": "Pokhara",
  "customer_address": "Lakeside, Ward 6",
  "customer_address_landmark": "Near the boat station",
  "order_note": "",
  "paymentMethod": "Khalti",
  "url": "https://mystore.com",
  "coupon": "6705f0c2a1b2c3d4e5f60718"
}

The package sends no Authorization header (guest checkout). The hosted storefront sends a Bearer token for logged-in members, for loyalty; customer login is not part of BlanxerAPI.

A product can have a print add-on (print_addon on the product): the shopper picks a print, and the order line carries its id as print_id. When the add-on is on and required, a line without print_id fails with Please choose a print for <name>; a print that is no longer allowed fails with The selected print is no longer available for <name>. The list of prints comes from GET /print-design/public/<store_id>, which isn't part of BlanxerAPI yet.

The package has no print picker yet, so it can't sell a product whose print is required. Before launch, check the store's products for print_addon.enabled and tell the merchant.

What the server checks, in order

  1. The body against the schema (lengths and formats above). Errors: HTTP 400 {"error_type":"zod_validation","fields":{"<field>":"<message>"}}. Field keys are body keys, for example customer_phone_number or products.0.quantity. An empty products list: Atleast a product is required.
  2. The testing short-circuit (see Testing).
  3. Email and sender email when the store requires them (HTTP 401, see the table above).
  4. Lines with an empty product are dropped; if none is left: Product is required. Every product exists in this store and is Active, else Either of the selected product is already deleted.
  5. A product with variants has a variant, else Variant selection required for this product; the print choice (see above); the variant exists, else Either of the selected product has been modified, please add to cart again.
  6. Stock, unless the product has continue_selling: Either of the selected product is out of stock. Stores with advanced inventory are checked against the website outlet's stock.
  7. Live flash sale limits: Flash sale: you can buy at most N of "<name>" per order.
  8. The stock hold. When the last units sit in other shoppers' checkouts: The last units of an item in your cart are in other shoppers' checkouts right now. Please try again in about N minutes.
  9. The delivery place and COD (messages above).
  10. The coupon (messages above).

Any other error is HTTP 400 {"message": "..."}.

Rate limit: more than 5 requests in 10 minutes for one store from one IP (the limiter keys on the store id and cf-connecting-ip) is HTTP 429 with the plain-text body Too many requests, please try again later. Every request counts, including test orders and failed ones. The limit runs before the test-phone check.

Prices are the server's: the variant's price if it has one, else the product's. The cart's prices are only shown. customization_charge and the custom-field values are taken from the request.

The response

json
{
  "success": true,
  "_id": "6709a1b2c3d4e5f607182930",
  "order_number": 1042,
  "total_price": 4180,
  "total_quantity": 3,
  "delivery_charge": 150,
  "email": "asha@example.com",
  "slug": "",
  "hold_seconds": 300,
  "hold_expires_at": "2026-10-11T09:15:00.000Z"
}
  • total_price = products + customization charges − discount. It does not include delivery. The shopper pays total_price + delivery_charge.
  • hold_seconds and hold_expires_at come only for online orders that hold stock.
  • slug is always ''.
  • COD orders are created as Pending. Online orders are created as Inactive with payment_status Unpaid; it becomes Processing when a payment starts, and the order becomes Pending / Paid when the payment is confirmed.

Right after a successful response the component stores, in sessionStorage:

  • checkout_order_id = _id (the success page needs it);
  • pixel_purchase_pending = the Purchase event data, including the event_id sent in meta.

Payment flows

Every payment start is a browser POST with the same body:

json
{ "store": "<store_id>", "order": "<order _id>", "url": "https://mystore.com" }

The API checks the order belongs to the store, creates a payment transaction for products + customization + delivery − discount, and remembers url for the return.

  • eSewa, Khalti, FonePay (and the unused card route) validate url with a strict URL check that rejects http://localhost:3000: HTTP 400 {"fields":{"url":"Valid Return URL is required"}}. Use http://127.0.0.1:3000 locally, or test on the deployed site.
  • QR, Checkout by Fonepay, NPX and Nabil card accept localhost. Their bad-URL error is {"error_type":"zod_validation","fields":{"url":"Invalid url"}}.
  • If the gateway's verification fails, the API sets payment_status to Failed; the order stays Inactive.

COD

No payment call.

  1. Fire the browser Pixel Purchase with the stored event_id (no CAPI relay; the server already sent the CAPI Purchase for COD).
  2. Remove the ordered lines from the cart and clear the two sessionStorage keys.
  3. router.replace('/track/<_id>?from=checkout').

eSewa (form post)

POST /payment/esewa/init returns the eSewa v2 form:

json
{
  "action_url": "https://rc-epay.esewa.com.np/api/epay/main/v2/form",
  "amount": 4330, "total_amount": 4330,
  "product_service_charge": 0, "product_delivery_charge": 0, "tax_amount": 0,
  "transaction_uuid": "6709a1b2c3d4e5f6071829aa",
  "product_code": "EPAYTEST",
  "success_url": "https://api.blanxer.com/payment/esewa/verify",
  "failure_url": "https://api.blanxer.com/payment/esewa/verify",
  "signed_field_names": "total_amount,transaction_uuid,product_code",
  "signature": "..."
}

Build a hidden <form method="post" action={action_url}> with one hidden input per key except action_url, append it to the body and submit it. eSewa returns to the API, which verifies the payment and redirects to <url>/payment/success or <url>/payment/failed.

  • product_code EPAYTEST means the store is on eSewa's test environment, with the rc-epay.esewa.com.np form above. A live store gets https://epay.esewa.com.np/api/epay/main/v2/form.
  • success_url and failure_url point at api.blanxer.com in production and at api-dev.blanxer.com elsewhere.
  • Known limitation (likely, not yet tested): if the shopper cancels and eSewa returns without a valid data parameter, the API answers HTTP 400 JSON on its own host instead of redirecting to /payment/failed. The order stays Inactive.

Khalti (redirect)

POST /payment/khalti/init returns Khalti's own initiate response, which includes payment_url (and pidx, expires_at, expires_in). Set window.location.href = payment_url. Khalti returns to the API (/payment/khalti/verify/<transaction>), which redirects to success or failed. Khalti's error text is passed through as {message}; show it. It can read [object Object] when Khalti returns a structured error. Opening the verify link again for an order that is already paid returns HTTP 400 {"message":"Payment already received"} on the API host.

FonePay (redirect)

POST /payment/fonepay/init returns {"url": "https://..."}. Set window.location.href = url.

NPX, Nepal Payment Solution (form post)

POST /payment/npx/init returns { "action_url": "...", ...fields, "Signature": "..." }. Post a hidden form exactly as for eSewa.

Nabil card (redirect)

POST /payment/nabil_card/init returns {"url": "https://..."}. Redirect to it. A failed card payment returns to <url>/payment/failed?method=card.

Fonepay QR (QR_Dynamic) and Blanxer QR (QR)

http
POST /payment/dynamic_qr/init
{ "store": "...", "order": "...", "url": "...", "self": true }   // QR_Dynamic: the store's own Fonepay
{ "store": "...", "order": "...", "url": "..." }                 // QR: Blanxer QR (needs addons.qr_request === 'approved')

Response:

json
{ "prn": "6709a1b2c3d4e5f6071829aaxkqz", "transaction": "6709a1b2c3d4e5f6071829aa",
  "amount": 4330, "socket_url": "wss://...", "qr_message": "000201010212..." }
  1. Open a modal. Render qr_message as a QR code and show amount.
  2. Start the hold timer (see Stock holds).
  3. Every 5 seconds while the tab is visible, and right away when the shopper returns to the tab, call GET /payment/dynamic_qr/check/<transaction>. HTTP 200 {"success": true} = paid. HTTP 400 {"message":"Payment not received"} = not yet. Payment confirmation itself happens on the server, from Fonepay's websocket.
  4. A Check Payment button runs the same check and says Payment has not been received yet. when it isn't.
  5. When paid: remove the ordered lines from the cart, close the modal, router.push('/payment/success').

Blanxer QR on a store whose QR request isn't approved fails with QR request is not approved for this store.

Checkout by Fonepay (FonepayCheckout)

POST /payment/fonepay_checkout/init returns the QR data plus a bank list:

json
{
  "prn": "6709a1b2c3d4e5f6071829aaxkqz", "transaction": "6709a1b2c3d4e5f6071829aa",
  "amount": 4330, "socket_url": "wss://...", "qr_message": "000201010212...",
  "banks": [{ "bankName": "...", "bankCode": "...", "bankIcon": "https://...", "packageName": "...", "intentScheme": "..." }]
}
  • Desktop: show the QR, as above.
  • Mobile with banks: show a searchable bank list. Tapping a bank opens ${intentScheme}/?qrPayload=${encodeURIComponent(qr_message)}. If the page is still visible 2 seconds later, show "Mobile banking App not found". Offer "Scan QR Code Instead" and "Check Status".
  • Websocket: open socket_url. Each message is JSON whose transactionStatus is itself a JSON string. When its productNumber equals prn and paymentSuccess is true, run the check below.
  • Check: every 10 seconds while visible (this one asks Fonepay, so less often), on tab return, and on "Check Status": GET /payment/fonepay_checkout/check/<transaction>. HTTP 200 {"success": true, "amount": 4330} = paid; HTTP 400 {"message":"Payment not received"} = not yet.
  • When paid: same as the QR above.
  • Outside production (api-dev), Checkout by Fonepay uses Fonepay's development credentials and charges 1.1, not the order amount. The amount in the response is still the order's real amount.

Errors when starting a payment

If a start call fails, the component shows that method's error (for example "Khalti payment is not available at the moment, please try again later."; for Khalti, Khalti's own message when there is one) and stops. (The hosted storefront shows the eSewa text for an NPX failure.) The order already exists as Inactive; the merchant sees it in the dashboard and may call the shopper. For redirect gateways the ordered lines were already removed from the cart, so they are gone even when the start fails.

Stock holds

  • When an order is created, the API holds the units of every line whose product does not have continue_selling. Holds are atomic across all shoppers.
  • COD reduces stock at once and ends the hold. Online orders keep the hold until paid, released or expired.
  • Hold time: 300 seconds by default. If a product in the order is in a live flash sale, the sale's hold time applies (2–5 minutes, default 3); the shortest wins.
  • hold_seconds is missing when nothing was held (every line continues selling) and for COD.

The QR timer

  • Timer = hold_seconds − 20 seconds, so a payment made in the last seconds still lands inside the hold. If hold_seconds is missing or ≤ 20, the timer is 280 seconds.
  • It is measured from when the QR opens, on the device clock.
  • Show the time left as MM:SS. It turns red in the last 60 seconds.
  • When it runs out, the modal shows "QR is expired" with Back to checkout and Go to home.

Releasing a hold

http
POST /order/public/release-hold
{ "store_id": "...", "order_id": "...", "transaction_id": "<transaction from the payment start>" }

Response: {"success": true, "released": true}, or "released": false when the order is already paid or no longer Inactive.

Release only when:

  1. the timer runs out (then show "QR is expired" and "Your items were released so other shoppers can buy them. If you already paid, your order is still confirmed."), or
  2. the shopper taps ✕ and confirms. While the QR is live, ✕ first asks "Cancel this payment?" with Check payment as the main button, then "Back to QR", then "Yes, cancel payment".

Never release on tab switch, page hide or page leave: shoppers pay in their banking app while the QR stays open. A payment that lands after a release is still honoured. The units were already released, so this can oversell (known). For redirect gateways the hold simply expires.

When the cart is cleared

Only the selected lines are ordered and removed; unselected lines stay in the cart.

Method Ordered lines removed
COD Right after the order is created
Esewa, Khalti, FonePay, NPX, nbank_card Right after the order is created, before the redirect. A shopper who comes back from a failed payment finds those lines gone.
QR, QR_Dynamic, FonepayCheckout Only when the payment is confirmed. Closing the QR keeps them, so the shopper can try again (which creates a new order).

The three required pages

These pages are not optional (rule 3):

  • every redirect gateway returns the shopper to <url>/payment/success or <url>/payment/failed;
  • the order confirmation email links to <url>/track/<order_id>;
  • once the site link is saved in Blanxer, the tracking link Blanxer generates (dashboard copy, get_tracking_link) and the delivered email point at <site>/track/<order_id>. (Whether SMS short links follow it is not yet verified.)

If a page is missing, the shopper sees a 404 after paying. The package ships all three: PaymentSuccess, PaymentFailed, OrderTracking. Mark all three noindex.

/payment/success

A client component, on the same origin as /checkout (it reads sessionStorage):

  1. Read and remove sessionStorage['checkout_order_id'].
  2. If sessionStorage['pixel_purchase_pending'] exists, fire Purchase on the Pixel and the CAPI relay with its event_id, email and phone, then remove it.
  3. router.replace('/track/<id>?from=checkout'), or / when there is no id.

The gateway adds no order id to the URL. sessionStorage is the only link between the checkout and this page.

/payment/failed

Show "Payment for your order failed" and "However, you might receive call/sms soon for confirmation", plus a "Shop More" link. With ?method=card, show "Transaction Failed" and the card reasons: wrong card number, expiry or CVV; wrong OTP; the bank declined online transactions; try again. Don't say the order was cancelled: it exists as Inactive.

/track/[id]

GET /order/public/<store_id>/id/<order_id> returns:

Field Notes
_id, slug, status, payment_status Status: Inactive, Draft, Pending, Processing, Dispatched, Delivered, Cancelled, Returned. Payment: Unpaid, Processing, Paid, Failed, Refunded.
customer_full_name, customer_email, customer_address_city, customer_address, customer_address_landmark, customer_address_province
customer_phone_number Masked: the first 7 digits become •••••••
ordered_products[] image_url, product_id, product_name, price, compare_at_price, variant_id, variant_name, quantity, print
product_total_price, delivery_charge, customization_total, discount (the full object: d_value, code, ...), partial_payment_amount Total = product_total_price + delivery_charge + customization_total − discount.d_value
store The store id
review {count, total_rating, received: [product ids], reviews: [ids]}
reviews[] product, images, review, rating

The response has no order_number. An unknown id is HTTP 400 {"message":"Order item not found"}; an id that isn't 24 hex characters is HTTP 400 {"error_type":"zod_validation","fields":{"order_id":"..."}}. The package shows "Order not found" for both. (The hosted storefront redirects to / on any error.)

What the page shows:

  • With ?from=checkout: "Congratulations! Your order has been placed successfully! Thank you for your purchase. You will receive a confirmation SMS or call soon."
  • The status and payment status, the lines, and the totals. If partial_payment_amount > 0, show it and "Cash on Delivery: total − partial".
  • Cancel when status is Pending or Processing and checkout.extras.allow_customer_cancellation is on. See Forms and extras → Cancel an order.
  • Review per product when status is Delivered and the product isn't in review.received. See Forms and extras → Review a product.

Meta events in the checkout

  • InitiateCheckout once, when the checkout has lines: content_ids (a variant line is <product>_<variant>, the same ids as the product feed), content_type: 'product', value (the payable total), currency: 'NPR', num_items (the number of unique content ids).
  • Purchase: num_items is the number of order lines. One event_id per order, sent in the order's meta block. COD: the server sends the CAPI Purchase and the browser fires the Pixel. Online: the success page fires both. Meta dedupes on event_id.

Details: SEO and tracking → Meta Pixel and Conversions API.

Testing without creating an order

On any store, an order with customer_phone_number 0000000000, or a customer_email containing mailinator.net, returns {"success": true} and creates nothing.

  • The body is validated first, so every other field must still be valid.
  • The response has no _id. The checkout must treat success without _id as a test response and stop: no payment start, no redirect.
  • It still counts toward the order rate limit (5 per 10 minutes per store and IP).
  • This tests the form, validation, delivery and coupon display. It can't test payments: there is no order to pay, so the gateway and QR screens are checked live (see Testing).

Not in v1

Partial payment, offers and agreed delivery are not in the package's checkout v1. They come in checkout v1.1. Neither is a print picker (see Print add-on products). Customer login, loyalty discounts and reward points are not part of BlanxerAPI.

Agents read this page at /api-docs/checkout.md · Edit on GitHub