Storefront API: your products on your own website
The Storefront API lets your own website (a bzlabs.io site, a page you built, a theme) show your SupplySynk products and take pickup orders. It gives your site exactly what your online store shows: the same products, prices, photos and stock labels, and the same checkout with the same stock holds and emails. Orders appear in Orders with the channel "online", like orders from your store.
Who can set it up: owners and admins. Before you start: publish your store (Settings → Online store) and turn on a way to pay (Settings → Payments). An unpublished store answers "not found".
1. Create a storefront key
- Go to Developers → Storefront keys and click Create key.
- Name it after the website ("Our website").
- Under Websites allowed to use it, list your site's addresses, one per
line, exactly as they appear in the browser's address bar without a path:
https://papaswillow.comandhttps://www.papaswillow.comare different. While building the site on your computer, addhttp://localhost:3000(onlylocalhostmay usehttp://). - Click Create key and copy the key (
pk_live_…). It's shown only once; we keep only a fingerprint of it. If you lose it, revoke it and create another.
A storefront key is safe to put in a web page. It can only read what your
store already shows to everyone, and place orders the way your store does.
It can't see costs, suppliers or stock counts, and it can't change anything.
It doesn't work with the back-office API (/api/v1) or AI assistants; those
use secret keys (Developers → API Keys), which must never go in a web page.
Websites not on the list are refused, so someone who copies your key into their own site gets nothing. Your own server (code that runs on a server, not in the browser) can always use the key, with a lower limit (see below).
To stop a website using a key, click Revoke. It stops working at once. Edit websites changes the list at once too.
2. Show your products
Every call sends the key in a header:
Authorization: Bearer pk_live_…
The address is https://www.supplysynk.com/api/storefront/v1, then:
| Call | What you get |
|---|---|
GET /store | Name, logo, colours, address, phone, email, opening hours with "Open now" or "Closed" (in your time zone), ways to pay and pickup locations |
GET /categories | Categories that have products, with counts |
GET /products | Products, 24 at a time. Add ?category=bats, ?q=bat (search), ?sort=price-asc, ?limit=48; for the next page add cursor= with the nextCursor you got |
GET /products/{slug} | One product: description, every photo, options and variants |
GET /products/{slug}/recommendations | "You may also like": up to 4 products, your pinned ones first, in stock only |
GET /recommendations | "Popular now" and "New arrivals" for a home page, up to 8 each |
GET /pages | Your published store pages (home, About, landing pages): slug, path, title, SEO title and description |
GET /pages/{slug} | One published page (home, about or a landing page's slug) as sections, ready to render |
POST /cart | Today's prices and stock labels for a cart your site keeps |
POST /checkout | Place a pickup order |
GET /orders/{token} | The order's status, by the token checkout gave you |
Stock shows as a label (availability.label): "In stock", "Only 3 left",
"Out of stock" or "Available".
For a business in India: products and variants carry mrp (show "MRP" next
to the price, struck through when the price is lower; prices include all
taxes), and a product page carries legal (country of origin, the
manufacturer, packer or importer with their address, net quantity, best
before, other details; null when none are set). GET /store has seller:
the legal name, GSTIN, grievance officer and returns policy, which an Indian
online store must show. A product whose price is above its MRP has
price: null, is "Not available online", is dropped from POST /cart and
can't be ordered. Outside India mrp, legal and
seller are null. Photos come in three sizes
(image.sizes.thumb square 256 px for cards, medium up to 800 px,
large up to 1600 px), with their width and height, so pages load fast on
phones. Photos made by AI have generated: true; show an "AI-generated
image" label with them, as your store does.
Store pages
The pages you build in Settings → Online store → Store pages come
through the API as structured sections, so your own site can show the same
home page or "New season bats" page as your SupplySynk store. Only
published pages are returned; drafts never are. No home in GET /pages
means the store uses its standard home page.
GET /pages/new-season-bats returns kind, slug, path (/p/new-season-bats),
title, seoTitle, seoDescription, publishedAt and sections, in order.
Each section has an id and a type:
type | Fields |
|---|---|
hero | heading, subheading, image, cta (label, href) |
rich_text | heading, body |
product_grid | heading, products: the same product summaries as GET /products |
category_links | heading, categories (id, name, slug, imageUrl) |
image_text | heading, body, image, imageSide (left or right), cta |
faq | heading, items (question, answer) |
testimonial | quote, author, detail: a customer's words, typed in by you |
newsletter | heading, text: show your own sign-up form here |
bodyand FAQ answers use a small text format: a blank line starts a paragraph, lines starting with-are a list,**bold**,*italic*and[text](link). There is never HTML. Render it as text, or with any Markdown renderer that has raw HTML turned off.- Links (
href) are paths in your store such as/category/bats(put your own site's address in front),#store-signup, or fullhttps://,mailto:andtel:links. - Images have
url(large),mediumUrl,thumbUrl,alt(always set) andgenerated. Product photos are always your own photos; an image withgenerated: trueis a decorative AI image, so label it "AI-generated". - Products that are no longer active simply drop out of
products.
The webhook store_page.published (and store_page.unpublished) tells your
site when to fetch a page again. See API and webhooks.
3. Copy-paste example
Save this as a page on your site, put in your key, and open it from one of the websites you listed. It lists your products and places a "pay at pickup" order for the first one.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Our products</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0 auto; max-width: 960px; padding: 16px; }
ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
li { border: 1px solid #ddd; border-radius: 8px; padding: 8px; }
img { width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
</style>
</head>
<body>
<h1 id="store">Loading…</h1>
<p id="hours"></p>
<ul id="products"></ul>
<form id="order" hidden>
<h2>Order <span id="order-name"></span> for pickup</h2>
<label>Name <input name="name" required autocomplete="name"></label><br>
<label>Email <input name="email" type="email" required autocomplete="email"></label><br>
<label>Phone <input name="phone" type="tel" required autocomplete="tel"></label><br>
<button>Place order</button>
</form>
<p id="result"></p>
<script>
const API = "https://www.supplysynk.com/api/storefront/v1";
const KEY = "pk_live_REPLACE_ME"; // Developers → Storefront keys
async function call(path, options = {}) {
const res = await fetch(API + path, {
...options,
headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json", ...options.headers },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error?.message || "Something went wrong");
return body;
}
let store, chosen;
async function start() {
store = await call("/store");
document.getElementById("store").textContent = store.name;
document.getElementById("hours").textContent = store.hours.status?.label ?? "";
const { data } = await call("/products?limit=24");
const list = document.getElementById("products");
for (const p of data) {
const li = document.createElement("li");
const img = p.image
? `<img src="${p.image.sizes.thumb.url}" width="256" height="256" alt="" loading="lazy">`
: "";
li.innerHTML = `${img}<strong></strong><div class="price"></div><div class="stock"></div>`;
li.querySelector("strong").textContent = p.name;
li.querySelector(".price").textContent = (p.priceFrom ? "From " : "") + store.currency + " " + p.price;
li.querySelector(".stock").textContent = p.availability.label;
list.append(li);
}
// Order the first product that's in stock and isn't a family of variants
chosen = data.find((p) => !p.hasVariants && p.availability.status !== "out_of_stock");
if (store.checkout.enabled && chosen) {
document.getElementById("order-name").textContent = chosen.name;
document.getElementById("order").hidden = false;
}
}
document.getElementById("order").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
const result = document.getElementById("result");
try {
const order = await call("/checkout", {
method: "POST",
// The same key on a retry never places the order twice
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({
items: [{ productId: chosen.id, quantity: 1 }],
customer: { name: form.get("name"), email: form.get("email"), phone: form.get("phone") },
locationId: store.checkout.locations[0].id,
provider: store.checkout.methods[0].provider, // e.g. "pay_at_pickup"
}),
});
if (order.next.kind === "redirect") {
location.href = order.next.url; // a hosted payment page
} else if (order.next.kind === "qr") {
location.href = order.statusUrl; // UPI: the order page shows the QR and takes the reference
} else {
result.textContent = `Order ${order.orderNumber} placed. ${order.next.lines.join(" ")}`;
}
} catch (err) {
result.textContent = err.message;
}
});
start().catch((err) => (document.getElementById("store").textContent = err.message));
</script>
</body>
</html>
In a Next.js site, read products on the server so the page is fast and your server makes the call (no browser origin needed), and cache it for a minute:
// app/products/page.tsx
const API = "https://www.supplysynk.com/api/storefront/v1";
export default async function Products() {
const res = await fetch(`${API}/products?limit=48`, {
headers: { Authorization: `Bearer ${process.env.SUPPLYSYNK_PK}` },
next: { revalidate: 60 }, // stay under the server limit
});
const { data } = await res.json();
return (
<ul>
{data.map((p: { id: string; name: string; price: string; availability: { label: string } }) => (
<li key={p.id}>
{p.name}: {p.price} ({p.availability.label})
</li>
))}
</ul>
);
}
Place orders from the browser (a client component calling /checkout), so
each shopper gets their own limit.
Checkout: what happens
- Prices come from SupplySynk. Your site sends only product ids and quantities; any price it sends is ignored.
- Buy a variant by its own id (
variants[].idfromGET /products/{slug}), not the family's id. provideris one ofstore.checkout.methods[].providerandlocationIdone ofstore.checkout.locations[].id. Each way to pay may carry your note (note, e.g. "Ask at the counter"); show it.- The stock is held at the pickup location until the hold ends (48 hours for pay at pickup, unless you changed it), the shopper gets the "order received" email, and your owners and admins get a "New online order" email.
- The response has
orderNumber,total,next(what the shopper does now) andstatusUrl, the shopper's order page on your store. Links in emails go there too. - Not enough stock: a 409 error that lists each short item. Too many tries: 429, wait and try again.
Offers opt-in at checkout
When the shop has its "Get offers" sign-up on, GET /store returns
marketing: { version, email, whatsapp } (otherwise marketing: null).
Show one unticked checkbox per channel with exactly that wording (the
WhatsApp one only once the shopper typed a phone), make it clear they're not
needed to order, and send only what was ticked:
"marketingConsent": { "email": true, "whatsapp": false, "version": "2026-10-15" }
- A ticked box is a request, not consent. For email, the "order received" email carries a link to confirm, and one click records the consent with the wording, the time, and the IP address and browser of the checkout call (so place orders from the shopper's browser). WhatsApp waits for the shop to confirm.
- Left out or
false: nothing is recorded. With the sign-up off, it's ignored. It never affects the order, and the response doesn't say what happened. versionis required whenever a box istrue. A missing or unknownversion, or an unknown field (such assms), is a 400.
Limits
| Who calls | Limit |
|---|---|
| A browser on one of your websites | 120 calls a minute per shopper, 3,000 a minute for the key |
| Your server (no Origin header) | 60 calls a minute for the key; cache responses |
| Checkout | 10 orders per shopper and 120 per store every 10 minutes (as in your store) |
Errors look like { "error": { "code": "origin_not_allowed", "message": "…" } }.
Common codes: invalid_key and key_revoked (401), origin_not_allowed
(403: add the website to the key), store_not_found (404: publish the
store), rate_limited (429).
The full reference for developers is at
https://www.supplysynk.com/api/storefront/v1/openapi.json. More about
SupplySynk's other APIs: API and webhooks.