returnUrl) that reads the outcome back. There is nothing to receive — Authentica never calls you.
1. Configuration
application.yml:
authentica:
base-url: https://authentica-merge-integration.t2.sa
client-id: YOUR_CLIENT_ID
client-secret: YOUR_CLIENT_SECRET
return-url: https://yourapp.com/verify/done
@ConfigurationProperties(prefix = "authentica")
public record AuthenticaProps(
String baseUrl,
String clientId,
String clientSecret,
String returnUrl
) {}
return-url in the portal, matched as a whole string, and send exactly that value on /session.
2. Start verify — POST /verify/start
@RestController
public class VerifyStartController {
/** What is being verified. Keyed by state. Use your database if the action
* outlives the process. */
public static final class Pending {
public final String userRef;
public final String action;
public final String sessionId;
public final AtomicBoolean done = new AtomicBoolean(false);
public Pending(String userRef, String action, String sessionId) {
this.userRef = userRef; this.action = action; this.sessionId = sessionId;
}
}
public static final Map<String, Pending> PENDING_BY_STATE = new ConcurrentHashMap<>();
private final RestClient http;
private final AuthenticaProps props;
public VerifyStartController(RestClient.Builder builder, AuthenticaProps props) {
this.props = props;
this.http = builder.baseUrl(props.baseUrl()).build();
}
// "action" is your own value: login, payment, phone-change, approval, …
public record StartVerifyRequest(String action, String destination) {}
public record HandoffResponse(String verifyUrl, String handoff) {}
@PostMapping("/verify/start")
public ResponseEntity<?> start(@RequestBody StartVerifyRequest body) {
// 1) YOUR checks for this action (password, balance, permissions, ownership …)
String userRef = "user-123"; // stable id — same value every time
String channel = "sms"; // SMS-only — set on the server
String operationSensitivity = "normal"; // your backend decides; "sensitive" forces OTP
String state = UUID.randomUUID().toString(); // opaque, single use
String basic = Base64.getEncoder().encodeToString(
(props.clientId() + ":" + props.clientSecret()).getBytes(StandardCharsets.UTF_8));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("userRef", userRef);
payload.put("destination", body.destination());
payload.put("channel", channel);
payload.put("state", state);
payload.put("returnUrl", props.returnUrl());
payload.put("operationSensitivity", operationSensitivity);
Map<?, ?> envelope = http.post()
.uri("/api/V1/Verify/session")
.header(HttpHeaders.AUTHORIZATION, "Basic " + basic)
.header("Idempotency-Key", state) // a timeout retry must not text the user twice
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.body(Map.class);
Map<?, ?> result = envelope == null ? null : (Map<?, ?>) envelope.get("result");
Object verifyUrl = result == null ? null : result.get("verifyUrl");
Object handoff = result == null ? null : result.get("handoff");
Object sessionId = result == null ? null : result.get("sessionId");
if (verifyUrl == null || handoff == null || sessionId == null) {
return ResponseEntity.status(502).body(Map.of("error", "session_failed"));
}
// Remember WHAT is being verified, WHO for, and the sessionId you read the outcome with.
PENDING_BY_STATE.put(state, new Pending(userRef, body.action(), sessionId.toString()));
// `handoff` is single-use with a 120 s TTL — never put it in a URL.
return ResponseEntity.ok(new HandoffResponse(verifyUrl.toString(), handoff.toString()));
}
}
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.@RestController
public class VerifyDoneController {
private final RestClient http;
private final AuthenticaProps props;
private final Fulfilment fulfilment; // your onVerified(sessionId) lives here
public VerifyDoneController(RestClient.Builder builder, AuthenticaProps props, Fulfilment fulfilment) {
this.props = props;
this.fulfilment = fulfilment;
this.http = builder.baseUrl(props.baseUrl()).build();
}
@GetMapping("/verify/done")
public ResponseEntity<Void> done(
@RequestParam(name = "session_id", required = false) String sessionId,
@RequestParam(required = false) String state,
HttpSession session) {
// 1) Resolve YOUR pending record.
var pending = state == null ? null : VerifyStartController.PENDING_BY_STATE.get(state);
if (pending == null) return seeOther("/verify/failed");
if (pending.done.get()) return seeOther("/verify/success"); // a refresh
// 2) Ask Authentica. Use the sessionId from YOUR record, not the URL.
String lookupId = pending.sessionId != null ? pending.sessionId : sessionId;
Map<?, ?> result = getSessionStatus(lookupId);
// 3) Three things must agree: verified, our state, our user.
if (result == null
|| !"verified".equals(result.get("status"))
|| !state.equals(result.get("state"))
|| !pending.userRef.equals(result.get("userRef"))) {
return seeOther("/verify/failed");
}
// 4) Fulfil once, then establish YOUR OWN session.
if (pending.done.compareAndSet(false, true)) {
fulfilment.onVerified(String.valueOf(result.get("sessionId")), pending.action, pending.userRef);
}
session.setAttribute("userId", pending.userRef); // your own auth
return seeOther("/verify/success");
}
/** GET /api/V1/Verify/session/{id} with Basic auth. Returns the `result` object, or null. */
Map<?, ?> getSessionStatus(String sessionId) {
if (sessionId == null || sessionId.isBlank()) return null;
String basic = Base64.getEncoder().encodeToString(
(props.clientId() + ":" + props.clientSecret()).getBytes(StandardCharsets.UTF_8));
Map<?, ?> envelope = http.get()
.uri("/api/V1/Verify/session/{id}", sessionId)
.header(HttpHeaders.AUTHORIZATION, "Basic " + basic)
.retrieve()
.body(Map.class);
return envelope == null ? null : (Map<?, ?>) envelope.get("result");
}
/** 303 See Other — never 302 or 307. */
private ResponseEntity<Void> seeOther(String target) {
return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create(target)).build();
}
}
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:@Scheduled(fixedDelay = 60_000)
public void reconcile() {
VerifyStartController.PENDING_BY_STATE.forEach((state, p) -> {
if (p.done.get()) return;
Map<?, ?> result = getSessionStatus(p.sessionId);
if (result != null && "verified".equals(result.get("status"))
&& p.userRef.equals(result.get("userRef"))
&& p.done.compareAndSet(false, true)) {
fulfilment.onVerified(String.valueOf(result.get("sessionId")), p.action, p.userRef);
}
});
}
5. Connect the frontend
const { verifyUrl, handoff } = await fetch("/verify/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ action: "payment", destination: "0501234567" }),
}).then((r) => r.json());
// POST into the hosted page — never navigate to verifyUrl.
const f = document.createElement("form");
f.method = "POST";
f.action = verifyUrl; // as returned — do not append anything
f.style.display = "none";
const i = document.createElement("input");
i.type = "hidden";
i.name = "hx";
i.value = handoff;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
/verify/done, and your server finds out what happened.
More: Session status · Frontend overview.
