Amba

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

MethodPathDescription
POST/client/auth/anonymousCreate an anonymous user and return session + refresh tokens.
POST/client/auth/socialApple / Google sign-in via identity token exchange.
POST/client/auth/email/signupEmail + password signup (bcrypt-hashed).
POST/client/auth/email/loginEmail + password login.
POST/client/auth/magic-link/requestSend a one-tap email magic-link.
POST/client/auth/magic-link/verifyExchange a magic-link token for a session.
POST/client/auth/email-otp/requestSend a 6-digit one-time passcode to the given email.
POST/client/auth/email-otp/verifyExchange (email, code) for a session.
POST/client/auth/sms-otp/requestSend a 6-digit one-time passcode by SMS to the given E.164 phone.
POST/client/auth/sms-otp/verifyExchange (phone, code) for a session.
POST/client/auth/linkLink an identifier (Apple / Google / SMS OTP / email OTP / email + password) to the current user.
POST/client/auth/refreshRotate session + refresh tokens.
POST/client/auth/logoutRevoke a refresh token (idempotent).

Auth token shape

All success responses return:

{
  "data": {
    "session_token": "eyJ…",
    "refresh_token": "eyJ…",
    "user": {
      "id": "…",
      "email": "…",
      "display_name": "…",
      "anonymous_id": "…",
      "auth_providers": [{ "provider": "email", "provider_id": "alice@example.com" }],
      "properties": {},
      "first_seen_at": "…",
      "last_seen_at": "…"
    }
  }
}

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:

{
  "data": {
    "session_token": "…",
    "refresh_token": "…",
    "user": {
      "id": "…",
      "email": null,
      "display_name": "OakHiker",
      "anonymous_id": "anon_…",
      "auth_providers": [],
      "properties": {},
      "first_seen_at": "…",
      "last_seen_at": "…"
    },
    "anonymous_id": "anon_…"
  }
}

Errors

  • 500 CREATE_FAILED.

Try it:

