returnUrl with a plain GET — and your server reads the outcome with an authenticated GET /session/{sessionId}.
The flow below is the same whether the action is a login, a payment, a profile change, or anything else in your product. Only your own code around it differs.
POST /session returns renderType: "redirect", a bare verifyUrl with no query string, and a single-use handoff token. Use that URL as returned — do not build it yourself and do not append anything to it. The hosted page is always a full top-level page; it sends frame-ancestors 'none' and cannot be embedded.
The result never travels through the browser. The redirect to your returnUrl carries only session_id and state — an identifier and your own value, both useless without your client secret. The outcome comes from GET /session/{sessionId}, and only from there.
This is the OAuth authorization-code shape. The front channel carries an opaque reference; the back channel redeems it with client credentials. Stripe Checkout hands you
?session_id= and expects you to retrieve the session; Adyen hands you redirectResult and expects you to submit it. Same idea, same reason: whatever the browser carries is worthless on its own.Step by step
1
User triggers an action in your app
Your frontend calls your backend — for example
POST /verify/start,
POST /payments/confirm, or POST /profile/change-phone. Put any of your own
checks here (password, balance, permissions, account status, etc.).2
Your backend creates a session
Server-side only:
POST {BASE}/api/V1/Verify/session with Basic auth and an Idempotency-Key.Body includes userRef, phone destination, channel: "sms", state, returnUrl, and operationSensitivity (set by your backend for that action).Store state and the returned sessionId together with whatever the action needs — the payment id, the new phone number, the record being approved — so you can pick the action up again on completion.3
Browser POSTs into verify
Render a hidden form that auto-submits the
handoff token to verifyUrl:handoff is single-use with a 120 s TTL, and must never appear in a URL. GET {verifyUrl} with any query string returns 400.4
The browser lands back on your app
A plain top-level
GET {returnUrl}?session_id=…&state=…. An ordinary page in your app: normal SameSite=Lax cookies arrive, no CSRF exemption, no POST route, and nothing sensitive on the URL.A failed verification lands here too, with the same query. A user whose code locked out is returned to you rather than stranded on our page, and the URL gives you no way to tell the two apart — which is the point of the next step.5
Your server reads the result
From that landing route, call
GET /session/{sessionId} with your Basic credentials. Check the echoed state against your pending record and the returned userRef against the user you started for. Only then run your idempotent onVerified(sessionId), set your own session or complete the action, and redirect the user onward.If status is failed, close the pending action instead and show the user the reason — otp_locked, device_changed or session_binding_failed. Never branch on the URL alone: it looks identical either way.One return URL, many actions [#one-callback-many-actions]
You register onereturnUrl per app, not one per action. Because you choose state and store it server-side, the same landing route can finish any action:
state, confirm the status endpoint agrees, and run the matching branch. Never take the action type — or a destination URL — from the browser. Full pattern and its pitfalls: Routing many actions through one return URL.
One onVerified, two callers [#one-onverified-two-callers]
Two things can tell you a verification succeeded, and both will happily fire for the same session:
- The landing route — the normal path, and it can be hit twice (a refresh, a back button, a link the user reopens). It fires for failures as well, so gate it on
status == "verified", never on arrival alone. - A reconciliation sweep — your periodic pass over pending records for users whose browser never came back.
onVerified(sessionId): check your own record, perform the action, and mark the record used in the same transaction. Both callers invoke the same function; running it a second time must be a no-op.
Who holds what
Next
- Quickstart — wire the same flow with request samples
- Routing many actions through one return URL — the
statepattern - Frontend overview — the handoff form and the landing page
- Create session · Session status — field reference

