You register one returnUrl for the app. Every verification in your product — login, payment, phone change, an admin approval — comes back to the same route. state is what tells that route which one it is.

The pattern

1

Mint an opaque, single-use `state`

A fresh random value per attempt (a UUID or 128+ bits of randomness). It carries no meaning of its own: it is a lookup key, not data.
2

Write a server-side pending record before you hand off

Keyed by state, holding at least:
sessionId comes from the POST /session response, so store the record — or update it — as soon as that call returns. Give it a short TTL; a pending record that outlives the session is just clutter.
3

On the landing route, resolve `state` → record

Look the record up. No record, or one already marked done? Render accordingly and stop — do not invent a flow from the URL.
4

Read the outcome and cross-check

Call GET /session/{sessionId} with Basic auth, using the sessionId from your record. Require that the echoed state matches, and that userRef matches the user on the record. Only then branch on kind and run the action.
5

Mark the record used in the same transaction

The landing page can be hit twice. Fulfil idempotently: perform the action and set done atomically.

Rules that keep it safe

Never put a URL in state and redirect to it. “Where should I send the user afterwards?” is a tempting thing to stuff into state, and it turns your landing route into an open redirect: anyone can start a flow with state set to their own site and use your domain to bounce users there. Store the destination server-side on the pending record, or map kind to a fixed set of routes in code. If you must accept a hint from the browser, accept a short key and look the URL up in an allow-list.
  • state is opaque. No action name, no user id, no amount, no JSON, no signed blob. Anything readable in state is readable by the user, and anything meaningful in it is something they can change.
  • Single use. One state belongs to one attempt. A retry gets a new one.
  • Bind userRef to the record. Store the user you started for, and compare it to the userRef the status endpoint returns. This is what stops a session started for one user being used to complete an action for another.
  • Never trust the query string. session_id and state on the URL are inputs to a lookup, never evidence. The action runs on what GET /session/{sessionId} says.
  • Do not branch in the browser. The landing page needs no per-action logic and no localStorage; your server owns the decision, keyed by state.

Why not one return URL per action?

You can register more than one URL on the app, but you rarely want to: every extra URL is another exact-match string to keep in sync between your code and the portal, and it puts the action type on the front channel where you then have to distrust it anyway. One URL plus a server-side record is fewer moving parts and strictly less to validate.