Phone-OTP authentication, token lifecycle, and the authenticated user's profile.
Paths are relative to the /api/v1 prefix.
See Common errors. The OTP endpoints are rate-limited in production.
| Method | URI | Auth | Description |
|---|---|---|---|
POST |
/auth/otp/request |
No | Send a one-time login code by SMS |
POST |
/auth/otp/verify |
No | Verify the code; logs in or signs up, returns a token |
GET |
/me |
Yes | Get the authenticated user's profile |
PATCH |
/me |
Yes | Complete/update name & email after onboarding |
POST |
/auth/refresh |
Yes | Rotate the current token |
POST |
/auth/logout |
Yes | Revoke the current token |
GET |
/auth/dev-lookup |
No · dev-only | Look up a user id by phone |
POST |
/auth/dev-token |
No · dev-only | Mint a token for an existing user |
dev-lookup and dev-token return 404 in production.
Sends a numeric code to the phone. Outside production the response includes the
generated code to make testing easy.
Rate limit (production): 3 requests / 10 minutes.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
phone |
string | yes | E.164-ish, ^\+\d{10,15}$ (e.g. +584241000001) |
Response 200
{ "sent": true, "mocked": true, "code": "123456" }
codeis present only outside production. In production the field is omitted.
Errors
| Status | Body | When |
|---|---|---|
429 |
{ "error": "SmsSendError", "message": "…" } |
The SMS service failed or was throttled |
Verifies the code and authenticates. Creates the account if none exists for the phone.
Rate limit (production): 5 requests / 10 minutes.
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
phone |
string | yes | ^\+\d{10,15}$ |
code |
string | yes | exactly 6 digits |
app |
string | no | client (default) or driver |
Response 200
{
"token": "12|abc...",
"isNew": false,
"needsUserData": false,
"user": {
"id": 1,
"name": "Ana MartĂnez",
"phone": "+584241000001",
"hasClientProfile": true,
"hasDriverProfile": false,
"isDriverPending": false
}
}
Field notes:
isNew — true when the user was just created.needsUserData — true when the profile still has a placeholder name and the
client must collect name/email before continuing. While true, user.name is
omitted (use user.phone for any provisional greeting).needsKyc — present only when app=driver; true until the driver starts KYC.Errors
| Status | Body | When |
|---|---|---|
401 |
{ "error": "InvalidOtp", "message": "CĂłdigo incorrecto o vencido." } |
Wrong or expired code |
Returns the authenticated user with their profile aggregates.
Response 200
{
"user": {
"id": 1,
"name": "Ana MartĂnez",
"phone": "+584241000001",
"isActive": true,
"clientProfile": {
"totalOrders": 12,
"totalLitersOrdered": 36000,
"totalSolidaryUSD": 4.2
},
"driver": null
}
}
For a driver the driver block is populated and clientProfile is null:
{
"user": {
"id": 3,
"name": "Carlos RodrĂguez",
"phone": "+584241001001",
"isActive": true,
"clientProfile": null,
"driver": {
"id": 3,
"status": "ACTIVE",
"isOnline": true,
"availableLiters": 8000,
"remainingLiters": 6000,
"avgRating": 4.8,
"completedOrders": 27,
"isCertified": true
}
}
}
clientProfile and driver are null when the user lacks that profile. When
present, the driver block carries:
| Field | Type | Description |
|---|---|---|
id |
integer | Driver profile id |
status |
string | ACTIVE, PENDING_VERIFICATION, SUSPENDED, … |
isOnline |
boolean | Whether the driver is currently online |
availableLiters |
integer | Water physically in the tank right now |
remainingLiters |
integer | Liters still free to commit to new orders |
avgRating |
number | Rolling average rating (0–5) |
completedOrders |
integer | Lifetime delivered orders |
isCertified |
boolean | Whether the driver is certified |
availableLiters vs remainingLiters:
availableLiters — water physically in the tank right now.remainingLiters — free capacity still available to commit to new orders.Completes or updates the user's name and email (typically right after OTP sign-up).
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
name |
string | yes | 2–80 chars |
email |
string | no | valid email, ≤120 chars, unique among users |
Response 200 — the updated user (same shape as user in otp/verify).
Revokes the current token and issues a new one.
Response 200
{ "token": "13|def..." }
Revokes the current token.
Response 200
{ "ok": true }
Available outside production only (otherwise 404).
Query: phone (string, required).
Response 200
{ "userId": 7, "name": "Carlos RodrĂguez", "hasClientProfile": false, "hasDriverProfile": true }
| Status | Body | When |
|---|---|---|
400 |
{ "error": "MissingPhone" } |
phone not provided |
404 |
{ "error": "UserNotFound" } |
No user for that phone |
Request body
| Field | Type | Required | Rules |
|---|---|---|---|
userId |
integer | yes | existing user id |
app |
string | yes | client or driver |
Response 200
{ "token": "14|ghi...", "user": { "id": 7, "name": "Carlos RodrĂguez", "hasClientProfile": false, "hasDriverProfile": true, "isDriverPending": false } }
| Status | Body | When |
|---|---|---|
404 |
{ "error": "UserNotFound" } |
No user for userId |
400 |
{ "error": "NoClientProfile" } |
app=client but the user has no client profile |
400 |
{ "error": "NoDriverProfile" } |
app=driver but the user has no driver profile |
# 1) Request a code (dev returns it inline)
curl -X POST /api/v1/auth/otp/request \
-H "Content-Type: application/json" \
-d '{"phone": "+584241000001"}'
# 2) Verify + log in
curl -X POST /api/v1/auth/otp/verify \
-H "Content-Type: application/json" \
-d '{"phone": "+584241000001", "code": "123456", "app": "client"}'
# 3) Use the token
curl /api/v1/me -H "Authorization: Bearer 12|abc..."
# 4) Log out
curl -X POST /api/v1/auth/logout -H "Authorization: Bearer 12|abc..."