Packages API
Browse, search, filter, and compare telecom SIM packages from Saudi operators (STC, Mobily, Zain).
GET /packages
List packages with optional filters. Returns up to 50 packages ordered by price ascending.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
operatorId | string | — | Filter by operator UUID |
dataMinMb | integer | — | Minimum data amount in MB |
voiceMinMinutes | integer | — | Minimum voice minutes |
validityDays | integer | — | Exact validity in days |
isActive | boolean | — | Only active packages (default: only active) |
isPromotional | boolean | — | Only promotional packages |
Example Requests
All packages:
GET /packagesFilter by operator:
GET /packages?operatorId=f47ac10b-58cc-4372-a567-0e02b2c3d479Filter by minimum data and promotional:
GET /packages?dataMinMb=5000&isPromotional=trueCombined filters:
GET /packages?operatorId=f47ac10b-58cc-4372-a567-0e02b2c3d479&dataMinMb=5000&voiceMinMinutes=100&validityDays=30Response
200 OK
{
"data": [
{
"id": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "STC 50 SAR Plan",
"description": "5GB data + 60 min voice",
"dataAmountMb": 5120,
"voiceMinutes": 60,
"smsCount": 50,
"validityDays": 30,
"priceSar": 49.99,
"isPromotional": false,
"createdAt": "2026-04-15T08:00:00.000Z",
"updatedAt": "2026-04-15T08:00:00.000Z"
},
{
"id": "8a17d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "STC 100 SAR Plan",
"description": "10GB data + 120 min voice",
"dataAmountMb": 10240,
"voiceMinutes": 120,
"smsCount": 100,
"validityDays": 30,
"priceSar": 99.99,
"isPromotional": true,
"createdAt": "2026-04-16T09:00:00.000Z",
"updatedAt": "2026-04-16T09:00:00.000Z"
}
],
"total": 2
}Display fields (
name,description) are localized based onAccept-Language. WhenAccept-Language: ar, the response returns Arabic names and descriptions.
Arabic response (Accept-Language: ar):
{
"data": [
{
"id": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "باقة STC ٥٠ ريال",
"description": "٥ جيجا بيانات + ٦٠ دقيقة صوت",
"dataAmountMb": 5120,
"voiceMinutes": 60,
"smsCount": 50,
"validityDays": 30,
"priceSar": 49.99,
"isPromotional": false,
"createdAt": "2026-04-15T08:00:00.000Z",
"updatedAt": "2026-04-15T08:00:00.000Z"
}
],
"total": 1
}GET /packages/search
Full-text search across package names and descriptions. Uses a two-phase search strategy:
- Primary: PostgreSQL
tsqueryfull-text search using thesearch_vectorcolumn, ranked byts_rank - Fallback: If full-text returns no results, trigram similarity (
%operator) onnameEn,nameAr,descriptionEn,descriptionAr, ordered bysimilarity()
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
q | string | Yes | — | Search query (1–200 characters) |
operatorId | string | No | — | Filter by operator UUID |
dataMinMb | integer | No | — | Minimum data amount in MB |
voiceMinMinutes | integer | No | — | Minimum voice minutes |
validityDays | integer | No | — | Exact validity in days |
isPromotional | boolean | No | — | Only promotional packages |
Results are limited to 20 items.
Example Requests
Simple search:
GET /packages/search?q=prepaid+dataSearch with filters:
GET /packages/search?q=prepaid+data&operatorId=f47ac10b-58cc-4372-a567-0e02b2c3d479&dataMinMb=5000Response
200 OK — Full-text match:
{
"data": [
{
"id": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "Prepaid Data 10GB",
"description": "10GB data package with unlimited social media",
"dataAmountMb": 10240,
"voiceMinutes": 0,
"smsCount": 0,
"validityDays": 30,
"priceSar": 49.99,
"isPromotional": false,
"relevance": 0.3521,
"matchedBy": "fulltext",
"createdAt": "2026-04-15T08:00:00.000Z"
}
],
"total": 1,
"matchedBy": "fulltext"
}200 OK — Fuzzy (trigram) fallback:
{
"data": [
{
"id": "8a17d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "Pre-Paid Data Pack 20GB",
"description": "20GB data with free STC WiFi",
"dataAmountMb": 20480,
"voiceMinutes": 100,
"smsCount": 50,
"validityDays": 30,
"priceSar": 79.99,
"isPromotional": false,
"matchedBy": "fuzzy",
"createdAt": "2026-04-15T08:00:00.000Z"
}
],
"total": 1,
"matchedBy": "fuzzy"
}Fuzzy results do not include a
relevancefield. ThematchedByfield indicates the search strategy used.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Missing q parameter or invalid filter values |
GET /packages/compare
Side-by-side comparison of 2–5 packages. Includes computed pricePerGb for easy comparison.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
ids | string | Yes | Comma-separated package UUIDs (2–5 IDs) |
Example Requests
GET /packages/compare?ids=9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e,8a17d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e3-way comparison:
GET /packages/compare?ids=id1,id2,id3Response
200 OK
{
"data": [
{
"id": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"name": "STC 50 SAR Plan",
"description": "5GB data + 60 min voice",
"operatorName": "Saudi Telecom Company",
"dataAmountMb": 5120,
"voiceMinutes": 60,
"smsCount": 50,
"validityDays": 30,
"priceSar": 49.99,
"pricePerGb": 9.76
},
{
"id": "7f16e5b3-2d6f-4c1a-9e8d-5b7a6c4d3e2f",
"name": "Mobily 50 SAR Plan",
"description": "6GB data + 45 min voice",
"operatorName": "Mobily",
"dataAmountMb": 6144,
"voiceMinutes": 45,
"smsCount": 30,
"validityDays": 30,
"priceSar": 49.99,
"pricePerGb": 8.14
}
]
}
operatorNameandname/descriptionare localized based onAccept-Language.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Fewer than 2 or more than 5 IDs provided |
Validation error example:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Please provide 2 to 5 package IDs for comparison"
}
}GET /packages/:id
Single package detail including localized display fields, raw bilingual fields for edit forms, customization flags, B2B pricing, and operator information.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the package |
Example Request
GET /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1eResponse
200 OK
{
"data": {
"id": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "STC 50 SAR Plan",
"description": "5GB data + 60 min voice",
"nameEn": "STC 50 SAR Plan",
"nameAr": "باقة STC ٥٠ ريال",
"descriptionEn": "5GB data + 60 min voice",
"descriptionAr": "٥ جيجا بيانات + ٦٠ دقيقة صوت",
"dataAmountMb": 5120,
"voiceMinutes": 60,
"smsCount": 50,
"validityDays": 30,
"priceSar": 49.99,
"priceSarB2b": 44.99,
"isActive": true,
"isPromotional": false,
"isNameCustomized": false,
"isPriceCustomized": false,
"operatorOriginal": {
"nameEn": "STC 50 SAR Plan",
"nameAr": "باقة STC ٥٠ ريال",
"descriptionEn": "5GB data + 60 min voice",
"descriptionAr": "٥ جيجا بيانات + ٦٠ دقيقة صوت",
"priceSar": 49.99
},
"operator": {
"name": "Saudi Telecom Company",
"slug": "stc"
},
"createdAt": "2026-04-15T08:00:00.000Z",
"updatedAt": "2026-04-15T08:00:00.000Z"
}
}
name,description, and operatornameare localized based onAccept-Language.nameEn,nameAr,descriptionEn, anddescriptionArare raw bilingual fields for admin editing.priceSarB2bisnullfor packages without B2B pricing.operatorOriginalholds the operator's current value for each customizable field — present even when a field is customized, so the editor can show what an override replaced and what a revert restores. Its fields arenulluntil the first sync. It is returned only to authenticatedsuper_admincallers (it reveals the pre-markup price) and is omitted from the public response — the example above shows the admin view.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid package ID |
PACKAGE_NOT_FOUND | 404 | Package not found or not active |
PATCH /packages/:id
Update package customization fields. Requires authentication and super_admin role.
This endpoint edits only fields that are safe for super-admin customization. Operator-owned package spec fields such as data amount, minutes, SMS count, validity, operator ID, and promotional state are not editable here. isActive is editable (sets isActiveCustomized — the sync's CASE guard preserves admin deactivations and reactivations). Every successful update writes an immutable audit entry with actor, client metadata, requested fields, and before/after customization values.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the package |
Request Body
| Field | Type | Description |
|---|---|---|
nameEn | string | English package name. Sets isNameCustomized to true. |
nameAr | string | Arabic package name. Sets isNameCustomized to true. |
descriptionEn | string | null | English package description. Sets isNameCustomized to true. |
descriptionAr | string | null | Arabic package description. Sets isNameCustomized to true. |
priceSar | number | Retail price. Sets isPriceCustomized to true. At most 99,999,999.99 (the numeric(10,2) column ceiling). |
priceSarB2b | number | null | B2B price. Does not use a customization flag because operator sync does not overwrite it. At most 99,999,999.99 (the numeric(10,2) column ceiling). |
isNameCustomized | false | Clear text customization ownership. Operator text values return on the next package sync. |
isPriceCustomized | false | Clear retail price customization ownership. Operator price returns on the next package sync. |
isActive | boolean | Set the active status. Sets isActiveCustomized to true so the package sync never overrides this decision. |
isActiveCustomized | false | Clear active-status customization ownership. The next package sync may reactivate or deactivate the row. |
The request body must include at least one field. Unknown fields are rejected.
Do not combine a field edit with clearing its matching flag in the same request. For example, nameEn with isNameCustomized: false is rejected because it is ambiguous.
Example Requests
Customize bilingual text:
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json
{
"nameEn": "Skyte STC 50 SAR Plan",
"nameAr": "باقة سكايتي STC ٥٠ ريال",
"descriptionEn": "5GB data + 60 min voice",
"descriptionAr": "٥ جيجا بيانات + ٦٠ دقيقة صوت"
}Customize retail price:
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json
{
"priceSar": 47.99
}Set or clear B2B price:
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json
{
"priceSarB2b": null
}Revert to operator-owned values on next sync:
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json
{
"isNameCustomized": false,
"isPriceCustomized": false
}Reactivate a previously-deactivated package:
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json
{
"isActive": true
}Setting isActive also sets isActiveCustomized to true so the next sync does not revert the reactivation. To hand ownership back to the sync engine, send isActiveCustomized: false in a separate request. The PATCH endpoint works on inactive packages (unlike GET /packages/:id which returns 404 for inactive rows).
Response
200 OK
Returns the same expanded detail shape as GET /packages/:id after the update.
{
"data": {
"id": "9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e",
"operatorId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "Skyte STC 50 SAR Plan",
"description": "5GB data + 60 min voice",
"nameEn": "Skyte STC 50 SAR Plan",
"nameAr": "باقة سكايتي STC ٥٠ ريال",
"descriptionEn": "5GB data + 60 min voice",
"descriptionAr": "٥ جيجا بيانات + ٦٠ دقيقة صوت",
"dataAmountMb": 5120,
"voiceMinutes": 60,
"smsCount": 50,
"validityDays": 30,
"priceSar": 47.99,
"priceSarB2b": null,
"isActive": true,
"isPromotional": false,
"isNameCustomized": true,
"isPriceCustomized": true,
"operatorOriginal": {
"nameEn": "STC 50 SAR Plan",
"nameAr": "باقة STC ٥٠ ريال",
"descriptionEn": "5GB data + 60 min voice",
"descriptionAr": "٥ جيجا بيانات + ٦٠ دقيقة صوت",
"priceSar": 49.99
},
"operator": {
"name": "Saudi Telecom Company",
"slug": "stc"
},
"createdAt": "2026-04-15T08:00:00.000Z",
"updatedAt": "2026-04-15T08:05:00.000Z"
}
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid package ID, empty body, unknown field, non-editable field, or edit-plus-revert conflict |
AUTH_REQUIRED | 401 | Missing or invalid authentication token |
AUTH_FORBIDDEN | 403 | Authenticated user is not super_admin |
PACKAGE_NOT_FOUND | 404 | Package not found or not active |
GET /packages/:id/availability
Real-time availability check via the operator plugin. Results are cached in Redis for 30 seconds.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | UUID of the package |
Example Request
GET /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e/availabilityResponse
200 OK — Available:
{
"data": {
"available": true,
"estimatedActivationMinutes": 5,
"degraded": false
},
"cached": false
}200 OK — Cached result:
{
"data": {
"available": true,
"estimatedActivationMinutes": 5,
"degraded": false
},
"cached": true
}200 OK — Out of stock:
{
"data": {
"available": false,
"reason": "out_of_stock",
"degraded": false
}
}200 OK — Operator unavailable (degraded mode):
{
"data": {
"available": null,
"reason": "operator_unavailable",
"degraded": true
}
}200 OK — Operator health check failed:
{
"data": {
"available": null,
"reason": "unavailable",
"degraded": true
}
}When
degraded: true, the availability is unknown (available: null) because the operator API is not reachable.
Error Responses
| Code | Status | Condition |
|---|---|---|
PACKAGE_NOT_FOUND | 404 | Package not found |