Skip to content

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 ​

ParameterTypeDefaultDescription
operatorIdstring—Filter by operator UUID
dataMinMbinteger—Minimum data amount in MB
voiceMinMinutesinteger—Minimum voice minutes
validityDaysinteger—Exact validity in days
isActiveboolean—Only active packages (default: only active)
isPromotionalboolean—Only promotional packages

Example Requests ​

All packages:

GET /packages

Filter by operator:

GET /packages?operatorId=f47ac10b-58cc-4372-a567-0e02b2c3d479

Filter by minimum data and promotional:

GET /packages?dataMinMb=5000&isPromotional=true

Combined filters:

GET /packages?operatorId=f47ac10b-58cc-4372-a567-0e02b2c3d479&dataMinMb=5000&voiceMinMinutes=100&validityDays=30

Response ​

200 OK

json
{
  "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 on Accept-Language. When Accept-Language: ar, the response returns Arabic names and descriptions.

Arabic response (Accept-Language: ar):

json
{
  "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
}

Full-text search across package names and descriptions. Uses a two-phase search strategy:

  1. Primary: PostgreSQL tsquery full-text search using the search_vector column, ranked by ts_rank
  2. Fallback: If full-text returns no results, trigram similarity (% operator) on nameEn, nameAr, descriptionEn, descriptionAr, ordered by similarity()

Query Parameters ​

ParameterTypeRequiredDefaultDescription
qstringYes—Search query (1–200 characters)
operatorIdstringNo—Filter by operator UUID
dataMinMbintegerNo—Minimum data amount in MB
voiceMinMinutesintegerNo—Minimum voice minutes
validityDaysintegerNo—Exact validity in days
isPromotionalbooleanNo—Only promotional packages

Results are limited to 20 items.

Example Requests ​

Simple search:

GET /packages/search?q=prepaid+data

Search with filters:

GET /packages/search?q=prepaid+data&operatorId=f47ac10b-58cc-4372-a567-0e02b2c3d479&dataMinMb=5000

Response ​

200 OK — Full-text match:

json
{
  "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:

json
{
  "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 relevance field. The matchedBy field indicates the search strategy used.

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Missing 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 ​

ParameterTypeRequiredDescription
idsstringYesComma-separated package UUIDs (2–5 IDs)

Example Requests ​

GET /packages/compare?ids=9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e,8a17d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e

3-way comparison:

GET /packages/compare?ids=id1,id2,id3

Response ​

200 OK

json
{
  "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
    }
  ]
}

operatorName and name/description are localized based on Accept-Language.

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Fewer than 2 or more than 5 IDs provided

Validation error example:

json
{
  "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 ​

ParameterTypeDescription
idstringUUID of the package

Example Request ​

GET /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e

Response ​

200 OK

json
{
  "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 operator name are localized based on Accept-Language. nameEn, nameAr, descriptionEn, and descriptionAr are raw bilingual fields for admin editing. priceSarB2b is null for packages without B2B pricing. operatorOriginal holds 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 are null until the first sync. It is returned only to authenticated super_admin callers (it reveals the pre-markup price) and is omitted from the public response — the example above shows the admin view.

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Invalid package ID
PACKAGE_NOT_FOUND404Package 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 ​

ParameterTypeDescription
idstringUUID of the package

Request Body ​

FieldTypeDescription
nameEnstringEnglish package name. Sets isNameCustomized to true.
nameArstringArabic package name. Sets isNameCustomized to true.
descriptionEnstring | nullEnglish package description. Sets isNameCustomized to true.
descriptionArstring | nullArabic package description. Sets isNameCustomized to true.
priceSarnumberRetail price. Sets isPriceCustomized to true. At most 99,999,999.99 (the numeric(10,2) column ceiling).
priceSarB2bnumber | nullB2B 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).
isNameCustomizedfalseClear text customization ownership. Operator text values return on the next package sync.
isPriceCustomizedfalseClear retail price customization ownership. Operator price returns on the next package sync.
isActivebooleanSet the active status. Sets isActiveCustomized to true so the package sync never overrides this decision.
isActiveCustomizedfalseClear 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:

http
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:

http
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "priceSar": 47.99
}

Set or clear B2B price:

http
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "priceSarB2b": null
}

Revert to operator-owned values on next sync:

http
PATCH /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "isNameCustomized": false,
  "isPriceCustomized": false
}

Reactivate a previously-deactivated package:

http
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.

json
{
  "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 ​

CodeStatusCondition
VALIDATION_ERROR400Invalid package ID, empty body, unknown field, non-editable field, or edit-plus-revert conflict
AUTH_REQUIRED401Missing or invalid authentication token
AUTH_FORBIDDEN403Authenticated user is not super_admin
PACKAGE_NOT_FOUND404Package 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 ​

ParameterTypeDescription
idstringUUID of the package

Example Request ​

GET /packages/9b18d6a4-3e7f-4a2b-8c5d-6f7a8b9c0d1e/availability

Response ​

200 OK — Available:

json
{
  "data": {
    "available": true,
    "estimatedActivationMinutes": 5,
    "degraded": false
  },
  "cached": false
}

200 OK — Cached result:

json
{
  "data": {
    "available": true,
    "estimatedActivationMinutes": 5,
    "degraded": false
  },
  "cached": true
}

200 OK — Out of stock:

json
{
  "data": {
    "available": false,
    "reason": "out_of_stock",
    "degraded": false
  }
}

200 OK — Operator unavailable (degraded mode):

json
{
  "data": {
    "available": null,
    "reason": "operator_unavailable",
    "degraded": true
  }
}

200 OK — Operator health check failed:

json
{
  "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 ​

CodeStatusCondition
PACKAGE_NOT_FOUND404Package not found

Internal documentation - Activation System