Merch · storefront API

Build your own
ecommerce.

Merch ships with thirty-two designs, and if none of them is what you want, the front end is replaceable. The shop that ships with it is just one consumer of this HTTP API and holds no privileged access — so anything it does, your own front end can do, in React, Vue, Svelte, Flutter or a native app, while the invoices, the FIFO stock and the books stay exactly as they are.

Fifty-six endpoints, on your own domain. Your store serves its own OpenAPI document at /api/openapi.json and a browsable page at /api/docs. This page is the same surface, so you can read it before installing anything.

Getting the store online

Stand up your store, then point a front end at it.

The store ships as prebuilt container images — nothing to compile, and almost nothing to configure on the command line. One image runs the backend, its admin panel and Redis together; you point it at a database and paste your Ragenaizer key from inside the admin, not from a wall of flags. Then a front end sits in front — the bundled thirty-two-design storefront, or your own.

First, a storefront key from your Ragenaizer tenantIn Ragenaizer, open Settings → API Keys and generate a storefront key. It is scoped — it can read your catalogue, stock and prices and write orders, and nothing else. The store holds it server-side; your front end never sees it.
A server with Docker, a Postgres, and a domainAny Linux box with Docker. A Postgres it can reach — a managed one, or a docker run postgres of your own (Redis is already inside the image). And a domain, for HTTPS.
Two images do itrzc-allinone is the backend, admin panel and Redis in one — the only image you run for the store itself. rzc-frontend is the thirty-two-design storefront, portable to any backend at run time. (A leaner rzc-backend without Redis exists too, if you would rather run your own.)

Three steps — and the store itself is one command.

1 Run the store

The backend, its admin panel and Redis, from one image. Three flags — a name, a data volume, a port — and nothing about the database or your key on the line, because those are set in the admin.

docker run -d --name store-api \
  -v store-data:/data \
  -p 8080:8080 abhishekanand0586/rzc-allinone:latest

Open http://your-server:8080/admin, sign in with the demo login (admin@demo / demo), go to Integrations, and paste your Postgres connection string and your Ragenaizer storefront key. They are verified together; the moment both pass, the demo login switches off and your setup saves to the store-data volume, so it survives restarts. That is the store running — if you are building your own front end, you are done here.

2 Add our storefront optional

Want the thirty-two-design shop rather than your own front end? Run its image and tell it one thing — the address your backend answers on. That single line is the whole configuration, and the same image works against any backend:

docker run -d --name store-web \
  -e MERCH_BACKEND_URL="https://api.your-store.com" \
  -p 3000:3000 abhishekanand0586/rzc-frontend:latest

MERCH_BACKEND_URL is simply wherever the backend from step 1 is reachable — the domain you give it in step 3, or http://SERVER-IP:8080 for a quick look before that. Building your own front end instead? Point it at the API and set Settings → Storefront mode → Headless in the admin, so the theme controls get out of your way.

3 Put it on your domain for a real store

Point two DNS A records at the server — your-store.com for shoppers, api.your-store.com for the backend (its admin panel is at api.your-store.com/admin). Caddy gives each one HTTPS and renews it itself. Save this as Caddyfile…

your-store.com {
    reverse_proxy localhost:3000
}
api.your-store.com {
    reverse_proxy localhost:8080
}

…then run Caddy, the only thing that opens a port to the internet:

docker run -d --name store-proxy --network host \
  -v $PWD/Caddyfile:/etc/caddy/Caddyfile -v caddy-data:/data caddy:2

Firewall 8080 and 3000 so only Caddy is reachable from outside — the backend trusts X-Forwarded-For for rate limiting and must always sit behind the proxy.

Want even that done for you? The installer stands up the backend plus a Postgres, a Redis and HTTPS from a single command — it asks for your domain and key and wires the rest: curl -fsSL https://raw.githubusercontent.com/Abhi-hyperscripts/ragenaizer-commerce/main/install.sh | bash

Updates are a docker pull of the newer image and a restart — your database connection and keys live in the store-data volume, encrypted, so they survive it. Set RZC_MASTER_KEY once and keep a copy if you want a restore on a fresh machine to still decrypt them; otherwise a key is generated into that volume for you.

Start from working code

A whole shop front you can clone, in plain JavaScript.

Everything on this page, already built. No framework, no build step, no dependencies — separate .html, .css and .js files, so a team that knows HTML, CSS and JavaScript can read all of it and change any of it. Point one line at your shop and it runs.

✓ What it already does
  1. Catalogue with search, category, sort and in-stock filtering
  2. Product pages — gallery, variant options, stock, reviews
  3. Cart that re-prices against the store before anyone pays
  4. Coupons, gift cards and delivery quotes
  5. Checkout, order confirmation with timeline, and order tracking

Exercised against a real store: an order was placed from the browser and the total shown matched the amount charged, to the paisa.

→ Running it takes about two minutes
git clone https://github.com/Abhi-hyperscripts/merch-reference-storefront
cd merch-reference-storefront

# edit ONE line in config.js:
#   export const API_BASE = 'https://shop.mybrand.com';

python3 -m http.server 5610

It is a folder of static files, so it deploys to GitHub Pages, Netlify, Vercel or your own nginx with nothing to build. And it does not have to live on the same domain as your shop.

MIT licensed — fork it, strip it, rewrite it. The README calls out the handful of things that are genuinely easy to get wrong, including the two checkout paths below and a shipping flag whose name means the opposite of what you would assume. Read the code on GitHub →

Before the endpoint list

Seven things worth knowing first.

The base URL is your own storeEvery path below is served from the shop’s own domain — https://shop.example.com/api/…. There is no Ragenaizer-hosted API to sign up for and no extra host or port; the store you installed is the API.
Authentication is a bearer tokenSend Authorization: Bearer <token>. A shopper token comes from the shopper sign-in endpoint; the browsing endpoints need no token at all. It is a stateless JWT, so there is no session affinity and no cookie handling — a mobile app authenticates exactly as a browser does.
You can call it straight from the browserThe store answers every origin, method and header, on purpose — so a front end on a different domain to the store needs no proxy of its own. Because the credential is an Authorization header and not a cookie, nothing depends on the two sharing a site.
Every failure is the same JSON shapeA failed call answers {"error": "…"}, with a message written to be shown to a shopper as-is. 400 is a rejected request, 401 a missing or expired token, 404 an unknown id, 429 rate limiting, and 503 the store being temporarily unreachable — that last one adds "storeUnavailable": true so you can tell it apart from an ordinary refusal and retry rather than blame the shopper. Refusals a client should branch on carry a boolean flag beside error — exists, weakPassword, invalidLink, useForgotPassword, sessionEnded, phoneRequired, needsSetup and so on — and the OpenAPI document declares that Error shape on every error response, so a generated client can switch on the flag instead of parsing prose.
Everything is rate limited, browsing most generouslyPer client IP, in fixed one-minute windows: 15 for sign-in, register, Google, every password route, sign-out-everywhere, gift-card checks, guest order lookup and posting a review; 25 for checkout, payment, creating or changing a return or subscription, and cancelling an order; 90 for cart previews, shipping quotes, cart save and the signed-in account reads and writes; 120 for browsing, events and reading an order. Over the line answers 429 in the usual error shape. There is no Retry-After; the window is fixed, so retry after the next minute boundary.
Every timestamp is ISO 8601 in UTCDates come back as 2026-08-09T00:00:00Z — always UTC with the trailing Z, never the store’s local time, so convert to the shopper’s zone in your own front end. new Date(value) parses them directly. The field name is not a reliable signal: alongside createdAt, updatedAt, fulfilledAt, cancelledAt and resolvedAt there are nextRun on a subscription and at on an order event, which carry the same format without the suffix.
What is not here, and will not beAdministering the shop stays with the bundled admin panel, and the API the store uses to reach your books is internal and never published — your front end never handles a Ragenaizer key, because the store holds it server-side. This is the shopfront contract, nothing wider.
Who the caller is

Two identities, and most of the shop needs neither.

There is one kind of credential on this surface: a shopper token, sent as Authorization: Bearer <token>. Everything else is anonymous. Worth reading before you design your sign-in flow, because a shopper can browse, fill a cart and complete a purchase without ever making an account — only the things that belong to a person need a token.

1 Getting a token
  1. Read GET /api/auth/config first. It answers { google: { enabled, clientId }, password: { enabled, resetByEmail, resetOrigin } }.
  2. Email and password always work. POST /api/shopper/register with { email, password, name?, phone? } creates the account and signs it in; POST /api/shopper/login signs an existing one in. Both answer { token, shopper }. Passwords are 8 to 128 characters with no composition rules.
  3. If google.enabled is true, also offer Google Sign-In with that clientId and POST the ID token to /api/shopper/google. Same reply shape.
  4. Forgotten password: POST /api/shopper/password/forgot with the address and the absolute URL of your reset page, built on password.resetOrigin. The email link is that URL with ?token=…; your page posts the token and the new password to /api/shopper/password/reset and is signed in. Show the control whenever resetByEmail is true.
  5. Send the token as Authorization: Bearer <token> on the calls listed below. It is valid for 30 days; POST /api/shopper/sessions/revoke ends every session everywhere and hands this device a fresh one.

Three doors, one token. A wrong password, an unknown address and a Google-only account with no password all answer the same 401 on login — do not try to tell them apart. A password change or reset ends every other session: those tokens then answer 401 with sessionEnded: true, and the right response is to sign in again, not to treat the shopper as a guest.

2 What needs one

Nineteen of the fifty-six. They are exactly the calls that read or change something belonging to a person, and each is marked security: bearer in the OpenAPI document, so a generated client attaches the token for you.

  • The shopper's own records — /api/shopper/me, /orders, /addresses (all four verbs), /wishlist.
  • Their commitments — /api/subscriptions (list, create, pause/resume/cancel) and /api/returns (list, create).
  • Acts only an owner may perform — POST /api/orders/{id}/cancel and POST /api/products/{itemId}/reviews.
  • The credential itself — POST /api/shopper/password/change and POST /api/shopper/sessions/revoke.

