Have your values from Get your credentials? Follow these steps in order.
/verify/start and /verify/done below are your route names — use whatever fits the action you are protecting (/auth/login, /payments/confirm, /profile/change-phone). Only the Authentica paths are fixed.
The result of a verification never travels through the browser, and nothing is ever delivered to your server. Your server learns the outcome by calling GET /session/{sessionId}. The browser only carries the single-use handoff token in a form POST on the way out, and two non-secret identifiers on the way back.

Step 1 — Create a session (your backend)

Add a route on your API (example: POST /verify/start). Inside that route:
  1. Run your own checks for the action — password, balance, permissions, ownership, whatever applies.
  2. Generate a random state and store what the action is (and its parameters) against it.
  3. Call Authentica from your backend with a stable userRef, phone destination, "channel": "sms", and operationSensitivity chosen by your server (not the browser). Send an Idempotency-Key so a timeout retry cannot text the user twice:
Use "sensitive" for higher-risk actions — payments, transfers, permission changes. Full field reference: Create session. Successful envelope:
  1. Store sessionId next to state on the pending action. It is public and non-secret; it is how you read the outcome in step 4.
verifyUrl is a bare endpoint with no query string. handoff is a single-use token with a 120 s TTL that opens the verify page — it must never be put in a URL. Return both to the browser:
Never return secret or Basic credentials to the browser.
Language guides: C# · Node.js · Java · PHP / Laravel

Step 2 — Hand off to the verify page (browser)

  1. Call your start route and get { verifyUrl, handoff }.
  2. Auto-submit a hidden form to verifyUrl — do not navigate to it:
Server-rendered equivalent:
window.location.href = verifyUrl does not work — GET {verifyUrl} with any query string returns 400. The only optional extra hidden field is lang. The hosted page is always a full top-level page; it cannot be put in an iframe.
More detail: Frontend overview · How it works.

Step 3 — Land the user (your backend)

{RETURN} is a plain GET route in your app. After verification the browser is redirected to:
An ordinary top-level GET to an ordinary page. Nothing on the URL is secret, and neither value proves anything by itself — both are useless without your client secret. Because it is a plain GET, your normal SameSite=Lax session cookies arrive as usual: no CSRF exemption, no POST route, no cookie tricks.
Do not render “verified” here, and do not perform the action here, on the strength of the query string. All you know so far is that a browser arrived at a URL.

Step 4 — Read the result (your backend)

From that same landing route, ask Authentica what actually happened:
Then, in order:
  1. Look up your pending record by state. No record, or already fulfilled? Stop.
  2. Require status == "verified", the echoed state to equal your record’s, and userRef to be the user you started for. (assertionExchanged is a legacy wire field — ignore it.)
  3. Run your idempotent onVerified(sessionId): perform the action and mark the record used in one transaction.
  4. Set your own session / cookie and redirect the user onward with a 303.
GET /session/{id} is idempotent, poll-safe, and answers for 24 hours — so it also covers the user who verified and closed the tab. A periodic sweep over your pending records finishes those. Per-language landing routes: C# · Node.js · Java · PHP / Laravel.

Step 5 — Smoke test

The returnUrl you send on POST /session is one of the URLs registered in the portal, character-for-character.
POST /session with Basic auth and an Idempotency-Key returns sessionId, verifyUrl and handoff. Repeating with the same key replays the response (Idempotent-Replayed: true) instead of sending a second SMS.
The browser POSTs hx to verifyUrl; the address bar shows the bare verify URL with no query string.
Complete a real verification: the browser lands on returnUrl?session_id=…&state=…, and GET /session/{sessionId} returns status: "verified" with your state echoed back.
Reloading the landing page performs the action exactly once.
Hand-editing session_id or state on the landing URL fulfils nothing.
Replaying the same handoff, or GETing verifyUrl with a query string, returns 400.
A session left to complete with the tab closed is still resolvable later by GET /session/{sessionId}.

Where each piece lives

Two backend routes. There is no receiver to build. Full checklist: Go-live checklist. Deeper flow: How it works.