Build an app (/oauth + /api/v1)
Build something other merchants install: register the app, take a store through OAuth, call the API with the token you get back, and submit a version for review.
An app is a program a merchant installs on their store. It gets its own OAuth credentials, its own 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 the app.
/api-docsis the other half — it documents thewbk_API key a merchant mints for their own tooling, which is a different credential for a different audience. Once your app holds awba_token, everything that page says about/api/v1applies to you unchanged.
This page is written in English, and the app ships in English and Vietnamese.
Where it names a button you have to press, the Vietnamese label follows in
(vi: …) — a path you cannot find in your own session is not an instruction.
The whole flow, in order:
create an app → wbc_ client id + wbs_ client secret
create a version → the scopes, the embed URL, the redirect URI a review judges
install it yourself → wbo_ authorization code (sandbox: works while still a draft)
exchange the code → wba_ access token + wbr_ refresh token
call /api/v1 → the merchant's data, bounded by the scopes they granted
open the embedded page → wbf_ frame token, resolved back to us
submit for review → approved, and every merchant can install it
There is a CLI, and it shortens the ends of that list
Every step above is an HTTP request, and this page documents all of them —
that is the contract, and it is what you read when a call comes back 401. But
you do not have to type the first and last stretches by hand. A small tool,
sb, covers them:
| Step above | By hand | With sb |
|---|---|---|
| write an app that can receive the callback and render the embed | §5 and §7, yours to write | sb init my-app — a running app with both, no dependencies |
| create the app, the version, and install it yourself | §2, §3, §4 | still by hand — sb makes no network calls until it has those ids |
| push a version's scopes, URLs, blocks and island code | §3 and /block-docs, one curl per door |
sb deploy, in dependency order |
| re-upload an island every time you edit it | one curl per save |
sb dev |
| submit for review | §8 | sb deploy --submit |
It is a faster path through the same territory, not a replacement for it.
Nothing sb does is unavailable to curl, the requests it sends are the ones
this page prints, and its refusals are the server's refusals with the next
action added. If you would rather not install a tool, or you are building your
own release pipeline, read straight on — nothing below assumes you have it.
Getting it is one command: npx @sbuilder/cli --help. /cli-docs
covers installing it properly, every command, every flag, and what each one
refuses.
1. Before you start
You need three things, and the third one is where most first attempts stop.
- An account and an organization. An app is published by an organization,
not by a person — create one at
/orgsif you have none. The developer portal says so on its own empty state. - A store you are a member of, to install onto while you build.
- Two URLs: a redirect URI (where an authorization code is delivered) and
an embed URL (the page framed inside Manage). Both are checked. Both must
be public
httpsby the time you submit — but while your version is a draft, both may point atlocalhost, which is the next section.
Developing against localhost — it works on a draft, and 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. That check runs when a draft version is created, again when it is edited, and again at submit.
There is exactly one exception, and it is the one you need: while a version is
a draft, a plaintext loopback address is accepted — for the redirect URI and for
the embed URL both. So this is a 201:
curl -X POST https://api.your-host/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"}'
and so is editing that draft afterwards, to a different port or a different path. You get the whole development loop against your own machine: install your own draft (§4), the authorization code lands on your local server, you exchange it (§5), and your local dev server is what the framed page loads (§7 — for a sandbox install, which is what your own not-yet-approved version is).
The exact shape that is 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 (any address in 127.0.0.0/8) |
accepted |
http://[::1]:3000/callback |
accepted |
https://localhost:3000/callback |
refused — the allowance is plaintext-only; there is no certificate story for a loopback name |
http://apps.example.com/callback |
refused — plaintext to a public host is still plaintext |
http://169.254.169.254/…, http://10.0.0.5/…, http://192.168.1.10/… |
refused — the metadata service and the private ranges are not loopback, and nothing here exempts them |
None of this depends on WB_DEV. That flag relaxes the https-only rule on a
deployment; the loopback allowance is a property of the version's status and
behaves the same on every deployment, including production.
Where it stops: submit. Submitting a draft that still points at localhost
is refused with
{ "error": "the embed URL or redirect URI is not usable — it must be a public https address", "code": "invalid_url" }
400, at submit — which is exactly where the allowance is supposed to end. A
version in the review queue is one a reviewer opens and a merchant installs, and
neither of them is sitting at your laptop. It is also what keeps your embed URL
away from the only place this server ever fetches it: the framing check in §8
runs immediately after this one, so a loopback embed URL is refused before
anything is fetched, never after.
So the last edit before you submit is to swap both URLs to public https — a
draft stays editable, so this is one PUT, not a new version.
A tunnel (ngrok, Cloudflare Tunnel, a bastion you control) is still worth
having, for the thing loopback cannot give you: testing the exact URLs you will
submit, over real https, before a reviewer sees them.
Two tests pin this section to the code —
TestQuickstartLocalhostSectionIsStillTruehere andTestDeveloperLocalhostIsAcceptedOnADraftAndRefusedAtSubmitininternal/apps/tests— so if the rule changes, this page fails the build rather than going quietly stale.
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 for at this point.
The equivalent call, if you would rather script it — the developer routes are on
the private API and authenticate with your own user access token (the one
POST /api/auth/login returns), not with an app credential:
curl -X POST https://api.your-host/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 this platform's operators — can
read it back, and no other route returns it.
Treat wbs_… accordingly: it goes in your secret store on the way out of this
response, and nowhere else.
If it leaks: rotate it
In the app: open the app → Rotate secret (vi: Đổi mã bí mật).
curl -X POST https://api.your-host/api/orgs/org_yourorg/apps/app_1a2b3c4d/secret \
-H "Authorization: Bearer <your user access token>"
{
"app": { "id": "app_1a2b3c4d", "clientId": "wbc_9f8e7d6c", "status": "active" },
"clientSecret": "wbs_bmV3c2VjcmV0Zm9ydGhpc2FwcA..."
}
200, and the same rule as the create: this response is the only place the new
secret exists. Your clientId does not change — every merchant's install
names it.
Rotating invalidates nothing that has already been issued. No merchant is logged out, no install re-authorises, and every
wba_access token your app is already holding keeps working: the client secret authenticates YOU at the token exchange, and access tokens are verified against the install instead. What changes is the next exchange — the old secret stops working there, so update your own configuration before your current access token expires and your app next presents a refresh token.
Every route in this section refuses a caller who is not a member of {orgId}
with 403 not_a_member, before any lookup happens — so a stranger holding an org
id cannot learn from a status code whether an app exists.
3. Create a version — the thing a review actually judges
Approval 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 frames, the one address it may receive a code at, and the listing copy.
In the app: open the app → New version (vi: Phiên bản mới).
curl -X POST https://api.your-host/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 — the server sets it rather than
taking it from you, because a client that could post a version already marked
approved would have skipped review entirely.
| Field | Rules that will bite you |
|---|---|
scopes |
At least one, from the same <domain>.read / <domain>.write vocabulary §4 of the API guide lists. Ask for the least that works. |
embedUrl |
Public https — or plaintext localhost while this version is a draft (§1). Its page must let this platform frame it — checked at submit, see §8. |
redirectUri |
Public https — or plaintext localhost while this version is a draft (§1). Exactly one, matched exactly — no prefixes, no wildcards. |
summary, description |
What a merchant reads on the consent screen. Optional; a blank 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 — approval pins a
payload, so editing a reviewed version in place would silently invalidate the
decision it carries. Create a new version instead.
4. Install your own draft — the development loop
You do not have to wait for review to run the flow. A developer installing their own not-yet-approved app is a sanctioned exception (a sandbox install), narrowed twice: you must already reach the store, and you must belong to the organization that publishes the app.
In the 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,
composes the URL below from the version's own client_id, version_id and
registered redirect_uri, adds a state, and shows you the whole thing before
it opens it — so you can copy it into another browser profile instead. The rest
of this section is what that button builds, which is what you need when you are
scripting it or reading a refusal.
Send the merchant's browser — yours, for now — to /oauth/authorize:
https://api.your-host/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 one and tells the merchant so. When a merchant starts the install from their own store's Apps (vi: Ứng dụng) screen, the app list fills it in for them; when your app starts the flow, you supply it.stateis echoed back to your redirect URI untouched. Use it.- The server validates before it redirects — an unknown client, a version
belonging to another app, or a
redirect_urithat is not the registered one are refused here as JSON, never bounced to the address you asked for. That refusal is the open redirect this endpoint exists to prevent.
The browser lands on the consent screen, which shows your app's name, your organization, and exactly the scopes this version asked for. An unapproved version is labelled as unreviewed — the merchant is told what they are agreeing to.
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 subset. The token response below carries no scope field, and there
is no endpoint that introspects one, so the only signal is a
403 insufficient_scope on the call that needed the permission you did not get.
Design for that: ask for the least that works, and degrade a feature rather than
assuming a scope you requested is a scope you hold.
5. Exchange the code — wba_ and wbr_
Server-to-server, with your client credentials. No browser, no user session.
curl -X POST https://api.your-host/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 redeeming it — and a mismatch does not consume the code,
so a typo does not destroy a code the retry would have worked with.
Renewing, before or after expiresAt:
curl -X POST https://api.your-host/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 your next refresh answers
400 invalid_refresh_token.
Removing yourself from a store — the app's own side of an uninstall:
curl -X POST https://api.your-host/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 on purpose: the token
names the install and your credentials prove it is yours, so no app can ever
uninstall another.
6. Call one endpoint
curl https://api.your-host/api/v1/products \
-H "Authorization: Bearer wba_..."
{ "products": [ { "id": "prod_...", "name": "..." } ], "total": 42 }
That is the whole difference between an app and a merchant's own key: the
credential. No {siteId} appears anywhere in a /api/v1 path — the store is
implied by the token. Every resource, envelope, query and error on that surface
is documented once, at /api-docs, and applies to a wba_ token
unchanged.
Two bounds apply on every call, and the second one surprises people:
- the scopes the merchant granted, and
- the live role of the member who installed the app.
The app can never do more than the person who installed it, resolved per request
— so an install made by an admin who is later demoted to viewer loses write
access on the next call, with nothing to sweep and no bookkeeping on your side. A
refusal is 403 insufficient_scope, and for an app it says so in those terms:
the merchant must reinstall and approve that permission.
# Sending the wrong half of the pair is a common first mistake, and it is named:
curl https://api.your-host/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 embedded page — wbf_
When a merchant opens your app inside Manage, this platform mints a short-lived frame token and loads your embed URL in an iframe with two query parameters appended:
https://apps.example.com/embed?wb_frame_token=wbf_...&wb_site_id=site_9f8e
The token is not an authorization. It answers "who is watching" — an install,
a store, and the member looking at the screen — and nothing else. What your app
may do is still bounded by its own wba_ token.
Never trust it as it arrives. Resolve it from your own server, with your client credentials:
curl -X POST https://api.your-host/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, and
401 unknown_client if it was minted for a different app than the credentials
you presented — no app can resolve 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 framed page itself
- The frame is sandboxed without
allow-same-origin. Your page renders, runs scripts, posts forms and opens links — but it is 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 re-minted on every open. Exchange it for your own session immediately; do not hold it as one.
- Your page must permit framing. See §8 — this is what rejects submissions.
The app bridge — talking to the Manage screen around you
Your page is in an iframe. It can ask the screen framing it for a few things —
today, the height it should be — over postMessage. The channel is
versioned and authenticated, and it is deliberately tiny.
No verb reaches this platform's server. Every one of them changes something
on the merchant's own screen. Nothing here reads store data, mints a
credential, or issues an API call on your behalf: your wba_ token and
/api/v1 are how you reach data, bounded by the consent the merchant actually
gave. If you find yourself wanting a bridge verb that touches store data, the
answer is an API call.
Open 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 — echo it always
const CHANNEL = 'wb.app.bridge';
const ADMIN_ORIGIN = 'https://<the admin origin you were given at registration>';
let ready = false;
window.addEventListener('message', (e) => {
// Verify the HOST. The admin origin is a constant you were told once — never
// read it from a URL parameter: whoever framed you supplies those, so an app
// that "pinned" one would be pinning a value its attacker chose.
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') {
// The screen framing you speaks m.supported, not what you asked for.
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);
Echo wb_bridge_nonce on every message. It is a 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 for every
open, and it authorizes nothing. It exists because of §7's first constraint —
your frame is sandboxed without allow-same-origin, so it has an opaque
origin, and every message you send arrives at the screen with
event.origin === "null", the same string every sandboxed frame on the
internet sends. An origin check on that side would be decoration. What the
screen checks instead is that the message came from the window it framed and
carries the nonce only your document ever saw. Lose the nonce and the bridge
goes quiet.
Your side of the check is the one that works 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 frame's height. Clamped to 200–4000; a value outside that is pulled into range, not refused. A value that is not a finite number 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. Whitespace is collapsed to one line; an empty or over-long message is refused rather than truncated. At most one per second — extras are dropped, not queued. |
confirm |
id — your own correlation key, up to 64 characters. message — up to 300 characters |
Asks the merchant a yes/no question in a dialog titled with your app's name, then replies { type: 'confirm-result', id, confirmed }. |
navigate |
to — a path |
Moves the merchant's admin to to, inside this store's own manage area. |
confirm in detail. One question at a time: send a second while the first
is open and it comes straight back as
{ confirmed: false, refused: 'busy' } — that is a refusal, not the merchant
saying no, and reading it as a no is the mistake this field exists to prevent.
Cancelling, pressing Escape and clicking outside all answer confirmed: false.
If the merchant closes the screen while your question is open, no reply
comes at all: key your state off the id and let it expire rather than
blocking on an answer that may never arrive.
navigate in detail. The bound is /manage/<wb_site_id>/… — the store the
merchant opened you on. Refused, silently from your side and audibly in the
merchant's console: absolute URLs, protocol-relative paths (//host), another
store's screens, the page editor, .. segments, and anything over 512
characters. Nothing about it bypasses a permission check — the route the
merchant lands on re-checks their role exactly as it would if they had clicked.
That bound is deliberate rather than provisional: an app installed on one store should not relocate its merchant into another store's screens, and the page editor is not somewhere to be dropped mid-task. If your app needs somewhere it cannot currently reach, say which screen and why — the answer may be to widen it, but widening is a decision, not an omission waiting to be noticed.
Degrade gracefully. If no welcome arrives, you are being framed by
something that does not speak this protocol — an older deployment, or a
different host. Keep working: render at a sensible fixed height and carry on.
Never block your page on a handshake.
The protocol version is
1, and it is negotiated rather than assumed: ahellonaming a version the screen does not speak is answered withunsupportedand the list it does. New verbs will arrive without a version bump; anything that changes the meaning of an existing one will 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 one.
curl -X POST https://api.your-host/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, and the
localhostallowance is withdrawn — the same check §1 describes, run a third time and one notch stricter. Two things fail here: a draft still pointing atlocalhost(§1 — swap both URLs to publichttpsfirst), and a hostname that was public when you saved the draft but resolves somewhere private by the time you submit, because DNS resolves at a moment in time. Failure is400 invalid_urleither way. - Your embed page is fetched, and its framing headers read. If it answers
X-Frame-Options: DENYorSAMEORIGIN, or aContent-Security-Policy: frame-ancestorslist that does not admit this platform, the submission is refused with422 embed_refuses_framing.
That second check is the one worth preparing for, because the alternative is
worse: a merchant discovering it as a permanently blank frame, days later, with
nothing in any log. Serve your embed page with a frame-ancestors that names
this platform's Manage origin (a wildcard * also passes, and a scoped list
naming this platform passes — you do not have to weaken your own policy to get
through). A network failure or a 4xx/5xx from your own server is not
treated as a refusal, so a server that is briefly down does not make a version
permanently unsubmittable.
A reviewer then approves it, or rejects it with a reason — an empty rejection is refused server-side, and the reason rides out on the app read your portal screen already makes, so you see why.
Once approved, the version is installable by any merchant, and the draft-only
part of the URL rules is over for it: an approved app's redirect_uri must be
https, full stop.
9. Debugging: the request log
Every /api/v1 call your installations make is recorded, by route — never by
URL, so nothing in it carries another merchant's identifiers.
curl "https://api.your-host/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 is capped at 200. The log keeps 7 days by
default (an operator may configure it shorter or longer). A record is handed to a
buffered channel after your response has been written, and dropped rather than
queued when that fills — logging can never slow your call down or fail it. For
the same reason a server with the log unwired answers
503 request_log_unavailable rather than an empty list, which would read as
"your app made no calls".
10. Errors worth branching on
The OAuth surface splits its refusals instead of collapsing them into one
invalid_request. Every row below has a different fix, and one generic code
would make you guess between them.
| Status | code |
Means |
|---|---|---|
| 401 | unknown_client |
Unknown client_id, wrong client_secret, or a credential that does not own the thing 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 redeemed already — a security event, not a timing one |
| 400 | code_expired |
Older than 60 seconds. Start the flow again |
| 400 | invalid_refresh_token |
Unknown, already-rotated, or revoked. Rotation means the previous one is dead |
| 400 | unsupported_grant_type |
grantType must be authorization_code or refresh_token |
| 400 | scope_not_requested |
A consent granting something this version never asked for |
| 400 | no_scopes |
A consent granting nothing |
| 403 | forbidden |
The person consenting cannot install on that store |
| 409 | not_installable |
The version is not approved (and no sandbox lift applies), or the app is suspended |
| 401 | install_unavailable |
The install is gone or switched off |
| 401 | invalid_frame_token |
A wbf_ token that is malformed, forged or expired. One minted for a different app is unknown_client instead |
| 404 | unknown_endpoint |
No such /oauth route |
On the developer routes:
| Status | code |
Means |
|---|---|---|
| 403 | not_a_member |
You are not a member of that organization |
| 404 | (no code) | No such app in this organization — deliberately the same answer a nonexistent id gets |
| 400 | invalid_url |
The embed URL or redirect URI is not a usable public address — or is a localhost one being submitted, which only a draft may hold (§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 will not let this platform frame it (§8) |
| 503 | request_log_unavailable |
The request log is not configured on this server |
Everything a wba_ token can hit on /api/v1 uses that surface's own table —
§5 of the API guide.
11. Lifetimes, and the numbers behind them
| Thing | Lives | Why |
|---|---|---|
wbo_ authorization code |
60 seconds, single use | The only legitimate holder redeems it immediately, server to server |
wba_ access token |
1 hour | It cannot be revoked individually, so it is short; the install is re-read on every call, so uninstalling takes effect at once anyway |
wbr_ refresh token |
60 days, rotating | Past that, the merchant consents again |
wbf_ frame token |
10 minutes | Re-minted on every open; anything longer is replayable after the tab is closed |
| Request log | 7 days by default | Long enough to debug, short enough not to become a second database |
12. What is not there yet
Stated so you can plan around it rather than discover it.
- No
localhostpast the draft stage. §1. Developing against your own machine works; submitting from it does not, and there is nohttpsform of the allowance — a tunnel is still the only way to test the exact URLs you will submit. - No app delete. There is no route for it: an app you no longer want is
suspended by an admin, not removed by you, and its
clientIdstays spoken for. Rotating the client secret is a different question and it is there — §2. - Free apps only. There is no payment rail, so there is nothing to charge
with; a version's pricing is
freeand the server sets it. - One redirect URI per version, matched exactly. A second environment (staging beside production) therefore needs a second version — which you can keep as a permanent draft and install through the sandbox path — or a second app. There is no list of allowed URIs.
- No scope introspection. Nothing tells your app which scopes it actually
holds; a
403 insufficient_scopeis the only signal (§4). - No public developer directory, and no transfer of an app between organizations.
- The app bridge has four verbs, and only those four (§7). It is not an SDK
and is not meant to become one: nothing on it reaches this platform's server.
A
hellogets awelcomewhoseverbslist is the truth about what the deployment framing you accepts — read it rather than assuming. - No test in this repository executes an app's page. The bridge's host half is covered by tests; the developer half in §7 is a worked example, run by a human against a real deployment, not by a fixture. Treat it as a starting point to verify, not as certified output.
Reference for everything a token reaches once you have one:
/api-docs to read, the API console to
fire a request at a live server.
Updated 22/08/2026