The four pieces
1
A back-channel route on your server that creates the session
Your frontend never calls Authentica. It calls your route (
POST /verify/start, POST /payments/confirm, whatever the action is), and that route calls POST {BASE}/api/V1/Verify/session with HTTP Basic auth.Send an Idempotency-Key header. This is the call that costs money in SMS, and the one your HTTP client will retry on timeout — see Create session.The secret can never live in a browser. Not in a bundle, not in a mobile app, not in an env var that gets inlined at build time. If your architecture has nowhere to put a secret, you cannot integrate — you need a backend, even a tiny one.2
Somewhere to store the pending action
Store, before you hand off: the
state you generated, the sessionId from the response, the userRef you started for, and what the action actually is (payment id, new phone number, record being approved).state is how the landing page finds the action. sessionId is how GET /session/{id} finds the outcome. Store both — they are the same record. See Routing many actions through one return URL.3
A landing route — the registered returnUrl
A plain
GET page the browser is redirected to, carrying ?session_id=…&state=…. Ordinary route, ordinary cookies, nothing secret on it.Its job is not to display a result it was handed — it is to fetch one: call GET /session/{sessionId} with your credentials, check the echoed state and userRef against your record, fulfil, then set your own session and redirect.4
One idempotent `onVerified(sessionId)` function
Write the fulfilment logic once and make it safe to call twice. The landing page can be reloaded, and a reconciliation sweep can reach the same session. See One
onVerified, two callers.The routes, concretely
You must also:
Register the return URL in the portal character-for-character — whole-string match, no trailing slash, no query string, and nothing in front of it that redirects
Send that same string as
returnUrl on every POST /session — a mismatch is invalid_return_urlLeave the landing page an ordinary page — no CSRF exemption, no POST route, no cookie tricks: it is a same-site GET and your
SameSite=Lax cookies arrive normallyHave a plan for the user who verifies and closes the tab: a sweep over pending records calling
GET /session/{id} inside the 24 h windowHandle
status: "failed" on the landing route — a failed verification returns the browser with the same query a successful one does, so arrival alone must never trigger fulfilmentWhat each side owns
Before you call it done
The secret appears in exactly one place: your server configuration
POST /session is sent with an Idempotency-Key, and 422 / 409 responses are handled rather than retried blindlyYou store
sessionId alongside state and userRefThe landing route never fulfils from
session_id / state alone — it always reads GET /session/{id} and matches the echoed state and userRefThe action is read from your stored record — never from query params or client-side storage
state is opaque and single-use, and no URL you redirect to ever comes out of itonVerified(sessionId) is idempotent — a reload of the landing page performs the action onceNothing tries to iframe the hosted page, and
handoff never appears in a URL
