Client lookup
Background
On Shukah the app looked up a client by phone number with GET /maeda/client?contact_phone=…. The
auth service has the same lookup at GET /api/v1/clients. It returns the same four fields, but it
needs a Locate JWT and it masks the last name.
Feature
Send a phone number and get back who has it: the client's id, phone, first name and a masked last name. The answer is an array with one row, or an empty array when no active client has that phone.
In the app snapshot of 2026-02-26, the app uses it only to find the recipient of a point gift, from the gift form and again at gift checkout. It reads only the first row, and an empty array becomes a "User not found" error in the app.
The call needs a signed-in session, but the phone you look up does not have to be your own. The token only identifies the caller. The phone in the query decides who comes back.
GET /api/v1/clients
GET /maeda/client✅ Replaced- 🔴 Shukah token gets 401 "Unauthorization"; send a Locate JWT (from exchange-token; keep the Shukah token)
- 🟡 Locate masks last_name ("User" becomes "U**r"); Shukah's masking is unknown (capture is scrubbed)
- 🟡 Unknown phone is 200 [] on Locate (from code); the app turns [] into "User not found" (user.service.ts:49-51)
- 🟡 Locate 401 body is {status,message}, not the Laravel body; the app reads message only
Request
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
Authorization | header | string | yes | Bearer <locate-jwt>, the caller's token. A Shukah token is refused with 401. | The caller's session. |
contact_phone | query | string | yes | Whitespace is removed. It is matched to the stored phone with or without a leading +. Blank or missing gives 400. A literal + in a URL is read as a space and removed, so an unencoded +966… still matches. | The phone to look up. It can belong to anyone, not only the caller. |
key, language and any other query key | query | string | no | Accepted and ignored. | Kept so the old Shukah query string still works. |
Response
200 with an array of zero or one row:
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
client_id | integer | no | — | The id of the client found. |
contact_phone | string | yes | The phone as stored, not masked. | The client's phone. |
first_name | string | yes | Trimmed. Blank becomes null. Not masked. | First name. |
last_name | string | yes | Masked: the first and last character are kept and every character between them becomes * (User becomes U**r). A name of one or two characters is returned as it is. Blank becomes null. | Last name, masked. |
When no active, not-deleted client has the phone, the answer is 200 with []. A client that is
suspended, blocked, pending or expired gets [] too.
[
{
"client_id": 356259,
"contact_phone": "+966500000000",
"first_name": "Test",
"last_name": "User"
}
]
Captured on dev, 2026-10-01 (client 356259); personal fields replaced. The capture replaced last_name with User, so the masked form of the real value is not visible here. The mask rule comes from the code and its unit test: User would become U**r.
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. |
401 | {"status":401,"message":"Unauthorization"} | The token is missing, malformed or not a Locate JWT, the caller's client does not exist, or the caller's client is not active. |
The 401 body was captured on dev on 2026-10-01 with no token. The 400 body has the same shape as
the one captured for reset-otp (see OTP login). It was not captured on this
route, because the capture run made no lookup call with a token and an empty phone.
Try it
export AUTH_BASE_URL=https://dev.authentication.locate.sa
export TOKEN='<locate-jwt>' # see "Getting a test token" on the module overview
export PHONE='<phone-to-look-up>' # the answer shows that client's first name and phone
curl -s -G "$AUTH_BASE_URL/api/v1/clients" --data-urlencode "contact_phone=$PHONE" -H "Authorization: Bearer $TOKEN"
Expected: 200 and an array with one row, as shown under Response. A phone nobody has gives [].
Differences from Shukah
The 200 body has the same four keys on both sides: on 2026-10-01 both services returned client_id,
contact_phone, first_name and last_name for client 356259.
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| Token | A Shukah token (the capture used one). | A Locate JWT only. A Shukah token gets 401 at the same guard as verify-token. | Exchange the Shukah token for the JWT before switching, and keep the Shukah token for order-v2. |
| URL | GET $SHUKAH_BASE_URL/maeda/client?contact_phone=…&key=maeda&language=en-GB (the captured call). | GET $AUTH_BASE_URL/api/v1/clients?contact_phone=…. Locate ignores key and language. | Change the base URL and the path. The app can keep sending key and language. |
last_name | Not documented yet: the dev capture had last_name replaced, so masking cannot be seen. | Masked: the first and last character are kept and the middle becomes *. | The app copies last_name from the first row, and Locate gives it masked. Check how the gift flow uses the name. |
| Unknown phone | Not documented yet. | 200 with [], also for a client that is not active. | The app already turns an empty array into "User not found", so [] needs no change. |
401 body | {"name":"Unauthorized","message":"Your request was made with invalid credentials.","code":0,"status":401,"type":"Illuminate\\Auth\\AuthenticationException"} (no token, captured on dev, 2026-10-01). | {"status":401,"message":"Unauthorization"} (no token, captured on dev, 2026-10-01). | Anything that matches on the error text, name or type must change. status is 401 in both. |
Watch out
last_namecomes back masked (UserbecomesU**r), butfirst_nameandcontact_phonecome back in full.- An unknown phone and a client that is not active both get
200with[], which the app turns into a "User not found" error, so it cannot tell them apart. - The token and the phone are independent: any signed-in caller can look up any phone and get that client's first name and phone.
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.
Route and lookup
GET /clientsis registered before any parameter route, behind theclientmiddleware, so it needs an active client's Locate JWT like verify-token does (src/routes/client.js:14-16,src/middlewares/client.js:8-42).- The handler reads
contact_phoneand answers400through the Yii error when it is blank (src/controllers/client/lookup/index.js:33-34). - The lookup is
findClientByPhone, the same as reset-otp: the stored phone with an optional+, not deleted, statusACTIVE, newestclient_idfirst, not cached (src/controllers/client/reset-otp/handler.js:24-30,src/shared/helpers/filter.js:1-4). It returns the single best match, so the array has at most one row (src/controllers/client/lookup/index.js:36-40). - A literal
+in a URL decodes to a space, andnormalizePhonethen strips it, so an unencoded+966…still matches (src/sync/lib/casts.js:40-41; code reading). - The design doc names merchant flows that hold a Locate client JWT as the use case
(
docs/plans/2026-08-04-client-lookup-by-phone-api-local.md:9-11). The app snapshot calls it only to resolve a gift recipient (see Sources). - The call is read-only, with no cache of its own, no pagination and no side effects
(
docs/plans/2026-08-04-client-lookup-by-phone-api-local.md:25-28).
Row mapping and masking
toLookupRow builds each row (src/controllers/client/lookup/index.js:23-28):
client_idis the storedclient_id.contact_phoneis the stored phone, ornull.first_nameis trimmed, and blank becomesnull(src/controllers/client/lookup/index.js:17-21).last_namegoes throughmaskLastName: trimmed,nullor blank givesnull, a length of two or less is unchanged, and otherwise the first character,length - 2stars and the last character (src/controllers/client/lookup/index.js:8-15). The unit test pinsUsertoU**r(src/controllers/client/__tests__/lookup.test.js:32).- Masking
first_nameorcontact_phoneis out of scope in the design doc (docs/plans/2026-08-04-client-lookup-by-phone-api-local.md:93). Any signed-in caller therefore gets them in full for any phone, and the call cannot tell an unknown phone from one that is not active.
Not documented yet
- Whether Shukah masks
last_name: the capture replaced the value. - What Shukah answers for an unknown phone or a blank phone: neither was captured.
- Whether Shukah's lookup also takes list filters. The design doc lists "Pagination /
status/pagelist filters from upstream" as out of scope for Locate (docs/plans/2026-08-04-client-lookup-by-phone-api-local.md:91), and the capture used onlycontact_phone,keyandlanguage. - Whether any environment has this route:
origin/productionat4f3bc96(2026-09-21) does not register it (itssrc/routes/client.jsimports nolookup), and whether anything runs from that ref is unknown.
Where the docs and the code disagree (code wins)
- The design doc's mask example has 13 stars (
U*************)forUser (500…000)) (docs/plans/2026-08-04-client-lookup-by-phone-api-local.md:30). The code and the unit test give 12 (src/controllers/client/__tests__/lookup.test.js:33), because the rule islength - 2. The difference is cosmetic.
Sources
locate-authentication-service@6757aa4:src/routes/client.js:14-16,src/middlewares/client.js:8-42,src/controllers/client/lookup/index.js:8-41,src/controllers/client/__tests__/lookup.test.js:32-33,src/controllers/client/reset-otp/handler.js:24-30,src/shared/helpers/filter.js:1-4,src/sync/lib/casts.js:40-41,src/shared/errors/yii-bad-request-error.js:12-23,src/middlewares/error-handler.js:10,src/shared/constant/error-message.js:59locate-authentication-service@6757aa4docs:docs/plans/2026-08-04-client-lookup-by-phone-api-local.md:9-11,25-30,56-59,90-93(2026-08-04)- Mobile snapshot
mobile/locate-client@e960c3a(branchdevelopment, 2026-02-26; the app's own code, not Locate):src/entities/user/model/api/services/user.service.ts:38-47,49-51,58-60,src/entities/user/model/api/dto/user.dto.ts:126-155,src/widgets/SendPointGiftForm/model/hooks/sendPointGiftForm.ts:64,src/widgets/PointGiftsCheckout/model/hooks/usePointGiftsCheckout.ts:376 - Dev captures, 2026-10-01:
locate-client-lookup,shukah-client-lookup,probe-locate-client-lookup,probe-shukah-client-lookup,probe-locate-reset-otp - Secondary evidence, not the pinned sha:
locate-authentication-serviceorigin/productionat4f3bc96(2026-09-21),src/routes/client.js