The JSON contract
For first-party SPA or mobile clients that exchange the token over JSON without an
interstitial, set api.enabled = true and send Accept: application/json:
'api' => [
'enabled' => true,
],
The secure default for the browser flow remains the GET confirmation page — see
Why a magic link costs one extra click.
The contract
| Outcome | Status | Body |
|---|---|---|
| Link / code requested | 200 | { "message": "…", "channel": "link"|"code" } |
| Signed in | 200 | { "authenticated": true, "two_factor": false, "redirect": "<url>" } |
| Two-factor required | 200 | { "authenticated": false, "two_factor": true, "redirect": "<challenge url>" } — with Fortify's views off, redirect is the URI the code is posted to, and it is left out when neither route exists |
| Invalid or expired | 422 | { "message": "…", "error": "invalid_or_expired" } |
| CAPTCHA challenge failed | 422 | { "message": "…", "error": "captcha_failed" } |
| Validation failed | 422 | { "message": "…", "errors": { … } } |
| Rate limited | 429 | { "message": "…" } + Retry-After / X-RateLimit-* headers |
| Resend held back | 429 | { "message": "…", "error": "resend_throttled" } + Retry-After |
The error code is stable and safe to branch on, while the human message stays generic so
it never reveals whether an account exists.
Posting the consume step
Since 0.25.0 the POST that spends a magic link requires the signature the emailed link
carries. A client that posts a bare token, or a link whose signature has expired, gets the
same 422 with "error": "invalid_or_expired" as any other dead token. Before 0.31.0 that
answer was the framework's 403 with a message and no error key.
Use the signed URL. IssuedLink::$url is it if you mint links yourself; otherwise it is the
URL from the email, unchanged. Both routes share a URI, so that one signature verifies the
POST as well as the GET that leads to it.
$link = $issuer->issueLink($user);
// $link->url already carries `expires` and `signature`. POST to it as-is.
Http::asJson()->post($link->url)->json();
See the signed POST for why the step is bound to the host the link was built for.
When two-factor is required
A two_factor response means the sign-in is not finished. The token is already consumed at
that point and the user is not logged in: the login completes inside Fortify only after
the code passes. Where the code goes depends on Fortify's views. With them on, redirect is
the challenge page to send the user to; with them off, it is the URI your client posts the
code to; and the key is left out when neither route is registered. The step is skipped only
when you turn the handoff off yourself, with fortify.mode or fortify.respect_two_factor.
See The two-factor handoff.
Changing the invalid-link code
invalid_or_expired is the default value of invalid_response.error_code. Change it there if
your clients expect a different string; it is the same code the 'json' invalid-link strategy
returns to browser clients. See
Invalid or expired links.
Backing off correctly
Throttled responses carry the standard Retry-After and X-RateLimit-* headers. The resend
guard's 429 additionally carries the seconds remaining in Retry-After, so a client can
render a countdown rather than retry blindly — see The resend guard.