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 awba_token, everything that page says about/api/v1applies 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.
- An account and an organization. An app is published by an organization,
not a person — create one at
/orgsif you have none. - A store you are a member of, to install on while you build.
- Two URLs: a redirect URI (where the authorization code is returned)
and an embed URL (the page framed inside Manage). Both must be public
httpswhen you submit — but while the version is a draft, both may point atlocalhost.
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_idis 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.stateis 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_urithat 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:
- the scopes the merchant granted, and
- 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 orlocalStorage. 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: ahellonaming a version the screen does not speak getsunsupportedback 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:
- The URLs are re-checked, with the
localhostexception withdrawn — the §1 check, run a third time and one notch stricter. Two things fail here: a draft still pointing atlocalhost, and a hostname that was public when you saved but resolves to a private address at submit time. Both fail with400 invalid_url. - Your embed page is fetched, and its framing headers read. If it answers
X-Frame-Options: DENYorSAMEORIGIN, or aContent-Security-Policy: frame-ancestorslist that excludes this platform, the submission is refused with422 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
localhostpast 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
clientIdkeeps its place. Rotating the client secret is different, and exists — §2. - Free apps only. There is no payment path; a version's price is
freeand 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_scopeis 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
verbslist inwelcomeis 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