Catalog
Discover the destinations and plans your account can sell.
GET /v1/catalog
Returns the list of plans available to your account. Supports filters and pagination.
Query parameters
scope—country,regional, orglobal.type—unlimited·fixed(data-only) ·calling(voice + data). Alias:data→fixed.country— ISO-2 country code (filters when scope=country).region— region code (filters when scope=regional).search— free-text match on plan name.page(default 1) +per_page(default 1000, max 1000) — page-based pagination.limit/offset— equivalent offset-based pagination (still supported).updated_since(aliassince) — ISO-8601 timestamp or Unix seconds. Returns only plans whoseupdated_atis at or after that moment.
The full catalog is large (18,000+ plans). Walk every page with page=1,2,3… until meta.current_page reaches meta.last_page (or follow links.next until it is null). A full ingest at per_page=1000 is ~18 requests, well inside the 100 req/min rate limit.
# Full catalog ingest
curl 'https://portal.esimerge.com/api/public/v1/catalog?page=1&per_page=1000' -H 'Authorization: Bearer esm_sk_live_...'
# then page=2, page=3 ... until links.next is nullCopy-paste pagination loops
Each example walks links.next from page 1 until it is null and prints a final summary.
#!/usr/bin/env bash
# Full 18k-plan ingest with curl + jq. Writes catalog.json.
KEY="esm_sk_live_..."
URL="https://portal.esimerge.com/api/public/v1/catalog?page=1&per_page=1000"
echo '[]' > catalog.json
pages=0
while [ -n "$URL" ] && [ "$URL" != "null" ]; do
body=$(curl -sS "$URL" -H "Authorization: Bearer $KEY")
jq -s '.[0] + .[1].data' catalog.json <(echo "$body") > catalog.tmp && mv catalog.tmp catalog.json
pages=$((pages+1))
echo "page $(echo "$body" | jq -r '.meta.current_page')/$(echo "$body" | jq -r '.meta.last_page')"
URL=$(echo "$body" | jq -r '.links.next')
done
echo "Imported $(jq 'length' catalog.json) plans across $pages pages"Incremental sync — updated_since
After a first full ingest, fetch only what changed. Pass updated_since (alias since) as an ISO-8601 timestamp or Unix seconds; the API returns plans whose updated_at is at or after that moment, paginated exactly the same way. Store the timestamp you started the run at and reuse it next time — that avoids missing plans changed mid-run. Every plan object now carries updated_at, and the response echoes the normalised updated_since you sent.
# Only plans changed since 1 July 2026
curl 'https://portal.esimerge.com/api/public/v1/catalog?updated_since=2026-07-01T00:00:00Z&page=1&per_page=1000' \
-H 'Authorization: Bearer esm_sk_live_...'
# Unix seconds work too
curl 'https://portal.esimerge.com/api/public/v1/catalog?since=1782000000' -H 'Authorization: Bearer esm_sk_live_...'// Node: nightly delta sync
const cursor = await loadCursor(); // ISO string from the previous run
const runStartedAt = new Date().toISOString(); // save this only after a clean run
let url = `https://portal.esimerge.com/api/public/v1/catalog?updated_since=${encodeURIComponent(cursor)}&per_page=1000`;
let changed = 0;
while (url) {
const body = await (await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } })).json();
await upsertPlans(body.data);
changed += body.data.length;
url = body.links.next;
}
await saveCursor(runStartedAt);
console.log(`${changed} plans changed since ${cursor}`);curl 'https://portal.esimerge.com/api/public/v1/catalog?scope=country&country=SA' \
-H 'Authorization: Bearer esm_sk_test_...'Example response
{
"object": "list",
"count": 1,
"data": [
{
"id": "esm_plan_sa_5gb_30d",
"object": "plan",
"name": "Saudi Arabia 5GB / 30 days",
"scope": "country",
"type": "data",
"country_code": "SA",
"country_name": "Saudi Arabia",
"region_code": null,
"data_mb": 5120,
"minutes": 0,
"sms": 0,
"validity_days": 30,
"quantity": 1,
"price_sar": 35.00,
"price_usd": 9.33,
"networks": ["STC", "Mobily", "Zain"],
"coverage": [
{ "country_code": "SA", "country_name": "Saudi Arabia", "networks": ["STC", "Mobily", "Zain"] }
],
"coverage_count": 1,
"socials": {
"tiktok": { "ios": true, "android": false },
"facebook": { "ios": true, "android": true },
"x": { "ios": true, "android": true },
"snapchat": { "ios": true, "android": true },
"google": { "ios": true, "android": true },
"telegram": { "ios": true, "android": true },
"chatgpt": { "ios": true, "android": true },
"gemini": { "ios": true, "android": true },
"claude": { "ios": true, "android": true }
}
}
],
"total": 18432,
"meta": {
"current_page": 1,
"per_page": 1000,
"last_page": 19,
"total": 18432,
"from": 1,
"to": 1000
},
"links": {
"first": "https://portal.esimerge.com/api/public/v1/catalog?page=1&per_page=1000",
"prev": null,
"next": "https://portal.esimerge.com/api/public/v1/catalog?page=2&per_page=1000",
"last": "https://portal.esimerge.com/api/public/v1/catalog?page=19&per_page=1000"
},
"limit": 1000,
"offset": 0,
"has_more": true,
"next_offset": 1000
}GET /v1/catalog/{id}
Fetch a single plan by id.
curl https://portal.esimerge.com/api/public/v1/catalog/esm_plan_sa_5gb_30d \
-H 'Authorization: Bearer esm_sk_test_...'Plan fields
| Field | Type | Description |
|---|---|---|
id | string | Plan identifier (use to issue orders). Prefix: esm_plan_ |
name | string | Display name in English. |
scope | string | country | regional | global |
type | string | data | data_voice | data_voice_sms |
country_code | string? | ISO-2 code when scope=country. |
country_name | string? | Country name when scope=country. |
region_code | string? | Region code when scope=regional (e.g. EU, GCC). |
data_mb | number | Data allowance in megabytes (0 = unlimited). |
minutes | number | Bundled voice minutes (0 if not included). |
sms | number | Bundled SMS count (0 if not included). |
validity_days | number | Validity from activation, in days. |
quantity | number | How many eSIMs can be issued in one order (usually 1). |
price_sar | number | Partner price in USD. |
price_usd | number | Partner price in USD. |
networks | string[] | Flat list of every mobile network reachable on this plan, across all covered countries. |
coverage | object[] | Countries included in the plan: [{ country_code, country_name, networks[] }]. One entry for country plans; every covered country for regional and global plans. |
coverage_count | number | Number of countries in coverage. |
fair_usage | string? | Free-form fair-usage policy text. |
socials | object? | Destination-wide social app support, split per device platform. Keys: tiktok, facebook, x, snapchat, google, telegram, chatgpt, gemini, claude — each an object { ios: boolean, android: boolean }. null when no data is available for the destination. |
supports_topup | boolean | true when the plan can be extended with top-ups (extra data / extra days). When false, the top-up endpoints return topup_not_supported. |