Store Builder

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-docs is the other half — it documents the wbk_ API key a merchant mints for their own tooling, which is a different credential for a different audience. Once your app holds a wba_ token, everything that page says about /api/v1 applies 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 handsb 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.

  1. An account and an organization. An app is published by an organization, not by a person — create one at /orgs if you have none. The developer portal says so on its own empty state.
  2. A store you are a member of, to install onto while you build.
  3. 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 https by the time you submit — but while your version is a draft, both may point at localhost, 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 — TestQuickstartLocalhostSectionIsStillTrue here and TestDeveloperLocalhostIsAcceptedOnADraftAndRefusedAtSubmit in internal/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 /developerCreate 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_id is 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.
  • state is 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_uri that 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:

  1. the scopes the merchant granted, and
  2. 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 or localStorage. 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. toneerror, 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: a hello naming a version the screen does not speak is answered with unsupported and 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:

  1. The URLs are re-checked, and the localhost allowance is withdrawn — the same check §1 describes, run a third time and one notch stricter. Two things fail here: a draft still pointing at localhost (§1 — swap both URLs to public https first), 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 is 400 invalid_url either way.
  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 does not admit this platform, the submission is refused with 422 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 localhost past the draft stage. §1. Developing against your own machine works; submitting from it does not, and there is no https form 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 clientId stays 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 free and 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_scope is 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 hello gets a welcome whose verbs list 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