Skip to main content

Point offers

Background​

The point gift form needs two lists: the point packages (offers) and the occasions. On Shukah the app reads them with GET /maeda/point-offer and GET /maeda/point-occasion. Locate has the same two routes, and one new route that returns a single offer. The fields are the same as Shukah's. Three things need care: the id of an offer and of an occasion is a 24-character string instead of a number, Locate needs a token where Shukah answered without one, and Shukah's ?id= returns the whole list.

Feature​

An offer is a package of points that a client can buy with a card or pay for with points, to send to another client. An occasion is the reason for the gift, such as Birthday. In the app snapshot of 2026-02-26 the calls happen on the send-gift form:

CallWhere in the app
Offers listWhen the gift-amount picker appears on the send-gift form.
One offer (Shukah ?id=)In the gift summary, in the checkout button and in the payment-method picker. The app asks Shukah for ?id= and then picks the offer out of the returned list itself.
Occasions listWhen the occasion picker appears on the send-gift form.
One occasion (Shukah ?id=)In the gift summary. The same client-side pick.

The ids go into the later calls. The app sends the offer id as point_offer_id and the occasion id as point_occasion_id when it asks for the message text (Point order description) and when it creates the order (Create point order).

All of these need a token on Locate, and on dev today only a Shukah token works: a Locate JWT got 401 Invalid or expired token on the offers and occasions lists on 2026-10-02, although both tokens worked on the balance route on 2026-10-01 (see Which tokens work). order-v2 passes each call to the point service and returns the answer unchanged.

Ids: store them, send them back, never parse them. An offer or occasion id is the 24-character hex string of its database row. It is different in every environment, so the app must read ids from the list and never write one in code. A Shukah integer sent to Locate in place of an id is refused with 400. The app already turns ids into text (String(id)) and compares them as text, which works with the new ids, but its TypeScript types still say number.

The is_custom rule. is_custom is 0 or 1, never a boolean. When it is 1 (on dev: Thank and Other) the app shows a box for free text and sends what the client typed as occasion, next to the occasion id.

GET /api/v2/client/point-offers​

Replaces ShukahGET /maeda/point-offer✅ Replaced
Drop-in0 breaking · 4 to watch
  • 🟡 id is a 24-hex string (Shukah 1..5); the app does String(id), so it works; a Shukah id gives 400
  • 🟡 Locate needs a token; Shukah answered 200 with none; the app always sends one
  • 🟡 Locate JWT gets 401 on dev; send the Shukah token
  • 🟡 The five offers are equal on point_amount, purchased_point_amount, amount ("40.00" string) and order

The ?id= filter is ignored: the call still returns the whole list.

Request​

FieldInTypeRequiredConstraintsDescription
AuthorizationheaderstringyesBearer <token>: a Shukah token on dev today. A Locate JWT gets 401 Invalid or expired token (captured on dev, 2026-10-02, on the two lists): see Which tokens work.The client's own token.
idqueryanynoAccepted and ignored: the whole list is always returned, as Shukah's ?id= does.Kept so the old Shukah call still works.
Any other query keyquery—noAccepted and ignored, for example language.—

Response​

200 with a bare array of the active offers. It can be empty. There is no paging.

FieldTypeNullableFormat / constraintsDescription
idstringnoA 24-character hex string, for example "6a46104f3dd7b3dd6a261d90". Different in every environment. Shukah sends an integer.The offer id. Send it back as point_offer_id.
point_amountnumbernoA JSON number.The points the recipient gets.
purchased_point_amountnumberyesnull when the offer has no points price. On dev it is higher than point_amount.The points the sender pays when paying with points.
amountstringnoExactly two decimals, for example "40.00".The price in SAR for a card payment.
[
{"id": "6a46104f3dd7b3dd6a261d90", "point_amount": 100, "purchased_point_amount": 110, "amount": "40.00"},
{"id": "6a46104f3dd7b3dd6a261d91", "point_amount": 200, "purchased_point_amount": 240, "amount": "60.00"},
{"id": "6a46104f3dd7b3dd6a261d94", "point_amount": 300, "purchased_point_amount": 360, "amount": "90.00"},
{"id": "6a46104f3dd7b3dd6a261d96", "point_amount": 400, "purchased_point_amount": 480, "amount": "120.00"},
{"id": "6a46104f3dd7b3dd6a261d98", "point_amount": 500, "purchased_point_amount": 600, "amount": "150.00"}
]

