Public API (/api/v1)
Authenticate with an API key, read and write your store over HTTP, and understand the shape of every response and every error.
The API a partner, app or agency integration uses. Everything here is reachable with an API key or an installed app token — no session, no cookie, no user login.
This page is for reading. It answers what a key reaches, what PUT
replaces, and where a price actually lives — the questions you have before
your first call works. The API console
is a live Swagger UI of exactly this surface, generated from the handlers,
with try-it-out: paste a key, fire a request at a real server, read the real
response. It is the better tool once you know what you are sending, and it
tells nobody how to start — a list of operations cannot.
If you are building an app that merchants install — rather than
integrating against one store you already control — start at
Build an app: it covers the OAuth flow and the
wba_ token this surface then accepts. Everything below applies to an app
unchanged once it holds a token.
1. Get a key
In the app: Manage store → Settings → API keys (vi: Quản lý cửa hàng → Cài đặt → Khoá API). Press New key (vi: Tạo khoá), name it, and tick the scopes it needs.
The secret is shown exactly once, at creation. It is stored as a SHA-256
hash, so nobody — including the platform's operators — can recover it. If it
is lost, revoke the key and mint another. The list only ever shows the
prefix (wbk_a1b2c3…), enough to tell keys apart and useless as a
credential.
A key belongs to one store. There is no key that spans stores; an agency operating five clients holds five keys.
2. Call it
curl https://api.sbuilder.io.vn/api/v1/products \
-H "Authorization: Bearer wbk_your_secret_here"
Two credentials, and one that is refused
| Prefix | What it is | Who holds it |
|---|---|---|
wbk_ |
An API key a merchant minted for their own tooling (§1) | The merchant, or whoever they gave it to |
wba_ |
An app access token, issued when a merchant installs a marketplace app | The app's own server |
wbr_ |
An app refresh token — NOT accepted here | The app; exchange it at /oauth/token first |
Both accepted kinds belong to exactly one store, are scoped, and are
revocable. Past the door they behave identically: the same endpoints, scope
rules and errors. A user's access token is refused — accepting one would
make this surface as powerful as whoever pasted it. Sending one answers
401 api_key_required.
Three rules that explain most of the surface
The store is implied by the key. There is no {siteId} anywhere in a v1
path. A key that could be pointed at another store by editing a URL would be
a key whose blast radius depends on the caller's honesty.
Every response is enveloped.
| Shape | Body |
|---|---|
| A list | {"products": [...], "total": 42} |
| One item | {"product": {...}} |
| Any error | {"error": "human message", "code": "machine_code"} |
total is the count before paging. Errors are always JSON — never plain
text — so a failure can be branched on.
Paging is ?limit= + ?offset=. limit defaults to 50 and maxes at 200;
asking for more is clamped, not refused.
3. What is there
products, orders, customers, pages, media, the blog
(articles + categories), webhooks, translations — and app-data,
for wba_ tokens only.
Products — full CRUD
GET /api/v1/products list
POST /api/v1/products create
GET /api/v1/products/{id} read
PUT /api/v1/products/{id} replace
DELETE /api/v1/products/{id} delete
Query: ?q= (name, slug, SKU, tags) · ?status=draft|active|archived ·
?sort=number|name|price|stock|created|updated (default number) ·
?dir=asc|desc · ?limit= · ?offset=. An unknown sort falls back to the
default rather than erroring.
# Create a product. PRICE LIVES ON THE VARIANT, never on the product: the
# product's priceCents is the lowest variant price, computed at write. Sent at
# the top level it is discarded.
curl -X POST https://api.sbuilder.io.vn/api/v1/products \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Cotton T-shirt","status":"active","variants":[{"sku":"TS-M","priceCents":250000,"stock":10}]}'
The server owns identity and derived data: ids, timestamps, the sequential number, the lowest price, total stock. Sending them is not an error — they are ignored.
Orders — read, plus a status patch
GET /api/v1/orders list
GET /api/v1/orders/{id} read
PATCH /api/v1/orders/{id} update status only
There is no POST, and that is a decision. Creating an order is checkout:
it consumes a discount code under a lock, enforces usage limits, and totals
against a live catalogue. A POST answers 405 orders_are_read_only, so an
integration's author knows the boundary is deliberate. One wrinkle: a POST
from a key without orders.write gets 403 insufficient_scope, not 405 —
scope is checked first (§4).
PATCH accepts only status, payment, fulfillment, all optional — an
absent field means unchanged:
# A warehouse marks a parcel shipped. It knows nothing about payment,
# and cannot erase it by not mentioning it.
curl -X PATCH https://api.sbuilder.io.vn/api/v1/orders/ord_123 \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"fulfillment":"fulfilled"}'
Line items, totals and the customer snapshot are history and cannot be rewritten through this route.
Customers — full CRUD
GET /api/v1/customers list
POST /api/v1/customers create
GET /api/v1/customers/{id} read
PUT /api/v1/customers/{id} replace
DELETE /api/v1/customers/{id} delete
ordersCount and totalSpentCents are computed at read. The merchant's
private note is not exposed — a support note is written for colleagues,
not for an integration to show back to the customer.
Pages — metadata CRUD, plus publish
GET /api/v1/pages list
POST /api/v1/pages create
GET /api/v1/pages/{id} read
PATCH /api/v1/pages/{id} update (absent means "unchanged")
DELETE /api/v1/pages/{id} delete
POST /api/v1/pages/{id}/publish compile the draft into the live page
The page body is not here, and will not be. A page's document is the builder's node tree, whose shape belongs to the render contract and changes with every new element. Publishing it would make every new element a breaking API change. Here a page is a thing with a URL — list, create, rename, reslug, reorder, delete, publish. Lay pages out in the editor; drive the lifecycle from here.
Three things worth knowing:
- The first page of a site becomes its homepage, and so does any page sent
with
isHomepage: true— demoting the one that held the role. settingsis passed through verbatim. Read it, change the keys you know, send the whole thing back.publishcascades: a page sharing a global section with others causes them to be republished too.
Media — the library, including upload
GET /api/v1/media list
POST /api/v1/media upload (multipart/form-data)
GET /api/v1/media/{id} read
PATCH /api/v1/media/{id} rename and/or move folder
DELETE /api/v1/media/{id} move to trash
POST /api/v1/media/{id}/restore take it out of the trash
GET /api/v1/media-folders folder tree
POST /api/v1/media-folders create a folder
Upload is the only non-JSON request:
curl -X POST https://api.sbuilder.io.vn/api/v1/media \
-H "Authorization: Bearer $SB_KEY" \
-F file=@hero.jpg -F folderId=mdf_123 -F name="Autumn hero"
Images, video and fonts (woff2/woff/ttf/otf), up to 25 MiB. Quota is measured
from the upload itself. Over quota answers 413 with used, limit and
incoming in bytes, refused before anything is stored.
DELETE trashes; it does not destroy, and it frees no quota. A published
page may still serve the file, so permanent deletion stays in the app, beside
the screen that shows what a deletion would break.
An asset carries a url, never an object key — the prefix is joined at read,
so a CDN change rewrites no rows. It is the one field here you should not
cache forever.
Blog — articles and categories, full CRUD
GET /api/v1/articles list
POST /api/v1/articles create
GET /api/v1/articles/{id} read
PUT /api/v1/articles/{id} replace
DELETE /api/v1/articles/{id} delete
GET /api/v1/blog-categories list
POST /api/v1/blog-categories create
GET /api/v1/blog-categories/{id} read
PUT /api/v1/blog-categories/{id} replace
DELETE /api/v1/blog-categories/{id} delete
Both sit under the same pair of permissions, blog.read / blog.write.
content is sanitised at write — script tags and event handlers are
stripped, because this body renders verbatim into a storefront page. Read the
response rather than assuming what you sent is what is served.
Two asymmetries: an article's slug is derived from its title when omitted,
and de-duplicated on collision; a category's slug is required and gets
neither. An article whose bodyType is page carries a builder document
instead of HTML — reading is fine, writing gets 409 unsupported_body_type,
because a PUT would not edit it but erase it. Unknown categoryIds are
dropped rather than refused; compare against the response to see which stuck.
Translations — the store's content in a second language
GET /api/v1/translations?locale=&entityType=&entityId= one entity's translations
GET /api/v1/translations?locale=&entityType=&entityIds=a,b many entities in one call
PUT /api/v1/translations write
DELETE /api/v1/translations?locale=&entityType=&entityId=&field= clear one field
GET /api/v1/translations/progress?locale= how much is translated
entityType is one of product, category, article, blogCategory,
node. Reading one entity returns a translations list; reading many returns
byEntity, keyed by id — an app syncing a catalogue asks for a page of ids at
a time instead of one call per product.
curl -X PUT https://api.sbuilder.io.vn/api/v1/translations \
-H "Authorization: Bearer $SB_KEY" -H "Content-Type: application/json" \
-d '{"entries":[{"locale":"en","entityType":"product","entityId":"prd_1a2b","field":"name","value":"Cotton T-shirt","source":"human"}]}'
# → {"written": 1}
source becomes human only when you say so explicitly; absent or anything
else is machine. That is deliberate: a machine translation wearing a
person's badge is the hardest failure to spot. Its own permission domain,
translations.read / translations.write, because translating a catalogue
is a job a shop hands to a translator or an app, and neither should gain the
right to edit the products they are translating. The full picture is in
Selling in several languages.
Webhooks — register a URL to be called
GET /api/v1/webhooks list
POST /api/v1/webhooks register (returns the endpoint AND the signing secret)
GET /api/v1/webhooks/{id} read
PUT /api/v1/webhooks/{id} replace
DELETE /api/v1/webhooks/{id} remove
GET /api/v1/webhooks/{id}/deliveries delivery history — §7
Register a URL once, and the platform calls it — signed — as order.created,
product.updated and the rest of the catalogue happen. It is also the reason
a key belongs to one store: an agency running fifty stores registers fifty
times through this route rather than clicking through fifty settings screens.
curl -X POST https://api.sbuilder.io.vn/api/v1/webhooks \
-H "Authorization: Bearer $SB_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/sbuilder","events":["order.created","order.updated"]}'
The response carries the signing secret, and it is the only response on
this surface that does — a sibling field to webhook:
{ "webhook": { "id": "whe_…", "url": "…", "events": [...], "status": "active", … },
"secret": "whsec_…" }
No route reads it back — GET, list and PUT return webhook with no secret.
Lose it and the fix is to delete the endpoint and register a new one:
webhooks.read reaches the lowest role on a store, and a GET that returned
the signing key would let that role forge deliveries.
The URL is checked before anything is stored, on create and on replace.
Loopback, link-local (the cloud metadata service lives there), or a hostname
resolving to either gets 400 blocked_url. events must name at least one
type the platform emits — an endpoint subscribed to nothing never fires, and
there is no error to notice.
PUT is a replace: send every field you want to keep. status accepts
active and disabled; failing is a server-set label after three
consecutive failed deliveries, and a PUT trying to set it gets
400 invalid_status — unless the endpoint you are replacing was already
failing, so read-modify-write works on the very endpoint you are fixing.
App data — for wba_ tokens
GET /api/v1/app-data this app's values on this store
PUT /api/v1/app-data set a value, named by its key
DELETE /api/v1/app-data/{key} delete one
Values an installed app stores on a store, for the merchant's pages to bind to — a reviews app keeps its ratings here, and the server prints them straight into the HTML. The namespace comes from the token, so there is no app id in the body. The whole model, limits, and how a block reads it: Build a block.
4. Scopes
A key holds a set of <domain>.read / <domain>.write permissions. The
domains: products, orders, customers, discounts, blog,
integrations, pages, theme, media, codefiles, webhooks, forms,
translations, appdata — the same vocabulary the app's roles use, so a key
is never granted something the product does not model.
Every request is gated twice, and scope is checked first:
- the key's own scopes must include the permission, and
- the member who created the key must still hold it through their role.
The second is what keeps a key safe over time: a key made by an admin who is later demoted to viewer loses write on the very next request, with no revocation sweep. Permission is resolved live, never snapshotted at creation.
A key missing a scope gets 403 insufficient_scope — not 404, so a caller
can tell "not allowed" from "does not exist".
5. The errors worth branching on
| Status | code |
Meaning |
|---|---|---|
| 401 | missing_credential |
No Authorization header at all |
| 401 | api_key_required |
A header, but neither a wbk_ key nor a wba_ app token (a user's session token, say) |
| 401 | refresh_token_not_accepted |
A wbr_. Exchange it at /oauth/token for a wba_ first |
| 401 | invalid_credential |
Looks like a key, matches none |
| 401 | key_revoked |
It existed and was disabled |
| 401 | app_token_invalid |
A forged or expired app token, or its app is no longer installed on that store. Three cases, ONE answer, deliberately: the fix is the same — reinstall |
| 403 | insufficient_scope |
The key was never granted this permission |
| 403 | key_unusable |
The key's stored scopes are no longer valid — mint a new key |
| 404 | not_found |
No such row in this key's store |
| 404 | unknown_resource |
No such v1 resource |
| 405 | orders_are_read_only |
A deliberate boundary |
| 409 | duplicate_email / duplicate_sku / duplicate_slug / duplicate_name |
Which field collided |
| 409 | unsupported_body_type |
This article's body is a builder document; edit it in the editor |
| 409 | nothing_to_publish |
The page has no saved draft |
| 409 | cycle_detected |
A category moved under its own descendant |
| 409 | too_many_keys |
The app already stores 500 values on this store |
| 413 | quota_exceeded |
Out of storage. The body carries used, limit, incoming |
| 429 | rate_limited |
Slow down; the limit is per key |
| 503 | resource_unavailable |
The route is real; this server has not configured that resource (media without an object store, an app token without a marketplace). Retry later |
| 400 | invalid_body / invalid_status / invalid_price / invalid_slug / invalid_translation / invalid_owner / invalid_key |
About the request |
| 400 | missing_file / empty_file / unsupported_type |
About an upload |
| 400 | blocked_url |
A webhook URL resolving somewhere the platform may not point |
| 400 | no_events / invalid_event |
A webhook subscribed to nothing, or naming an event type the platform does not emit |
The 401 codes are separated on purpose: "you sent nothing", "you sent the wrong kind of thing", "you sent the wrong HALF of the app's credential pair", "this key is not real" and "this key was revoked" need different fixes.
6. What is not there
Four domains (discounts, theme, integrations, codefiles) exist in the
scope vocabulary; what they lack is a frozen public shape. Two things are
absent by decision:
- The page document, and page-type article bodies. They are the builder's node tree; freezing their shape would freeze the render contract with them. If that boundary ever moves it will be through a separately versioned document endpoint.
- Permanently deleting a media asset.
DELETEtrashes; purging can break a live page with no reference count to say which, so it stays in the app.
7. Webhooks: what actually lands
The event catalogue
Ten types, and the list is a frozen public contract: adding is free, renaming or removing breaks every receiver at once.
| Event | Fires when |
|---|---|
order.created |
An order is placed |
order.updated |
An order's status, payment or fulfillment changes |
customer.created |
A customer is added |
customer.updated |
A customer record changes |
product.created |
A product is created |
product.updated |
A product changes |
product.deleted |
A product is removed |
page.published |
A page goes live (POST /pages/{id}/publish) |
course.enrolled |
The Courses app enrols a student |
course.revoked |
The Courses app takes access back — both, so an LMS mirroring access does not keep a refunded student forever |
An endpoint's events must name at least one of these; anything else gets
400 invalid_event.
The envelope
Every delivery is a POST with this body, whatever the event:
{
"id": "evt_01h...",
"type": "order.created",
"createdAt": "2026-08-14T10:00:00Z",
"data": { "...": "the event's own payload" }
}
id is stable across every attempt of one delivery — it is also the
X-WB-Event-Id header — and it is the key for your de-duplication.
| Header | Carries |
|---|---|
X-WB-Signature |
sha256=<hex> — see Verifying |
X-WB-Timestamp |
Send time, RFC3339 — also the value folded into the signature |
X-WB-Event-Id |
The same string as id |
X-WB-Event-Type |
The same string as type |
X-WB-Attempt |
1 the first time, incrementing on each retry |
Delivery guarantees
At least once, never exactly once. A delivery the platform believes failed
— a timeout, a dropped connection, a 5xx — is resent in full, even when the
receiver actually processed it and only the reply was lost. Duplicates
will happen. A receiver that turns order.created straight into a charge
without checking id will eventually do it twice. Record the ids you have
handled, and let a repeat pass through.
No ordering. Deliveries go out in the order they come due, not the order
events happened: a retry waits out its backoff, so a newer event for the same
resource can land first. If you need sequence, read the resource's own
updatedAt.
The retry schedule
Seven attempts, the first immediate, each later one further out:
| Attempt | Sent at |
|---|---|
| 1 | Immediately |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +30 minutes |
| 5 | +2 hours |
| 6 | +6 hours |
| 7 | +24 hours |
About 32.6 hours from first to last — enough to survive a deploy, an expired
certificate or a bad night. After the 7th failure the delivery is marked
dead. That does not disable the endpoint — it keeps receiving new
events.
Verifying a delivery is really from us
The signed string is <unix-seconds>.<raw body> — not the
X-WB-Timestamp header verbatim. The header is RFC3339; the HMAC'd string is
the same instant in Unix seconds. Parse the header, take its epoch seconds,
and build the string yourself:
const crypto = require('crypto');
function isGenuine(secret, timestampHeader, rawBody, signatureHeader) {
const unixSeconds = Math.floor(new Date(timestampHeader).getTime() / 1000);
const signedString = `${unixSeconds}.${rawBody}`;
const expected = 'sha256=' +
crypto.createHmac('sha256', secret).update(signedString).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
// Constant-time compare, not `===`: a byte-by-byte compare returns at the
// first mismatch, and its timing leaks how many leading characters an
// attacker has guessed right.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
rawBody must be the exact bytes received, before any JSON parsing — a
framework that re-serialises the body before your handler sees it produces a
string that never matches, against a signature that was never wrong.
The timestamp inside the signed string is deliberate. A signature over the body alone never expires: capture one real delivery and it replays forever. Folding the timestamp in lets the receiver refuse anything older than a few minutes. The platform imposes no tolerance for you — its width is yours.
Checking it actually arrives
GET /api/v1/webhooks/{id}/deliveries returns a page of that endpoint's
attempts, newest first — 50 without ?limit:
{ "deliveries": [
{ "id": "whd_…", "endpointId": "whe_…", "eventId": "evt_…",
"eventType": "order.created", "attempt": 2, "status": "delivered",
"nextAttemptAt": "…", "lastStatusCode": 200, "lastError": "",
"createdAt": "…", "deliveredAt": "…" }
],
"total": 1 }
payload is never included. webhooks.read can be granted on its own,
and a delivery's payload is the order or customer record that triggered it;
returning it here would make that independence fiction. If you need to know
exactly what was sent, the envelope your receiver already has is it.
This route has no ?offset, permanently. You always get the endpoint's
MOST RECENT deliveries; older history is unreachable through the API once a
busy endpoint has produced more than limit since you last looked. If you
need the whole history, track id on your receiver; this route answers "is
it working".
The SSRF refusal
A webhook URL is checked before it is stored, on create and on replace:
private, loopback and link-local addresses are refused, and — because a
hostname's DNS answer can change afterwards — the resolved address is checked
again at connect time, never following redirects. Pointing at an internal
address gets 400 blocked_url immediately.
Updated 26/09/2026