Auth API

FonProxy Auth API reference documentation.

FonProxy API — Auth

Base URL: http://localhost:3100

All entity IDs in responses are hashid-encoded. Do not assume sequential integers. All error responses omit statusCode. Use the HTTP status code from the response header. In NODE_ENV=development, errors also include a stack field with the full stack trace.

Rate limits apply globally (30 req/60s) and per auth route (see below).


Captcha (Cloudflare Turnstile)

Certain auth endpoints require a Cloudflare Turnstile captcha token. The backend uses Managed mode with actions to verify each endpoint independently.

Site key: 0x4AAAAAACueMwGuHty40Ptm

How to send the token

Include the Turnstile response via one of:

MethodDetails
Headercf-turnstile-response: <token>
Body"captchaToken": "<token>" (alongside other body fields)

Protected endpoints and actions

EndpointTurnstile Action
POST /auth/request-coderequest-code
POST /auth/verifylogin
POST /auth/external/verifylogin

Error responses

Missing token (403):

{ "message": "captcha.token_required" }

Failed verification (403):

{ "message": "captcha.verification_failed" }

Development

Set TURNSTILE_ENABLED=false in .env to bypass captcha verification in local development.


How to use Auth

Flow overview

1. POST /auth/request-code  { email }
       ↓
   Server sends 6-digit code to email.
   Response includes `hasPassword: true|false`.

2. POST /auth/verify  { email, code, visitorToken? }       ← code from email
          — OR —
   POST /auth/verify  { email, password, visitorToken? }   ← only if hasPassword was true

       ↓
   Response: { accessToken, user }

3. Use the token:
   Authorization: Bearer <accessToken>

4. (Optional) Link a password while logged in:
   POST /auth/set-password  { password }

   Next time, the user can log in with either code or password.

Visitor tracking: Before the auth flow, call POST /track/init to obtain a visitorToken (stores UTM params, referrer, landing page). Pass it in visitorToken on step 2. If the user is registering for the first time, the visitor record is automatically linked to their account so attribution is preserved. See Tracking API.

Step-by-step example

1. Request a code

curl -X POST http://localhost:3100/auth/request-code \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'
{ "message": "auth.code_sent", "hasPassword": false }

2a. Verify with code

curl -X POST http://localhost:3100/auth/verify \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "code": "482916", "visitorToken": "visitor_a1b2c3..."}'

2b. Verify with password (only if hasPassword was true)

curl -X POST http://localhost:3100/auth/verify \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "mySecurePassword", "visitorToken": "visitor_a1b2c3..."}'

3. Use the token for authenticated requests

curl http://localhost:3100/user/me \
  -H "Authorization: Bearer eyJhbGciOi..."

4. Update settings (name, currency, password) while logged in

curl -X PATCH http://localhost:3100/user/settings \
  -H "Authorization: Bearer eyJhbGciOi..." \
  -H "Content-Type: application/json" \
  -d '{"currency": "UAH"}'

Auth

POST /auth/request-code

Send a verification code to the given email address. Also returns whether the user already has a password linked.

If the user has a password, the code is not sent by default — the response tells the frontend to show the password input instead. Pass force: true to send a code anyway (e.g. "I forgot my password" button).

Rate limit: 5 requests per 60 seconds.

Captcha: Required — action request-code. See Captcha section.

Request body:

{
  "email": "user@example.com",
  "force": false,
  "captchaToken": "0.xxxxxxx..."
}
FieldTypeDefaultDescription
emailstringRequired.
forcebooleanfalseIf true, always send a code even when the user has a password.
captchaTokenstringTurnstile token (or send via cf-turnstile-response header).

Response — user has no password (200):

{ "message": "auth.code_sent", "hasPassword": false, "codeSent": true }

Response — user has a password, force not set (200):

{ "message": "auth.use_password", "hasPassword": true, "codeSent": false }

Response — user has a password, force: true (200):

{ "message": "auth.code_sent", "hasPassword": true, "codeSent": true }

POST /auth/verify

Verify using either a code or a password. Send one of code or password, not both.

Rate limit: 10 requests per 60 seconds.

Captcha: Required — action login. See Captcha section.

Request body (code):

{
  "email": "user@example.com",
  "code": "482916",
  "visitorToken": "visitor_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
}

Request body (password):

{
  "email": "user@example.com",
  "password": "mySecurePassword",
  "visitorToken": "visitor_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
}
FieldTypeRequiredDescription
emailstringyes
codestringnoOne-time code from email.
passwordstringnoPassword (if hasPassword was true).
captchaTokenstringnoTurnstile token (or via cf-turnstile-response header).
visitorTokenstringnoVisitor token from POST /track/init. If the account is new, the visitor record (UTM, referrer, landing page) is linked to the created user. Has no effect on existing accounts.

Response (200):

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": {
    "id": "k5Xz9qR2Wp",
    "name": null,
    "displayName": "user@example.com",
    "email": "user@example.com",
    "initials": "US",
    "balance": 0,
    "displayBalance": 0,
    "currency": "USD",
    "hasPassword": false,
    "createdAt": "2026-03-15T12:00:00.000Z"
  }
}

