Skip to main content

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​

OutcomeStatusBody
Link / code requested200{ "message": "…", "channel": "link"|"code" }
Signed in200{ "authenticated": true, "two_factor": false, "redirect": "<url>" }
Two-factor required200{ "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 expired422{ "message": "…", "error": "invalid_or_expired" }
CAPTCHA challenge failed422{ "message": "…", "error": "captcha_failed" }
Validation failed422{ "message": "…", "errors": { … } }
Rate limited429{ "message": "…" } + Retry-After / X-RateLimit-* headers
Resend held back429{ "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.

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.