PUBLIC
Completes the magic link login flow. Submits the challenge token from the magic
link email and returns Cognito JWT tokens on success.
Events fired
| Event | Trigger | Description |
|---|---|---|
authorization.CodeRedeemed | sync | Fired when the token is successfully verified and exchanged. |
authorization.CodeRedeemFailed | sync | Fired when verification fails (invalid or already used token). |
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
Events —authorization.CodeRedeemed,authorization.CodeRedeemFailed
Why this endpoint exists
The customer clicked the link in their e-mail and arrived at your callback_url with a challenge token attached. This endpoint completes the login: it verifies the token and returns a session.
Possession of the token proves control of the mailbox, which is the whole basis of the magic-link flow. That is also why the token is single-use and short-lived.
Call it from the front-end, so the tokens and cookies land in the customer's browser.
Request
curl -X POST https://api.acc.funtrips.io/v2/auth/link/verify \
-H "Content-Type: application/json" \
-d '{
"token": "d3f8a1c…",
"session": "AYABeH…"
}'| Field | Type | Required | Notes |
|---|---|---|---|
token | string | Yes | Challenge token from the magic link URL |
session | string | No | Opaque session handle from the create step, when your flow carried it through |
Read both off your callback URL's query string. URL-decode once — double-decoding is a common cause of a 404 on a token that is actually fine.
Response
200 OK, plus cookies.
{
"data": {
"access_token": "eyJraWQ…",
"id_token": "eyJraWQ…",
"refresh_token": "eyJjdHki…",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2026-09-03T15:35:00Z"
},
"meta": { "correlation_id": "…" }
}| Field | Use |
|---|---|
access_token | Bearer token for authenticated calls |
id_token | Identity token. Present here, unlike on code exchange |
refresh_token | Exchange at refresh before expiry |
expires_in / expires_at | Lifetime, relative and absolute |
The response also sets access_token and refresh_token as HttpOnly; Secure; SameSite=None cookies. A browser client can rely on those and never handle the tokens in JavaScript.
Errors
| Status | type | Meaning | What to do |
|---|---|---|---|
400 | — | token missing | Check your URL parsing |
404 | authorization-code-not-found | Token unknown or expired | Offer to send a new link |
409 | authorization-code-already-redeemed | Link already used | See below |
401 | authorization-challenge-failed | Verification failed | Offer to send a new link |
500 | — | Unexpected fault. Retryable |
409 is usually not a problem
409 is usually not a problemMail clients and security scanners pre-fetch links. By the time the customer clicks, the token may already be spent — and the session it minted went to a scanner, not to them.
There is no way to distinguish that from a genuine second click, so handle 409 gracefully: check whether you already hold a valid session (GET /me) and continue if so; otherwise offer to send a fresh link with a plain explanation. Never show a raw error.
Expect a background rate of this. It is a property of e-mail, not of the integration.
const res = await fetch(`${BASE}/auth/link/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ token, session }),
});
if (res.status === 409 || res.status === 404) {
const me = await fetch(`${BASE}/me`, { credentials: 'include' });
if (me.ok) return goToStorefront(); // already signed in
return offerNewLink(); // ask for a fresh e-mail
}Strip the token from the URL after verifying (history.replaceState) so a refresh does not retry a spent token.
Notes
id_tokenis returned here but not on code exchange. If your client depends on it, do not assume it is present on both paths.authorization.CodeRedeemedon success;authorization.CodeRedeemFailedon an invalid or already-used token — which is where a pre-fetching mail scanner shows up in the event stream.
