Store Builder

Build an app (/oauth + /api/v1)

Build something other merchants can install: register an app, walk a store through OAuth, call the API with the token you get, and submit a version for review.

An app is a program a merchant installs on their store. It has its own OAuth credentials, scoped access to that store's data, and a page of its own framed inside the merchant's Manage screens.

This page is for the developer building an app. Public API is the other half — it describes the wbk_ API key a merchant mints for their own tooling. Once your app holds a wba_ token, everything that page says about /api/v1 applies to you unchanged.

Where a button you have to press is named, the Vietnamese label follows in (vi: …).

The whole flow, in order:

create an app          → client id wbc_ + client secret wbs_
create a version       → the scopes, embed URL and redirect URI a reviewer looks at
install it yourself    → authorization code wbo_   (sandbox: works while still a draft)
exchange the code      → access token wba_ + refresh token wbr_
call /api/v1           → the merchant's data, within the scopes they granted
render your embed      → frame token wbf_, resolved back with us
submit for review      → approved, and any merchant can install

There is a CLI, and it shortens both ends of that list. Every step is an HTTP request and this page describes them all — that is the contract, and what you read when a call returns 401. But sb does the mechanical parts:

Step By hand With sb
write an app that receives the callback and renders the embed §5 and §7, yourself sb init my-app — a working app with both
create the app, the version, and install it §2, §3, §4 still by hand — sb makes no network call until those ids exist
push a version's scopes, URLs, blocks and island code §3 and Build a block, one curl per door sb deploy
re-upload an island on every edit one curl per save sb dev
submit for review §8 sb deploy --submit

Nothing sb does that curl cannot. If you would rather not install a tool, read straight on — nothing below assumes you have it.


1. Before you start

You need three things, and the third is where most first attempts stop.

  1. An account and an organization. An app is published by an organization, not a person — create one at /orgs if you have none.
  2. A store you are a member of, to install on while you build.
  3. Two URLs: a redirect URI (where the authorization code is returned) and an embed URL (the page framed inside Manage). Both must be public https when you submit — but while the version is a draft, both may point at localhost.

Developing on localhost — works on a draft, stops at submit

Every URL you register goes through the same SSRF guard the webhook endpoints use: it resolves the hostname and refuses loopback, private, link-local, multicast and unspecified addresses. The check runs when a draft is created, when it is edited, and again at submit.

There is exactly one exception: while a version is a draft, an unencrypted loopback address is accepted — for both the redirect URI and the embed URL. So this is a 201:

curl -X POST https://api.sbuilder.io.vn/api/orgs/org_yourorg/apps/app_1a2b3c4d/versions \
  -H "Authorization: Bearer <your user access token>" \
  -H "Content-Type: application/json" \
  -d '{"version":"1.0.0","scopes":["products.read"],"embedUrl":"http://localhost:5173/embed","redirectUri":"http://localhost:3000/callback"}'

You get the whole development loop on your own machine: install your draft (§4), the authorization code lands on your local server, you exchange it (§5), and your local dev server is what the embed loads (§7, with a sandbox install).

The exact shape allowed, because it is narrower than "localhost works now":

Registered on a draft Result
http://localhost:3000/callback accepted
http://127.0.0.1:3000/callback (anything in 127.0.0.0/8) accepted
http://[::1]:3000/callback accepted
https://localhost:3000/callback refused — the exception is for unencrypted connections only
http://apps.example.com/callback refused — unencrypted to a public host is still unencrypted
http://169.254.169.254/…, http://10.0.0.5/…, http://192.168.1.10/… refused — metadata services and private ranges are not loopback

Where it stops: submit. Submitting a draft still pointing at localhost is refused with 400:

{ "error": "the embed URL or redirect URI is not usable — it must be a public https address", "code": "invalid_url" }

A version in the review queue is one a reviewer will open and a merchant will install, and neither sits at your computer. So the last edit before submitting is changing both URLs to public https — a draft is still editable, so that is a PUT, not a new version.

A tunnel (ngrok, Cloudflare Tunnel) is still worth having, for the one thing loopback cannot give you: testing the exact URLs you will submit, over real https.


2. Create the app — wbc_ and wbs_

In the app: Developer (vi: Nhà phát triển) in the left rail, at /developer → Create an app (vi: Tạo ứng dụng). Name it; nothing else is asked.

The equivalent call, if you script it — the developer routes are on the private API and authenticate with your own user access token (what POST /api/auth/login returns), not the app's credentials:

