Sign in with Wonder
Authenticate end-users with the Wonder consumer app via QR code or app-to-app deep link, then exchange the result for a session on your site.
"Sign in with Wonder" lets merchants and partners authenticate end-users with their existing Wonder consumer account. Two flows are supported:
- QR code (desktop / cross-device) — your web app renders a short-link QR code. The user scans it with the Wonder app to approve sign-in. Your server polls for completion.
- App-to-app deep link (mobile) — on a phone that has the Wonder app installed, your web or native app opens a
wonder://authorization?...URL. Wonder returns an authorization code to a callback you control, which you exchange for the user profile server-side.
Both flows end the same way: your server receives a verified user identity (email, Wonder user ID, display name) and creates / signs in the matching account on your platform.
[CLARIFY: confirm the canonical product name used in marketing — "Sign in with Wonder" vs "Sign in with Wonder ID" vs "Wonder Login".]
When to use which flow
| Flow | Best for | User has Wonder app on… |
|---|---|---|
| QR code | Desktop web, kiosks, terminals, secondary-screen sign-in | a separate phone |
| App-to-app | Mobile web, native iOS/Android partner apps | the same device |
You typically implement both and let the client pick based on device capability (e.g. isMobile() && hasWonderInstalled()).
Prerequisites
Before either flow works, your tenant must be onboarded by Wonder. Wonder will provision:
bindo_client_id— sent asx-client-idon every Bindo call.app_key— sent asx-app-keyon gateway calls.app_slug— sent asx-app-slugon gateway calls.app_bearer— long-lived JWT used asAuthorization: Bearer …on the gateway.app_to_app_client_id+app_to_app_secret— used to sign app-to-app deep links via HMAC-SHA256.app_scheme— the Wonder URL scheme for the target environment (wonder,wonder-alpha,wonder-stg).
Environment hosts:
| Env | Bindo host | Gateway host | App scheme |
|---|---|---|---|
| Production | https://main.bindo.co | https://gateway.wonder.app | wonder |
| Alpha | https://main-alpha.bindo.co | https://gateway-alpha.wonder.app | wonder-alpha |
| Staging | https://main-stg.bindo.co | https://gateway-stg.wonder.app | wonder-stg |
On Alpha, all requests must also include x-internal: TRUE.
Store these credentials server-side only. Never ship app_bearer, app_to_app_secret, or the bearer JWT to the browser.
Flow 1 — QR code sign-in
Sequence
sequenceDiagram
participant U as User (phone)
participant B as Browser
participant S as Your server
participant Bindo as Bindo (main.bindo.co)
participant GW as Wonder Gateway
B->>S: POST /sign-in/wonder/qr/start
S->>Bindo: POST /user/b2c/qr_code
Bindo-->>S: { uuid }
S->>GW: POST /api/short-chain/qrcode-links<br/>{ link_type: "Scan Code Login", data: { uuid, client_id } }
GW-->>S: { shortChain.sUrl }
S-->>B: { uuid, qr_url }
B->>B: Render qr_url as a QR image
U->>U: Open Wonder app, scan QR
loop every 2s
B->>S: POST /sign-in/wonder/qr/poll { uuid }
S->>Bindo: GET /user/b2c/qr_code/info?id={uuid}
Bindo-->>S: { is_scan, access_token, is_expired, is_cancel }
end
S->>Bindo: GET /user/me (with x-user-access-token)
Bindo-->>S: { email, name, user_id }
S->>S: Find or create local user, mint session
S-->>B: { status: "completed", token_hash }
B->>B: Verify magic-link / set session cookie → redirectStep 1 — Start a QR session (server)
Call Bindo to mint a session UUID, then ask the gateway to wrap it in a short link. The short link is what you encode as a QR code.
// Server function (TanStack Start / Node / any backend)
const baseHeaders = {
accept: "application/json, text/plain, */*",
"x-client-id": cfg.bindo_client_id,
"x-i18n-lang": "en-US",
"x-request-id": crypto.randomUUID(),
...(isAlpha ? { "x-internal": "TRUE" } : {}),
};
// 1. Mint a UUID
const r1 = await fetch(`${bindoBase}/user/b2c/qr_code`, {
method: "POST",
headers: { ...baseHeaders, "content-length": "0" },
});
const { uuid } = (await r1.json()).data ?? (await r1.json());
// 2. Wrap as a short link
const r2 = await fetch(`${gatewayBase}/api/short-chain/qrcode-links`, {
method: "POST",
headers: {
...baseHeaders,
authorization: `Bearer ${cfg.app_bearer}`,
"content-type": "application/json",
"x-app-key": cfg.app_key,
"x-app-slug": cfg.app_slug,
},
body: JSON.stringify({
type: "Normal",
link_type: "Scan Code Login",
data: { uuid, client_id: cfg.bindo_client_id },
}),
});
const j2 = await r2.json();
const qr_url = j2.data?.shortChain?.sUrl;
return { uuid, qr_url };[CLARIFY: response shape for /user/b2c/qr_code and /api/short-chain/qrcode-links varies across environments (sometimes wrapped in data, sometimes flat, sometimes result). Please publish the canonical envelope so SDK consumers don't have to defensively try every shape.]
Step 2 — Render the QR code (browser)
import QRCode from "qrcode";
const { uuid, qr_url } = await startWonderQrSession();
const dataUrl = await QRCode.toDataURL(qr_url, { width: 256, margin: 1 });
setQr(dataUrl);
beginPolling(uuid);Step 3 — Poll for completion (server)
Poll every 2 seconds. Stop on cancelled, expired, or completed.
const r = await fetch(`${bindoBase}/user/b2c/qr_code/info?id=${uuid}`, { headers });
const { data: d } = await r.json();
if (d.is_cancel) return { status: "cancelled" };
if (d.is_expired) return { status: "expired" };
if (!d.is_scan || !d.access_token) return { status: "pending" };
// Approved → fetch profile using the returned access token
const me = await fetch(`${bindoBase}/user/me`, {
headers: { ...headers, "x-user-access-token": d.access_token },
}).then((r) => r.json());
const email = me.email ?? me.data?.email;
// → find-or-create local user, mint your own session[CLARIFY: the profile endpoint we currently hit is /user/me, but several deployments only respond on /user/profile, /user/info, /user/b2c/me, or /user/b2c/profile. Please confirm the single canonical endpoint partners should call, and the exact JSON shape of the response (email, name, user_id locations).]
[CLARIFY: polling cadence and the official expired timeout. We currently use 2s polling and assume Bindo expires the UUID after ~5 minutes. Please confirm.]
Step 4 — Issue a local session
Map the verified Wonder email to a local account (create on first sign-in), then issue your normal session — magic-link verification, signed cookie, JWT, etc. Wonder does not manage your platform's session; it only attests identity.
Flow 2 — App-to-app deep link
Use when the user is already on a mobile device with the Wonder app installed. You build a signed wonder://authorization?... URL, redirect the browser to it, Wonder approves the user, and Wonder calls back to your callback URL with an authorization code you exchange server-side.
Sequence
sequenceDiagram
participant B as Browser (mobile)
participant S as Your server
participant W as Wonder app
participant GW as Wonder Gateway
B->>S: POST /sign-in/wonder/applink/start { callback }
S->>S: requestTime = now()<br/>nonce = uuid()<br/>signature = HMAC_SHA256(secret, `${clientId}|${requestTime}|${nonce}`)
S-->>B: { url: "wonder://authorization?client_id=…&request_time=…&nonce=…&signature=…&callback=…" }
B->>W: window.location = url
W->>W: User approves
W->>B: Redirect to {callback}?code=…
B->>S: POST /sign-in/wonder/applink/exchange { code }
S->>GW: POST /api/authorization/access_token (signed)
GW-->>S: { access_token }
S->>GW: GET /user/me (with x-user-access-token)
GW-->>S: { email, name, user_id }
S-->>B: { token_hash } → set sessionStep 1 — Build the signed deep link (server)
async function hmacSha256Hex(key: string, message: string) {
const enc = new TextEncoder();
const k = await crypto.subtle.importKey("raw", enc.encode(key),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", k, enc.encode(message));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
const requestTime = Date.now();
const nonce = crypto.randomUUID();
const signature = await hmacSha256Hex(
cfg.app_to_app_secret,
`${cfg.app_to_app_client_id}|${requestTime}|${nonce}`,
);
const url =
`${cfg.app_scheme}://authorization` +
`?client_id=${encodeURIComponent(cfg.app_to_app_client_id)}` +
`&request_time=${requestTime}` +
`&nonce=${encodeURIComponent(nonce)}` +
`&signature=${signature}` +
`&callback=${encodeURIComponent(callback)}`;
return { url };Step 2 — Open the link (browser)
const { url } = await startWonderAppLink({ callback: `${origin}/auth/wonder/callback` });
window.location.assign(url);If the Wonder app isn't installed, the deep link will silently fail on most platforms. Fall back to the QR flow (rendered in a modal) after a short timeout.
[CLARIFY: is there an officially supported way to detect "Wonder app not installed" on iOS/Android so we can auto-fall back to the QR flow without the 2-3 second flicker?]
Step 3 — Receive the callback and exchange the code
Wonder redirects the browser to {callback}?code=.... Your callback route forwards the code to your server, which exchanges it for an access token, then loads the profile.
// Server-side exchange
const requestTime = Date.now();
const nonce = crypto.randomUUID();
const signature = await hmacSha256Hex(
cfg.app_to_app_secret,
`${cfg.app_to_app_client_id}|${requestTime}|${nonce}`,
);
const r = await fetch(`${gatewayBase}/api/authorization/access_token`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
"x-client-id": cfg.bindo_client_id,
"x-app-key": cfg.app_key,
"x-app-slug": cfg.app_slug,
authorization: `Bearer ${cfg.app_bearer}`,
...(isAlpha ? { "x-internal": "TRUE" } : {}),
},
body: JSON.stringify({
client_id: cfg.app_to_app_client_id,
code,
authorization_code: code,
grant_type: "authorization_code",
request_time: requestTime,
nonce,
signature,
}),
});
const { access_token } = (await r.json()).data ?? (await r.json());
const me = await fetch(`${bindoBase}/user/me`, {
headers: { ...baseHeaders, "x-user-access-token": access_token },
}).then((r) => r.json());[CLARIFY: the canonical exchange endpoint. Our current integration tries seven different paths across three hosts (/api/authorization/access_token, /api/oauth/access_token, /api/authorization/token, /authorization/access_token, /oauth/access_token, /user/b2c/authorization/access_token, /user/b2c/app_to_app/access_token) because behavior differs between Production, Alpha, and Staging. Please publish one canonical path per environment.]
[CLARIFY: the exact field name returned for the user token — we've seen access_token, data.access_token, result.access_token, user_access_token, and data.user_access_token. Please standardise.]
[CLARIFY: signature algorithm details. We currently sign ${clientId}|${requestTime}|${nonce} with HMAC-SHA256 and submit hex-lowercase. Please confirm (a) field separator, (b) field ordering, (c) hash algorithm, (d) encoding (hex vs base64), and (e) whether the body must also be included in the signature.]
Reference: HTTP headers
Every request you make to Bindo or the gateway should carry:
| Header | Value | Required on |
|---|---|---|
x-client-id | bindo_client_id | All requests |
x-i18n-lang | en-US (or user locale) | All requests |
x-request-id | Fresh UUID per request | All requests |
x-app-key | app_key | Gateway requests |
x-app-slug | app_slug | Gateway requests |
authorization | Bearer ${app_bearer} | Gateway requests |
x-internal | TRUE | Alpha environment only |
x-user-access-token | Token from QR/exchange | Profile lookup only |
Storing the Wonder identity
Once you have a verified email and wonder_user_id, persist them on your user record:
alter table public.profiles
add column wonder_user_id text,
add column signup_source text;
create unique index profiles_wonder_user_id_unique
on public.profiles (wonder_user_id)
where wonder_user_id is not null;Subsequent sign-ins should match by wonder_user_id first, then fall back to email, so users who change their Wonder email don't get a duplicate account.
Testing
- Use the Alpha environment with
x-internal: TRUEfor end-to-end tests; it accepts test accounts that don't bill or send notifications. - The QR
qr_urlis a normal HTTPS short link — you can open it in a regular browser to inspect what the Wonder app will see. - Use a fresh
uuidfor every sign-in attempt; reused UUIDs are rejected as expired.
[CLARIFY: are there public test Wonder accounts (email + scan-only) that partners can use during integration without standing up a real Wonder consumer profile?]
Security checklist
- Keep
app_bearer,app_to_app_secret, and the bearer JWT server-side. Never expose them to the browser. - Verify the
signatureon the deep-link callback before trusting thecode. - Rate-limit
/qr/startand/applink/startper IP — both create gateway resources. - After exchanging a
codeor completing a QR session, invalidate the correspondinguuid/codeon your side so it cannot be replayed. - Issue your own session (cookie, JWT, magic-link) rather than relying on the Wonder access token for ongoing authorization — Wonder only attests the user's identity at one point in time.