Stocka Storefront API
The storefront journey, as JSON
Everything the hosted storefront can do — browse, price, hold a cart, check out, pay by FPX,
track a parcel to the door — answered over one publishable key. Built for Nuvantara.com;
/v1 is append-only, so what you build against today keeps working.
https://api.stocka.io/v1 — this very host; these docs live at its root.
Every response is JSON. Every price is integer minor units — a float never crosses the wire.
Authentication
Send the shop's publishable key on every request: X-Storefront-Key: pk_….
The key names exactly one shop and grants what any shopper's browser has — nothing more.
Keys are issued and revoked on the shop's Settings → Integrations page; revocation is the whole
security story, so rotate freely. Browser calls work only from the origins allow-listed on the key;
a key with no origins is server-to-server only.
Conventions
| Thing | Rule |
|---|---|
| Money | {"minor": 1990, "currency": "MYR", "formatted": "RM 19.90"} — do arithmetic on minor, render formatted (the symbol its own speakers write, thousands grouped). |
| Locale | ?locale=ms on any read returns Bahasa Melayu copy where the shop provides it. |
| Caching | Reads return ETags that fold the platform release — send If-None-Match, get 304s, and a deploy busts every cache for you. |
| Retries | Placing an order requires an Idempotency-Key header; the same key always lands on the same order. |
| Limits | 600/min per IP and 300/min per key. A busy storefront is many shoppers behind one key — the key number is the generous one. |
| Suspension | A suspended shop keeps aftercare (order tracking, returns, pay pages) working while browsing and placing close. Your client sees 403s with reasons, never mysteries. |
Reading the shop
First-render facts: name, locales, currency, checkout_ready, policy slugs, analytics pixels.
Navigable categories — visible, and holding something sellable.
The catalogue, paginated, with the same full-text search and did-you-mean the hosted storefront uses (?q=).
One product: variants, sell units, resolved prices, images with srcset.
{
"name": "Ikan Kembung",
"variants": [{
"sku": "IKN-01",
"availability": "in_stock",
"sold_by_weight": true, // ← weighted goods
"approx_weight_g": 500,
"price_per_kg": {"minor": 790, "currency": "MYR", "formatted": "RM 7.90"},
"units": [{"code": "unit", "price": {"minor": 395, "…": "…"}}] // derived per piece
}],
"package_contents": null, // or [{"name": "Mug", "per_package": 2}]
"registration_no": "MAL20231234X", // regulated goods — null when the product carries none
"halal_cert": "JAKIM 1234",
"claims_disclaimer": "Traditionally used to…" // localised; always render it beside the description
}
Render weighted items with the honest ≈500 g · RM 7.90/kg line — the unit price is already
the derived per-piece figure. A fixed package's package_contents feeds a "What's inside" box;
its availability already reads the emptiest component shelf.
Published policy and content pages, localised; the contact page carries a ready wa.me number.
Cart & checkout
The cart token is the capability — keep it client-side, treat it like a session.
Four PUTs mirror the hosted checkout's four steps; the cart payload always tells you
checkout.next_step, so your UI never has to guess.
Mint a basket; the response's token addresses everything below. Pass {"referral_code": "…"} when your page was reached through a partner's ?ref= link — the basket carries the attribution (echoed back as referral_code) and the order that follows pays the partner. An unknown code is ignored, never an error.
The basket, totals, and the whole checkout state in one read:
"checkout": {
"next_step": "delivery",
"delivery_options": [
{"code": "courier:JNT-STD", "label": "J&T Standard",
"fee": {"minor": 790, "…": "…"}, "estimate": "1–2 days"}, // live courier quote
{"code": "pickup:01a0…", "label": "Pickup — LEURA",
"fee": {"minor": 0, "…": "…"}, "estimate": "12 Jalan Telawi 3, KL"}
],
"slot_required": true, // grocery: a window must be chosen
"delivery_slots": [
{"id": "01a0…", "date": "2026-09-02", "window": "10:00–12:00", "full": false},
{"id": "01a0…", "date": "2026-09-02", "window": "14:00–16:00", "full": true}
],
"substitution": null,
"payment_options": [
{"method": "fpx", "available": true, "total": {"minor": 19690, "…": "…"}},
{"method": "cod", "available": true, "total": {"minor": 19700, "…": "…"}} // cash-rounded
]
}
Courier prices are live quotes to the address entered at the contact step; when the courier is
unreachable the shop's flat rate stands in — your client just renders the list.
Draw full: true windows disabled: the seat race is settled server-side at placement,
never in the browser.
Add (variant, optional sell unit, quantity), change quantity (0 removes), remove.
Step 1: name, phone, email, address{line1, city, postcode, state}.
Step 2, three answers in one PUT:
{
"delivery": "courier:JNT-STD", // an option code from delivery_options
"slot": "01a0…", // required when slot_required is true
"substitution": "contact" // refund | substitute | contact (grocery)
}
Step 3: {"payment": "fpx"}. Availability is re-checked server-side.
Apply or remove a store-credit code; it is spent only at placement.
Requires Idempotency-Key; accepts an optional referral_code with the same effect as on cart creation. Returns 201 with the order — and for FPX, card or
e-wallet, a payment_redirect_url: send the shopper to that hosted page, then show your order
screen. The webhook settles, not the redirect — poll GET /orders/{token} or
subscribe to order.paid to flip your UI. A full delivery window answers 422 with the
slot named; sold-out lines answer 409 with each line listed.
Orders, tracking, returns
The cart token doubles as the tracking capability. The payload says how it reaches the customer:
{
"reference": "SO-0042", "status": "paid",
"order_type": "shipping",
"pickup": null, // or {"name": "LEURA", "address": "…"}
"delivery_slot": {"date": "2026-09-02", "window": "10:00–12:00"},
"substitution": "contact",
"shipments": [{
"status": "in_transit",
"tracking_number": "TRK123",
"tracking_url": "https://my.delyva.app/customer/track?trackingNo=TRK123",
"timeline": [{"at": "2026-09-01T10:02:11+08:00", "status": "In transit to hub"}]
}],
"returns": []
}
File a return — reason, lines, photos (multipart). The window and rules are the shop's own.
Pay links
The WhatsApp payment-link page as JSON: read the state (payable, paid, expired, replaced — each
worded for its holder), then POST the chosen tender. Gateway methods return the same
payment_redirect_url contract as placement.
Outbound webhooks
Register endpoints on Settings → Integrations and stop polling: order.placed,
order.paid, order.cancelled, return.requested… Each delivery is signed
t={ts},v1={hmac} with your endpoint's whsec_ secret (HMAC-SHA256 over
"{t}.{body}", 300s tolerance). Delivery is at-least-once — key your handler on the payload's
event id.
// verify (node)
const [t, v1] = sig.split(',').map(kv => kv.split('=')[1]);
const expect = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
const fresh = Math.abs(Date.now()/1000 - t) < 300;
ok = fresh && crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(v1));
Errors
| Status | Meaning |
|---|---|
401 | Missing or revoked key. |
403 | The shop's state (suspended tier) closes this capability; the body says which. |
404 | Not this shop's thing — another store's token gets the same 404 as a guess. |
409 | State conflict: sold-out lines at placement (each named), or checkout not ready. |
422 | Validation — field-keyed messages, including slot when a window is missing or just filled. |
429 | Rate limited; honor Retry-After. |
Changelog — additive, as promised
| Added | Where | What |
|---|---|---|
| 1 Sep 2026 | GET /cart/{token} | slot_required, delivery_slots, substitution; delivery_options[].estimate; options now carry live courier quotes and pickup counters. |
| 1 Sep 2026 | PUT …/delivery | slot and substitution accepted. |
| 1 Sep 2026 | GET /orders/{token} | order_type, pickup, delivery_slot, substitution, shipments[] with tracking timelines. |
| 1 Sep 2026 | GET /products/{slug} | sold_by_weight, approx_weight_g, price_per_kg, package_contents. |
| 5 Sep 2026 | GET /products/{slug} · POST /cart · POST /cart/{token}/place · webhooks | registration_no, halal_cert, claims_disclaimer on products; referral_code accepted on cart creation and placement and echoed on the cart; the order.completed webhook event now fires. |
| 1 Sep 2026 | POST …/place, POST /pay/{token} | payment_redirect_url now live for FPX/card/e-wallet (CHIP, Billplz). |