curl -X POST https://api.sbuilder.io.vn/api/orgs/org_yourorg/apps \
  -H "Authorization: Bearer <your user access token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"Shipping Helper"}'
{
  "app": {
    "id": "app_1a2b3c4d",
    "developerOrgId": "org_yourorg",
    "name": "Shipping Helper",
    "clientId": "wbc_9f8e7d6c",
    "status": "active"
  },
  "clientSecret": "wbs_Zm9vYmFyYmF6cXV4..."
}

201. This is the only response that ever carries clientSecret. It is stored as a SHA-256 hash, so nobody — including the platform's operators — can read it back. Put wbs_… straight into your secret store as it leaves this response.

If it leaks: rotate

In the app: open the app → Rotate secret (vi: Đổi mã bí mật).

curl -X POST https://api.sbuilder.io.vn/api/orgs/org_yourorg/apps/app_1a2b3c4d/secret \
  -H "Authorization: Bearer <your user access token>"

200, with the same rule as creation: the response is the only place the new secret exists. Your clientId does not change — every merchant's install names it.

Rotating invalidates nothing already granted. No merchant is signed out, no install re-authorizes, and every wba_ token you hold keeps working: the client secret authenticates YOU at the token exchange, while access tokens are checked against the install. What changes is the next exchange — the old secret stops working there, so update your configuration before the current access token expires.

Every route in this section refuses a caller who is not a member of {orgId} with 403 not_a_member, before any lookup — so a stranger holding an organization id cannot learn from a status code whether an app exists.


3. Create a version — what review actually looks at

Review attaches to a version, never to the app. Everything a reviewer and a merchant read lives on it: the scopes it asks for, the page it embeds, the one address it may receive codes at, and its pitch.

In the app: open the app → New version (vi: Phiên bản mới).

curl -X POST https://api.sbuilder.io.vn/api/orgs/org_yourorg/apps/app_1a2b3c4d/versions \
  -H "Authorization: Bearer <your user access token>" \
  -H "Content-Type: application/json" \
  -d '{"version":"1.0.0","scopes":["products.read","orders.read"],"embedUrl":"https://apps.example.com/embed","redirectUri":"https://apps.example.com/oauth/callback","summary":"Rate-shops carriers at checkout.","description":"Longer copy the merchant reads before consenting."}'
{ "version": { "id": "apv_5e6f7a8b", "appId": "app_1a2b3c4d", "status": "draft", "...": "..." } }

201, and the status is always draft — set by the server, never taken from you, because a client that could post an already-approved version would have skipped review.

Field The rules that will bite
scopes At least one, from the <domain>.read / <domain>.write vocabulary §4 of the Public API lists. Ask for as little as works.
embedUrl Public https — or unencrypted localhost while the version is a draft (§1). Its page must allow this platform to frame it — checked at submit, §8.
redirectUri Public https — or unencrypted localhost while a draft. Exactly one, matched exactly — no prefix, no wildcard.
summary, description What the merchant reads on the consent screen. Optional; an empty consent screen is still a consent screen, just a worse one.

Editing: PUT …/versions/{versionId} rewrites a draft only. Once a version has been submitted or reviewed it answers 409 not_draft — review pins a payload. Create a new version.


4. Install your own draft — the development loop

You do not have to wait for approval to run this flow. A developer installing their own unreviewed app is a permitted exception (a sandbox install), narrowed twice: you must already reach that store, and belong to the organization publishing the app.

In the developer portal: open the app, find the version, and press Install on my store (vi: Cài lên cửa hàng của tôi). It picks a store you belong to, assembles the URL below from the version's registered client_id, version_id and redirect_uri, adds a state, and shows it to you before opening. The rest of this section is what that button builds — what you need when scripting it yourself or reading a refusal.

Send the merchant's browser — yours, for now — to /oauth/authorize:

https://api.sbuilder.io.vn/oauth/authorize
  ?client_id=wbc_9f8e7d6c
  &version_id=apv_5e6f7a8b
  &redirect_uri=https://apps.example.com/oauth/callback
  &site_id=<the store's id>
  &state=<your own anti-forgery value>
  • site_id is not optional in practice. The consent screen cannot approve without it. When a merchant starts an install from their store's Apps (vi: Ứng dụng) screen, the app list fills it in; when your app starts the flow, you supply it.
  • state is returned untouched to your redirect URI. Use it.
  • The server checks before it redirects — an unknown client, a version of another app, or a redirect_uri that is not the registered one is refused right here as JSON, never bounced to the address you asked for.

