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

  1. Validates phone number format (10-digit Indian mobile)
  2. Generates a cryptographically random 6-digit OTP
  3. Hashes it with bcrypt (cost 12) — the plaintext is never stored
  4. Creates an otp_sessions row: phone, otp_hash, expires_at = now() + 10 min, attempts = 0
  5. In production: forwards the plaintext OTP to ValueFirst SMS API
  6. 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.
Content-Type
string
required
application/json
phone
string
required
10-digit Indian mobile number. Accepted formats: 9876543210, +919876543210, 91-9876543210. Normalized to +91 format internally.
curl -X POST http://localhost:8080/auth/otp/trigger \
  -H 'Content-Type: application/json' \
  -d '{
    "phone": "9876543210"
  }'

Edge Cases

ScenarioBehaviour
Phone not yet registeredCreates a new otp_sessions row — a new users row is created on successful verify
Phone already registeredSame flow — OTP trigger does not differentiate new from existing users
OTP triggered multiple times in windowEach 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

  1. Looks up the latest otp_sessions row for the phone number
  2. Checks: expires_at > now() — if not, returns OTP_EXPIRED
  3. Checks: verified_at IS NULL — if not, returns INVALID_OTP (already used)
  4. Checks: attempts < 5 — if not, returns TOO_MANY_OTP_ATTEMPTS
  5. Verifies OTP against otp_hash using bcrypt constant-time comparison
  6. If wrong: increments attempts, returns INVALID_OTP
  7. If correct: sets verified_at = NOW()
  8. Creates or fetches the users row for this phone number
  9. Generates access token (JWT, 24h) and refresh token (256-bit random hex, 30d)
  10. Creates user_sessions row with SHA-256(refresh_token) as hash
  11. Returns token pair with is_new_user flag
  12. Async: triggers wallet::ensure_customer_wallet(user_id) — does not block the response
phone
string
required
Same phone number used in the trigger call.
otp
string
required
6-digit OTP received via SMS. In UAT, always "123456".
curl -X POST http://localhost:8080/auth/otp/verify \
  -H 'Content-Type: application/json' \
  -d '{
    "phone": "9876543210",
    "otp": "123456"
  }'
user_id
string (UUID)
required
Permanent unique identifier for the user. Never changes.
access_token
string (JWT)
required
JWT access token, HS256-signed. Valid for 24 hours. Claims: { "user_id": "...", "exp": 1750086400 }.
refresh_token
string (opaque)
required
256-bit random hex string. Valid for 30 days. Single-use — rotated on each refresh call.
is_new_user
boolean
required
true on the user’s very first login. The app should show the onboarding / profile-fill screen when true.
access_token_expires_at
integer (Unix timestamp)
When the access token expires. Refresh before this time to avoid interrupting the user.
refresh_token_expires_at
integer (Unix timestamp)
When the refresh token expires. After this point, the user must log in again via OTP.
After a successful verify, the backend asynchronously provisions the Juspay customer and wallet. The wallet may be in CREATED or PENDING status for a few seconds to minutes depending on Juspay API latency. Always check wallet status before initiating payments.

Timing Constraints

ParameterValue
OTP valid for10 minutes from trigger
Max wrong attempts5 (then session permanently invalidated)
Access token lifetime24 hours
Refresh token lifetime30 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

  1. Computes SHA-256(refresh_token) → lookup hash
  2. Queries user_sessions WHERE refresh_token_hash = lookup_hash AND NOT is_revoked AND expires_at > NOW()
  3. If no match: returns 401 INVALID_TOKEN
  4. Atomically: marks old session as is_revoked = true
  5. Generates new access token + refresh token pair
  6. Creates new user_sessions row with new hash
  7. Returns new token pair
refresh_token
string
required
The opaque refresh token from a previous verify or refresh call.
curl -X POST http://localhost:8080/auth/token/refresh \
  -H 'Content-Type: application/json' \
  -d '{
    "refresh_token": "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9..."
  }'
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

ScenarioBehaviour
Refresh token used twice concurrentlySecond call returns 401 INVALID_TOKEN
Refresh token expired (> 30 days)Returns 401 INVALID_TOKEN — user must log in again
User logged out on another deviceOld refresh token revoked; returns 401 INVALID_TOKEN

POST /auth/logout

Revokes the current device session. The access token used in the Authorization header determines which session is revoked. Other active sessions (other devices) are not affected.

What happens server-side

  1. Validates the JWT in the Authorization header
  2. Extracts user_id from JWT claims
  3. Looks up user_sessions matching the user_id and refresh_token_hash
  4. Sets is_revoked = true on that session
  5. 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.
Authorization
string
required
Bearer <access_token>
curl -X POST http://localhost:8080/auth/logout \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
  -H 'Content-Type: application/json'

Data Owned

OTP and refresh tokens are never stored in plaintext. otp_hash uses bcrypt; refresh_token_hash uses SHA-256. The raw values exist only in transit.