Everything else — the catalogue, search, collections, blog, reviews (reading), shipping quotes, coupon and gift-card previews, checkout, payment and order lookup — is anonymous.

Guest checkout is a first-class path, not a fallbackPOST /api/checkout and the whole payment flow take no token. A guest finds their order afterwards with POST /api/orders/lookup (email + order reference) or by the order's own id. Sending a token where one is optional is still useful — the order is then linked to that shopper and appears in their history — but nothing refuses you for omitting it.
A closed store answers 503 with storeUnavailable on every routeA shop trades only while the merchant's Ragenaizer Accounts licence is active — Accounts is what turns an order into an invoice, a GST document and a stock movement, so a shop that cannot book what it sells is closed. When it lapses, every endpoint here answers 503 with {"error":"This store is currently unavailable. Please try again later.","storeUnavailable":true} and a Retry-After header. Handle that one shape globally rather than per call. GET /api/theme keeps answering so you can still render the shop's branding on your own “temporarily unavailable” page. The same storeUnavailable flag already appears on transient upstream failures, so one handler covers both.
Render orderStatus, not statusstatus is the PLACEMENT-TIME payment state and is never written again, so a cancelled order reads "confirmed" there forever and so does a shipped one — cancellation and shipping live in fulfillmentStatus. Every order shape now also carries orderStatus: "cancelled" | "shipped" | "failed" | "confirmed" | "processing", already resolved. It is on the order, on the order list, on guest lookup and on the checkout and verify replies. status is unchanged for existing clients.
refundState says whether the money actually came backCancelling raises a credit note in the merchant's books, and that can be refused — on a tax-exclusive store it always is. refundState is "none" (nothing to refund), "credited" (the invoice was reversed in full), "partial" (only part of it was — a return on a cart mixing physical and digital lines credits the physical ones only) or "pending" (cancelled, reversal not completed). It is on the order, on the order list and on guest lookup — not on the checkout and verify replies. Those two carry orderStatus but not this field, so read the order itself before telling a shopper anything about their money. Render "pending" as “refund being processed”, never as refunded — an order can read cancelled while the merchant still holds a live receivable.
Some baskets can never be bought: 400 with mixedCartA cart that mixes tax-inclusive and tax-exclusive products cannot be put on one invoice, so both checkout and payment/create-order refuse it with 400 and {"mixedCart":true}. It is PERMANENT and specific to that basket — do not retry it, and do not treat it as storeUnavailable, which means the opposite. Ask the shopper to order those items separately.
invoicePdfUrl is null today — render invoiceId insteadThe tax invoice is issued and stored by Ragenaizer Accounts, and this storefront has no route that can serve it, so the field is reserved and arrives null on every order. Do not build a download button from it. Show invoiceId: that is the number the confirmation email carries and the one a shopper quotes to the merchant. We will not generate a PDF here to fill the gap — a second document competing with the real GST invoice is worse than no button.
Send each scalar filter at most onceRepeated query values are COMBINED before binding, and a comma is a group separator — so ?minPrice=1&minPrice=0&minPrice=0 binds "1,0,0" and filters at 100, hiding every cheaper product behind an ordinary 200. /api/catalog and /api/catalog/facets therefore refuse a repeated search, category, minPrice, maxPrice, inStock, sort, locale, page or pageSize with 400. search, category, minPrice, maxPrice, sort and locale name the offending parameter in the message; page, pageSize and inStock are refused earlier, while the request is being read, so those carry the generic error envelope instead. brand, color, size, attr and ids are lists and repeat freely. Replace a filter value rather than appending it.
A cart line's qty must be a whole number of at least 1Every endpoint that takes lines — checkout, payment/create-order, shipping/quote, coupon/validate, discounts/auto, discounts/bxgy, subscriptions — refuses a line whose qty is 0, negative, or above 100000. checkout, payment/create-order, subscriptions and shipping/quote answer 400 with {"error":"One of the items has an invalid quantity."}; the three preview routes (coupon/validate, discounts/auto, discounts/bxgy) answer 200 with the preview simply not applying, because a preview never fails a request. The delivery quote is NOT a preview in this sense: it is the figure checkout charges, so it refuses whatever checkout refuses. Remove a line rather than sending qty: 0. The one exception is POST /api/cart/save, which stores a basket for an abandoned-cart reminder and clamps rather than refusing, because a malformed line should not cost the shopper the reminder. The number you send is the number that is priced, charged and invoiced — nothing is silently rounded up to one unit or down to the ceiling.
Sign-in itself is anonymous, obviously — and the document says soregister, login, google, password/forgot and password/reset are where a token comes from, so none of them requires one. They are the only /api/shopper/* routes without a padlock, and the spec marks them that way: a generated client calls them without credentials.
Categories, search and filters

The server filters five things. Everything else is yours.

Browsing is one endpoint — GET /api/catalog — and it takes query parameters. Knowing exactly where the line falls saves you hunting for a brand-filter endpoint that does not exist: the server narrows the catalogue by category, price, stock, text and order; every other facet you have seen on a shop — brand, colour, size, rating, on-offer — is derived on the client from the products it returns. That is how the bundled storefront does it too, so it is a supported pattern, not a workaround.

A What the server does
ParameterTypeNotes
searchstringMatches name, description and category, so a category tile can link to ?search=Coffee and still find things.
categorystringOne category name, as returned by GET /api/categories.
minPrice / maxPricedecimalInclusive bounds, in the store's base currency.
inStockbooleantrue drops anything not currently sellable.
sortstringprice_asc, price_desc or name. Anything else — including omitting it — keeps the natural order.
localestringReturns translated name and description where the merchant has supplied them.
idsstringComma-separated ids. Use this to refresh a cart — see the note below; it behaves differently from the browse filters on purpose.

All parameters are optional and combine. GET /api/catalog with none of them is the whole sellable catalogue.

B What you do on the client

These are not query parameters and there is no endpoint for them — but every product already carries the data, so you build them from the list you just fetched:

  • Brand — group by brandSlug and label with brandName. A live grocery tenant yields 639 distinct brands this way.
  • Size, colour, and anything else — attributes is a free-form list of {key, value}. Group by key to discover which facets a catalogue even has, then by value for the options. Deliberately not fixed columns: a pharmacy's strength matters as much as an apparel store's size, and both arrive the same way.
  • Nested categories and breadcrumbs — categoryPath is root→leaf including the leaf; categorySlug is the leaf on its own. The flat category string cannot express a hierarchy, which is why both exist.
  • On offer — compare price against mrp.
  • Facet counts — count within your current result set. The server has already applied its filters, so counting client-side stays consistent with what the shopper sees.

Every one of these is nullable. A merchant who has not filled in their item master sends null, and the honest rendering of that is no facet at all — never a blank or placeholder option.

Facet counts need the full set; the product grid does not. Count facets from an unpaged call, then fetch the grid with page and pageSize — the server applies your filters before it pages, so X-Total-Count is the number of matches, not the number on the page. On a large catalogue that keeps the first paint small without making the counts wrong.

Rating is the exception, and it is not cheap yet. Ratings live on GET /api/products/{itemId}/reviews, one product at a time — there is no bulk source, so a rating filter over a listing means one call per product. Use it on the product page, and tell us if you need it as a facet; a batch endpoint is the fix and we would rather add it than have you fan out.

The one trap: ids ignores the browse filters, deliberatelyWhen you pass ids, the browse facets (search, category, price, stock) are not applied, and variants are returned individually rather than folded to one representative per group. That is what makes it safe for refreshing a cart: a line the shopper already chose must not vanish because a filter happens to be in the URL, and a specific variant must come back as itself. A product that has genuinely been removed, hidden or unpriced is still absent — which is exactly the signal you want when reconciling a stale cart.
Categories and collections are different thingsGET /api/categories returns the category names present in the catalogue — a flat list of strings, derived from the products themselves, for building a nav or filter rail. GET /api/collections returns merchandising groups the merchant curates by hand or by rule; a smart collection is resolved at request time, so it stays current without anyone re-saving it. Use categories for navigation, collections for campaigns.
The one flow you cannot guess

There are two ways to place an order. Pick one.

Both take the same request body, so nothing about their shapes tells you which to call. Choosing wrong is the one mistake here that costs money, so it is worth thirty seconds: an order is created by exactly one call, and which call it is depends on whether you are collecting payment up front.

A Paying later — cash on delivery, or an invoice to settle
  1. POST /api/checkout with the cart, the customer, and any coupon or gift card.
  2. That is the whole flow. The order exists, and the response carries its id, order reference and invoice PDF.

Do not touch the payment endpoints on this path.

B Paying now — card, UPI, net banking
  1. POST /api/payment/create-order with that same body. Nothing is ordered yet; you get back what the payment widget needs.
  2. Open the gateway with the keyId, amount and razorpayOrderId it returned. Note the amount is in paise, not rupees. The gateway window is Razorpay’s own script, not something this API serves — load https://checkout.razorpay.com/v1/checkout.js, then call new Razorpay(options).open(). Its options and callback are documented by Razorpay; the reference storefront lazy-loads it in js/checkout.js if you want a worked example.
  3. On success the gateway hands you three values. POST /api/payment/verify with them. This is the call that creates the order, and it answers with the same shape /api/checkout would have.

Never call /api/checkout as well. It would place a second, unpaid order for the same basket.

Two things that will save you writing code you do not need. Verifying twice is safe — a repeated verify returns the order already created, never a second one — so a retry after a dropped connection needs no guard of your own. And if the shopper pays and closes the tab before your verify fires, the store still receives the gateway’s own notification and creates the order anyway, so that basket is not lost.

When it does not work

Eight status codes, and one of them is not a failure.

Every one of these is documented per-operation in the OpenAPI document, so a generated client can branch on them. The distinction worth internalising: a 4xx is an answer about your request; a 5xx or a timeout is the absence of an answer. Rendering the second as though it were the first is the single most common way a storefront lies to a shopper.

StatusMeansWhat your front end should do
400The request is malformed or the basket is not sellable — an unpriced item, a bad pincode, an empty cart.Show the message. It is written for the shopper, not for you.
401No token, one that has expired, or — with sessionEnded: true — one ended by a password change, a reset or sign-out-everywhere.Send them back through sign-in and retry. Never treat it as "you have nothing".
403The token is valid but this account may not do that — today only password/change on an account whose address now belongs to someone else.Show the message; there is nothing to retry.
404Genuinely absent — no such product, no such order.Render an empty state. This is a definite answer.
409The state moved under you: a return already in progress, a gift card already spent, not enough stock left for the quantity asked (the body carries availableQty).Re-read and show the current state. Do not blindly retry.
429Rate limited. Login, checkout and payment are throttled per client.Back off and retry. This is not a failure — treating it as one turns a slow moment into a lost sale.
502The store reached its books and the books refused or did not answer — an order that could not be placed, a payment captured but not yet confirmed (payment/verify says whether that is permanent).Read the flag. permanent: false means "check Track order in a minute"; true means the shopper must contact the store.
503 + storeUnavailableThe shop could not reach its own books — a lapsed licence, an upstream blip. Nothing is wrong with your request.Say the shop is temporarily unavailable and offer a retry. Do not show "no products" or "no orders".

Every 503 carries a Retry-After header in seconds — 60 for a transient failure, 300 when the store is closed on a licence lapse — so honour that rather than backing off on your own schedule. A 429 carries no Retry-After; its limit window is a fixed minute, so retry after the next minute boundary. A 503 with needsSetup: true is a store that has not finished first-run setup — treat it like storeUnavailable.

The failure that looks like dataIf a catalogue read times out and you render "this shop has nothing to sell", you have made a claim about the merchant's business on the strength of a network blip. The same mistake on /api/shopper/orders tells a customer their orders are gone; on /api/returns it tells them they cannot file one. Keep "we could not load this" and "there is none" as different states in your UI — it is one boolean, and it is the difference between a retry and a support ticket.
Writes are idempotent where it mattersOrder placement and subscription creation are de-duplicated upstream, so a retry after a lost response does not create a second order or a second billing schedule. That is what makes a 429 or a dropped connection safe to retry on the paths where money is involved. Where an order is PLACED — checkout, and the fully gift-covered ₹0 branch of payment/create-order — your own idempotencyKey is matched together with the basket, with no time limit: a resubmit of the same cart collapses onto the one order for as long as that order exists, and changing the cart under the same key gives you a new order rather than the old one, so a key held in localStorage across an abandoned attempt cannot hand back lines the shopper has since removed. Omit the key and the match is on the basket alone — same buyer, same lines, same coupon, same gift card — for at least 30 minutes and at most 60, because that fallback is bucketed on the wall clock and both the current and previous bucket are checked; past that window the same basket is a new order, which is what lets a shopper re-order their usual cart. Two exceptions worth knowing. The ordinary prepaid path of payment/create-order does not read the key at all — it opens a gateway order under a fresh reference every time. payment/verify then de-duplicates per gateway order, not per basket, so two create-order calls are two independent payments: if both are paid, both are invoiced. Do not retry this call blindly — a second call also takes a second gift-card hold that nothing releases, so the shopper can be refused by their own first attempt. And subscriptions keys on the buyer and the plan with no time component, so while a schedule is live a repeat POST answers with it; once you cancel, the same cart can be subscribed again.
Where your code runs

Same origin by default, another origin if you want one.

The store serves your front end and the API from one domain, so the ordinary case involves no CORS at all: you call /api/… as a relative path and the browser never asks a cross-origin question. That is the shape the bundled storefront uses, and the one we would recommend — it also means the merchant's Ragenaizer key stays server-side and never touches a browser.

A Replacing the storefront (recommended)

Run your own front end on the store's domain and call /api/* relatively. Nothing to configure, no preflight, no origin list to maintain. The shipped admin panel keeps working alongside it.

B Hosting your front end elsewhere

Point it at https://the-store-domain and call the same paths absolutely. CORS is open: the store reflects your origin, and preflight requests for Content-Type: application/json and Authorization are answered correctly, so writes and authenticated reads work from any origin.

If you are hitting an older store image and a POST fails while a GET succeeds, that is the symptom of a preflight that was not being forwarded. It is fixed — update the storefront image.

Webhooks are not part of your surface/api/webhooks/* exists for Ragenaizer and Razorpay to call the store, authenticated by signature. It is deliberately absent from the OpenAPI document, is never called by a browser, and needs nothing from your front end. If you are looking for "how do I know an order was paid", the answer is to read the order, not to receive a webhook.
The surface

Fifty-six endpoints, all documented.

Grouped the way a front end consumes them. Open any one for a runnable curl, what it does, its parameters, and the JSON going in and out. The shapes are illustrative — field names and types are real, the values are placeholders.

Storefront 22 endpoints

GET/api/catalog

The product list, returned as a bare array — there is no envelope, and there is none when you page either. Omit page and pageSize and one call gives you the whole visible catalogue, exactly as it always has. Add them and you get that page, still as a plain array, with the counts in the X-Total-Count and X-Total-Pages response headers — both CORS-exposed, so a front end on another origin can read them. pageSize defaults to 24 and is capped at 200; a page below 1 is treated as the first, and a page past the end is an empty array rather than the last page, so an infinite scroll stops instead of repeating. Paging is applied after variant folding, so pages never overlap or skip a card. It does not apply to the ids form below, which resolves cart lines and would silently drop items if it were sliced. Optional query: search, category, minPrice, maxPrice, inStock, locale, and sort, which accepts exactly price_asc, price_desc or name — any other value is ignored rather than rejected, so a typo silently returns catalogue order. The filter-rail parameters: brand (comma-separated slugs, ids or names), color and size (comma-separated values), and attr (repeatable key:value, any attribute the merchant defined — attr=material:Cotton&attr=fit:Slim). Several values of one key mean either; different keys all apply. Limits: 12 attribute keys, 40 values per key, keys up to 60 and values up to 200 characters; anything past that, or an attr without key:value, answers 400 rather than being ignored. Pair it with /api/catalog/facets for the counts. Products that share a variant group fold to one card here; use the variants endpoint to expand it. Prices and stock are live from the merchant's books, and hidden products are already excluded.

Try it
curl https://your-shop.example.com/api/catalog

# one page, with the totals in the headers
curl -D - -o /dev/null \
     "https://your-shop.example.com/api/catalog?page=2&pageSize=24"
# → X-Total-Count: 7829
# → X-Total-Pages: 327
Parameters
  • search query string
  • category query string
  • minPrice query number
  • maxPrice query number
  • inStock query boolean
  • sort query string
  • locale query string
  • page query integer
  • pageSize query integer
  • brand query string
  • color query string
  • size query string
  • attr query string[]
Response
[
  {
    "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
    "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
    "description": "Pure, unrefined oil extracted from premium organic peanuts using traditional cold-pressing methods.",
    "imageUrls": [
      "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"
    ],
    "category": "Edible Oils",
    "unit": "pcs",
    "price": 468.0,
    "mrp": 520.0,
    "taxNote": "incl. GST",
    "availability": "in_stock",
    "qtyHint": 12,
    "featured": true,
    "variantGroupId": null,
    "variantCount": 0,
    "isDigital": false,
    "brandId": "41b34130-6ecb-4a0b-ac7d-96b0ffb41913",
    "brandName": "24 Mantra",
    "brandSlug": "24-mantra",
    "categoryId": "29d835f4-92f8-4308-b04d-d2c4b81616c1",
    "categorySlug": "edible-oils",
    "categoryPath": [
      {
        "id": "60834fd0-e7f6-456c-a4a9-88bec7e4b472",
        "name": "Masalas, Oils & Dry Fruits",
        "slug": "masalas-oils-dry-fruits"
      }
    ],
    "attributes": [
      {
        "key": "size",
        "value": "1 ltr"
      }
    ]
  }
]
GET/api/catalog/facets

The counts a filter rail is built from, for the same query /api/catalog takes (search, category, minPrice, maxPrice, inStock, brand, color, size, attr). Each dimension is counted with every constraint applied except its own, so a shopper who picked Red still sees how many Blue there are, and price is the range of the result without the price band, so a slider can always be widened again. Brands and categories are counted per product card, attribute values per variant. Every list holds at most 100 entries, most common first. What a facet shows is what the filter matches: send categories[].name back verbatim as category=, and a brand's slug as brand=. price is null only when nothing matches even with the band removed. Over-cap or malformed filters answer 400.

Try it
curl "https://your-shop.example.com/api/catalog/facets?category=Edible%20Oils&color=Red"
Parameters
  • search query string
  • category query string
  • minPrice query number
  • maxPrice query number
  • inStock query boolean
  • brand query string
  • color query string
  • size query string
  • attr query string[]
  • locale query string
Response
{
  "total": 37,
  "brands": [
    { "id": "41b34130-6ecb-4a0b-ac7d-96b0ffb41913", "name": "24 Mantra", "slug": "24-mantra", "count": 12 }
  ],
  "categories": [
    { "name": "Edible Oils", "count": 25 }
  ],
  "attributes": [
    { "key": "colour", "values": [ { "value": "Red", "count": 9 }, { "value": "Blue", "count": 4 } ] },
    { "key": "size", "values": [ { "value": "1 ltr", "count": 7 } ] }
  ],
  "price": { "min": 199.0, "max": 1299.0 }
}
GET/api/catalog/{id}

One product in full, including its description, images and live price and availability.

Try it
curl https://your-shop.example.com/api/catalog/ITM-001
Parameters
  • id path string required
  • locale query string
Response
{
  "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
  "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
  "description": "Pure, unrefined oil extracted from premium organic peanuts using traditional cold-pressing methods.",
  "imageUrls": [
    "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"
  ],
  "category": "Edible Oils",
  "unit": "pcs",
  "price": 468.0,
  "mrp": 520.0,
  "taxNote": "incl. GST",
  "availability": "in_stock",
  "qtyHint": 12,
  "featured": true,
  "variantGroupId": "8f2c1a90-5d33-4e77-9a10-6b0d2f4c7e51",
  "variantCount": 3,
  "isDigital": false,
  "brandId": "41b34130-6ecb-4a0b-ac7d-96b0ffb41913",
  "brandName": "24 Mantra",
  "brandSlug": "24-mantra",
  "categoryId": "29d835f4-92f8-4308-b04d-d2c4b81616c1",
  "categorySlug": "edible-oils",
  "categoryPath": [
    {
      "id": "60834fd0-e7f6-456c-a4a9-88bec7e4b472",
      "name": "Masalas, Oils & Dry Fruits",
      "slug": "masalas-oils-dry-fruits"
    }
  ],
  "attributes": [
    {
      "key": "size",
      "value": "1 ltr"
    }
  ]
}
GET/api/categories

The category facets present in the catalogue, for building navigation or a filter rail.

Try it
curl https://your-shop.example.com/api/categories
Response
[
  "All Purpose Cleaners"
]
GET/api/collections

Merchandising collections — manual picks and rule-based ones — as configured by the merchant.

Try it
curl https://your-shop.example.com/api/collections
Response
[
  {
    "id": "c8a472e1-5b09-4f63-a71d-0e26b3f95d47",
    "handle": "cold-pressed-oils",
    "title": "Cold Pressed Oils",
    "description": "Pure, unrefined oil extracted from premium organic peanuts using traditional cold-pressing methods.",
    "imageUrl": "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"
  }
]
GET/api/collections/{handle}

One collection and the products currently in it, resolved at request time so a smart collection stays current.

Try it
curl https://your-shop.example.com/api/collections/coffee-essentials
Parameters
  • handle path string required
  • locale query string
Response
{
  "collection": {
    "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
    "handle": "cold-pressed-oils",
    "title": "Cold Pressed Oils",
    "description": "Pure, unrefined oil extracted from premium organic peanuts using traditional cold-pressing methods.",
    "imageUrl": "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"
  },
  "products": [
    {
      "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "description": "Pure, unrefined oil extracted from premium organic peanuts using traditional cold-pressing methods.",
      "imageUrls": [
        "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"
      ],
      "category": "Edible Oils",
      "unit": "pcs",
      "price": 468.0,
      "mrp": 520.0,
      "taxNote": "incl. GST",
      "availability": "in_stock",
      "qtyHint": 12,
      "featured": true,
      "variantGroupId": "8f2c1a90-5d33-4e77-9a10-6b0d2f4c7e51",
      "variantCount": 3,
      "isDigital": false,
      "brandId": "41b34130-6ecb-4a0b-ac7d-96b0ffb41913",
      "brandName": "24 Mantra",
      "brandSlug": "24-mantra",
      "categoryId": "29d835f4-92f8-4308-b04d-d2c4b81616c1",
      "categorySlug": "edible-oils",
      "categoryPath": [
        {
          "id": "60834fd0-e7f6-456c-a4a9-88bec7e4b472",
          "name": "Masalas, Oils & Dry Fruits",
          "slug": "masalas-oils-dry-fruits"
        }
      ],
      "attributes": [
        {
          "key": "size",
          "value": "1 ltr"
        }
      ]
    }
  ]
}
POST/api/coupon/validate

Preview a coupon code against the current basket without applying it. Send email if you know it — coupon limits are commonly per-customer, and without the buyer this preview cannot answer that half of the question, so it may report a code as valid that checkout then refuses. The subtotal is computed server-side from live prices, so a client cannot claim a total.

Try it
curl -X POST https://your-shop.example.com/api/coupon/validate \
  -H "Content-Type: application/json" \
  -d '{"code":"SAVE10","lines":[{"itemId":"ITM-001","qty":2}]}'
Request body
{
  "code": "SAVE10",
  "email": "shopper@example.com",
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "qty": 2
    }
  ]
}
Response
{
  "valid": true,
  "reason": "Applied.",
  "code": "SAVE10",
  "discountAmount": 93.6,
  "newSubtotal": 842.4
}
GET/api/currencies

The display currencies the merchant has enabled, with their rates, for a currency switcher.

Try it
curl https://your-shop.example.com/api/currencies
Response
{
  "base": {
    "code": "SAVE10",
    "symbol": "₹"
  },
  "currencies": [
    {
      "code": "SAVE10",
      "symbol": "₹",
      "rate": 1.0
    }
  ]
}
POST/api/discounts/auto

Preview the best automatic (no-code) discount for the current basket, so the saving can be shown before checkout.

Try it
curl -X POST https://your-shop.example.com/api/discounts/auto \
  -H "Content-Type: application/json" \
  -d '{"lines":[{"itemId":"ITM-001","qty":2}]}'
Request body
{
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "qty": 2
    }
  ]
}
Response
{
  "applies": true,
  "title": "Cold Pressed Oils",
  "discountAmount": 93.6,
  "newSubtotal": 842.4,
  "freeShipping": false
}
POST/api/discounts/bxgy

Preview any buy-X-get-Y saving that applies to the current basket.

Try it
curl -X POST https://your-shop.example.com/api/discounts/bxgy \
  -H "Content-Type: application/json" \
  -d '{"lines":[{"itemId":"ITM-001","qty":2}]}'
Request body
{
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "qty": 2
    }
  ]
}
Response
{
  "applies": true,
  "title": "Cold Pressed Oils",
  "discountAmount": 93.6
}
POST/api/events

Report a small batch of funnel events for the merchant's analytics. type must be one of page_view, product_view, add_to_cart or checkout_start — any other value is DROPPED and the response still says ok, so check recorded against the number of events you sent rather than the status code. Unknown event types are ignored rather than rejected.

Try it
curl -X POST https://your-shop.example.com/api/events \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"sess_7f3a91c2b8d4","events":[{"type":"product_view","itemId":"ITM-001"}]}'
Request body
{
  "sessionId": "sess_7f3a91c2b8d4",
  "events": [
    {
      "type": "product_view",
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4"
    }
  ]
}
Response
{
  "ok": true,
  "recorded": 1
}
POST/api/giftcard/check

Check a gift card's balance before offering it at checkout. This does not redeem it.

Try it
curl -X POST https://your-shop.example.com/api/giftcard/check \
  -H "Content-Type: application/json" \
  -d '{"code":"GIFT500"}'
Request body
{
  "code": "GIFT500"
}
Response
{
  "valid": true,
  "reason": "Applied.",
  "code": "SAVE10",
  "balance": 500.0
}
GET/api/media/ph/{seed}.svg

A deterministic placeholder image, generated locally. Useful while building against a demo store with no product photography.

Try it
curl https://your-shop.example.com/api/media/ph/ITM-001-a.svg
Parameters
  • seed path string required

Answers with an image (image/svg+xml), not JSON.

GET/api/products/{id}/variants

The sibling options of a product (size, colour and so on) for a variant selector on the product page.

Try it
curl https://your-shop.example.com/api/products/ITM-001/variants
Parameters
  • id path string required
  • locale query string
Response
{
  "groupId": "8f2c1a90-5d33-4e77-9a10-6b0d2f4c7e51",
  "title": "Size",
  "options": [
    {
      "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "label": "1 ltr",
      "price": 468.0,
      "availability": "in_stock",
      "current": true
    }
  ]
}
GET/api/products/{itemId}/reviews

Published reviews for a product, with the rating summary used for stars on a listing.

Try it
curl https://your-shop.example.com/api/products/ITM-001/reviews
Parameters
  • itemId path string required
Response
{
  "summary": {
    "average": 4.6,
    "count": 38
  },
  "reviews": [
    {
      "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "reviewer": "Priya S.",
      "rating": 5,
      "title": "Exactly as described",
      "body": "Genuinely cold pressed — the aroma is unmistakable. Repeat purchase.",
      "verified": true,
      "createdAt": "2026-08-21T09:14:03Z"
    }
  ]
}
POST/api/products/{itemId}/reviewstoken

Write or update the signed-in shopper's review. Requires a shopper token. Whether the review counts as verified is decided server-side from that shopper's order history — a client cannot claim it.

Try it
curl -X POST https://your-shop.example.com/api/products/ITM-001/reviews \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"rating":5,"title":"string","body":"string"}'

Requires Authorization: Bearer <shopper token>.

Parameters
  • itemId path string required
Request body
{
  "rating": 5,
  "title": "Exactly as described",
  "body": "Genuinely cold pressed — the aroma is unmistakable. Repeat purchase."
}
Response
{
  "ok": true,
  "verified": true
}
GET/api/recently-viewed

The viewer's recently viewed products, most recent first, resolved against the live catalogue so retired items drop out. A guest needs sessionId (the same id your analytics events use) — without one the call answers 400. With a shopper token the account's history is merged in, so the list follows a sign-in across devices. limit defaults to 12, maximum 24.

Try it
curl "https://your-shop.example.com/api/recently-viewed?sessionId=s_8f3a2c&limit=8"
Parameters
  • sessionId query string
  • limit query integer
  • locale query string
Response
{
  "products": [ {
      "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "imageUrls": ["https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"],
      "category": "Edible Oils",
      "price": 468.0,
      "mrp": 520.0,
      "availability": "in_stock",
      "brandName": "24 Mantra",
      "brandSlug": "24-mantra",
      "isDigital": false
    } ]
}
POST/api/recently-viewed

Record that the viewer opened a product. Send itemId and the same sessionId the analytics events use; add the shopper token when signed in and the view is recorded against the account too, so guests and signed-in shoppers both get a list. Call it from the product page; the home page's recentlyViewed section reads it back.

Try it
curl -X POST https://your-shop.example.com/api/recently-viewed \
  -H "Content-Type: application/json" \
  -d '{"itemId":"16a8357b-4fa1-40ff-a70c-43094daee1d4","sessionId":"s_8f3a2c"}'
Request body
{
  "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
  "sessionId": "s_8f3a2c"
}
Response
{
  "ok": true
}
POST/api/shipping/quote

The delivery cost for a destination pincode and basket. This is the FINAL figure checkout will charge, resolved the same way the order is priced, so the number shown is the number billed. READ options, NOT available. Despite the name, available reports only whether a LIVE COURIER RATE was found — a store on flat-rate shipping, or one that has not connected a courier account, answers available: false on every quote while shipping perfectly happily. Wiring that flag to “we cannot deliver to your pincode” tells most shoppers of most stores that the shop does not serve them. options is always populated and is the real answer; shipping is the cheapest option’s amount.

Try it
curl -X POST https://your-shop.example.com/api/shipping/quote \
  -H "Content-Type: application/json" \
  -d '{"pincode":"110001","lines":[{"itemId":"ITM-001","qty":2}],"paymentMethod":"cod"}'
Request body
{
  "pincode": "110001",
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "qty": 2
    }
  ],
  "paymentMethod": "cod"
}
Response
{
  "available": true,
  "shipping": 49.0,
  "courier": "Delhivery",
  "etaDays": 3,
  "options": [
    {
      "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "amount": 49.0,
      "etaMin": 2,
      "etaMax": 4,
      "live": true
    }
  ]
}
GET/api/theme

The shop's branding and layout — colours, fonts, logo, banners, legal pages and the chosen design preset. Fetch this first; it is what makes the storefront look like this particular business.

Try it
curl https://your-shop.example.com/api/theme
Response
{
  "brandName": "Your Store",
  "tagline": "Powered by Ragenaizer",
  "logoUrl": "https://cdn.yourshop.com/brand/logo.svg",
  "faviconUrl": "https://cdn.yourshop.com/brand/favicon.png",
  "layout": "editorial-modern",
  "colors": {
    "primary": "#1F2937",
    "primaryHover": "#111827",
    "accent": "#F97316",
    "bg": "#FCFCFB",
    "surface": "#FFFFFF",
    "text": "#141821",
    "muted": "#6B7280",
    "border": "#EAEBEE"
  },
  "radius": "10px",
  "fontHeading": "Space Grotesk",
  "fontBody": "Inter",
  "customCss": "",
  "hero": {
    "headline": "Everything your shop makes, now online.",
    "subtext": "Browse the full catalog. Every order books straight into the accounts.",
    "ctaText": "Shop the catalog",
    "imageUrl": ""
  },
  "banners": [],
  "featuredItemIds": [],
  "hiddenItemIds": [],
  "navLinks": [
    {
      "label": "Shop",
      "href": "/shop",
      "labels": null
    }
  ],
  "footerLinks": [],
  "currency": "INR",
  "currencySymbol": "₹",
  "footer": {
    "about": "",
    "phone": "9999999999",
    "email": "shopper@example.com",
    "address": ""
  },
  "shipping": {
    "flatRate": 0,
    "freeAbove": 0,
    "useLiveRates": true,
    "zones": []
  },
  "payment": {
    "codEnabled": true,
    "onlineEnabled": false
  },
  "pages": {
    "privacy": "## Privacy Policy\nWe collect the information you provide when you crea …",
    "terms": "## Terms of Service\nBy placing an order with us you agree to these ter …",
    "refund": "## Returns & Refunds\nIf something isn't right, contact us within 7 day …",
    "shipping": "## Shipping Policy\nWe ship across India. Orders are typically dispatch …",
    "about": "## About Us\nWe're a small team who care about the things we make and s …"
  },
  "customPages": [],
  "priceListId": null,
  "storefrontMode": "auto",
  "homeSections": []
}
GET/api/homepage

The home page the merchant composed in the admin, already resolved: an ordered list of the sections they switched on, each carrying the data it renders, so a front end walks the list and draws each section by its type instead of composing a home page in code. A banner section can sit anywhere in the order, and more than once, so "banner at the top, the middle and the bottom" is three banner sections. Types and the arm each carries: banner → banners (live ones only, with an optional imageMobileUrl for narrow screens); browseCategories and categoryCarousel → categories; brandsCarousel → brands; featuredProducts, newArrivals (newest to this store), bestSellers (units sold in the last 30 days) and homepageProducts (the merchant's picks, optionally narrowed to categories) → products; categoryProducts → groups, one { slug, name, products } per category; recentlyViewed carries nothing — fetch /api/recently-viewed for the viewer at hand. Every other arm is null. title and limit are the merchant's. Bounded: 12 banners per slot, 12 category rows per section, 600 products on the page. Cached about a minute after an admin change.

Try it
curl "https://your-shop.example.com/api/homepage?locale=hi"
Parameters
  • locale query string
Response
{
  "sections": [
    {
      "id": "top-banner",
      "type": "banner",
      "title": "Big deals this week",
      "banners": [
        {
          "id": "cf68d1e4-0af0-47b1-915a-9edccf149b3a",
          "title": "Monsoon sale",
          "imageUrl": "https://cdn.yourshop.com/banners/monsoon-1600x600.jpg",
          "imageMobileUrl": "https://cdn.yourshop.com/banners/monsoon-800x600.jpg",
          "link": "/shop",
          "alt": "Monsoon sale banner"
        }
      ],
      "categories": null, "brands": null, "products": null, "groups": null
    },
    {
      "id": "browse",
      "type": "browseCategories",
      "title": "Browse Categories",
      "categories": [
        { "slug": "edible-oils", "name": "Edible Oils", "count": 25, "imageUrl": "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png" }
      ]
    },
    {
      "id": "brands",
      "type": "brandsCarousel",
      "title": "Shop by Brand",
      "brands": [
        { "slug": "24-mantra", "name": "24 Mantra", "count": 41, "logoUrl": "https://cdn.yourshop.com/brands/24-mantra.png" }
      ]
    },
    {
      "id": "best",
      "type": "bestSellers",
      "title": "Best sellers",
      "limit": 8,
      "products": [ {
      "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "imageUrls": ["https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"],
      "category": "Edible Oils",
      "price": 468.0,
      "mrp": 520.0,
      "availability": "in_stock",
      "brandName": "24 Mantra",
      "brandSlug": "24-mantra",
      "isDigital": false
    } ]
    },
    {
      "id": "cat-products",
      "type": "categoryProducts",
      "title": "Shop by aisle",
      "groups": [
        { "slug": "tea", "name": "Tea", "products": [ {
      "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "imageUrls": ["https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"],
      "category": "Edible Oils",
      "price": 468.0,
      "mrp": 520.0,
      "availability": "in_stock",
      "brandName": "24 Mantra",
      "brandSlug": "24-mantra",
      "isDigital": false
    } ] }
      ]
    },
    { "id": "recent", "type": "recentlyViewed", "title": "Recently viewed", "limit": 8 }
  ]
}
POST/api/storefront/register

A themed storefront announces its identity and theme catalogue here, on boot and periodically. It lets the admin panel render its theme picker from the connected storefront's own themes, and lets the shop tell an official storefront apart from a custom headless front end. The bundled storefront image calls this for you — a custom front end does not need it, and ignoring it costs you nothing but the admin theme picker. storefront and version are required (400 without them); themes with no usable key or name are dropped, duplicates keep the first, and at most 200 are kept. A registration lasts 15 minutes, so a real storefront heartbeats every 5.

Try it
curl -X POST https://your-shop.example.com/api/storefront/register \
  -H "Content-Type: application/json" \
  -d '{"storefront":"my-frontend","version":"1.0.0","themes":[]}'
Request body
{
  "storefront": "acme-storefront",
  "version": "1.4.0",
  "themes": [
    {
      "key": "editorial-modern",
      "name": "Priya Sharma",
      "vertical": "grocery",
      "emoji": "🛒",
      "blurb": "Editorial layout with a large hero and category rail.",
      "archetype": "editorial",
      "colors": {
        "primary": "#1F2937",
        "primaryHover": "#111827",
        "accent": "#F97316",
        "bg": "#FCFCFB",
        "surface": "#FFFFFF",
        "text": "#141821",
        "muted": "#6B7280",
        "border": "#EAEBEE"
      },
      "radius": "12px",
      "fontHeading": "Bricolage Grotesque",
      "fontBody": "Inter",
      "dark": false
    }
  ]
}
Response
{
  "ok": true
}

Cart and checkout 5 endpoints

POST/api/cart/save

Persist the in-progress basket against an email address. This is what makes abandoned-cart recovery possible; guests are included deliberately.

Try it
curl -X POST https://your-shop.example.com/api/cart/save \
  -H "Content-Type: application/json" \
  -d '{"email":"shopper@example.com","lines":[{"itemId":"ITM-001","name":"string","qty":2,"price":0.0}]}'
Request body
{
  "email": "shopper@example.com",
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "Priya Sharma",
      "qty": 2,
      "price": 468.0
    }
  ]
}
Response
{
  "ok": true
}
POST/api/checkout

Place a Cash-on-Delivery order. Works for a guest with inline customer details or for a signed-in shopper. Online payment uses the payment endpoints instead, because there the order must not exist until the money is verified. Answers 409 when a line asks for more units than are in stock, with the same body the online path returns: {"error":"Only 3 of one of your items is left. …","availableQty":3} — show availableQty and let the shopper adjust. Stock is reserved for the moment of placement and released straight after, so two shoppers cannot both buy the last unit.

Try it
curl -X POST https://your-shop.example.com/api/checkout \
  -H "Content-Type: application/json" \
  -d '{"lines":[{"itemId":"ITM-001","qty":2}],"customer":{"name":"string","email":"shopper@example.com","phone":"9999999999","address":"string","city":"New Delhi","state":"DL","pincode":"110001"},"paymentMethod":"cod","idempotencyKey":"string","couponCode":"SAVE10","giftCardCode":"GIFT500","shippingRateId":"string"}'
Request body
{
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "qty": 2
    }
  ],
  "customer": {
    "name": "Priya Sharma",
    "email": "shopper@example.com",
    "phone": "9999999999",
    "address": "12 Nehru Place, New Delhi 110001",
    "city": "New Delhi",
    "state": "DL",
    "pincode": "110001"
  },
  "paymentMethod": "cod",
  "idempotencyKey": "9f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "couponCode": "SAVE10",
  "giftCardCode": "GIFT500",
  "shippingRateId": "standard"
}
Response
{
  "id": "7c4d1e88-9a02-4f31-b6c5-2d81e0aa93f4",
  "invoiceId": "INV-2026-00142",
  "orderRef": "ORD-2026-00142",
  "status": "confirmed",
  "orderStatus": "confirmed",
  "total": 985.6,
  "invoicePdfUrl": null,
  "giftCardApplied": 0.0,
  "amountDue": 985.6
}
POST/api/orders/lookup

Guest order lookup by email plus a reference. The reference is any one of: the order number the order page shows (#2CEE6A8F — the first eight characters of the order id, with or without the #, any case), the invoice id, the order reference printed in the confirmation email, or the order's own id. Both halves must match, and the call is rate limited, so it cannot enumerate other people's orders. Every miss is the same 404 with phoneRequired: true — offer a phone field and resend with phone: when the address changed hands after the order was placed, the last four digits of the phone typed at checkout must match too. The order's own id sent as the reference needs no phone.

Try it
curl -X POST https://your-shop.example.com/api/orders/lookup \
  -H "Content-Type: application/json" \
  -d '{"email":"shopper@example.com","reference":"#2CEE6A8F"}'
Request body
{
  "email": "shopper@example.com",
  "reference": "#2CEE6A8F"
}
Response
{
  "id": "7c4d1e88-9a02-4f31-b6c5-2d81e0aa93f4",
  "invoiceId": "INV-2026-00142",
  "orderRef": "ORD-2026-00142",
  "status": "confirmed",
  "orderStatus": "shipped",
  "refundState": "none",
  "total": 985.6,
  "invoicePdfUrl": null,
  "createdAt": "2026-08-21T09:14:03Z",
  "awb": "SRTP0012345678",
  "trackingUrl": "https://www.delhivery.com/track/package/SRTP0012345678",
  "fulfillmentStatus": "fulfilled",
  "carrier": "Delhivery",
  "trackingNumber": "SRTP0012345678",
  "fulfilledAt": "2026-08-22T17:45:12Z",
  "cancelledAt": null,
  "placedAt": "2026-08-21T09:13:51Z",
  "amountDue": 985.6,
  "giftCardApplied": 0,
  "paymentMethod": "cod",
  "amountUncollected": false
}
GET/api/orders/{id}

An order's current status and timeline, for the confirmation page.

Try it
curl https://your-shop.example.com/api/orders/3fa85f64-5717-4562-b3fc-2c963f66afa6
Parameters
  • id path string required
Response
{
  "order": {
    "id": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
    "invoiceId": "INV-2026-00142",
    "orderRef": "ORD-2026-00142",
    "status": "confirmed",
    "orderStatus": "shipped",
    "refundState": "none",
    "total": 985.6,
    "invoicePdfUrl": null,
    "createdAt": "2026-08-21T09:14:03Z",
    "awb": "SRTP0012345678",
    "trackingUrl": "https://www.delhivery.com/track/package/SRTP0012345678",
    "fulfillmentStatus": "fulfilled",
    "carrier": "Delhivery",
    "trackingNumber": "SRTP0012345678",
    "fulfilledAt": "2026-08-22T17:45:12Z",
    "cancelledAt": null,
    "placedAt": "2026-08-21T09:13:51Z",
    "amountDue": 985.6,
    "giftCardApplied": 0,
    "paymentMethod": "cod",
    "amountUncollected": false
  },
  "events": [
    {
      "type": "payment_captured",
      "at": "2026-08-21T09:14:03Z"
    }
  ],
  "access": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "url": "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"
    }
  ],
  "items": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "Aurora Desk Lamp",
      "qty": 2,
      "price": 449.0,
      "imageUrl": "https://cdn.example.com/items/aurora-lamp.jpg",
      "isDigital": false
    }
  ]
}
POST/api/orders/{id}/canceltoken

Let a signed-in shopper cancel their own order while it is still unfulfilled. Reverses the invoice with a credit note and emails a confirmation. A shipped order cannot be cancelled — it must be returned instead — and only the order's owner may cancel it.

Try it
curl -X POST https://your-shop.example.com/api/orders/3fa85f64-5717-4562-b3fc-2c963f66afa6/cancel \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason":"Ordered the wrong size"}'

Requires Authorization: Bearer <shopper token>. Returns 403 if the order belongs to another account, 404 if there is no such order, and 400 with an error message if it has already shipped (request a return instead), is already cancelled, was never completed, or is digital and already delivered. refund is the amount credited in the merchant's books (the credit note raised against the invoice); the shopper's card or UPI refund is issued by the merchant separately — the shop does not call the payment gateway. refundBlocked is true when the books refused the reversal and a person has to issue it.

Request body
{
  "reason": "Seal was broken on arrival."
}
Response
{
  "ok": true,
  "refund": 468.0,
  "refundBlocked": false
}

Shopper accounts 18 endpoints

GET/api/auth/config

Which sign-in methods this shop offers. google.enabled says whether to render a Google button (the client id is public by design). password.enabled is always true. password.resetByEmail says whether the shop has outgoing email configured with a public origin — show the forgot-password control when it is, and let that call's reply say whether sending is paused. password.resetOrigin is the origin your reset page must sit on: build the resetUrl from it, never from the browser's address bar.

Try it
curl https://your-shop.example.com/api/auth/config
Response
{
  "google": {
    "enabled": true,
    "clientId": "812345678901-ab1cd2ef3gh4ij5kl6mn7op8qr9st0uv.apps.googleusercontent.com"
  },
  "password": {
    "enabled": true,
    "resetByEmail": true,
    "resetOrigin": "https://your-shop.example.com"
  }
}
POST/api/shopper/register

Create a shopper account with an email address and a password (8 to 128 characters, no composition rules); name and phone are optional. Answers the same { token, shopper } as sign-in, so the shopper is signed in at once. 409 with exists: true when the address already has an account — a Google sign-in counts, a guest checkout does not; tell them to sign in or use forgot-password (the reply does not say which kind of account it is). Earlier guest orders under the address are not attached here; they attach after a password reset proves the inbox (or at once on a Google sign-in). 400 with weakPassword: true for the length rule.

Try it
curl -X POST https://your-shop.example.com/api/shopper/register \
  -H "Content-Type: application/json" \
  -d '{"email":"shopper@example.com","password":"correct horse battery","name":"Priya Sharma"}'
Request body
{
  "email": "shopper@example.com",
  "password": "correct horse battery",
  "name": "Priya Sharma",
  "phone": "9999999999"
}
Response
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzaG9wcGVyIn0.7Qf1m0Zt3xR",
  "shopper": {
    "id": "4e6b0a92-8d17-42f5-b3c9-7a05e2d81f34",
    "email": "shopper@example.com",
    "name": "Priya Sharma",
    "phone": "9999999999",
    "emailDetached": false
  },
  "passwordDropped": false
}
POST/api/shopper/login

Sign in with email and password. Returns the shopper token to send as Authorization: Bearer on account calls, plus a profile brief. A wrong password, an unknown address and an account that has no password (Google-only) all answer the same 401 with { "error": "Email or password is incorrect." } — do not try to tell them apart. passwordDropped is always false on this door. The token is valid for 30 days.

Try it
curl -X POST https://your-shop.example.com/api/shopper/login \
  -H "Content-Type: application/json" \
  -d '{"email":"shopper@example.com","password":"correct horse battery"}'
Request body
{
  "email": "shopper@example.com",
  "password": "correct horse battery"
}
Response
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzaG9wcGVyIn0.7Qf1m0Zt3xR",
  "shopper": {
    "id": "4e6b0a92-8d17-42f5-b3c9-7a05e2d81f34",
    "email": "shopper@example.com",
    "name": "Priya Sharma",
    "phone": "9999999999",
    "emailDetached": false
  },
  "passwordDropped": false
}
POST/api/shopper/password/forgot

Start a password reset. Send the address and resetUrl, the absolute URL of your reset page: it must be on the store's configured public origin (password.resetOrigin from /api/auth/config — same scheme, host and port, or 400), at most 500 characters, with no fragment and no token parameter of its own. When the address has an account and the shop can send mail, an email goes out whose link is that URL with ?token=… appended, valid once for one hour; five requests per address per hour. Always answers ok. emailed reports whether the shop can send email — never whether this address has an account or whether a message went out. sendingPaused: true means mail is configured but pausing after failures: say "try again shortly". Tell the shopper "if an account exists for that address, a link is on its way", never "we sent you an email".

Try it
curl -X POST https://your-shop.example.com/api/shopper/password/forgot \
  -H "Content-Type: application/json" \
  -d '{"email":"shopper@example.com","resetUrl":"https://your-shop.example.com/account/reset"}'
Request body
{
  "email": "shopper@example.com",
  "resetUrl": "https://your-shop.example.com/account/reset"
}
Response
{
  "ok": true,
  "emailed": true,
  "sendingPaused": false
}
POST/api/shopper/password/reset

Finish a password reset with the token your reset page read from its URL and the new password. Signs the shopper in on success (same reply as login) and ends every other session on the account. 400 with invalidLink: true when the token is unknown, already used or older than an hour — retrying cannot help, offer "request a new link"; 400 with weakPassword: true for the 8–128 rule. A successful reset also attaches that address's guest orders from the last 30 days to the account, because the inbox is now proven.

Try it
curl -X POST https://your-shop.example.com/api/shopper/password/reset \
  -H "Content-Type: application/json" \
  -d '{"token":"<token from the email link>","newPassword":"a new long password"}'
Request body
{
  "token": "3f9c1b2e7a4d5f608b1c2d3e4f5a6b7c",
  "newPassword": "a new long password"
}
Response
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzaG9wcGVyIn0.7Qf1m0Zt3xR",
  "shopper": {
    "id": "4e6b0a92-8d17-42f5-b3c9-7a05e2d81f34",
    "email": "shopper@example.com",
    "name": "Priya Sharma",
    "phone": "9999999999",
    "emailDetached": false
  },
  "passwordDropped": false
}
POST/api/shopper/password/changetoken

Change the signed-in shopper's password, given the current one. Every other session ends the moment it changes; the reply carries a fresh token for this device — keep using it. The 400s are flagged: useForgotPassword (the account has no password yet — it signed up with Google — and its first password must come through the reset email, so a stolen token cannot become a permanent credential), wrongCurrentPassword, samePassword, weakPassword. 403 when the address now belongs to another account; 409 when the password changed elsewhere while this call ran (sign in again).

Try it
curl -X POST https://your-shop.example.com/api/shopper/password/change \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"currentPassword":"correct horse battery","newPassword":"a new long password"}'

Requires Authorization: Bearer <shopper token>.

Request body
{
  "currentPassword": "correct horse battery",
  "newPassword": "a new long password"
}
Response
{
  "ok": true,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzaG9wcGVyIn0.Nn3wT0k3n"
}
POST/api/shopper/sessions/revoketoken

Sign out everywhere: ends every session of the signed-in shopper, including the one that called, and returns a fresh token for this device. Needs no password, so it is the one revocation lever an account that signed in with Google only (or a store that cannot send reset emails) has against a token copied from a lost device. Older tokens answer 401 with sessionEnded: true from here on. It works on a recycled-address account too, where the replacement expires when the presented token would have.

Try it
curl -X POST https://your-shop.example.com/api/shopper/sessions/revoke \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Response
{
  "ok": true,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzaG9wcGVyIn0.Nn3wT0k3n"
}
GET/api/shopper/addressestoken

The shopper's saved delivery addresses. Requires a shopper token.

Try it
curl https://your-shop.example.com/api/shopper/addresses \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Response
[
  {
    "id": "d5810c37-2f96-4ab4-85e3-1c740b9fd268",
    "name": "Priya Sharma",
    "phone": "9999999999",
    "line": "12 Nehru Place",
    "city": "New Delhi",
    "state": "DL",
    "pincode": "110001",
    "isDefault": true
  }
]
POST/api/shopper/addressestoken

Save a new delivery address for the signed-in shopper.

Try it
curl -X POST https://your-shop.example.com/api/shopper/addresses \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"string","phone":"9999999999","line":"12 Nehru Place","city":"New Delhi","state":"DL","pincode":"110001","makeDefault":false}'

Requires Authorization: Bearer <shopper token>.

Request body
{
  "name": "Priya Sharma",
  "phone": "9999999999",
  "line": "12 Nehru Place",
  "city": "New Delhi",
  "state": "DL",
  "pincode": "110001",
  "makeDefault": false
}
Response
{
  "id": "d5810c37-2f96-4ab4-85e3-1c740b9fd268",
  "name": "Priya Sharma",
  "phone": "9999999999",
  "line": "12 Nehru Place",
  "city": "New Delhi",
  "state": "DL",
  "pincode": "110001",
  "isDefault": true
}
DELETE/api/shopper/addresses/{id}token

Remove one of the shopper's saved addresses.

Try it
curl -X DELETE https://your-shop.example.com/api/shopper/addresses/ITM-001 \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Parameters
  • id path string required
Response
{
  "ok": true
}
PUT/api/shopper/addresses/{id}token

Update one of the shopper's saved addresses.

Try it
curl -X PUT https://your-shop.example.com/api/shopper/addresses/ITM-001 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"string","phone":"9999999999","line":"12 Nehru Place","city":"New Delhi","state":"DL","pincode":"110001","makeDefault":false}'

Requires Authorization: Bearer <shopper token>.

Parameters
  • id path string required
Request body
{
  "name": "Priya Sharma",
  "phone": "9999999999",
  "line": "12 Nehru Place",
  "city": "New Delhi",
  "state": "DL",
  "pincode": "110001",
  "makeDefault": false
}
Response
{
  "ok": true
}
POST/api/shopper/addresses/{id}/defaulttoken

Mark a saved address as the shopper's default for checkout.

Try it
curl -X POST https://your-shop.example.com/api/shopper/addresses/ITM-001/default \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Parameters
  • id path string required
Response
{
  "ok": true
}
POST/api/shopper/google

Sign in with a Google ID token. The token is verified server-side and the shopper is found or created from the verified email; earlier guest orders under that address (last 30 days) attach at once, because Google proved the inbox. Returns a shopper JWT. passwordDropped: true means this was the first Google sign-in on an address that had a password but no Google link yet: that password is gone (whoever set it may not be this person) and every older session has ended — say so on the spot and point at forgot-password. 400 when Google is off or the credential is missing, 401 when it does not verify, 503 with storeUnavailable when Google itself is unreachable (offer a retry).

Try it
curl -X POST https://your-shop.example.com/api/shopper/google \
  -H "Content-Type: application/json" \
  -d '{"credential":"<google id token>"}'
Request body
{
  "credential": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjB"
}
Response
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzaG9wcGVyIn0.7Qf1m0Zt3xR",
  "shopper": {
    "id": "4e6b0a92-8d17-42f5-b3c9-7a05e2d81f34",
    "email": "shopper@example.com",
    "name": "Priya Sharma",
    "phone": "9999999999",
    "emailDetached": false
  },
  "passwordDropped": false
}
GET/api/shopper/metoken

The signed-in shopper's profile. linked is true once they are a customer in the merchant's books (they have bought). emailDetached: true with email: "" means the address was recycled to another account: show "this address now belongs to another account — contact the store" rather than a blank line, and expect checkout to need a typed email. Requires a shopper token.

Try it
curl https://your-shop.example.com/api/shopper/me \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Response
{
  "id": "4e6b0a92-8d17-42f5-b3c9-7a05e2d81f34",
  "email": "shopper@example.com",
  "name": "Priya Sharma",
  "phone": "9999999999",
  "linked": true,
  "emailDetached": false
}
GET/api/shopper/orderstoken

The signed-in shopper's order history, newest first. Requires a shopper token. Returns at most the 100 most recent orders, and takes no paging or filter parameters — any query string is ignored rather than refused, so do not build a pager against it. For an older order, look it up by its reference with POST /api/orders/lookup.

Try it
curl https://your-shop.example.com/api/shopper/orders \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Response
[
  {
    "id": "7c4d1e88-9a02-4f31-b6c5-2d81e0aa93f4",
    "invoiceId": "INV-2026-00142",
    "orderRef": "ORD-2026-00142",
    "status": "confirmed",
    "orderStatus": "shipped",
    "refundState": "none",
    "total": 985.6,
    "invoicePdfUrl": null,
    "createdAt": "2026-08-21T09:14:03Z",
    "awb": "SRTP0012345678",
    "trackingUrl": "https://www.delhivery.com/track/package/SRTP0012345678",
    "fulfillmentStatus": "fulfilled",
    "carrier": "Delhivery",
    "trackingNumber": "SRTP0012345678",
    "fulfilledAt": "2026-08-22T17:45:12Z",
    "cancelledAt": null,
    "placedAt": "2026-08-21T09:13:51Z",
    "amountDue": 985.6,
    "giftCardApplied": 0,
    "paymentMethod": "cod",
    "amountUncollected": false
  }
]
GET/api/shopper/wishlisttoken

The signed-in shopper's wishlist. Requires a shopper token.

Try it
curl https://your-shop.example.com/api/shopper/wishlist \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Response
[
  "16a8357b-4fa1-40ff-a70c-43094daee1d4"
]
POST/api/shopper/wishlisttoken

Add a product to the shopper's wishlist.

Try it
curl -X POST https://your-shop.example.com/api/shopper/wishlist \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"itemId":"ITM-001"}'

Requires Authorization: Bearer <shopper token>.

Request body
{
  "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4"
}
Response
{
  "ok": true
}
DELETE/api/shopper/wishlist/{itemId}token

Remove a product from the shopper's wishlist.

Try it
curl -X DELETE https://your-shop.example.com/api/shopper/wishlist/ITM-001 \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Parameters
  • itemId path string required
Response
{
  "ok": true
}

Payments 4 endpoints

GET/api/payment/config

Whether online payment is switched on, and the public key needed to open the payment widget. Never returns the secret.

Try it
curl https://your-shop.example.com/api/payment/config
Response
{
  "enabled": true,
  "keyId": "rzp_live_A1b2C3d4E5f6G7",
  "currency": "INR",
  "symbol": "₹",
  "name": "Your Store"
}
POST/api/payment/create-order

Price the basket server-side and open a payment against it. Answers 409 with availableQty when a line asks for more units than are in stock — the same body checkout returns, and now also reachable on a basket a gift card or a 100%-off coupon covers entirely. A ₹0 basket returns a different shape: it places the order immediately instead of opening a payment, and answers {"freeOrder": true, "orderId": "…", "replayed": false} — no razorpayOrderId, no amount. Branch on freeOrder before reading anything else, and send the shopper straight to the order page rather than to the gateway. replayed is true when your call collapsed onto an order that already existed. The amount is computed from live prices, coupons and gift cards — the client never states what it owes. This route is for card/UPI/netbanking only; place a cash-on-delivery order at /api/checkout instead. The shared request body therefore carries two fields that behave differently here: paymentMethod is not how you choose the tender — its only effect is to select a COD-rated courier quote, so sending "cod" on a card payment charges your shopper the wrong delivery rate. And idempotencyKey is read only on the fully-covered ₹0 branch, which places an order outright; on the normal prepaid branch a repeat call opens a second gateway order and takes a second gift-card hold that nothing releases, so the shopper can be refused by their own first attempt. Do not retry this call. If you lost the response, do not re-send it and do not start a second payment either — poll GET /api/orders/lookup (or the shopper's order list) first, and only open a new payment if no order appears. The first attempt's gift-card hold lapses on its own after 15 minutes.

Try it
curl -X POST https://your-shop.example.com/api/payment/create-order \
  -H "Content-Type: application/json" \
  -d '{"lines":[{"itemId":"ITM-001","qty":2}],"customer":{"name":"string","email":"shopper@example.com","phone":"9999999999","address":"string","city":"New Delhi","state":"DL","pincode":"110001"},"idempotencyKey":"string","couponCode":"SAVE10","giftCardCode":"GIFT500","shippingRateId":"string"}'
Request body
{
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "qty": 2
    }
  ],
  "customer": {
    "name": "Priya Sharma",
    "email": "shopper@example.com",
    "phone": "9999999999",
    "address": "12 Nehru Place, New Delhi 110001",
    "city": "New Delhi",
    "state": "DL",
    "pincode": "110001"
  },
  "idempotencyKey": "9f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "couponCode": "SAVE10",
  "giftCardCode": "GIFT500",
  "shippingRateId": "standard"
}
Response
{
  "razorpayOrderId": "order_PjK2mNqR8sT1vX",
  "amount": 98560,
  "total": 985.6,
  "currency": "INR",
  "keyId": "rzp_live_A1b2C3d4E5f6G7",
  "name": "Your Store",
  "prefill": {
    "name": "Priya Sharma",
    "email": "shopper@example.com",
    "contact": "9999999999"
  }
}
POST/api/payment/verify

Verify the signed payment result and create the order. Idempotent per payment, so a retried or duplicated callback cannot produce two orders. On a store where online payment is switched off it answers 400 ("Payment is not configured") before anything else — check /api/payment/config first.

Try it
curl -X POST https://your-shop.example.com/api/payment/verify \
  -H "Content-Type: application/json" \
  -d '{"razorpayOrderId":"string","razorpayPaymentId":"string","razorpaySignature":"string"}'
Request body
{
  "razorpayOrderId": "order_PjK2mNqR8sT1vX",
  "razorpayPaymentId": "pay_PjK2nOqS9tU2wY",
  "razorpaySignature": "9a1f0c5e7b3d2a8f4c6e0b1d5a9f3c7e2b8d4a6f0c1e5b9d3a7f2c8e4b0d6a1f"
}
Response
{
  "id": "7c4d1e88-9a02-4f31-b6c5-2d81e0aa93f4",
  "invoiceId": "INV-2026-00142",
  "orderRef": "ORD-2026-00142",
  "status": "confirmed",
  "orderStatus": "confirmed",
  "total": 985.6,
  "invoicePdfUrl": null,
  "giftCardApplied": 0.0,
  "amountDue": 985.6
}
POST/api/payment/abandon

Tell the shop the payment window was closed or failed, so any gift-card balance reserved for that attempt is handed straight back instead of staying locked until the reservation lapses. Moves no money and is safe to call more than once — call it whenever the Razorpay modal is dismissed.

Try it
curl -X POST https://your-shop.example.com/api/payment/abandon \
  -H "Content-Type: application/json" \
  -d '{"razorpayOrderId":"order_QxYz123"}'
Request body
{
  "razorpayOrderId": "order_PjK2mNqR8sT1vX"
}
Response
{
  "ok": true
}

Returns 2 endpoints

GET/api/returnstoken

The signed-in shopper's own return requests and their status. Refused with 400 for an order that was cancelled (already refunded), never completed, has no invoice, or is digital-only; 404 for an order that is not this shopper's; 409 when a return is already in progress. An order that has not shipped yet is accepted — the merchant decides in the admin.

Try it
curl https://your-shop.example.com/api/returns \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Response
[
  {
    "id": "b3e5f721-6c48-4d90-8a12-5f7c0d92ea36",
    "orderId": "7c4d1e88-9a02-4f31-b6c5-2d81e0aa93f4",
    "orderRef": "ORD-2026-00142",
    "invoiceId": "INV-2026-00142",
    "reason": "Applied.",
    "status": "requested",
    "creditNoteNumber": "CN-2026-00031",
    "refundAmount": 468.0,
    "adminNote": "Seal broken on arrival — approved for a full refund.",
    "orderTotal": 985.6,
    "createdAt": "2026-08-21T09:14:03Z",
    "resolvedAt": "2026-08-23T10:05:00Z"
  }
]
POST/api/returnstoken

Request a return against one of the shopper's own orders. The merchant's books issue a credit note when it is accepted.

Try it
curl -X POST https://your-shop.example.com/api/returns \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"orderId":"3fa85f64-5717-4562-b3fc-2c963f66afa6","reason":"string"}'

Requires Authorization: Bearer <shopper token>.

Request body
{
  "orderId": "7c4d1e88-9a02-4f31-b6c5-2d81e0aa93f4",
  "reason": "Seal was broken on arrival."
}
Response
{
  "id": "b3e5f721-6c48-4d90-8a12-5f7c0d92ea36",
  "orderId": "7c4d1e88-9a02-4f31-b6c5-2d81e0aa93f4",
  "orderRef": "ORD-2026-00142",
  "invoiceId": "INV-2026-00142",
  "reason": "Seal was broken on arrival.",
  "status": "requested",
  "creditNoteNumber": "CN-2026-00031",
  "refundAmount": 468.0,
  "adminNote": "Seal broken on arrival — approved for a full refund.",
  "orderTotal": 985.6,
  "createdAt": "2026-08-21T09:14:03Z",
  "resolvedAt": "2026-08-23T10:05:00Z"
}

Subscriptions 3 endpoints

GET/api/subscriptionstoken

The signed-in shopper's recurring orders. Only tax-exclusive items can be subscribed: a line priced tax-inclusive (an MRP item) answers 400 naming the item, so hide the subscribe control on MRP products. 409 with subscriptionId + status when one already exists under your key.

Try it
curl https://your-shop.example.com/api/subscriptions \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Response
[
  {
    "id": "2a9d6f14-7b53-4c81-9e07-3d5a8b1f6c42",
    "title": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
    "frequency": "monthly",
    "status": "active",
    "nextRun": "2026-09-21T00:00:00Z",
    "createdAt": "2026-08-21T09:14:03Z"
  }
]
POST/api/subscriptionstoken

Start a recurring order for the signed-in shopper.

Try it
curl -X POST https://your-shop.example.com/api/subscriptions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"lines":[{"itemId":"ITM-001","qty":2}],"customer":{"name":"string","email":"shopper@example.com","phone":"9999999999","address":"string","city":"New Delhi","state":"DL","pincode":"110001"},"frequency":"string","title":"string"}'

Requires Authorization: Bearer <shopper token>.

Request body
{
  "lines": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "qty": 2
    }
  ],
  "customer": {
    "name": "Priya Sharma",
    "email": "shopper@example.com",
    "phone": "9999999999",
    "address": "12 Nehru Place, New Delhi 110001",
    "city": "New Delhi",
    "state": "DL",
    "pincode": "110001"
  },
  "frequency": "monthly",
  "title": "Exactly as described"
}
Response
{
  "id": "2a9d6f14-7b53-4c81-9e07-3d5a8b1f6c42",
  "title": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
  "frequency": "monthly",
  "status": "active",
  "nextRun": "2026-09-21T00:00:00Z",
  "createdAt": "2026-08-21T09:14:03Z",
  "access": [
    {
      "itemId": "16a8357b-4fa1-40ff-a70c-43094daee1d4",
      "name": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
      "url": "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png"
    }
  ],
  "accessPendingPayment": false
}
POST/api/subscriptions/{id}/{op}token

Change a subscription's state. The operation is one of pause, resume or cancel.

Try it
curl -X POST https://your-shop.example.com/api/subscriptions/ITM-001/pause \
  -H "Authorization: Bearer $TOKEN"

Requires Authorization: Bearer <shopper token>.

Parameters
  • id path string required
  • op path string required
Response
{
  "id": "2a9d6f14-7b53-4c81-9e07-3d5a8b1f6c42",
  "title": "24 Mantra Cold Pressed Organic Groundnut Oil 1 ltr",
  "frequency": "monthly",
  "status": "paused",
  "nextRun": "2026-09-21T00:00:00Z",
  "createdAt": "2026-08-21T09:14:03Z",
  "access": null,
  "accessPendingPayment": false
}

Blog 2 endpoints

GET/api/blog

Published posts, newest first, without bodies — for an index page.

Try it
curl https://your-shop.example.com/api/blog
Response
[
  {
    "slug": "cold-pressed-oils",
    "title": "Why cold pressed matters",
    "excerpt": "How cold pressing keeps more of what the peanut started with.",
    "coverUrl": "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png",
    "createdAt": "2026-08-21T09:14:03Z"
  }
]
GET/api/blog/{slug}

One published post in full, by slug.

Try it
curl https://your-shop.example.com/api/blog/coffee-essentials
Parameters
  • slug path string required
Response
{
  "id": "f27b9d54-3e61-48ca-90b2-6d1af80c35e9",
  "slug": "cold-pressed-oils",
  "title": "Why cold pressed matters",
  "excerpt": "How cold pressing keeps more of what the peanut started with.",
  "coverUrl": "https://cdn.yourshop.com/product/6476/groundnut-oil-1l.png",
  "body": "Cold pressing keeps the oil below 50°C, which preserves the aroma compounds heat would strip out.",
  "published": true,
  "createdAt": "2026-08-21T09:14:03Z",
  "updatedAt": "2026-08-22T11:02:47Z"
}

Generated from a live store’s OpenAPI document. If your store is a newer build, trust /api/openapi.json on it over this page.

Getting the document

Point your tooling at your store.

The OpenAPI document
Spechttps://your-shop.example.com/api/openapi.json
Browsablehttps://your-shop.example.com/api/docs
What to do with it
ExploreImport the spec into Postman, Insomnia or Bruno
Generate a clientAny OpenAPI generator — TypeScript, Dart, Swift, Kotlin, Python
Try it before installingLeave the API key blank on first run and the store boots a demo with sample products, so the API answers with real data immediately

The store is free and self-hosted. It still needs a Ragenaizer tenant to post into, because the invoices, the stock and the returns belong to the Accounts module — that is the trade: you build the front end, the business OS keeps the books.

Your front end. Our ledger.

Thirty-two designs if you want one, and a documented API if you do not.

This does not make Merch open source — the application ships as prebuilt container images and its source is not published. What is yours is the shopfront: the API is a contract you can build anything against, without inheriting the part that is genuinely hard to get right, which is GST-correct invoicing and stock that agrees with the books.