The browser lands on the consent screen: your app's name, your organization, and exactly the scopes the version asks for. An unreviewed version is labelled as such.

When they approve, they are sent to:

https://apps.example.com/oauth/callback?code=wbo_...&state=<yours>

The merchant may grant a subset of what you asked for — and nothing tells you which. The token response carries no scope field and there is no lookup endpoint, so the only signal is a 403 insufficient_scope on the very call that needs the permission you were not granted. Design for it: ask for as little as possible, and degrade a feature rather than assume a scope you asked for is a scope you hold.


5. Exchange the code — wba_ and wbr_

Server to server, with your credentials. No browser, no user session.

curl -X POST https://api.sbuilder.io.vn/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"grantType":"authorization_code","clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","code":"wbo_...","redirectUri":"https://apps.example.com/oauth/callback"}'
{
  "accessToken": "wba_ins_1a2b3c4d.site_9f8e.1771234567.AbCdEf...",
  "refreshToken": "wbr_...",
  "tokenType": "Bearer",
  "expiresAt": "2026-08-17T10:00:00Z"
}

The authorization code lives 60 seconds and is single-use. redirectUri is matched as part of the exchange — and a mismatch does not consume the code, so a typo does not burn a code the retry could have used.

Renew, before or after expiresAt:

curl -X POST https://api.sbuilder.io.vn/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"grantType":"refresh_token","clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","refreshToken":"wbr_..."}'

Refresh tokens rotate. The one you send is burned and a new one comes back in the same response; store the new one, or the next renewal gets 400 invalid_refresh_token.

Remove yourself from a store — the app's side of an uninstall:

curl -X POST https://api.sbuilder.io.vn/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{"clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","accessToken":"wba_..."}'

204. It takes an access token rather than an install id, deliberately: the token names the install and your credentials prove it is yours, so no app can uninstall another.


6. Call an endpoint

curl https://api.sbuilder.io.vn/api/v1/products \
  -H "Authorization: Bearer wba_..."
{ "products": [ { "id": "prod_...", "name": "..." } ], "total": 42 }

That is the whole difference between an app and the merchant's own key: the credential. There is no {siteId} in a /api/v1 path — the store is implied by the token. Every resource, envelope, query and error is described once, in Public API.

Each call is gated twice, and the second gate surprises people:

  1. the scopes the merchant granted, and
  2. the current role of the member who installed the app.

An app never does more than the person who installed it, resolved per request — an install made by an admin who is later demoted to viewer loses write on the next call. The refusal is 403 insufficient_scope: the merchant must reinstall and consent to that permission.

# Sending the other half of the token pair is a common first mistake, and it is named:
curl https://api.sbuilder.io.vn/api/v1/products -H "Authorization: Bearer wbr_..."
# → 401 {"error":"that is a refresh token; exchange it at /oauth/token for an wba_ access token first",
#        "code":"refresh_token_not_accepted"}

7. Render your embed — wbf_

When a merchant opens your app inside Manage, the platform mints a short-lived frame token and loads your embed URL in an iframe with two query parameters:

https://apps.example.com/embed?wb_frame_token=wbf_...&wb_site_id=site_9f8e

The token is not an authorization. It answers "who is looking" — an install, a store, the member at the screen — and nothing else. What your app may do is still bounded by its wba_ token.

Never trust it as received. Resolve it from your own server, with your credentials:

curl -X POST https://api.sbuilder.io.vn/oauth/frame \
  -H "Content-Type: application/json" \
  -d '{"clientId":"wbc_9f8e7d6c","clientSecret":"wbs_...","token":"wbf_..."}'
{ "installId": "ins_1a2b3c4d", "siteId": "site_9f8e", "memberId": "8c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f" }

401 invalid_frame_token if the token is forged or expired, 401 unknown_client if it was minted for a different app — no app resolves another's frame token. The install is re-read as part of answering, so a merchant who disabled your app ten seconds ago gets a refusal here, not a stale yes.

Three constraints on the embedded page

  • The iframe is sandboxed without allow-same-origin. Your page renders, runs scripts, submits forms and opens links — but lives in an opaque origin, so it cannot read or write cookies or localStorage. Carry your session in the token you were handed, not in a cookie.
  • The token is short-lived (10 minutes) and reissued on every open. Exchange it for a session of your own immediately; do not keep it as one.
  • Your page must allow framing. See §8 — this is what gets submissions refused.

The app bridge — talking to the Manage screen around you

Your page can ask the screen around it for a few things over postMessage. The channel is versioned, authenticated, and deliberately small. No verb reaches the platform's server: each one changes something on the merchant's own screen. If you want store data, the answer is an API call.

