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…",
    "expires_at": "2026-11-13T09:30:00.000Z",
    "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 JWT that lasts 30 days, and expires_at is its expiry; keep the session until then. 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.

Pass anonymous_id to sign back in to the anonymous user that holds it. That works while the user is purely anonymous: no email, phone, password, or linked Apple / Google identity. A user with any of those signs in with that identity (or continues their session with /refresh), and an anonymous_id that is theirs, or was theirs before a link, answers 401 IDENTITY_SIGN_IN_REQUIRED and creates nobody. To start a new anonymous user, call without anonymous_id. Store the anonymous_id from the response with the session tokens: it is the credential of an anonymous account.

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

FieldTypeRequiredDescription
anonymous_idstringno^[A-Za-z0-9_-]{1,128}$. Signs back in to the purely anonymous user that holds it (200).

No body creates a new anonymous user.

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

  • 400 INVALID_INPUT — anonymous_id is not a string matching ^[A-Za-z0-9_-]{1,128}$.
  • 401 IDENTITY_SIGN_IN_REQUIRED — anonymous_id belongs to a user who signs in with a linked identity.
  • 429 RATE_LIMIT_EXCEEDED — more than 120 requests a minute or 1,200 an hour from one client address on this app (server keys skip this), or more than 300 new users a minute per app (3,000 on a paid plan); Retry-After header set. Resuming a known anonymous_id never spends the per-app budget.
  • 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 '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
tokenstringyes*Provider identity token. *Send it as token or id_token.
id_tokenstringnoSame value as token (the field name the SDKs send).
noncestringnoRaw sign-in nonce (1–512 characters). The token's nonce claim must be its SHA-256 hex digest.
session_tokenstringnoExisting session for automatic upgrade.

A token whose nonce claim was set by an Amba SDK (it starts with amba1.) is accepted only with the matching nonce.

Response 200

Standard auth response.

