Two routes: a start route your frontend calls, and the GET landing route (your registered returnUrl) that reads the outcome back. There is nothing to receive — Authentica never calls you.

1. Configuration

application.yml:
Register return-url in the portal, matched as a whole string, and send exactly that value on /session.

2. Start verify — POST /verify/start


3. Landing route — GET /verify/done

The registered return-url. A plain GET — normal SameSite=Lax cookies (including JSESSIONID) arrive, no CSRF exemption needed, nothing secret on the URL. This is where the verification is finished.
session_id and state are inputs to a lookup, not evidence. Nothing is trusted until GET /session/{id} — authenticated with your client credentials — says so.
The status response also carries assertionExchanged. It is a legacy wire field — do not read it and do not branch on it. Fulfilment.onVerified must be idempotent: perform the action and mark your record used in one transaction. The landing page can be reloaded, and the sweep below calls the same method.

4. The user who never came back

No redirect fires if the tab is closed. Sweep your own pending records inside the 24 h retention window:

5. Connect the frontend

That is the whole frontend. The browser lands on /verify/done, and your server finds out what happened. More: Session status · Frontend overview.