Skip to main content

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.

  1. 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}.
  2. If no active client has that phone and the call carries no registration fields, reset-otp answers 400 with Last 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.
  3. 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:

RuleValue
Length4 digits. It can start with 0.
Valid for3 minutes. It can be used once.
ResendWhile 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.
AttemptsNo limit on wrong codes and no rate limit on either call.
Test modeA 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​

Replaces ShukahGET /maeda/api/reset-otp✅ Replaced
Drop-in0 breaking · 3 to watch
  • 🟡 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.

FieldInTypeRequiredConstraintsDescription
contact_phonequerystringyesWhitespace 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_namequerystringregistration onlyNeeded 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_namequerystringregistration onlyBlank gives 400 First Name cannot be blank. (checked second). At most 100 characters.First name of the new client.
email_addressquerystringregistration onlyBlank gives 400 Email Address cannot be blank. (checked third). No email-format check.Email of the new client.
birth_datequerystringnoRead 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.
sexquery1 or 2no1 is male and 2 is female. Any other value is stored as unknown.Gender of the new client.
languagequerystringnoStored 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 headerquery, header—noIgnored, 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:

FieldTypeNullableFormat / constraintsDescription
statusbooleannoAlways 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​

HTTPBodyWhen
400{"name":"Bad Request","message":"Phone number cannot be blank.","code":0,"status":400,"type":"yii\\web\\BadRequestHttpException"}contact_phone is missing or blank.
400Same 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​

AspectShukahLocateImpact on the app
URLGET $SHUKAH_BASE_URL/maeda/api/reset-otpGET $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 fields400 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​

Replaces ShukahGET /maeda/api/verify-otp✅ Replaced
Adapt1 breaking · 3 to watch
  • 🔴 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.

FieldInTypeRequiredConstraintsDescription
contact_phonequerystringyesWhitespace 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.
codequerystringyesCompared 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 keyquerystringnoIgnored.—

Response​

200 with the token and the profile:

FieldTypeNullableFormat / constraintsDescription
access_tokenstringnoA Locate JWT: three dot-separated parts, signed with HS256. It never expires.Send it as Authorization: Bearer <access_token> on every later call.
dataobjectnoThe 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​

HTTPBodyWhen
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.
400Same 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​

AspectShukahLocateImpact on the app
URLGET $SHUKAH_BASE_URL/maeda/api/verify-otpGET $AUTH_BASE_URL/api/v1/clients/verify-otpChange the base URL and the path.
TokenAn 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 keysThe 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 body200 {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}, so true does 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 are status and message only.

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​

RuleFactSource
Length4 characters, decimal digits, possibly starting with 0: Math.random().toString().slice(2, 6).src/services/client-otp.js:7-8
Validity3 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 useVerify sets verified: true and verified_at, and a used code no longer matches.src/controllers/client/verify-otp/handler.js:26,38-41
ResendIf 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 limitsNone: no counter, lockout or rate limiter.docs/plans/2026-07-28-k6-load-test-suite.md:9
Fixed code 2022, test-mode accountaccount_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 phonesA 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 setaccount_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 numbersNone 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 lookupreset-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
SMSPOST ${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
LoggingThe 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_system and change_origin set to locate, from_locate: true, empty Shukah ids, the client_language, and the model defaults: status ACTIVE, mode STANDARD, 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 as shukah_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 (highest client_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 gives 500, 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_bridge row has source_system: 'merchant', and the raw token only when NODE_ENV is not production (src/services/merchant-token-bridge.js:6-16).
  • The push to Shukah is POST {SHUKAH_HOST}/maeda/client/add-token with {access_token, client_id: <shukah_id>}, best effort and awaited. It does nothing when the client has no shukah_id or 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 400 with 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_hash index, which gives that 400. 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 (commit a5a5315, 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 2022 in 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) say 8888, and that unit test fails against the code.
  • A design doc says verify-otp sets last_login_at and 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 commit d837492 (2026-07-10) every client route is under PREFIX_API_V1/clients, and the auth host answers 404 for /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-23
  • locate-authentication-service@6757aa4 docs: 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 (branch development, 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