POST/client/auth/anonymous
client auth
curl -X POST 'https://api.amba.dev/v1/client/auth/anonymous'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X POST '${BASE_URL}/client/auth/anonymous' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Authorization: Bearer ${SESSION_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{}'

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

FieldTypeRequiredDescription
provider"apple" | "google"yes
tokenstringyesProvider identity token.
session_tokenstringnoExisting 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:

POST/client/auth/social
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/social'

Curl:

curl -X POST '${BASE_URL}/client/auth/social' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{}'

POST /client/auth/email/signup

Request

FieldTypeRequiredDescription
emailstringyes
passwordstringyesHashed server-side with bcrypt at cost factor 10.
display_namestringnoIf 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:

POST/client/auth/email/signup
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/email/signup'

Curl:

curl -X POST '${BASE_URL}/client/auth/email/signup' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{}'

POST /client/auth/email/login

Request

FieldTypeRequired
emailstringyes
passwordstringyes

Response 200

Standard auth response.

Errors

  • 400 INVALID_INPUT.
  • 401 INVALID_CREDENTIALS — wrong email / password.
  • 500 LOGIN_FAILED.

Try it:

POST/client/auth/email/login
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/email/login'

Curl:

curl -X POST '${BASE_URL}/client/auth/email/login' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{}'

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

BucketLimit
Per IP, per minute10 / 60s
Per IP, per day200 / 24h
Per (project, email), per hour5 / 60min
Per (project, email), per day20 / 24h

Request

FieldTypeRequiredDescription
emailstringyesRecipient email. Whitespace-trimmed and lowercased server-side.

Response 200

{ "data": { "ok": true } }

Errors

  • 400 INVALID_INPUT — body is missing email or the value isn't a plausible address.
  • 429 RATE_LIMIT_EXCEEDED — per-IP or per-(project, email) bucket exhausted; Retry-After header 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:

POST/client/auth/magic-link/request
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/magic-link/request'

Curl:

curl -X POST '${BASE_URL}/client/auth/magic-link/request' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Origin: https://app.example.com' \
  -H 'Content-Type: application/json' \
  -d '{ "email": "alice@example.com" }'

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

FieldTypeRequiredDescription
tokenstringyesThe raw token from the link's ?token= query parameter.

Response 200

Standard auth response — same shape as /email/login:

{
  "data": {
    "session_token": "eyJ…",
    "refresh_token": "eyJ…",
    "user": {
      "id": "…",
      "email": "alice@example.com",
      "display_name": "…",
      "anonymous_id": "anon_…"
    }
  }
}

Errors

  • 400 INVALID_INPUT — body missing token.
  • 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:

POST/client/auth/magic-link/verify
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/magic-link/verify'

Curl:

curl -X POST '${BASE_URL}/client/auth/magic-link/verify' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{ "token": "<raw-token-from-email-link>" }'

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:

{ "email": "alice@example.com" }

Response (always 200):

{ "data": { "ok": true } }

Errors: 400 INVALID_INPUT (malformed email shape), 429 RATE_LIMIT_EXCEEDED (with Retry-After seconds in the response header + body details).

Curl:

curl -X POST '${BASE_URL}/client/auth/email-otp/request' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{ "email": "alice@example.com" }'

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:

{ "email": "alice@example.com", "code": "123456" }

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:

Tierper phone per hourper phone per day
Free310
Pro1050
Scale50500
Enterpriseunboundedunbounded

Per-IP caps (10/min, 200/day) stay constant across tiers as abuse defense.

Request:

{ "phone": "+15551234567" }

Response (always 200):

{ "data": { "ok": true } }

Errors: 400 INVALID_INPUT (non-E.164 phone), 429 RATE_LIMIT_EXCEEDED.

Curl:

curl -X POST '${BASE_URL}/client/auth/sms-otp/request' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{ "phone": "+15551234567" }'

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:

{ "phone": "+15551234567", "code": "123456" }

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:

providerRequired fieldsNotes
appletoken, session_tokenApple identity token, JWKS-verified with audience validation.
googletoken, session_tokenGoogle identity token, JWKS-verified with audience validation.
sms_otpphone, code, session_tokenPhone + a code from /client/auth/sms-otp/request.
email_otpemail, code, session_tokenEmail + a code from /client/auth/email-otp/request.
email_passwordemail, password, session_tokenAttaches 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_PROVIDERprovider is "email" or not one of the five supported values.
  • 400 INVALID_INPUT — missing session_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_id names it.
  • 500 LINK_FAILED.

Try it:

POST/client/auth/link
client auth
curl -X POST 'https://api.amba.dev/v1/client/auth/link'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X POST '${BASE_URL}/client/auth/link' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Authorization: Bearer ${SESSION_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{}'

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

FieldTypeRequired
refresh_tokenstringyes

Response 200

{ "data": { "session_token": "…", "refresh_token": "…" } }

Errors

  • 400 INVALID_INPUT.
  • 401 INVALID_TOKEN — signature invalid, session not found, project mismatch, expired, or revoked.
  • 500 REFRESH_FAILED.

Try it:

POST/client/auth/refresh
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/refresh'

Curl:

curl -X POST '${BASE_URL}/client/auth/refresh' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Content-Type: application/json' \
  -d '{}'

POST /client/auth/logout

Revoke the session. Idempotent — invalid tokens return success.

Request

FieldTypeRequired
refresh_tokenstringyes

Response 200

{ "data": { "success": true } }

Errors

  • 400 INVALID_INPUT.
  • 500 LOGOUT_FAILED.

Try it:

POST/client/auth/logout
client auth
curl -X POST 'https://api.amba.dev/v1/client/auth/logout'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X POST '${BASE_URL}/client/auth/logout' \
  -H 'X-Api-Key: ${CLIENT_API_KEY}' \
  -H 'Authorization: Bearer ${SESSION_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{}'