Errors

  • 400 AUDIENCE_NOT_CONFIGURED — project has no configured audience for the provider.
  • 400 INVALID_INPUT — nonce is present but not a string of 1–512 characters.
  • 401 INVALID_TOKEN — identity token signature, audience or nonce verification failed.
  • 401 NONCE_REUSED — an earlier request already signed in with this nonce. A nonce is accepted once; start a new sign-in with the provider.
  • 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' \
  -H 'Content-Type: application/json' \
  -d '{
  "provider": "apple",
  "token": "<apple identity token>"
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X POST "${BASE_URL}/client/auth/social" \
  -H "X-Api-Key: ${CLIENT_API_KEY}" \
  -H 'Content-Type: application/json' \
  -d '{"provider":"apple","token":"<apple identity token>"}'

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. The user's email_verified is false: the password stays unproven until the inbox is. POST /client/auth/link with provider: "email_otp" and the same address from this session verifies it and keeps the password; a sign-in that proves the inbox from anywhere else (email OTP, magic link, a verified Apple / Google email) clears the password and revokes every earlier session.

Errors

  • 400 INVALID_INPUT — missing email / password.
  • 409 EMAIL_EXISTS — email already registered (matched case-insensitively).
  • 429 RATE_LIMIT_EXCEEDED — more than 10 requests a minute or 100 an hour from one client address on this app (server keys skip this), or more than 60 new users a minute per app (600 on a paid plan); Retry-After header set. A taken address or a malformed request never spends the per-app budget.
  • 500 CREATE_FAILED.

Try it:

POST/client/auth/email/signup
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/email/signup' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "user@example.com",
  "password": "example-password"
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

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

POST /client/auth/email/login

Request

FieldTypeRequired
emailstringyes
passwordstringyes

Response 200

Standard auth response.

Errors

  • 400 INVALID_INPUT.
  • 401 INVALID_CREDENTIALS — wrong email / password.
  • 429 RATE_LIMIT_EXCEEDED — a sign-in budget is spent, checked before the password: 20/minute and 200/hour per IP, 10 per 15 minutes and 100/day per account (the lower-cased email), 60/minute per free project or 600/minute per paid project. Retry-After and details.retryAfterSeconds say how long to wait.
  • 500 LOGIN_FAILED.

Try it:

POST/client/auth/email/login
public auth
curl -X POST 'https://api.amba.dev/v1/client/auth/email/login' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "user@example.com",
  "password": "example-password"
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

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

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. Configure the project's exact HTTPS callback with PATCH /v1/admin/projects/{projectId} and magic_link_redirect_url; Amba appends the token query parameter while preserving any other configured query parameters. When unset, the legacy link is ${ORIGIN}/auth/verify?token=<raw>, where ORIGIN comes from MAGIC_LINK_REDIRECT_BASE_URL (platform env override) → an allowlisted request Origin → https://app.amba.dev (default).

Set auth_email_brand_name on the same project-update route to control auth-email copy. For a branded sender, register and verify a sending domain under the project's Email page, then set default_from_email and default_from_name. OTP and magic-link emails use that sender. Without a verified default, they use Amba's shared mailbox and display <brand> via Amba.

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' \
  -H 'Content-Type: application/json' \
  -d '{
  "email": "alice@example.com"
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this 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' \
  -H 'Content-Type: application/json' \
  -d '{
  "token": "<raw-token-from-email-link>"
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

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 token must match the API key's project. Linking is idempotent on re-link of the same identifier to the same user.

A link that adds an identity gives the user a new anonymous_id (in user.anonymous_id). From then on the user signs in with the linked identity: the old anonymous id is recorded for attribution, and POST /client/auth/anonymous resumes the user by neither id.

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. Optional nonce as on /client/auth/social.
googletoken, session_tokenGoogle identity token, JWKS-verified with audience validation. Optional nonce as on /client/auth/social.
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. email_verified stays true only when this user had already verified the same address; otherwise it is false until the inbox is proven.

The sms_otp, email_otp, and email_password providers also accept rotate_session (boolean, default false). Apple / Google record the email in the identity token, as verified, only when the token says the provider verified it and no other user holds that address; otherwise the link completes and the user's email fields stay as they were.

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. When the link adds the identity, every session of the user minted before it is revoked, and the returned pair is the one that keeps working.

The OTP and email_password paths return { "data": { "user": { … } } }, and the existing session stays valid. With rotate_session: true they also return session_token and refresh_token: when the link adds the identity, every session of the user minted before it is revoked, including the one that made the call, so store the returned pair. @layers/amba-react-native 4.0.14+ and @layers/amba-expo 4.0.13+ send rotate_session: true and store the pair.

Errors

  • 400 UNSUPPORTED_PROVIDER — provider is "email" or not one of the five supported values.
  • 400 INVALID_INPUT — missing session_token, the fields required by the chosen provider, a rotate_session that is not a boolean, or a nonce that is not a string of 1–512 characters.
  • 400 AUDIENCE_NOT_CONFIGURED — Apple / Google audience not configured.
  • 401 INVALID_SESSION — session token invalid.
  • 401 SESSION_REVOKED — the session token was minted before an earlier link or an inbox-proof takeover signed out the user's sessions. Sign in again, or link with the session pair the earlier link returned.
  • 401 INVALID_TOKEN — identity token (signature, audience or nonce) or OTP code invalid.
  • 401 NONCE_REUSED — an earlier request already used this sign-in nonce (Apple and Google links).
  • 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. Every *_ALREADY_LINKED error carries code, message and an empty details object; it names no account.
  • 429 RATE_LIMIT_EXCEEDED — 20/minute and 200/hour per client address on this app (server keys skip this), or 10/minute and 60/hour per signed-in user; Retry-After header set.
  • 500 LINK_FAILED.

Try it:

POST/client/auth/link
client auth
curl -X POST 'https://api.amba.dev/v1/client/auth/link' \
  -H 'Content-Type: application/json' \
  -d '{
  "provider": "email_password",
  "email": "user@example.com",
  "password": "example-password",
  "session_token": "<session_token>"
}'
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 '{"provider":"email_password","email":"user@example.com","password":"example-password","session_token":"<session_token>"}'

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

The new pair, the session token's expires_at, and the user:

{ "data": { "session_token": "…", "refresh_token": "…", "expires_at": "…", "user": { "id": "…" } } }

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' \
  -H 'Content-Type: application/json' \
  -d '{
  "refresh_token": "<refresh_token>"
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

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

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' \
  -H 'Content-Type: application/json' \
  -d '{
  "refresh_token": "<refresh_token>"
}'
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 '{"refresh_token":"<refresh_token>"}'