OTP login
Background
On Shukah the app logs in with two calls. GET /maeda/api/reset-otp sends a code by SMS, and
GET /maeda/api/verify-otp trades the code for a token. The same reset-otp call also registers a
new client. The auth service has both routes with the same query names as Shukah's, so the switch is
mostly a change of base URL and path prefix. Three other things need care: verify-otp returns a
Locate JWT instead of a Shukah token, its errors have a different body, and it ignores the
fcm_token and platform that the app sends. reset-otp also words its missing-phone error
differently.
Feature
Login and sign-up use the same two calls.
- The app calls reset-otp with the phone number. If an active client has that phone, the service
sends a 4-digit code by SMS and answers
{"status":true}. - If no active client has that phone and the call carries no registration fields, reset-otp
answers
400withLast Name cannot be blank.. That message is the app's cue to open the registration screen. The app then calls reset-otp again with the registration fields, and the service creates the client and sends the code. - The app calls verify-otp with the phone and the code. It gets a Locate JWT and the client's
profile, and sends the JWT as
Authorization: Bearer <token>on every later call.
Do not move login to Locate yet. Locate login returns only a JWT, and the order-v2 points routes
refuse the JWT today. Until order-v2 accepts it, keep logging in on Shukah, get the JWT with
exchange-token (target_platform: locate), and keep both tokens:
the JWT for the auth service, the Shukah token for order-v2 (see the
Switch checklist).
In the app snapshot of 2026-02-26, sign-in sends the phone with the + removed, and registration
adds the names and email, and optionally sex, birth_date and receive_offers. The app opens the
registration screen when reset-otp answers 400 with a message that includes Last Name, and it
stores the access_token that verify-otp returns.
The code rules:
| Rule | Value |
|---|---|
| Length | 4 digits. It can start with 0. |
| Valid for | 3 minutes. It can be used once. |
| Resend | While a code is pending, calling reset-otp again sends nothing and still answers {"status":true}. A new code is possible only after the old one expires or is used. |
| Attempts | No limit on wrong codes and no rate limit on either call. |
| Test mode | A test-mode account always gets the code 2022, and no SMS is sent. Which numbers on dev are test-mode: Not documented yet. |
GET /api/v1/clients/reset-otp
GET /maeda/api/reset-otp✅ Replaced- 🟡 Missing-phone 400 is English (Yii) on Locate, Arabic (Laravel) on Shukah; the app shows message, so the text changes
- 🟡 Unknown phone gets 400 "Last Name cannot be blank." on Locate (from code); Shukah's text today is not captured
- 🟡 The app opens registration only if a 400 message contains "Last Name"; confirm Shukah and Locate agree
One route serves sign-in and registration, as on Shukah; on Locate an unknown phone gets 400 "Last Name cannot be blank." (the cue for the app to open registration; from code, while the current Shukah text is not documented) and receive_offers is not read.
Request
No token is needed. When the phone belongs to an active client, the registration fields are ignored.
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
contact_phone | query | string | yes | Whitespace is removed. It is matched to the stored phone with or without a leading + (+966… equals 966…, but 05… does not equal +9665…). No length or format check. Blank or missing gives 400. | The phone to log in with or register. |
last_name | query | string | registration only | Needed when no active client has the phone. Blank gives 400 Last Name cannot be blank. (checked first). At most 100 characters. | Last name of the new client. |
first_name | query | string | registration only | Blank gives 400 First Name cannot be blank. (checked second). At most 100 characters. | First name of the new client. |
email_address | query | string | registration only | Blank gives 400 Email Address cannot be blank. (checked third). No email-format check. | Email of the new client. |
birth_date | query | string | no | Read as a date and stored as YYYY-MM-DD. A value that is not a date is silently stored as empty. | Birth date of the new client. |
sex | query | 1 or 2 | no | 1 is male and 2 is female. Any other value is stored as unknown. | Gender of the new client. |
language | query | string | no | Stored as the part before -, in lower case (ar-SA becomes ar). Missing becomes ar. It does not change the SMS language, which is always Arabic. | Language of the new client. |
| Any other query key or header | query, header | — | no | Ignored, for example key. receive_offers is not read either: a new client starts with it off. | — |
Response
200 with the same body for login and for registration:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
status | boolean | no | Always true. | A code was issued, or one is already pending. It does not confirm that an SMS was sent. |
{
"status": true
}
Captured on dev, 2026-10-02, for a known phone, on Locate and on Shukah: both answered this body.
The call sent one SMS. The same body is in docs/api/client-auth-and-address-book.md §1
(locate-authentication-service@6757aa4, doc last changed 2026-07-27).
Errors
| HTTP | Body | When |
|---|---|---|
400 | {"name":"Bad Request","message":"Phone number cannot be blank.","code":0,"status":400,"type":"yii\\web\\BadRequestHttpException"} | contact_phone is missing or blank. |
400 | Same shape, with message set to Last Name cannot be blank., First Name cannot be blank. or Email Address cannot be blank.. | No active client has the phone, and the first blank registration field (in that order) is the one named. A bare login call for an unknown phone gets Last Name cannot be blank.. |
500 | {"status":500,"message":"Internal server error"} | An unexpected failure, for example a name longer than 100 characters at registration, a database error, or an SMS gateway that gives no HTTP response. Not captured. Outside production the message is the raw error text and a stack field is added. |
The Phone number cannot be blank. body was captured on dev on 2026-10-01 (call with no
parameters). The Last Name cannot be blank. body is from docs/api/client-auth-and-address-book.md
§1 (2026-07-27): the capture run made no call with a phone number, because a call can send an SMS.
Try it
A successful call sends a real SMS unless the number belongs to a test-mode account. Use a test-mode number that you control. Which numbers are test-mode on dev: Not documented yet.
export AUTH_BASE_URL=https://dev.authentication.locate.sa
export PHONE='<test-mode-phone>' # placeholder: replace it with a test-mode number
curl -s -G "$AUTH_BASE_URL/api/v1/clients/reset-otp" --data-urlencode "contact_phone=$PHONE"
Expected: 200 and {"status":true}. With no phone you get the 400 shown under Errors.
Differences from Shukah
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| URL | GET $SHUKAH_BASE_URL/maeda/api/reset-otp | GET $AUTH_BASE_URL/api/v1/clients/reset-otp. Locate ignores key, every other unknown query key and every header. | Change the base URL and the path. The app can keep sending key and its headers. |
| Missing-phone error | {"name":"Bad Request","message":"حقل contact phone مطلوب.","code":0,"status":400,"type":"Illuminate\\Validation\\ValidationException"} (captured on dev, 2026-10-01): an Arabic message and a Laravel type. | {"name":"Bad Request","message":"Phone number cannot be blank.","code":0,"status":400,"type":"yii\\web\\BadRequestHttpException"} (captured on dev, 2026-10-01): an English message and a Yii type. | The keys are the same, the text and type differ. Do not match on this text. |
| Unknown phone, no registration fields | 400 with Last Name cannot be blank. in a Yii body, in the July 2026 capture kept in the auth service's draft doc (see Sources under Details). Today's Shukah text: Not documented yet. | 400 with Last Name cannot be blank. in a Yii body. | The registration cue keeps working if Shukah still sends the same text today. Confirm Shukah's current text before relying on it. |
| Success body | {"status":true} (captured on dev, 2026-10-02). | {"status":true} (captured on dev, 2026-10-02). | None. |
GET /api/v1/clients/verify-otp
GET /maeda/api/verify-otp✅ Replaced- 🔴 Do not switch login yet: Locate login returns only a JWT, which order-v2 point routes refuse today
- 🟡 Wrong-code 400 is {status,message}; Shukah adds name/code/type (Laravel); the app reads message only
- 🟡 fcm_token and platform are ignored on Locate; what Shukah does with them is not visible in its answer
- 🟡 Live success call (dev, 2026-10-02): same {access_token,data} and 13-key profile on both; earlier sessions stay valid
Returns access_token and a data profile, where the token is a Locate JWT (the Shukah success body has the same shape, captured 2026-10-02), and fcm_token and platform are ignored.
Request
No token is needed.
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
contact_phone | query | string | yes | Whitespace is removed. It is matched to the phone the code was sent to, with or without a leading +. Missing gives the same 400 as a wrong code. | The phone used in reset-otp. |
code | query | string | yes | Compared as text with the 4-digit code, so a leading zero matters. | The code from the SMS. |
language, platform, fcm_token and any other query key | query | string | no | Ignored. | — |
Response
200 with the token and the profile:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
access_token | string | no | A Locate JWT: three dot-separated parts, signed with HS256. It never expires. | Send it as Authorization: Bearer <access_token> on every later call. |
data | object | no | The client's profile: the 13 fields listed under Verify token. | The signed-in client. |
{
"access_token": "<locate-jwt>",
"data": {
"client_id": 356259,
"odoo_id": "1342170",
"contact_phone": "+966500000000",
"status": "active",
"email_address": "user@example.com",
"first_name": "Test",
"last_name": "User",
"sex": 1,
"birth_date": "1990-01-01",
"receive_offers": 0,
"push_enabled": 0,
"is_food_truck_user": 1,
"is_cod_disabled": 0
}
}
Shape from docs/api/client-auth-and-address-book.md §2 (locate-authentication-service@6757aa4, doc last changed 2026-07-27), and called live on dev on 2026-10-02 (200, same keys and values as above; see Differences from Shukah). The token value was redacted at capture time, so its kind is not recorded here: the JWT claim is from the code. The profile values are the illustrative ones used across this site.
Errors
| HTTP | Body | When |
|---|---|---|
400 | {"status":400,"message":"Sorry, but your verification code is incorrect"} | No unused, unexpired code matches the phone and the code (wrong, expired, already used, or a parameter is missing), or no active client has the phone. One message covers all of them, on purpose, so the call does not reveal which phones have an account. |
400 | Same shape, with the raw error text as message. | Any other failure inside the handler, for example a database error or a failed token write. Not captured. |
The first body was captured on dev on 2026-10-01 (call with no parameters), and again on 2026-10-02 with a wrong code. It is not Yii-shaped, unlike the reset-otp errors.
Try it
A successful call uses up the code and creates a new Locate token. Earlier tokens stay valid.
export AUTH_BASE_URL=https://dev.authentication.locate.sa
export PHONE='<test-mode-phone>' # the number you used in reset-otp
export CODE='<4-digit-code>' # 2022 for a test-mode account
curl -s -G "$AUTH_BASE_URL/api/v1/clients/verify-otp" --data-urlencode "contact_phone=$PHONE" --data-urlencode "code=$CODE"
Expected: 200 with access_token and data. A wrong or expired code gives the 400 shown under Errors.
Differences from Shukah
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| URL | GET $SHUKAH_BASE_URL/maeda/api/verify-otp | GET $AUTH_BASE_URL/api/v1/clients/verify-otp | Change the base URL and the path. |
| Token | An opaque Shukah token. | A Locate JWT in access_token. | Every later /api/v1/clients/* call needs this JWT, but the order-v2 points routes refuse it today, so keep logging in on Shukah for now. |
| Extra query keys | The app also sends fcm_token (its push token, left out when the app cannot get one) and platform (Android, iOS or Huawei). What Shukah does with them: Not documented yet. | Locate reads only contact_phone and code, so it drops fcm_token and platform. | The call still works. If Shukah used either value, that is lost on Locate: confirm what Shukah did with them. |
| Wrong-code error | {"name":"Bad Request","message":"Sorry, but your verification code is incorrect","code":0,"status":400,"type":"Symfony\\Component\\HttpKernel\\Exception\\HttpException"} (captured on dev, 2026-10-02). The July 2026 capture kept in the auth service's draft doc had the same message with the Yii type yii\\web\\BadRequestHttpException (see Sources under Details). | {"status":400,"message":"Sorry, but your verification code is incorrect"} (captured on dev, 2026-10-01 and 2026-10-02). | message and status are in both, with the same text. name, code and type are gone, so do not read them. |
| Success body | 200 {access_token, data}, with data the 13-field profile (captured on dev, 2026-10-02, same phone and code as Locate). | 200 {access_token, data}, with data the same 13 fields and equal values (captured on dev, 2026-10-02). | The shape and the profile are the same, so the app's parsing needs no change. The access_token value was redacted at capture time, so its kind on each side was not recorded here (the Token row is from the code). A new login did not end the earlier sessions on either side. |
Watch out
- A second reset-otp call within 3 minutes sends no new SMS but still answers
{"status":true}, sotruedoes not mean an SMS was sent. - A client that is not active (suspended, blocked, pending or expired) is treated as an unknown phone, so it is sent to registration.
- Error bodies differ: reset-otp errors are Yii-shaped (
name,message,code,status,type), verify-otp errors arestatusandmessageonly.
Details (for backend)
Only dev was checked: host https://dev.authentication.locate.sa, prefix /api/v1. The UAT and
production hosts and prefix: Not documented yet. Dev does not run with NODE_ENV=production: its
error bodies carry a stack field (captured on 2026-10-01 on the address-book, profile and setting
errors), and the handler adds stack only outside production (src/middlewares/error-handler.js:12-35).
OTP rules
| Rule | Fact | Source |
|---|---|---|
| Length | 4 characters, decimal digits, possibly starting with 0: Math.random().toString().slice(2, 6). | src/services/client-otp.js:7-8 |
| Validity | 3 minutes from creation. Verify needs expired_at to be now or later. | src/services/client-otp.js:5,37; src/controllers/client/verify-otp/handler.js:27 |
| Single use | Verify sets verified: true and verified_at, and a used code no longer matches. | src/controllers/client/verify-otp/handler.js:26,38-41 |
| Resend | If an unverified, unexpired code exists for the stored phone, nothing is created or sent and the answer is still {"status":true}. | src/services/client-otp.js:20-23 |
| Attempts and rate limits | None: no counter, lockout or rate limiter. | docs/plans/2026-07-28-k6-load-test-suite.md:9 |
Fixed code 2022, test-mode account | account_mode TEST gives 2022 and no SMS. | src/services/client-otp.js:7-8,40-42; src/shared/constant/user.js:27-30 |
Fixed code 2022, two phones | A stored phone that is exactly +966500000000 or 966500000000 gets 2022 in any mode, and the SMS is still sent unless the account is test-mode. Added on 2026-07-14 (commit 7aad3af). The sample phone used on this page is one of these two. | src/services/client-otp.js:25-27 |
| How test mode is set | account_mode defaults to STANDARD. No API sets it: it is written straight into the database. | src/models/client.js:87-90; docs/plans/2026-07-28-k6-load-test-suite.md:18 |
| Test numbers | None documented for dev. The load-test suite reserves test-mode phones made of 9665000 plus six digits (client ids from 9,000,000,000), seeded straight into the database. Whether such accounts exist on the shared dev database: Not documented yet. | docs/plans/2026-07-28-k6-load-test-suite.md:19,33-40; k6/seed/seed-test-clients.js:23-24,40-58; k6/README.md:13-23 |
| Account lookup | reset-otp, verify-otp and the client lookup find the client by the stored phone with an optional +, not deleted, status ACTIVE, newest client_id first, uncached. A client that is not active is invisible to all three. | src/controllers/client/reset-otp/handler.js:24-30; src/shared/helpers/filter.js:1-4; docs/api/client-auth-and-address-book.md:52-54 |
| SMS | POST ${SMS_API}/v1/messages with Bearer ${SMS_TOKEN}. The text is Arabic whatever language says. A recipient 0XXXXXXXXX becomes 966XXXXXXXXX, otherwise the stored phone is sent as a number. The gateway response is saved on the OTP row. | src/services/client-otp.js:11-14,30,41; src/shared/helpers/sms.js:4-24 |
| Logging | The generated code is written to the server log. | src/services/client-otp.js:29 |
Registration through reset-otp
When no active client has the phone and the three required fields are present, the handler creates
a client and then issues the code (src/controllers/client/reset-otp/handler.js:48-73).
- The new client gets
client_id = 1,000,000,000 + counter,source_systemandchange_originset tolocate,from_locate: true, empty Shukah ids, theclient_language, and the model defaults: statusACTIVE, modeSTANDARD, push on, offers off (src/controllers/client/reset-otp/handler.js:51-69,src/models/counter.js:7,17-24,src/models/client.js:83-90,110-111). The names are stored trimmed and the model allows 100 characters for each (src/models/client.js:45-52). - After the code is issued, the service pushes the client to Shukah:
POST {SHUKAH_HOST}/maeda/client, an upsert by phone. It is best effort and awaited, and it stores the id Shukah returns asshukah_id. It does nothing when the outbound sync is off or the host or admin token is not set (src/controllers/client/reset-otp/handler.js:71-73,src/services/shukah-sync.js:21-30,184-234). - A phone that is not active behaves as an unknown one. The model declares no unique index on
phone, so with all registration fields the handler would insert a second client with the same phone, and that client would win the lookup (highestclient_id). Code reading, not tested live. Whether this is intended: Not documented yet (src/models/client.js:147-156). - The request waits for the SMS gateway (
await sendMessage, no timeout). A gateway answer with an HTTP error is saved on the OTP row and ignored, so the reply is still{"status":true}. A gateway failure with no HTTP response throws inside the catch and gives500, after the OTP row was already created, so a retry within 3 minutes sends nothing (src/shared/helpers/sms.js:18-23; code reading).
Verifying the code
Order of operations (src/controllers/client/verify-otp/handler.js:20-46): find the code, find the
active client, mint the JWT, insert the token_bridge row, push the JWT to Shukah, mark the code used,
answer. The code is consumed only after the token write and the push have finished.
- The success shape
{access_token, data}is pinned by a unit test (src/controllers/client/__tests__/verify-otp.test.js:77-94). - Each success mints a new token. Earlier tokens stay valid because nothing revokes them
(
src/shared/helpers/jwt.js:4-10). Confirmed live on dev on 2026-10-02: after a new login on each side, the two tokens issued earlier still worked. - The
token_bridgerow hassource_system: 'merchant', and the raw token only whenNODE_ENVis notproduction(src/services/merchant-token-bridge.js:6-16). - The push to Shukah is
POST {SHUKAH_HOST}/maeda/client/add-tokenwith{access_token, client_id: <shukah_id>}, best effort and awaited. It does nothing when the client has noshukah_idor the sync is off (src/services/shukah-sync.js:13-75). This is how Shukah learns Locate JWTs (docs/plans/2026-07-16-shukah-token-outbound-push.md:10-28). Whether the push is enabled on dev, and which Shukah APIs accept a Locate JWT after it: Not documented yet. - Any exception in the handler (a database error, a failed token write, a duplicate token hash) is
caught and answered as
400with the raw message (src/controllers/client/verify-otp/handler.js:47-53; code reading). - Two verify calls for the same client in the same second would mint a byte-identical JWT and hit the
unique
token_hashindex, which gives that400. Code reading and an offline determinism check, not reproduced: Not documented yet whether it happens on dev. - There is no attempt counter: the 4-digit code can be tried repeatedly during its 3 minutes.
Where the docs and the code disagree (code wins)
- The service doc's closing flow diagram says verify-otp returns
{id, token}(docs/api/client-auth-and-address-book.md:542). The code, and the same doc at lines 110-131, say{access_token, data}. - The service doc says verify "deletes all OTPs of the phone"
(
docs/api/client-auth-and-address-book.md:135). The code marks only the matched code as used (commita5a5315, 2026-07-17). - A design doc says a wrong code gives a Yii-shaped
400(docs/plans/2026-07-06-shukah-auth-api-local.md:101-104). The code and the API doc give{"status":400,"message":...}. - The test-mode code is
2022in the code. The unit test (src/services/__tests__/client-otp.test.js:6) and a design doc (docs/plans/2026-07-06-shukah-auth-api-local.md:155,213) say8888, and that unit test fails against the code. - A design doc says verify-otp sets
last_login_atand stores the token on the client (docs/plans/2026-07-06-shukah-auth-api-local.md:106-108). The handler does neither. - The 2026-07-06 design doc and the draft flow chart write the paths as
/maeda/api/*. Since commitd837492(2026-07-10) every client route is underPREFIX_API_V1/clients, and the auth host answers404for/maeda/*. - 2026-10-02: this page said a successful reset-otp was not captured. On 2026-10-02 both Shukah and
Locate answered
200 {"status":true}for a known phone. - 2026-10-02: this page gave no current Shukah body for a wrong verify-otp code. Dev Shukah answers
the Laravel body with a Symfony
type, shown under Differences from Shukah. - 2026-10-02: this page said a successful verify-otp was not captured live, and that Shukah's success body was not documented. On 2026-10-02 both Shukah and Locate answered
200 {access_token, data}with the same 13-key profile and equal values, and the earlier tokens still worked after the new login.
Sources
locate-authentication-service@6757aa4:src/routes/client.js:11-12,src/controllers/client/reset-otp/handler.js:11-30,34-74,src/controllers/client/verify-otp/handler.js:14-53,src/controllers/client/__tests__/verify-otp.test.js:77-94,src/services/client-otp.js:5-45,src/shared/helpers/sms.js:4-24,src/shared/helpers/filter.js:1-4,src/sync/lib/casts.js:2-18,35-41,src/shared/errors/yii-bad-request-error.js:12-25,src/middlewares/error-handler.js:10-35,src/shared/constant/user.js:14,27-30,src/shared/helpers/jwt.js:4-10,src/services/merchant-token-bridge.js:6-16,src/services/shukah-sync.js:13-75,184-234,src/models/client.js:45-52,83-90,110-111,147-156,src/models/counter.js:7,17-24,src/services/__tests__/client-otp.test.js:6,k6/seed/seed-test-clients.js:23-24,40-58,k6/README.md:13-23locate-authentication-service@6757aa4docs:docs/api/client-auth-and-address-book.md§1 and §2 (lines 45-146, 542; last changed 2026-07-27),docs/draft/api-auth-draft.md:5-31,90,109-116(2026-07-07),docs/draft/flow-chart-shukah2locate.md:60,140-142(2026-07-08),docs/plans/2026-07-06-shukah-auth-api-local.md:8-11,26,101-108,155,213,237-239,docs/plans/2026-07-16-shukah-token-outbound-push.md:10-28,docs/plans/2026-07-28-k6-load-test-suite.md:9,18-19,33-40- Mobile snapshot
mobile/locate-client@e960c3a(branchdevelopment, 2026-02-26; the app's own code, not Locate):src/entities/user/model/api/services/auth.service.ts:22-31,45-54,65-74,src/entities/user/model/api/dto/auth.dto.ts:43-88,103-122,src/shared/lib/helpers/phoneNumber/getClearPhoneNumber.ts:1-3,src/shared/lib/helpers/phone/getPlatform.ts:4-14,src/features/SignInForm/model/services/enterPhoneAuth.ts:48,53-67,src/shared/api/reqError.ts:125-127,src/features/RegisterNewUserForm/model/services/registerNewUser.ts:105,src/entities/user/model/services/sendCode/sendCode.ts:28-35,src/features/VerificationCodeForm/model/services/sendCode.ts:46-57 - Dev captures, 2026-10-02 (parity audit):
reset-otp--shukah,reset-otp--locate,reset-otp-missing--shukah,reset-otp-missing--locate,verify-otp-wrong--shukah,verify-otp-wrong--locate - Dev captures, 2026-10-01:
probe-locate-reset-otp,probe-locate-verify-otp,probe-shukah-reset-otp,probe-auth-host-maeda-prefix