Auth
Client-side auth — anonymous, Apple, Google, email signup / login, magic-link, email OTP, SMS OTP, account linking, refresh, logout.
The client-side auth surface. Every route accepts an X-Api-Key header (the project's client key). All token verification is project-scoped — a token issued for project A is rejected with 401 INVALID_TOKEN when presented for project B, even if the signature is valid.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /client/auth/anonymous | Create an anonymous user and return session + refresh tokens. |
| POST | /client/auth/social | Apple / Google sign-in via identity token exchange. |
| POST | /client/auth/email/signup | Email + password signup (bcrypt-hashed). |
| POST | /client/auth/email/login | Email + password login. |
| POST | /client/auth/magic-link/request | Send a one-tap email magic-link. |
| POST | /client/auth/magic-link/verify | Exchange a magic-link token for a session. |
| POST | /client/auth/email-otp/request | Send a 6-digit one-time passcode to the given email. |
| POST | /client/auth/email-otp/verify | Exchange (email, code) for a session. |
| POST | /client/auth/sms-otp/request | Send a 6-digit one-time passcode by SMS to the given E.164 phone. |
| POST | /client/auth/sms-otp/verify | Exchange (phone, code) for a session. |
| POST | /client/auth/link | Link an identifier (Apple / Google / SMS OTP / email OTP / email + password) to the current user. |
| POST | /client/auth/refresh | Rotate session + refresh tokens. |
| POST | /client/auth/logout | Revoke a refresh token (idempotent). |
Auth token shape
All success responses return:
session_token is a short-lived JWT; refresh_token is a long-lived JWT backed by a app_user_sessions row (sha256 hashed). On /refresh we verify the stored hash matches and that the session row hasn't been revoked.
POST /client/auth/anonymous
Create an anonymous user. Returns tokens immediately — no credentials to collect.
A display_name is auto-generated server-side from a 32-adjective × 32-noun wordlist (e.g. "OakHiker", "DustOwl") so leaderboards / friends-list UI have a stable label for every user from the first event onward. The user can override it later via PATCH /client/users/me. See Display names for the full behaviour across signup paths.
Request
No body required.
Response 201
Standard auth response plus anonymous_id:
Errors
500 CREATE_FAILED.
Try it:
/client/auth/anonymouscurl -X POST 'https://api.amba.dev/v1/client/auth/anonymous'Curl:
POST /client/auth/social
Apple / Google sign-in. The identity token is verified against the provider's JWKS with strict audience validation (aud must match the project's bundle_id for Apple, google_oauth_client_id for Google). Missing audience config is fail-closed.
Request
| Field | Type | Required | Description |
|---|---|---|---|
provider | "apple" | "google" | yes | |
token | string | yes | Provider identity token. |
session_token | string | no | Existing session for automatic upgrade. |
Response 200
Standard auth response.
Errors
400 AUDIENCE_NOT_CONFIGURED— project has no configured audience for the provider.401 INVALID_TOKEN— identity token signature or audience verification failed.404 NOT_FOUND— project not found.500 CREATE_FAILED/SOCIAL_LOGIN_FAILED.
Try it:
/client/auth/socialcurl -X POST 'https://api.amba.dev/v1/client/auth/social'Curl:
POST /client/auth/email/signup
Request
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | |
password | string | yes | Hashed server-side with bcrypt at cost factor 10. |
display_name | string | no | If omitted, the API auto-generates one (e.g. "OakHiker"). See Display names. |
Response 201
Standard auth response.
Errors
400 INVALID_INPUT— missing email / password.409 EMAIL_EXISTS— email already registered.500 CREATE_FAILED.
Try it:
/client/auth/email/signupcurl -X POST 'https://api.amba.dev/v1/client/auth/email/signup'Curl:
POST /client/auth/email/login
Request
| Field | Type | Required |
|---|---|---|
email | string | yes |
password | string | yes |
Response 200
Standard auth response.
Errors
400 INVALID_INPUT.401 INVALID_CREDENTIALS— wrong email / password.500 LOGIN_FAILED.
Try it:
/client/auth/email/logincurl -X POST 'https://api.amba.dev/v1/client/auth/email/login'Curl:
POST /client/auth/magic-link/request
Mint a magic-link token, store it server-side (sha256 hashed), and email the raw token to the user. The link the user clicks is ${ORIGIN}/auth/verify?token=<raw>, where ORIGIN comes from MAGIC_LINK_REDIRECT_BASE_URL (env override) → the request's Origin header → https://app.amba.dev (default).
This endpoint always returns 200 — even for unknown emails — so an unauthenticated caller can't enumerate registered users via timing or response shape.
Tokens are 32 bytes (base64url, ~256 bits of entropy), expire in 15 minutes, and are single-use.
Rate limits
| Bucket | Limit |
|---|---|
| Per IP, per minute | 10 / 60s |
| Per IP, per day | 200 / 24h |
| Per (project, email), per hour | 5 / 60min |
| Per (project, email), per day | 20 / 24h |
Request
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | Recipient email. Whitespace-trimmed and lowercased server-side. |
Response 200
Errors
400 INVALID_INPUT— body is missingemailor the value isn't a plausible address.429 RATE_LIMIT_EXCEEDED— per-IP or per-(project, email) bucket exhausted;Retry-Afterheader set.
Send-time errors (email delivery failure, SMTP rejection) do not surface in the response — they're logged server-side. From the caller's perspective the endpoint either accepts the request (200) or refuses it (400 / 429).
Try it:
/client/auth/magic-link/requestcurl -X POST 'https://api.amba.dev/v1/client/auth/magic-link/request'Curl:
POST /client/auth/magic-link/verify
Exchange a magic-link token for a session. The lookup is by sha256(token); a non-matching, expired, or already-used token all return 401 INVALID_TOKEN with the same generic message — same shape as /email/login so a probing caller can't differentiate between "token doesn't exist" and "token was consumed".
If the email already maps to an existing user, the session is bound to that user. Otherwise a new user is created (passwordless signup); the verify step is the email-verified moment, so this is also how a customer onboards a fresh inbox.
Request
| Field | Type | Required | Description |
|---|---|---|---|
token | string | yes | The raw token from the link's ?token= query parameter. |
Response 200
Standard auth response — same shape as /email/login:
Errors
400 INVALID_INPUT— body missingtoken.401 INVALID_TOKEN— token unknown, expired, or already used.402 MAU_CAP_EXCEEDED— only on the create-new-user branch when the project hit its free-tier MAU cap.500 VERIFY_FAILED.
Try it:
/client/auth/magic-link/verifycurl -X POST 'https://api.amba.dev/v1/client/auth/magic-link/verify'Curl:
POST /client/auth/email-otp/request
Mint a 6-digit code, send it to email via the platform's transactional email provider, and return 200 { data: { ok: true } } — always, regardless of whether the email is registered. The uniform response prevents account enumeration. The plaintext code never lands in the DB; only sha256(code + per-row-salt) is persisted, and the code only ever exists in the user's mailbox until verify or expiry.
Rate limits: per-IP (10/min, 200/day) AND per-(project, email) (5/hour, 20/day). The per-(project, email) caps run BEFORE the per-IP day cap so a Retry-After from one doesn't burn the other's budget.
Request:
Response (always 200):
Errors: 400 INVALID_INPUT (malformed email shape), 429 RATE_LIMIT_EXCEEDED (with Retry-After seconds in the response header + body details).
Curl:
POST /client/auth/email-otp/verify
Exchange (email, code) for a session. Code is 6 ASCII digits; verify rejects any other shape. The verify atomically claims the live challenge (single-use), pre-checks the MAU cap on the new-user path (so a valid code on a capped project returns 429 MAU_CAP_REACHED without consuming the OTP — the user can retry the same code once cap pressure subsides), and finally mints session + refresh tokens.
A wrong code bumps the per-challenge attempts counter; on the 5th wrong attempt the challenge is invalidated for the remainder of its TTL. Typing a recently-superseded code (from a /request rotation) does NOT burn attempts on the live challenge — the verify path recognizes superseded codes and rejects them without penalty.
Every failure mode returns the same 400 INVALID_CODE envelope so an attacker cannot distinguish "wrong code" from "expired" from "exhausted attempts".
Request:
Response: identical to /email/login (see Auth token shape above).
Errors: 400 INVALID_CODE (any failure mode), 400 INVALID_INPUT (malformed email shape), 429 MAU_CAP_REACHED (project at MAU cap; OTP NOT consumed).
POST /client/auth/sms-otp/request
Mint a 6-digit code, send via SMS to the given E.164 phone number, return 200 { data: { ok: true } } regardless of registration. Same enumeration-resistance as email-OTP.
phone MUST be E.164: starts with +, total 8–15 digits, no spaces or dashes. The API rejects non-conforming phones with 400 INVALID_INPUT before any provider round-trip (each SMS send costs real money).
Per-phone rate limits scale with project tier — SMS sends are billed per segment (~$0.0075 US domestic), so the free tier caps are deliberately tight:
| Tier | per phone per hour | per phone per day |
|---|---|---|
| Free | 3 | 10 |
| Pro | 10 | 50 |
| Scale | 50 | 500 |
| Enterprise | unbounded | unbounded |
Per-IP caps (10/min, 200/day) stay constant across tiers as abuse defense.
Request:
Response (always 200):
Errors: 400 INVALID_INPUT (non-E.164 phone), 429 RATE_LIMIT_EXCEEDED.
Curl:
POST /client/auth/sms-otp/verify
Exchange (phone, code) for a session. Same atomic-claim, MAU-pre-check, superseded-code-doesn't-burn-attempts, single-envelope-error-shape semantics as email-otp/verify. The user record is created or matched by exact-string phone equality on the verified E.164 input.
Request:
Response: identical to /email/login (see Auth token shape above).
Errors: 400 INVALID_CODE, 400 INVALID_INPUT (non-E.164), 429 MAU_CAP_REACHED.
POST /client/auth/link
Attach an identifier to the current user (typically an anonymous one, to preserve history) without minting a new user row. The session stays valid — no token rotation. The session token must match the API key's project. Linking is idempotent on re-link of the same identifier to the same user.
Identity is preserved in place
Linking updates the same app_user row — the user_id does not change, and
everything keyed to it (collections rows, currency balances, inventory, streaks, XP,
entitlements) stays exactly where it was. The email / phone / social credential is
attached to the existing user; nothing is copied to a new row and nothing is orphaned.
When the user was anonymous, Amba additionally records an account_links row mapping
the prior anonymous_id to the now-identified user_id, so the upgrade is auditable.
This is why the standard flow is: start with POST /client/auth/anonymous so the user
has real data from the first launch, then call /client/auth/link when they sign in —
the anonymous session's data carries straight over with no migration step on your side.
Five providers are supported. The fields required depend on the provider:
provider | Required fields | Notes |
|---|---|---|
apple | token, session_token | Apple identity token, JWKS-verified with audience validation. |
google | token, session_token | Google identity token, JWKS-verified with audience validation. |
sms_otp | phone, code, session_token | Phone + a code from /client/auth/sms-otp/request. |
email_otp | email, code, session_token | Email + a code from /client/auth/email-otp/request. |
email_password | email, password, session_token | Attaches an email + password credential; sets email_verified=true. |
The legacy raw provider="email" shape is permanently refused (400 UNSUPPORTED_PROVIDER)
— it accepted an unverified email. Use email_otp (OTP-verified) or email_password
(password credential) instead.
Response 200
apple / google return a standard auth response. The OTP and email_password paths
do not rotate tokens and return { "data": { "user": { … } } } only — the existing
session stays valid.
Errors
400 UNSUPPORTED_PROVIDER—provideris"email"or not one of the five supported values.400 INVALID_INPUT— missingsession_token, or the fields required by the chosen provider.400 AUDIENCE_NOT_CONFIGURED— Apple / Google audience not configured.401 INVALID_SESSION— session token invalid.401 INVALID_TOKEN— identity token (or OTP code) invalid.403 FORBIDDEN— session belongs to a different project.404 NOT_FOUND— project or user not found.409 IDENTITY_ALREADY_LINKED— the social identity is already bound to another account.409 PHONE_ALREADY_LINKED/409 EMAIL_ALREADY_LINKED— the phone / email is owned by another user.error.details.conflicting_user_idnames it.500 LINK_FAILED.
Try it:
/client/auth/linkcurl -X POST 'https://api.amba.dev/v1/client/auth/link'Curl:
POST /client/auth/refresh
Rotate tokens. The old session row is revoked. Replaying a revoked token returns 401 INVALID_TOKEN — this is how token theft is detected.
Request
| Field | Type | Required |
|---|---|---|
refresh_token | string | yes |
Response 200
Errors
400 INVALID_INPUT.401 INVALID_TOKEN— signature invalid, session not found, project mismatch, expired, or revoked.500 REFRESH_FAILED.
Try it:
/client/auth/refreshcurl -X POST 'https://api.amba.dev/v1/client/auth/refresh'Curl:
POST /client/auth/logout
Revoke the session. Idempotent — invalid tokens return success.
Request
| Field | Type | Required |
|---|---|---|
refresh_token | string | yes |
Response 200
Errors
400 INVALID_INPUT.500 LOGOUT_FAILED.
Try it:
/client/auth/logoutcurl -X POST 'https://api.amba.dev/v1/client/auth/logout'Curl: