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

appsettings.json (or environment variables):
Register ReturnUrl 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 ReturnUrl. A plain GET: normal SameSite=Lax cookies arrive, no CSRF exemption needed, nothing secret on the URL. This is where the verification is actually finished.
session_id and state are inputs to a lookup, not evidence. Nothing here is trusted until GET /session/{id} — authenticated with your client credentials — says so.
IFulfilment.OnVerifiedAsync must be idempotent: perform the action and mark your record used in one transaction. The landing page can be hit twice, and your reconciliation sweep calls the same method.

4. The user who never came back

No redirect fires if the tab is closed. Sweep your own pending records — one GET /session/{id} each, inside the 24 h retention window — and feed verified results through the same OnVerifiedAsync:

5. Connect the frontend

There is nothing else for the frontend to do. The browser lands on /verify/done, and your server finds out what happened. More: Session status · Frontend overview.