Authentication: Auth endpoints are public (no JWT required) except
POST /auth/logout.UAT: OTP is hardcoded as 123456 — no SMS is sent in the sandbox environment.Flow Overview
POST /auth/otp/trigger
Sends a 6-digit OTP to the provided phone number via SMS (production) or returns it in the response body (UAT/dev).What happens server-side
- Validates phone number format (10-digit Indian mobile)
- Generates a cryptographically random 6-digit OTP
- Hashes it with bcrypt (cost 12) — the plaintext is never stored
- Creates an
otp_sessionsrow:phone,otp_hash,expires_at = now() + 10 min,attempts = 0 - In production: forwards the plaintext OTP to ValueFirst SMS API
- Returns
200 OK
In UAT and local development, the plaintext OTP (
"123456") is included in the response body for developer convenience. This field is not present in production responses.application/json10-digit Indian mobile number. Accepted formats:
9876543210, +919876543210, 91-9876543210. Normalized to +91 format internally.Edge Cases
| Scenario | Behaviour |
|---|---|
| Phone not yet registered | Creates a new otp_sessions row — a new users row is created on successful verify |
| Phone already registered | Same flow — OTP trigger does not differentiate new from existing users |
| OTP triggered multiple times in window | Each trigger creates a new session; only the latest is valid for verify |
| Rate limit hit (5 triggers/minute) | Returns 429 with retry_after seconds |
POST /auth/otp/verify
Verifies the OTP and returns a JWT token pair. Creates the user account if it doesn’t exist. Triggers wallet provisioning asynchronously.What happens server-side
- Looks up the latest
otp_sessionsrow for the phone number - Checks:
expires_at > now()— if not, returnsOTP_EXPIRED - Checks:
verified_at IS NULL— if not, returnsINVALID_OTP(already used) - Checks:
attempts < 5— if not, returnsTOO_MANY_OTP_ATTEMPTS - Verifies OTP against
otp_hashusing bcrypt constant-time comparison - If wrong: increments
attempts, returnsINVALID_OTP - If correct: sets
verified_at = NOW() - Creates or fetches the
usersrow for this phone number - Generates access token (JWT, 24h) and refresh token (256-bit random hex, 30d)
- Creates
user_sessionsrow withSHA-256(refresh_token)as hash - Returns token pair with
is_new_userflag - Async: triggers
wallet::ensure_customer_wallet(user_id)— does not block the response
Same phone number used in the trigger call.
6-digit OTP received via SMS. In UAT, always
"123456".Permanent unique identifier for the user. Never changes.
JWT access token, HS256-signed. Valid for 24 hours. Claims:
{ "user_id": "...", "exp": 1750086400 }.256-bit random hex string. Valid for 30 days. Single-use — rotated on each refresh call.
true on the user’s very first login. The app should show the onboarding / profile-fill screen when true.When the access token expires. Refresh before this time to avoid interrupting the user.
When the refresh token expires. After this point, the user must log in again via OTP.
Timing Constraints
| Parameter | Value |
|---|---|
| OTP valid for | 10 minutes from trigger |
| Max wrong attempts | 5 (then session permanently invalidated) |
| Access token lifetime | 24 hours |
| Refresh token lifetime | 30 days |
POST /auth/token/refresh
Exchanges a valid refresh token for a new access token + refresh token pair. The submitted refresh token is immediately revoked (single-use rotation).What happens server-side
- Computes
SHA-256(refresh_token)→ lookup hash - Queries
user_sessions WHERE refresh_token_hash = lookup_hash AND NOT is_revoked AND expires_at > NOW() - If no match: returns
401 INVALID_TOKEN - Atomically: marks old session as
is_revoked = true - Generates new access token + refresh token pair
- Creates new
user_sessionsrow with new hash - Returns new token pair
The opaque refresh token from a previous verify or refresh call.
Token Rotation: The old refresh token is revoked when this endpoint is called. The response contains a new refresh token — replace the stored one immediately and atomically. If the same refresh token is used twice (race condition), the second call returns
401. Use a mutex in your refresh logic to prevent concurrent calls.Edge Cases
| Scenario | Behaviour |
|---|---|
| Refresh token used twice concurrently | Second call returns 401 INVALID_TOKEN |
| Refresh token expired (> 30 days) | Returns 401 INVALID_TOKEN — user must log in again |
| User logged out on another device | Old refresh token revoked; returns 401 INVALID_TOKEN |
POST /auth/logout
Revokes the current device session. The access token used in theAuthorization header determines which session is revoked. Other active sessions (other devices) are not affected.
What happens server-side
- Validates the JWT in the
Authorizationheader - Extracts
user_idfrom JWT claims - Looks up
user_sessionsmatching theuser_idandrefresh_token_hash - Sets
is_revoked = trueon that session - Returns
200 OK
Logout operates on the session level — it revokes the refresh token associated with the current access token. The access token itself is short-lived (24h) and stateless, so it cannot be individually invalidated. Clients should clear the access token from memory on logout.
Bearer <access_token>