Start with a handshake. The screen ignores every verb until you have said hello and been welcomed:

const params = new URLSearchParams(location.search);
const nonce = params.get('wb_bridge_nonce');       // see below — always send it back
const CHANNEL = 'wb.app.bridge';
const ADMIN_ORIGIN = 'https://<the admin origin you were given at registration>';

let ready = false;
window.addEventListener('message', (e) => {
  // The admin origin is a constant you were told once — never read it from a
  // URL parameter: whoever frames you supplies those parameters.
  if (e.origin !== ADMIN_ORIGIN) return;
  const m = e.data;
  if (!m || m.channel !== CHANNEL) return;
  if (m.type === 'welcome') { ready = true; resize(); }
  if (m.type === 'unsupported') {
    console.warn('app bridge versions supported here:', m.supported);
  }
});

parent.postMessage({ channel: CHANNEL, v: 1, nonce, type: 'hello' }, ADMIN_ORIGIN);

function resize() {
  if (!ready) return;
  parent.postMessage(
    { channel: CHANNEL, v: 1, nonce, type: 'resize', height: document.body.scrollHeight },
    ADMIN_ORIGIN,
  );
}
new ResizeObserver(resize).observe(document.body);

Send wb_bridge_nonce back in every message. It is the third query parameter beside wb_frame_token and wb_site_id, and it is a different credential from the frame token: it never leaves the browser, it is minted fresh on every open, and it authorizes nothing. It exists because your iframe is sandboxed without allow-same-origin, so every message you send reaches the screen with event.origin === "null" — the string every sandboxed iframe on the internet sends. What the screen checks instead is that the message came from the very window it framed and carries the nonce only your document ever saw. Lose the nonce and the bridge is silent. Your side checks origin properly: the screen is not sandboxed, so its replies reach you with its real origin.

Verb Payload What it does
resize height — a number, CSS pixels Sets your iframe's height. Clamped to 200–4000; an out-of-range value is pulled in, not refused. A non-finite value is ignored.
toast message — one line, up to 200 characters. tone — error, or anything else for neutral Shows a line in the merchant's chrome, titled with your app's name. An empty or over-long message is refused, not truncated. At most once a second — the surplus is dropped.
confirm id — your own correlation key, up to 64 characters. message — up to 300 characters Asks the merchant a yes/no in a dialog titled with your app's name, and replies { type: 'confirm-result', id, confirmed }.
navigate to — a path Takes the merchant's admin to to, inside this store's own Manage area.

confirm: one question at a time. A second sent while the first is open returns immediately with { confirmed: false, refused: 'busy' } — a refusal, not the merchant saying no. Cancel, Escape and clicking outside all return confirmed: false. If they close the screen with your question open, no answer comes at all: attach your state to the id and let it expire.

navigate: the bound is /manage/<wb_site_id>/… — the store the merchant opened you on. Refused, silently on your side and audibly in the merchant's console: absolute URLs, //host, another store's screens, the page editor, .. segments, and anything over 512 characters. Nothing gets past a permission check — the route they land on re-checks their role exactly as if they had clicked it.

Degrade gently. No welcome means you are framed by something that does not speak this protocol. Carry on: render at a sensible fixed height. Never let your page hang waiting for a handshake.

The protocol version is 1, and it is negotiated: a hello naming a version the screen does not speak gets unsupported back with the versions it does. New verbs arrive without a version bump; a change to an existing verb's meaning does not.


8. Submit for review

In the app: open the draft version → Submit for review (vi: Gửi duyệt). Submitting locks the version; to change anything afterwards, create a new version.

curl -X POST https://api.sbuilder.io.vn/api/orgs/org_yourorg/apps/app_1a2b3c4d/versions/apv_5e6f7a8b/submit \
  -H "Authorization: Bearer <your user access token>"

200, and the version moves to pending. Two checks run here, and the second runs nowhere else:

  1. The URLs are re-checked, with the localhost exception withdrawn — the §1 check, run a third time and one notch stricter. Two things fail here: a draft still pointing at localhost, and a hostname that was public when you saved but resolves to a private address at submit time. Both fail with 400 invalid_url.
  2. Your embed page is fetched, and its framing headers read. If it answers X-Frame-Options: DENY or SAMEORIGIN, or a Content-Security-Policy: frame-ancestors list that excludes this platform, the submission is refused with 422 embed_refuses_framing.

