This is the reference for what the API returns from each flow: which token comes back where, what each token carries, and the status codes that callers (and the SDK adapters) branch on. For a runnable walkthrough, see direct-http-quickstart.md; for the token design rationale, see architecture.md.
- Identifier — the value a user logs in with at
POST /login: either an email or a phone number. The request field isidentifier(notemail), and the response echoesidentifierType: "email" | "phone". Endpoints that clearly apply to one channel (for example/otp/generate-login-email-otp) name that channel explicitly. - Ephemeral / access / refresh token — the three token states below.
- Service token — a separate credential presented by a trusted server adapter
(
x-seamless-service-token), unrelated to a user session. Not interchangeable with the bearer tokens below.
All tokens are returned in the JSON body (the API never sets cookies). Present them as
Authorization: Bearer <token>.
| Token | Issued by | Presented to | Purpose | Lifetime |
|---|---|---|---|---|
| Ephemeral | POST /login (and registration start) |
the continuation step (OTP generate/verify, magic-link request/poll, WebAuthn login) | carry a pre-authenticated identity between login steps | short (about 5 minutes) |
| Access | OTP/WebAuthn/magic-link completion, POST /refresh |
protected routes (e.g. GET /users/me) |
authenticated application access | access_token_ttl (system config) |
| Refresh | the same completion steps and POST /refresh |
POST /refresh only |
obtain a new access token | refresh_token_ttl (system config) |
- Access token — a signed JWT (RS256). Claims include
sub(user id),sid(session id),iss,typ: "access",roles, andorg_idwhen the session has an active organization. Verify it against the JWKS atGET /.well-known/jwks.json. - Ephemeral token — a signed JWT scoped to the pre-auth step; treat it as opaque.
- Refresh token — an opaque random string (not a JWT), stored server-side only as a hash plus
a lookup fingerprint. It is rotated on every
POST /refresh: the presented token is invalidated and a new one returned, and reusing a retired refresh token revokes the session chain. Always persist the newestrefreshToken.
POST /refreshreads the refresh token from theAuthorizationheader, not the request body.
Most endpoints use conventional codes (200 success, 400 invalid input, 401/403 auth
failures, 500 server error). A few carry branch-significant meaning that clients must handle:
| Endpoint | Code | Meaning |
|---|---|---|
GET /magic-link/check (poll) |
204 |
Not yet verified — keep polling (no body). |
GET /magic-link/check (poll) |
200 |
Verified — body carries the issued session. |
GET /magic-link/check (poll) |
403 |
Polling device fingerprint does not match the pending link. |
POST /login |
200 |
Returns an ephemeral token plus loginMethods for this user/device. |
POST /login |
423 |
The account is locked out. Carries retryAfterSeconds. |
POST /refresh |
401 |
Missing, invalid, expired, or already-rotated refresh token. |
POST /refresh |
405 |
Method other than POST. |
The SDK adapters branch on these exact codes (for example, the magic-link poll treats 204 as
"pending"). Changing a branch-significant status code is a contract change; see the ripple
protocol in ecosystem.md.
GET /magic-link accepts an optional redirectUri query parameter deciding where the emailed
link lands. Omit it and the link keeps the tenant-wide destination, frontend_url falling back
to the first configured origin, which is what every existing caller gets.
A supplied value is validated against the configured origins the same way
resolveOAuthRedirectUri validates an OAuth redirect, and a value outside them answers 400.
That is what lets one tenant serve a web client and a mobile client without them sharing a
single destination. The token is set as a token query parameter on the target, replacing one
of that name the caller had already put there.
A destination that cannot be expressed as an origin, a custom scheme such as myapp://auth or a
universal link on a host that is not a WebAuthn origin, goes in the magic_link_redirect_uris
system config key instead. Entries there are matched exactly, which is what makes a custom
scheme safe to accept: there is no origin to compare, so only a literal an operator listed is
allowed. The key defaults to [], and an empty list falls back to comparing against origins,
so a deployment that sets nothing behaves as described above.
Setting it narrows rather than widens. Once the list is non-empty it is the whole allowlist, and
a destination on a configured origin that is not listed is refused. That is deliberate: a tenant
that needs a mobile scheme should not have to widen origins, which gates passkey ceremonies, to
get it.
Every 4xx and 5xx response uses one shape, with one additive extension for schema
validation failures covered below:
{ "error": "User already exists", "message": "optional extra detail" }erroris always present and carries the human-readable reason. Read this field.messageis optional extra detail. It is never a substitute forerror, so a consumer never has to check two fields to find out why a call failed.
Some handlers used to return { message } with no error, which left the shape ambiguous. They
now all set error. tests/unit/routes/errorShapeCoverage.spec.ts walks every registered route
and fails if any failure response declares a schema without a required error string, so a new
route cannot reintroduce the split.
A 429 from the rate limiters is included. It used to be the one exception, answering plain text
because express-rate-limit sends a string message through res.send, and it is now
{ "error": "Too many requests, please try again later" } like everything else. The limiters are
middleware rather than route handlers, so nothing a handler declares would mention them;
defineRoute declares the 429, and the 500 from the top-level error handler, on every route
it registers, so both appear in the OpenAPI document and in a client generated from it.
ErrorSchema in src/schemas/generic.responses.ts is the
canonical definition. InternalErrorSchema is a deprecated alias of it and is identical on the
wire.
A request that fails its route's params, query or body schema is refused by
defineRoute before the handler runs, and answers with the same shape plus a details list
naming the fields that were rejected:
{
"error": "invalid_request",
"message": "Request failed schema validation.",
"details": { "issues": [{ "path": ["attachment"], "code": "invalid_value", "message": "..." }] }
}error is the stable code invalid_request, so a client branches on that rather than on prose.
details is additive: a consumer reading only error is unaffected. Issues are mapped field by
field rather than passed through from the validator, so the response names which field was wrong
without echoing back the value that was sent.
ValidationErrorSchema is the definition, and defineRoute declares it as the 400 for any
route that validates a request, so the documented response matches what validation actually
returns. A route that already declares a richer 400 of its own, such as
AdminValidationErrorSchema, keeps it.
Success responses are unaffected: { "message": "Success" } on a 200 is a success payload, not
an error body, and clients that branch on it keep working.
POST /login answers 200 with an ephemeral token and loginMethods for every identifier it
accepts, whether or not an account exists. An identifier with no usable account gets a decoy
token: real, signed, and indistinguishable from one issued to a genuine account. Every endpoint
that accepts a pre-auth token answers for a decoy the way it answers for a real one, so the
continuation steps do not disclose it either.
POST /login no longer returns 401. A client that branched on 401 to mean "no such user"
will now follow the normal continuation flow instead, and the failure will surface at the
continuation step (a wrong OTP, an assertion that cannot verify) rather than at login. That is the
point: there is no longer an answer to give.
What still distinguishes an account: a 423 lockout, which only a real account can be in, and a
400 for a malformed identifier, which does not depend on whether an account exists.
See security-posture.md for the full design, including how a decoy subject is derived and what remains observable.