Stocka Storefront API v1

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.

Base URL  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

ThingRule
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.
CachingReads return ETags that fold the platform release — send If-None-Match, get 304s, and a deploy busts every cache for you.
RetriesPlacing an order requires an Idempotency-Key header; the same key always lands on the same order.
Limits600/min per IP and 300/min per key. A busy storefront is many shoppers behind one key — the key number is the generous one.
SuspensionA 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

GET/store

First-render facts: name, locales, currency, checkout_ready, policy slugs, analytics pixels.

GET/categories

Navigable categories — visible, and holding something sellable.

GET/products

The catalogue, paginated, with the same full-text search and did-you-mean the hosted storefront uses (?q=).

GET/products/{slug}

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.

GET/pages · /pages/{slug}

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.

POST/cart

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.

GET/cart/{token}

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.

POST/cart/{token}/lines · PUT…/lines/{line} · DEL…/lines/{line}

Add (variant, optional sell unit, quantity), change quantity (0 removes), remove.

PUT/cart/{token}/contact

Step 1: name, phone, email, address{line1, city, postcode, state}.

PUT/cart/{token}/deliveryextended

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)
}
PUT/cart/{token}/payment

Step 3: {"payment": "fpx"}. Availability is re-checked server-side.

POST/cart/{token}/credit · DEL…/credit

Apply or remove a store-credit code; it is spent only at placement.

POST/cart/{token}/place

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

GET/orders/{token}extended

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": []
}
POST/orders/{token}/return

File a return — reason, lines, photos (multipart). The window and rules are the shop's own.

Pay links

GET/pay/{token} · POST/pay/{token}

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

StatusMeaning
401Missing or revoked key.
403The shop's state (suspended tier) closes this capability; the body says which.
404Not this shop's thing — another store's token gets the same 404 as a guess.
409State conflict: sold-out lines at placement (each named), or checkout not ready.
422Validation — field-keyed messages, including slot when a window is missing or just filled.
429Rate limited; honor Retry-After.

Changelog — additive, as promised

AddedWhereWhat
1 Sep 2026GET /cart/{token}slot_required, delivery_slots, substitution; delivery_options[].estimate; options now carry live courier quotes and pickup counters.
1 Sep 2026PUT …/deliveryslot and substitution accepted.
1 Sep 2026GET /orders/{token}order_type, pickup, delivery_slot, substitution, shipments[] with tracking timelines.
1 Sep 2026GET /products/{slug}sold_by_weight, approx_weight_g, price_per_kg, package_contents.
5 Sep 2026GET /products/{slug} · POST /cart · POST /cart/{token}/place · webhooksregistration_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 2026POST …/place, POST /pay/{token}payment_redirect_url now live for FPX/card/e-wallet (CHIP, Billplz).