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:
| Method | Details |
|---|---|
| Header | cf-turnstile-response: <token> |
| Body | "captchaToken": "<token>" (alongside other body fields) |
Protected endpoints and actions
| Endpoint | Turnstile Action |
|---|---|
POST /auth/request-code | request-code |
POST /auth/verify | login |
POST /auth/external/verify | login |
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/initto obtain avisitorToken(stores UTM params, referrer, landing page). Pass it invisitorTokenon 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..."
}
| Field | Type | Default | Description |
|---|---|---|---|
email | string | — | Required. |
force | boolean | false | If true, always send a code even when the user has a password. |
captchaToken | string | — | Turnstile 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | — |
code | string | no | One-time code from email. |
password | string | no | Password (if hasPassword was true). |
captchaToken | string | no | Turnstile token (or via cf-turnstile-response header). |
visitorToken | string | no | Visitor 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:
| Param | Required | Description |
|---|---|---|
redirectUrl | yes | Frontend URL to return to after auth |
mode | no | login (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 success → 302 redirect to {redirectUrl}?token=<accessToken>
On error → 302 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
provider | string | yes | Provider key, e.g. google. |
credential | string | yes | Raw provider credential / id_token. |
captchaToken | string | no | Turnstile token (or via cf-turnstile-response header). |
visitorToken | string | no | Visitor 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/sessionsendpoints requireAuthorization: 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 }