Captured on dev, 2026-10-01 (client 356259): all five offers.

The sender pays more points than the recipient gets: 110 for 100, 240 for 200, 360 for 300, 480 for 400 and 600 for 500. An offer with purchased_point_amount of null cannot be paid with points: create point order answers 400 for it.

Order. The offers come sorted by an admin-set sequence number, then by the Shukah id, then by the row id. That sort was committed on 2026-10-01. Whether dev ran it when the sample was taken: Not documented yet.

Errors​

HTTPBodyWhen
401{"success":false,"error":{"code":"UNAUTHORIZED","message":"Missing or invalid authorization header"}}There is no Authorization header, or it does not start with Bearer . Captured on dev, 2026-10-01, with no token.
401The same shape, with message Invalid or expired token, auth service unreachable: <error text> or auth response missing numeric client id.The auth service refused the token, could not be reached (an auth outage looks like a bad token), or answered without a client id. Invalid or expired token was captured on dev, 2026-10-02, with a Locate JWT on the two lists.
500{"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}Any unexpected failure in the point service, for example a database error. The message is AUTH_SERVICE_URL not set when the service is misconfigured. Not captured.
502{"success":false,"error":{"code":"POINT_SERVICE_UNAVAILABLE","message":"Point service unavailable: <error text>"}}order-v2 could not reach the point service. This body comes from order-v2, not from the point service. Not captured.
504{"success":false,"error":{"code":"POINT_SERVICE_TIMEOUT","message":"Point service did not respond in time; the request outcome is unknown"}}The point service did not answer within 10 seconds. This body comes from order-v2. Not captured.

Every error from the point service has the envelope {"success":false,"error":{"code":"…","message":"…"}}.

Try it​

export ORDER_BASE_URL=https://dev.v2.api.order.locate.sa
export SHUKAH_TOKEN='<shukah-token>' # a Locate JWT gets 401 here today; see "Which tokens work" on Points wallet
curl -s "$ORDER_BASE_URL/api/v2/client/point-offers" -H "Authorization: Bearer $SHUKAH_TOKEN"

Expected: 200 and the array shown under Response. Use a Shukah token on dev today: a Locate JWT gets 401 Invalid or expired token (2026-10-02). With no token you get the 401 shown under Errors.

Differences from Shukah​

The five offers are equal on point_amount, purchased_point_amount, amount and key order on 2026-10-01 and again on 2026-10-02. Only id differs.

AspectShukahLocateImpact on the app
URLGET $SHUKAH_BASE_URL/maeda/point-offerGET $ORDER_BASE_URL/api/v2/client/point-offersChange the base URL and the path.
TokenNone needed: 200 with no token (captured on dev, 2026-10-01).Required: 401 with no token (captured on dev, 2026-10-01). A Shukah token only on dev today: a Locate JWT gets 401 (2026-10-02).The app already sends its token on every call, and must keep it a Shukah token here. A screen that shows offers before login would fail. Whether such a screen exists: Not documented yet.
idAn integer, 1 to 5 (captured on dev, 2026-10-01).A 24-character string.The app turns ids into text, so it works. Its types say number. Never mix the two kinds: Locate refuses a Shukah integer.
?id=Returns the whole list. The app picks the offer out itself.Accepted and ignored: the whole list again.None: the app's client-side pick works on the new ids.
purchased_point_amountA number (captured on dev, 2026-10-01).A number, or null when the offer has no points price.The app types it as a number and copies it to costInPoints. A null has to be handled.
OrderNot documented yet.By an admin-set sequence number, then Shukah id, then row id.If the app shows the list in server order, the order can change.
Error bodyNot documented yet. The route is public, so there is no 401 to compare.The point-service envelope under Errors.Anything that matches on error text must change.

GET /api/v2/client/point-offers/:id​

New endpoint — no Shukah equivalent.

New route. Shukah has no route that returns one offer: its ?id= returns the whole list, and the app picks the offer out of that list itself. The app does not call this route today.

Request​

FieldInTypeRequiredConstraintsDescription
AuthorizationheaderstringyesBearer <token>: a Shukah token on dev today. A Locate JWT gets 401 Invalid or expired token (captured on dev, 2026-10-02, on the two lists): see Which tokens work.The client's own token.
idpathstringyesExactly 24 hex characters (upper or lower case). A Shukah integer is refused, not looked up.The offer id from the list.
Any query keyquery—noForwarded by order-v2 and ignored by the point service.—

Response​

200 with one object, the same shape as a row of the list (id, point_amount, purchased_point_amount, amount; see the list).

{"id": "6a46104f3dd7b3dd6a261d90", "point_amount": 100, "purchased_point_amount": 110, "amount": "40.00"}

Captured on dev, 2026-10-01 (client 356259): the first offer.

Errors​

HTTPBodyWhen
400{"success":false,"error":{"code":"VALIDATION_ERROR","message":"point offer id must be a 24-character hex ObjectId"}}:id is not 24 hex characters. There is no details key. Not captured.
404{"success":false,"error":{"code":"NOT_FOUND","message":"point offer <id> not found or inactive"}}No offer with that id, or it is inactive or deleted. A 24-hex id that does not exist is a 404, not a 400. Not captured.
401{"success":false,"error":{"code":"UNAUTHORIZED","message":"Missing or invalid authorization header"}}There is no Authorization header, or it does not start with Bearer . Captured on dev, 2026-10-01, with no token.
401The same shape, with message Invalid or expired token, auth service unreachable: <error text> or auth response missing numeric client id.The auth service refused the token, could not be reached (an auth outage looks like a bad token), or answered without a client id. Invalid or expired token was captured on dev, 2026-10-02, with a Locate JWT on the two lists.
500{"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}Any unexpected failure in the point service, for example a database error. The message is AUTH_SERVICE_URL not set when the service is misconfigured. Not captured.
502{"success":false,"error":{"code":"POINT_SERVICE_UNAVAILABLE","message":"Point service unavailable: <error text>"}}order-v2 could not reach the point service. This body comes from order-v2, not from the point service. Not captured.
504{"success":false,"error":{"code":"POINT_SERVICE_TIMEOUT","message":"Point service did not respond in time; the request outcome is unknown"}}The point service did not answer within 10 seconds. This body comes from order-v2. Not captured.

Try it​

export ORDER_BASE_URL=https://dev.v2.api.order.locate.sa
export SHUKAH_TOKEN='<shukah-token>' # a Locate JWT gets 401 here today; see "Which tokens work" on Points wallet
export OFFER_ID='<id-from-the-offers-list>'
curl -s "$ORDER_BASE_URL/api/v2/client/point-offers/$OFFER_ID" -H "Authorization: Bearer $SHUKAH_TOKEN"

Expected: 200 and the offer shown under Response. An id that is not 24 hex characters gives the 400 shown under Errors. Use a Shukah token on dev today: this route has the same token check as the lists.

GET /api/v2/client/point-occasions​

Replaces ShukahGET /maeda/point-occasion✅ Replaced
Drop-in0 breaking · 4 to watch
  • 🟡 id is a 24-hex string (Shukah 1..7); the app does String(id); a Shukah id sent to Locate create is 400
  • 🟡 Locate needs a token; Shukah answered 200 with none; the app always sends one
  • 🟡 Locate JWT gets 401 on dev; send the Shukah token
  • 🟡 Seven rows are equal on occasion, occasion_en, is_custom (number 0/1) and key order

Request​

FieldInTypeRequiredConstraintsDescription
AuthorizationheaderstringyesBearer <token>: a Shukah token on dev today. A Locate JWT gets 401 Invalid or expired token (captured on dev, 2026-10-02, on the two lists): see Which tokens work.The client's own token.
languagequerystringnoAfter trimming and lower-casing, exactly ar gives the Arabic label. Any other value, including ar-SA and en-GB, and no value, gives English. Never an error.The language of the occasion label.
idqueryanynoAccepted and ignored: the whole list is returned.Kept so the old Shukah call still works.

The app adds language=en-GB or language=ar-SA to every call, so with the app's own values the label is always English. Whether that is intended: Not documented yet.

Response​

200 with a bare array of the active occasions. It can be empty. There is no paging.

FieldTypeNullableFormat / constraintsDescription
idstringnoA 24-character hex string. Different in every environment. Shukah sends an integer.The occasion id. Send it back as point_occasion_id.
occasionstringnoThe English label, trimmed. With language=ar, the Arabic label, or the English one when there is no Arabic.The label to show.
occasion_enstringnoThe English label, trimmed, always.The English label.
is_customintegerno0 or 1, never a boolean.1 means the app shows a box for free text.
[
{"id": "6a4610503dd7b3dd6a261dbf", "occasion": "Birthday", "occasion_en": "Birthday", "is_custom": 0},
{"id": "6a4610503dd7b3dd6a261dc0", "occasion": "Born", "occasion_en": "Born", "is_custom": 0},
{"id": "6a4610503dd7b3dd6a261dc1", "occasion": "Gift", "occasion_en": "Gift", "is_custom": 0},
{"id": "6a4610503dd7b3dd6a261dc3", "occasion": "Wedding", "occasion_en": "Wedding", "is_custom": 0},
{"id": "6a4610503dd7b3dd6a261dc5", "occasion": "Graduation", "occasion_en": "Graduation", "is_custom": 0},
{"id": "6a4610503dd7b3dd6a261dc6", "occasion": "Thank", "occasion_en": "Thank", "is_custom": 1},
{"id": "6a4610503dd7b3dd6a261dc7", "occasion": "Other", "occasion_en": "Other", "is_custom": 1}
]

Captured on dev, 2026-10-01 (client 356259), with no language: all seven occasions.

The app has a type problem that exists on Shukah too: it reads the English label as occasionEn, but both servers send occasion_en, so the app's nameEn is undefined today.

Errors​

HTTPBodyWhen
401{"success":false,"error":{"code":"UNAUTHORIZED","message":"Missing or invalid authorization header"}}There is no Authorization header, or it does not start with Bearer . Captured on dev, 2026-10-01, with no token.
401The same shape, with message Invalid or expired token, auth service unreachable: <error text> or auth response missing numeric client id.The auth service refused the token, could not be reached (an auth outage looks like a bad token), or answered without a client id. Invalid or expired token was captured on dev, 2026-10-02, with a Locate JWT on the two lists.
500{"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}Any unexpected failure in the point service, for example a database error. The message is AUTH_SERVICE_URL not set when the service is misconfigured. Not captured.
502{"success":false,"error":{"code":"POINT_SERVICE_UNAVAILABLE","message":"Point service unavailable: <error text>"}}order-v2 could not reach the point service. This body comes from order-v2, not from the point service. Not captured.
504{"success":false,"error":{"code":"POINT_SERVICE_TIMEOUT","message":"Point service did not respond in time; the request outcome is unknown"}}The point service did not answer within 10 seconds. This body comes from order-v2. Not captured.

Try it​

export ORDER_BASE_URL=https://dev.v2.api.order.locate.sa
export SHUKAH_TOKEN='<shukah-token>' # a Locate JWT gets 401 here today; see "Which tokens work" on Points wallet
curl -s "$ORDER_BASE_URL/api/v2/client/point-occasions?language=en-GB" -H "Authorization: Bearer $SHUKAH_TOKEN"

Expected: 200 and the array shown under Response. Use a Shukah token on dev today: a Locate JWT gets 401 Invalid or expired token (2026-10-02). With no token you get the 401 shown under Errors.

Differences from Shukah​

The seven rows are equal on occasion, occasion_en, is_custom and key order on 2026-10-01 and again on 2026-10-02. Only id differs.

AspectShukahLocateImpact on the app
URLGET $SHUKAH_BASE_URL/maeda/point-occasionGET $ORDER_BASE_URL/api/v2/client/point-occasionsChange the base URL and the path.
TokenNone needed: 200 with no token (captured on dev, 2026-10-01).Required: 401 with no token (captured on dev, 2026-10-01). A Shukah token only on dev today: a Locate JWT gets 401 (2026-10-02).The app already sends its token on every call, and must keep it a Shukah token here.
idAn integer, 1 to 7 (captured on dev, 2026-10-01).A 24-character string.The app turns ids into text, so it works. Never mix the two kinds.
?id=Returns the whole list.Accepted and ignored: the whole list again.None: the app's client-side pick works on the new ids.
occasion labelThe same English text in occasion and occasion_en (captured on dev, 2026-10-01).English, or Arabic when language is exactly ar.The app sends ar-SA, so it never gets the Arabic label.
OrderNot documented yet.By an admin-set sequence number, then Shukah id, then row id.If the app shows the list in server order, the order can change.
Error bodyNot documented yet. The route is public, so there is no 401 to compare.The point-service envelope under Errors.Anything that matches on error text must change.

Watch out​

  • Offer and occasion ids are 24-character strings that differ per environment: store them, send them back, never parse them, and never send a Shukah integer.
  • Locate needs a token on all three routes, where Shukah's lists were public.
  • ?id= still returns the whole list, so pick the offer or occasion out of the array, or use GET /point-offers/:id.

Details (for backend)​

Only dev was checked, through order-v2 at https://dev.v2.api.order.locate.sa, prefix /api/v2/client. UAT and production hosts: Not documented yet. In this section OV2: means locate-order-v2-api@d9e4e5f6 and PS: means locate-point-service@2c44ce3. How the proxy, the token check and the error envelope work is described once, under How the proxied calls work.

Routes and proxy​

App route (order-v2)Upstream route (point service)ForwardedLimit
GET /api/v2/client/point-offersGET /api/client/point-offersAuthorization and the whole query10 s
GET /api/v2/client/point-offers/:idGET /api/client/point-offers/<id>, with the id passed through encodeURIComponentAuthorization and the whole query (ignored upstream)10 s
GET /api/v2/client/point-occasionsGET /api/client/point-occasionsAuthorization and the whole query, so language arrives10 s

(OV2: src/modules/client/point/point.controller.ts:61-108, PS: services/api/src/routes/client.routes.ts:20-22, PS: services/api/src/handlers/client-point-offers.ts:12-23, PS: services/api/src/handlers/client-point-occasions.ts:12-14.)

What the point service reads​

  • Offers. Rows of point_offers with status: 'active' and deleted_at: null, sorted by sequence, then shukah_id, then _id. There is no paging or limit. A Locate-made offer with no Shukah id is listed like any other (PS: services/api/src/services/point-settings.service.ts:10-25). The sort was changed on 2026-10-01 (commit 105119c, merged 14:25 +07), while the integration doc still says "ordered by shukah_id" (PS: docs/plans/2026-10-01-app-language-and-arabic-reason.md:6-8, PS: docs/integrations/2026-07-16-client-facing-point-apis.md:321-322). The captures were taken at about 17:19 +07. Whether that build was on dev: Not documented yet.
  • One offer. The same filter on _id. An inactive or deleted offer is 404 (PS: services/api/src/services/point-settings.service.ts:29-34). The id check is ^[0-9a-fA-F]{24}$ (PS: services/api/src/lib/object-id.ts:12-14, PS: services/api/src/handlers/client-point-offers.ts:16,19-23).
  • Occasions. Rows of point_occasions with the same filter, sorted the same way (PS: services/api/src/services/point-settings.service.ts:38-42). The language rule (exactly ar after trim and lower-case) shipped in the same commit 105119c (PS: services/api/src/handlers/client-point-occasions.ts:5-8, PS: docs/plans/2026-10-01-app-language-and-arabic-reason.md:10-17).
  • Ids. The ids are the rows' own _id values, rendered as 24-character lowercase hex. Uppercase is accepted on input (PS: services/api/src/services/point-settings.mappers.ts:25-58). The integers that Shukah uses are still stored on the rows as shukah_id, but the client routes no longer accept them: a point_offer_id that is not 24 hex characters fails with must be a 24-character hex ObjectId (checked with a local run of the schema, zod 3.25.76).
  • The ?id= query. The handler reads no id; the whole list is returned, which the point service doc says matches Shukah, "measured on dev 2026-09-21" (PS: services/api/src/handlers/client-point-offers.ts:7-11, PS: docs/integrations/2026-07-16-client-facing-point-apis.md:324-328).
  • is_custom. The mapper writes doc.is_custom ? 1 : 0 (PS: services/api/src/services/point-settings.mappers.ts:56). A custom occasion is the one case where the app sends free text in occasion together with the id (PS: services/api/src/services/point-settings.service.ts:57-58).
  • Edits. Offers and occasions are changed through admin routes under /api/admin/… with a service key, never by the app (PS: services/api/src/routes/admin.routes.ts:34-44).

The app​

The app's types say id: number for offers and occasions (mobile/locate-client@e960c3a: src/entities/points/model/types/pointGiftOption.server.ts:1-6, mobile/locate-client@e960c3a: src/entities/points/model/types/pointGiftOccasion.server.ts:1-6). The DTOs turn the id into text and compute cost as Number(amount), costInPoints as purchased_point_amount, and isCustom as String(is_custom) === '1' (mobile/locate-client@e960c3a: src/entities/points/model/api/dto/points.dto.ts:104-136). The by-id helpers filter the returned list with String(item.id) === request.id (mobile/locate-client@e960c3a: src/entities/points/model/api/services/points.service.ts:212-241,285-295). The language interceptor adds language=en-GB or language=ar-SA to every call (mobile/locate-client@e960c3a: src/shared/api/interceptors/languageInterceptor.ts:5-15).

Where the docs and the code disagree (code wins)​

  • The integration doc says offers are ordered by shukah_id with _id as the tiebreak (PS: docs/integrations/2026-07-16-client-facing-point-apis.md:321-322). The code sorts by sequence first.
  • 2026-10-02: this page said a Shukah token and a Locate JWT both worked on these routes. On 2026-10-02 the JWT got 401 on the offers and occasions lists, and the Try it calls now use a Shukah token (PS: services/api/src/services/client-auth.service.ts:22-29; see Which tokens work).

Sources​

  • locate-order-v2-api@d9e4e5f6: src/modules/client/point/point.controller.ts:61-108
  • locate-point-service@2c44ce3: services/api/src/routes/client.routes.ts:20-22, services/api/src/handlers/client-point-offers.ts:7-23, services/api/src/handlers/client-point-occasions.ts:5-14, services/api/src/services/point-settings.service.ts:10-42,57-58, services/api/src/services/point-settings.mappers.ts:25-58, services/api/src/lib/object-id.ts:12-14, services/api/src/routes/admin.routes.ts:34-44
  • locate-point-service@2c44ce3 docs: docs/integrations/2026-07-16-client-facing-point-apis.md:321-328, docs/plans/2026-10-01-app-language-and-arabic-reason.md:6-17
  • Mobile snapshot mobile/locate-client@e960c3a (branch development, 2026-02-26; the app's own code, not Locate): src/entities/points/model/api/services/points.service.ts:190-263,285-295, src/entities/points/model/api/dto/points.dto.ts:104-136, src/entities/points/model/types/pointGiftOption.server.ts:1-6, src/entities/points/model/types/pointGiftOccasion.server.ts:1-6, src/features/SelectPointGift/model/hooks/useSelectPointGiff.ts:16, src/features/SelectPointGiftOccasion/model/hooks/useSelectPointGiftOccasion.ts:10, src/widgets/SendPointGiftForm/ui/SendPointGiftForm/SendPointGiftForm.tsx:149-185, src/features/PointGiftSummary/ui/PointGiftSummary/PointGiftSummary.tsx:34-37, src/features/PointGiftsCheckoutButton/ui/PointGiftsCheckoutButton/PointGiftsCheckoutButton.tsx:33, src/features/SelectPointGiftPaymentMethod/model/hooks/useSelectPointGiftPaymentMethod.ts:52, src/shared/api/interceptors/languageInterceptor.ts:5-15
  • Dev captures, 2026-10-02 (parity audit): point-offer--shukah, point-offer--locate-st, point-offer--locate-jwt, point-occasion--shukah, point-occasion--locate-st, point-occasion--locate-jwt
  • Dev captures, 2026-10-01: locate-point-offers, locate-point-offers-again, locate-point-offer, locate-point-occasions, locate-point-occasions-again, shukah-point-offer, shukah-point-occasion, probe-locate-point-offers, probe-locate-point-offer, probe-locate-point-occasions, probe-shukah-point-offer, probe-shukah-point-occasion