Skip to content
On this page

Core concepts

Authentication

JWTs for the dashboard, API keys for inference, and the account flows around them.

Every authenticated request uses Authorization: Bearer <token>. Auth resolution looks at the prefix: sk-or- is treated as an API key, anything else as a JWT.

Sign up

POST/auth/signupPublic
curl -X POST https://api.relayrouter.org/auth/signup \
-H 'content-type: application/json' \
-d '{
"email":"[email protected]",
"password":"at-least-8-chars",
"turnstile":"<captcha token>",
"fingerprint":"<opaque device hash>"
}'
FieldTypeDescription
emailrequiredstringMust be an address from a supported consumer email provider. Unsupported providers are rejected with 400.
passwordrequiredstring8–200 characters.
turnstilestringCloudflare Turnstile token. Required when captcha is configured. Signup only — sign-in never asks for a captcha.
fingerprintstringOpaque client hash, 16–128 chars. Strongly recommended: it powers the one-account-per-device rule.

The signup gate

Signup is intentionally strict. Checks run in this order, so cheap rejections never consume a captcha token:

CheckBehaviourNotes
Domain allowliststaticThe backend allows a configured set of consumer email providers. Everything else — throwaway providers, freshly registered domains, corporate domains and lookalikes — is rejected.
Canonical emailuniqueDots, +tags and case collapse, so hello@, h.ello@ and hello+2@ are the same account. One signup per human.
CaptchaTurnstileVerified server-side with the client IP and a hostname allowlist.
Email reputationnetworkDisposable blocklist, MX records, and a minimum domain age of 7 days (RDAP). Cached 7 days.
Daily budget1/dayOne created account per IP and per fingerprint per UTC day. ASN is recorded but not limited at signup.

Signup errors

StatusErrorMeaning
400invalid inputMissing email or password shorter than 8 characters.
400invalid emailMalformed address.
400invalid email domainDomain is missing or malformed.
400… addresses can sign upThe provider is not on the server-side allowlist. The exact list is deployment configuration, not part of the public API contract.
400disposable email addresses are not acceptedBlocklisted domain.
400that email domain cannot receive mailNo MX records.
400that email domain is too new — use a longer-standing addressDomain younger than SIGNUP_MIN_DOMAIN_AGE_DAYS.
400captcha token missingTurnstile token absent.
400captcha failed — try againTurnstile rejected the token.
409an account already exists for this addressRaw or canonical duplicate. Auth-worker mode may surface this as 400.
429an account was already created from this network todayPer-IP daily limit.
429an account was already created from this device todayPer-fingerprint daily limit.

Fingerprint format

A stable, non-identifying hash the client computes, for example SHA-256 over userAgent | languages | screen | timezoneOffset | hardwareConcurrency | deviceMemory | platform | maxTouchPoints | WebGL renderer. Send hex or URL-safe characters only, 16–128 chars. The server stores the hash, never raw device data.

Sign in

POST/auth/signinPublic

Same body and response as signup, without a captcha. The backend verifies the password with the credential authority, then mints an HS256 JWT locally containing sub, email, iat, and exp.

StatusErrorMeaning
200object{ "token": "eyJ…", "user_id": "uuid" }
401bad credentialsWrong email or password.
429too many sign-in attempts — try again in 15 minutes10 failures for the same IP + email inside 15 minutes. A success clears the counter.

Sign-in resolves the canonical email, so an account created with [email protected] can sign in with [email protected] or [email protected]. Password reset behaves the same way.

Sign-in alerts

Every successful sign-in emails the owner from [email protected] with time, IP, and client. Alerts are deduped to one per IP per day and can be disabled with email_prefs.security = false.

Current session

GET/auth/meJWT or API key
{
"id": "998d9efd-1560-47d4-aa66-5919c1f236a3",
"email": "[email protected]",
"created_at": 1792276481,
"email_verified": true,
"role": "user",
"is_admin": false,
"balance_micro": 1522976,
"balance_usd": 1.522976,
"network_fee_micro": 100
}
  • balance_micro is the source of truth. balance_usd is a convenience float.
  • email_verified reflects the canonical verified flag. A completed deposit sets it.
  • role is user or admin, and is_admin is a convenience boolean. Admin accounts can call operator endpoints with their normal session.
  • network_fee_micro: -1 uses the model fee, 0 waives it, and a positive value is a custom fee for this account.

Not returned yet

/auth/me does not return token_limit, tokens_used, allowed_models, price_overrides, or token_multiplier. Use /usage for spend and the ops CLI for the rest.

Email verification

Method & pathBodyResult
GET /auth/verify-email?token=…HTMLUser-facing success or failure page. No JSON.
POST /auth/verify-emailJSON{ "token": "…" } → { "verified": true } or 400.
POST /auth/resend-verificationJWT→ { "sent": true } or { "sent": false, "reason": "already verified" }.

Verification tokens are single-use and live 24 hours. Consuming one sets both verified and email_verified.

Password reset

POST/auth/forgot-passwordPublic

Always returns 200 so it never reveals whether an address is registered:

{
"ok": true,
"message": "if that address is registered, a reset link is on its way"
}
Method & pathBodyResult
GET /auth/reset-password?token=…HTMLReset form page linked from the email.
POST /auth/reset-passwordJSON{ "token": "…", "password": "newpass123" } → { "ok": true }. Returns 400 for an invalid or expired token, or a password outside 8–200 characters, and 503 if the auth worker is unreachable.

Reset tokens are single-use and live 30 minutes. A successful reset burns every other outstanding reset token for the account.

Auth limits

Auth routes are limited to 2 req/s sustained, burst 30 per IP with a 45 second timeout. Surface a friendly "try again in a moment" on 429.