The second check is worth preparing for, because the alternative is worse: a merchant discovers it as a permanently blank frame, days later, with nothing in any log. Serve your embed with a frame-ancestors that names the platform's Manage origin (* also passes). A network error or a 4xx/5xx from your own server is not treated as a refusal, so a temporary outage does not make a version permanently unsubmittable.

A reviewer then approves, or rejects with a reason — the reason comes back in the app read the developer portal already makes, so you see why.

Once approved, the version is installable by any merchant, and the draft-only URL rule ends with it: an approved app's redirect_uri must be https, no exceptions.


9. Debugging: the logs

Every /api/v1 call your installs make is recorded, by route — never by URL, so nothing in it carries another merchant's identifiers.

curl "https://api.sbuilder.io.vn/api/orgs/org_yourorg/apps/app_1a2b3c4d/requests?limit=50" \
  -H "Authorization: Bearer <your user access token>"
{
  "requests": [
    { "id": "…", "method": "GET", "path": "/api/v1/products/{id}", "status": 403, "code": "insufficient_scope", "at": "2026-08-17T09:41:00Z" }
  ],
  "total": 1
}

limit defaults to 50 and maxes at 200. The log is kept for 7 days by default. A record is pushed onto a buffered channel after your response is written, and dropped rather than queued when it is full — logging never slows or breaks your call. For the same reason a server with no log wired answers 503 request_log_unavailable rather than an empty list, because an empty list reads as "your app never called".

The app's install and uninstall history is at GET …/apps/{appId}/events, paged the same way, kept for 90 days.


10. The errors worth branching on

The OAuth surface separates its refusals rather than folding them into one invalid_request. Each row has a different fix.

Status code Meaning
401 unknown_client Unknown client_id, wrong client_secret, or a credential that does not own what it is asking about
400 redirect_uri_mismatch Not exactly the URI registered on this version, or not the one the code was issued for
400 insecure_redirect_uri Plain http on a version that has left draft
400 code_already_used The authorization code was already redeemed — a security event, not a timing issue
400 code_expired Older than 60 seconds. Start the flow again
400 invalid_refresh_token Unknown, rotated away, or revoked
400 unsupported_grant_type grantType must be authorization_code or refresh_token
400 scope_not_requested A consent granting something the version never asked for
400 no_scopes A consent granting nothing
403 forbidden The consenting person cannot install on that store
409 not_installable The version is not approved (and no sandbox exception applies), or the app is suspended
401 install_unavailable The install has gone or been disabled
401 invalid_frame_token A malformed, forged or expired wbf_ token. One minted for a different app is unknown_client
404 unknown_endpoint No such /oauth route

On the developer routes:

Status code Meaning
403 not_a_member You are not a member of that organization
404 (no code) No such app in this organization — deliberately identical to a non-existent id
400 invalid_url The embed URL or redirect URI is not a usable public address — or a localhost one being submitted (§1)
400 no_scopes / invalid_name / no_developer The version asks for nothing / the app has no name / no owning organization
409 not_draft That version has been submitted or reviewed. Create a new one
422 embed_refuses_framing Your embed page does not let this platform frame it (§8)
503 request_log_unavailable The request log is not configured on this server

Everything a wba_ token touches on /api/v1 uses that surface's own table — §5 of the Public API.


11. Lifetimes

Thing Lives Why
Authorization code wbo_ 60 seconds, single-use The only legitimate holder redeems it immediately, server to server
Access token wba_ 1 hour Not individually revocable, so short; the install is re-read on every call, so uninstall takes effect at once
Refresh token wbr_ 60 days, rotating Past that the merchant consents again
Frame token wbf_ 10 minutes Reissued on every open
Request log 7 days by default Long enough to debug, short enough not to become a second database

12. What is not there

  • No localhost past the draft stage. A tunnel is still the only way to test the exact URLs you will submit.
  • No deleting an app. An app you no longer want is suspended by an administrator, and its clientId keeps its place. Rotating the client secret is different, and exists — §2.
  • Free apps only. There is no payment path; a version's price is free and the server sets it.
  • One redirect URI per version, matched exactly. A second environment (staging) needs a second version — keep it a permanent draft and install through the sandbox — or a second app.
  • No scope lookup. 403 insufficient_scope is the only signal (§4).
  • No public developer directory, and no moving an app between organizations.
  • The app bridge has four verbs, and only four (§7). It is not an SDK: nothing on it reaches the platform's server. The verbs list in welcome is the truth about what the deployment framing you accepts.

Everything a token reaches: Public API to read, the API console to fire a request at a live server.

Updated 26/09/2026