Error (401):

{
  "message": "auth.code_invalid",
  "path": "/auth/verify",
  "timestamp": "2026-03-15T12:00:00.000Z"
}

External Auth

The external auth system uses a server-side OAuth redirect flow:

1. GET /auth/external/:provider/url?redirectUrl=https://app.fonproxy.io/auth/callback
       ↓ returns { url: "https://accounts.google.com/o/oauth2/..." }

2. Frontend does: window.location.href = url

3. User logs in on provider's page

4. Provider redirects to: GET /auth/external/:provider/callback?code=...&state=...

5. Backend exchanges code → profile → issues JWT
   → redirects to: {redirectUrl}?token=<accessToken>

6. Frontend reads ?token from URL, stores it, proceeds to dashboard

Telegram auth flow

Telegram uses a different flow via a backend-served waiting page instead of a traditional OAuth redirect:

1. GET /auth/external/telegram/url?redirectUrl=https://app.fonproxy.io/auth/callback
       ↓ returns { url: "https://api.fonproxy.io/telegram/internal/auth/wait/<state>" }

2. Frontend navigates to the URL — opens a branded waiting page with spinner

3. User clicks "Open Telegram" button → opens t.me/fonproxy_bot?start=<state>
   User presses /start in the bot chat

4. Bot processes the auth and stores the profile in the pending state

5. Waiting page auto-detects completion (polls every 3s)
   → redirects to {redirectUrl}?token=<accessToken>

Note: Telegram does not provide user email. First-time Telegram logins create an account without email. The user can add their email later in settings. See Telegram Internal API for internal endpoints.


GET /auth/external/providers

List available external authentication providers. Public — no auth required.

Response (200):

{
  "providers": [
    { "key": "google", "name": "Google", "icon": "google" },
    { "key": "telegram", "name": "Telegram", "icon": "telegram" }
  ]
}

GET /auth/external/:provider/url

Get the OAuth authorization URL to redirect the user to.

Query parameters:

ParamRequiredDescription
redirectUrlyesFrontend URL to return to after auth
modenologin (default) or link

Response (200):

{
  "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...&response_type=code&scope=openid+email+profile&state=eyJ..."
}

GET /auth/external/:provider/callback

OAuth callback — called by the provider after the user grants consent.

On success302 redirect to {redirectUrl}?token=<accessToken> On error302 redirect to {redirectUrl}?error=auth.external_failed


POST /auth/external/link

Link an external provider to the current user's account.

Headers: Authorization: Bearer <token>

Request body:

{
  "provider": "google",
  "credential": "eyJhbGciOi..."
}

Response (200):

{ "message": "auth.external_linked", "provider": "google" }

POST /auth/external/verify

Login/register by sending a provider credential directly (Google One Tap / id_token).

Captcha: Required — action login. See Captcha section.

Request body:

{
  "provider": "google",
  "credential": "eyJhbGciOi...",
  "visitorToken": "visitor_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
}
FieldTypeRequiredDescription
providerstringyesProvider key, e.g. google.
credentialstringyesRaw provider credential / id_token.
captchaTokenstringnoTurnstile token (or via cf-turnstile-response header).
visitorTokenstringnoVisitor token from POST /track/init. Linked to the user on first registration.

Response (200): { accessToken, user } — same shape as POST /auth/verify


GET /auth/external/links

Get the current user's linked external provider accounts. Requires JWT.

Response (200):

{
  "links": [
    {
      "id": "k5Xz9qR2Wp",
      "provider": "google",
      "email": "user@gmail.com",
      "displayName": "John Doe",
      "avatarUrl": "https://lh3.googleusercontent.com/...",
      "createdAt": "2026-03-15T12:00:00.000Z"
    }
  ]
}

DELETE /auth/external/links/:id

Unlink an external provider account. Requires JWT.

Response (200):

{ "message": "auth.external_unlinked" }

Sessions

All /auth/sessions endpoints require Authorization: Bearer <token>.

GET /auth/sessions

List active sessions for the current user.

Response (200):

{
  "sessions": [
    {
      "id": "k5Xz9qR2Wp",
      "ip": "91.123.45.67",
      "location": { "country": "UA", "city": "Kyiv" },
      "device": {
        "browser": "Chrome 120",
        "os": "macOS 14.2",
        "device": "Desktop",
        "summary": "Chrome 120 / macOS 14.2"
      },
      "isCurrent": true,
      "lastActiveAt": "2026-03-15T12:00:00.000Z",
      "createdAt": "2026-03-10T08:00:00.000Z"
    }
  ]
}

DELETE /auth/sessions/:id

Revoke a specific session. Cannot revoke the current session.

Response (200):

{ "message": "auth.session_revoked" }

DELETE /auth/sessions

Revoke all sessions except the current one.

Response (200):

{ "message": "auth.sessions_revoked", "revokedCount": 3 }
Auth API — FonProxy