Store Builder

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.
  • settings is passed through verbatim. Read it, change the keys you know, send the whole thing back.
  • publish cascades: 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:

  1. the key's own scopes must include the permission, and
  2. 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. DELETE trashes; 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