Verify token
Background
On Shukah the app called GET /maeda/api/verify-token to check its saved token and load the
profile. The auth service returns the same profile with the same Shukah field names, so the switch
is mostly a change of base URL and path prefix. Three other things need care: it accepts only a
Locate JWT, its 401 body is different, and it does not send the app-version headers that the app
reads from this call.
Feature
Send the Locate JWT from exchange-token and get the client's profile
back. The call only reads: it does not refresh the token or change anything. It answers 401 when
the token is missing or invalid, or when the client's account is not active.
In the app snapshot of 2026-02-26, the app calls it every time it starts, to check its saved token
and load the profile, and again from the Home screen. When the answer is 401, the app deletes its
stored token.
GET /api/v1/clients/verify-token
GET /maeda/api/verify-token✅ Replaced- 🔴 Shukah token gets 401 "Unauthorization"; send a Locate JWT (from exchange-token; keep the Shukah token)
- 🔴 On this 401 the app deletes its stored token (auth.service.ts:101), so exchange the token first
- 🟡 Locate 401 body is {status,message}, not the Laravel body; the app reads message only
- 🟡 Locate sends no app-version headers (new_version etc.); Shukah's are unknown (no response headers captured)
Request
| Field | In | Type | Required | Constraints | Description |
|---|---|---|---|---|---|
Authorization | header | string | yes | Bearer <locate-jwt>. A Shukah token is refused with 401. | The Locate JWT. Today the app gets it from exchange-token and keeps the Shukah token; Locate verify-otp also returns one, but login stays on Shukah for now. |
language | query | string | no | Accepted and ignored. | Kept so the old Shukah query string still works. |
| Any other query key or header | query, header | — | no | Accepted and ignored, for example x-from-application. | — |
Response
200 with the profile as a bare object (no status or data wrapper):
| Field | Type | Nullable | Format / constraints | Description |
|---|---|---|---|---|
client_id | integer | no | The same number as on Shukah for a migrated client (356259 for the dev test client). Clients registered in Locate get ids from 1,000,000,001 up. | The client's id. It is also inside the JWT. |
odoo_id | string | yes | A numeric string on the test client; null when the client has none. | The client's Odoo (ERP) id. |
contact_phone | string | yes | The phone exactly as stored; the service does not reformat it. App-registered phones are stored as the app sent them and migrated phones keep Shukah's format (whitespace removed in both). | The client's phone. |
status | string | no | active, pending, suspended, blocked or expired. This route returns only active, because every other status gets 401. | The account status. |
email_address | string | yes | No format check. Can be "" after a profile update with "". | The client's email. |
first_name | string | yes | Trimmed, at most 100 characters on write. Can be "" after a profile update with "". | First name. |
last_name | string | yes | Trimmed, at most 100 characters on write. Can be "" after a profile update with "". | Last name. |
sex | integer | no | 0 unknown or not given, 1 male, 2 female. | Gender. |
birth_date | string | yes | YYYY-MM-DD: the UTC date of the value that was written. | Birth date. |
receive_offers | integer | no | 0 or 1. | Whether the client receives offers. |
push_enabled | integer | no | 0 or 1. | Whether push notifications are on. |
is_food_truck_user | integer | no | 0 or 1. | Food-truck user flag. |
is_cod_disabled | integer | no | 0 or 1. | 1 when cash on delivery is switched off for the client. |
{
"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
}
Captured on dev, 2026-10-01 (client 356259); personal fields replaced.
Errors
| HTTP | Body | When |
|---|---|---|
401 | {"status":401,"message":"Unauthorization"} | There is no Authorization header, or it has no token part. |
401 | Same body. | The token is not a Locate JWT: a Shukah token, a malformed token or one with a wrong signature. |
401 | Same body. | The token is valid but its client does not exist, or the client's status is not active (pending, suspended, blocked or expired). |
500 | {"status":500,"message":"Internal server error"} | An unexpected failure inside the service. Not captured. Outside production the message is the raw error text and a stack field is added. |
The 401 body was captured on dev on 2026-10-01 with no token, and again with a Shukah token.
Try it
export AUTH_BASE_URL=https://dev.authentication.locate.sa
export TOKEN='<locate-jwt>' # see "Getting a test token" on the module overview
curl -s "$AUTH_BASE_URL/api/v1/clients/verify-token" -H "Authorization: Bearer $TOKEN"
Expected: 200 and the profile shown under Response. With a Shukah token in $TOKEN you get the
401 shown under Errors.
Differences from Shukah
The 200 body is the same: on 2026-10-01 both services returned the same 13 keys, types and values
for client 356259. The rows below cover the token, the URL, the error body and the app-version
headers.
| Aspect | Shukah | Locate | Impact on the app |
|---|---|---|---|
| Token | Shukah opaque token. | Locate JWT only. A Shukah token gets 401 (captured on dev, 2026-10-01). | Exchange the Shukah token for the JWT before switching, and keep the Shukah token for order-v2. |
| URL | GET $SHUKAH_BASE_URL/maeda/api/verify-token | GET $AUTH_BASE_URL/api/v1/clients/verify-token. The auth host does not serve /maeda/*: it answers 404. | Change the base URL and the path. |
401 body | {"name":"Unauthorized","message":"Your request was made with invalid credentials.","code":0,"status":401,"type":"Illuminate\\Auth\\AuthenticationException"} | {"status":401,"message":"Unauthorization"} | Anything that matches on the error text, name or type must change. status is 401 in both. |
| App-version headers | The app reads new_version, old_version, has_new_version and is_required_update from the response headers of any URL that contains api/verify-token or client/points (its default list), and uses them for its update popup. Whether Shukah sends them: Not documented yet, because the captures hold request headers only. | Not sent: no Locate service sets these headers. The Locate URL /api/v1/clients/verify-token does not contain api/verify-token, so the app would not look for them there. | If Shukah sends the headers today, the update popup silently stops appearing after the switch. |
Watch out
- Only a Locate JWT works here. A Shukah token gets
401and the app deletes its stored token on a401, so exchange it for the JWT first, and keep the Shukah token: order-v2 still needs it. - A Locate token never expires and is never revoked, so
401means a bad token or an inactive account, never a timeout. - The
401body is Locate's own{"status":401,"message":"Unauthorization"}(spelled that way), not Shukah's.
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).
Route and guard
GET /clients/verify-token runs the client middleware and then a one-line handler that returns
the client the middleware loaded (src/routes/client.js:13,
src/controllers/client/verify-token/index.js:7).
- The guard reads only the second space-separated part of
Authorization. The first word (Bearer,Token, anything) is not checked. A header with no second part fails with "jwt must be provided" and gives401(src/middlewares/client.js:15-22,38-41). - A malformed token, a wrong signature or a Shukah token makes
jwt.verifythrow, and the guard answers401(src/middlewares/client.js:22,38-41). - The guard loads the client by the
client_idin the token. A missing client gives401(src/middlewares/client.js:27-33). - Not checked: the soft-delete flag, the token's
phoneagainst the stored phone, thetoken_bridgerows, the device and the IP (src/services/client.js:5-10: the query is{ client_id }only).
Token rules
A Locate JWT is signed with HS256 and carries {client_id, phone, iat}. The service signs and
verifies without options, so no token has an exp: nothing expires and nothing is revoked
(src/shared/helpers/jwt.js:4-10).
Active-status rule
Only ACTIVE may authenticate. CLIENT_ACTIVE_STATUSES is [ACTIVE], so PENDING, SUSPENDED,
BLOCKED, EXPIRED and a missing status all give 401
(src/shared/constant/user.js:4-14, src/middlewares/client.js:27-33).
The client lookup is cached for CLIENT_CACHE_TTL seconds, default 300
(src/services/client.js:5-10). Writes through Mongoose save() clear the cache. The bulk ETL write
(bulkWrite) does not, so a status change that comes from the Shukah sync can take up to that long
to apply (src/models/client.js:158-161).
Profile mapper
formatClientProfileForResponse builds the body (src/controllers/client/setting/mapper.js:24-38).
statusis the lower-case form of the stored status. A stored value outside the five becomespending(src/controllers/client/setting/mapper.js:5-11,28). The sync maps Shukah's status text to the stored status insrc/sync/lib/casts.js:24-33.sexcomes from the stored gender:MALEis1,FEMALEis2, anything else is0(src/controllers/client/setting/mapper.js:14-18). The four flags are0or1(src/controllers/client/setting/mapper.js:20).contact_phoneis the stored phone. A phone registered through reset-otp is stored as sent, with whitespace removed (src/controllers/client/reset-otp/handler.js:40,56,src/sync/lib/casts.js:40-41). Migrated phones go through the same whitespace-stripping normaliser (src/sync/mappers/client-mapper.js:99).- Write rules behind the field constraints:
first_nameandlast_nameare written trimmed. A profile update acceptsnullor a string of at most 100 characters after trimming, and answers400otherwise.""is stored as"". The model also caps both names at 100 characters, so a registration with a longer name fails (src/controllers/client/update-profile/validate.js:4-6,24-25,src/controllers/client/update-profile/index.js:13-15,src/controllers/client/update-profile/parse-payload.js:5,11-12,src/models/client.js:45-52).email_address: a profile update accepts any string ornull, with no format check and no length limit, and stores it trimmed, so""stays""(src/controllers/client/update-profile/validate.js:26,src/controllers/client/update-profile/parse-payload.js:13,src/models/client.js:57-60).birth_dateis kept as aYYYY-MM-DDstring, the UTC date of the value written. A profile update answers400for a value thatnew Date()cannot read, andnullclears it (src/sync/lib/casts.js:2-11,src/controllers/client/update-profile/validate.js:27-30,src/controllers/client/update-profile/parse-payload.js:14-16). The service doc gives the format asYYYY-MM-DD, andnullwhen empty (docs/api/client-auth-and-address-book.md:454).
- The same object is returned by verify-otp (
data),PUT /api/v1/clients/settingandPUT /api/v1/clients/:client_id.GET /api/v1/usersreturns a different object in one mode (see User detail). - Ids: the sync copies a migrated client's Shukah id into
client_id(src/sync/mappers/client-mapper.js:90). Clients registered in Locate get1,000,000,000 + sequence(src/models/counter.js:7,23).
Where the docs and the code disagree (code wins)
- The service doc says
statusis"active"or"inactive"(docs/api/client-auth-and-address-book.md:177). The code returns the five values above, and the same doc lists them at line 453.
Sources
locate-authentication-service@6757aa4:src/routes/client.js:13,src/middlewares/client.js:8-42,src/controllers/client/verify-token/index.js:3-7,src/controllers/client/setting/mapper.js:5-38,src/shared/helpers/jwt.js:4-10,src/shared/constant/user.js:4-14,src/services/client.js:5-10,src/models/client.js:158-161,src/models/counter.js:7,23,src/sync/lib/casts.js:2-11,24-33,40-41,src/sync/mappers/client-mapper.js:90,99,src/shared/constant/error-message.js:59,src/middlewares/error-handler.js:12-35,src/models/client.js:45-52,57-60,src/controllers/client/update-profile/validate.js:4-6,24-30,src/controllers/client/update-profile/index.js:13-15,src/controllers/client/update-profile/parse-payload.js:5,11-16,src/controllers/client/reset-otp/handler.js:40,56locate-authentication-service@6757aa4docs:docs/api/client-auth-and-address-book.md§3 (lines 150-183, 453, 454; last changed 2026-07-27),docs/plans/2026-07-14-shukah-verify-token-api-local.md§2 (2026-07-14)- Mobile snapshot
mobile/locate-client@e960c3a(branchdevelopment, 2026-02-26; the app's own code, not Locate):platforms/mobile/products/locate/app/sideEffects/effects/initSecondaryResources.tsx:37-43,platforms/mobile/products/locate/screens/home/mainInner/Home/components/Screen/Home.tsx:53,src/entities/user/model/services/init/init.ts:21,src/entities/user/model/services/fetchUserInfo/fetchUserInfo.ts:50,src/entities/user/model/api/services/auth.service.ts:85-112(the stored token is deleted on a401at:101-103),src/shared/api/interceptors/createResponseInterceptor.ts:18-29,src/shared/features/featuresConfigs.ts:41,90,107-110,src/features/AppVersionUpdate/ui/AppVersionUpdatePopup/AppVersionUpdatePopup.tsx:64 - A search of
locate-authentication-service@6757aa4,locate-point-service@2c44ce3andlocate-order-v2-api@d9e4e5f6fornew_version,old_version,has_new_versionandis_required_updatefinds no match (2026-10-01). - Dev captures, 2026-10-01:
locate-verify-token,shukah-verify-token,locate-verify-token-with-shukah-token,probe-locate-verify-token,probe-shukah-verify-token,probe-auth-host-maeda-prefix