Tổng Kho eSIM
OpenAPI 2.4.0 · 30 operations

API Reference

Complete /v1 API surface. This page renders directly from the OpenAPI specification generated during backend builds — paths, parameters, schemas, enums, and error codes match live server behavior with zero manual discrepancies.

Live

https://api.tongkhoesim.com/v1

Test mode

https://api-testing.tongkhoesim.com/v1

Live: api.tongkhoesim.com / tke_live_ (partners) or tke_member_live_ (approved members). Test: api-testing.tongkhoesim.com / tke_test_ / tke_member_test_ (partners / members).

Universal API Conventions

The following 6 rules apply to all 30 operations; individual endpoint descriptions do not restate them.

Authentication

Every /v1 endpoint (including GET) signs with HMAC-SHA256 using 4 headers: X-TKE-Key · X-TKE-Timestamp · X-TKE-Nonce · X-TKE-Signature. No unauthenticated bypasses.

Envelope
Success { ok: true, data }, Error { ok: false, error: { code, message, field?, meta?, request_id } } with semantic HTTP statuses. Never 200 for errors; do not branch on body when status suffices.
Data Types
Currency is integer VND (BIGINT, no decimals, no currency symbols). Data volumes are in bytes EVERYWHERE, catalog included (data.total_bytes / data.daily_bytes). Timestamps are ISO-8601 UTC; convert to your local timezone upon presentation.
Rate Limiting
429 responses include Retry-After along with X-RateLimit-Limit / Remaining / Reset. Read headers rather than guessing: limits are configurable per key.
request_id
Every response carries X-Request-Id, and error envelopes mirror it in error.request_id. Always log this value: it is the sole key for tracing backend requests when submitting support tickets.
Idempotency-Key

Same key + same body within 24h = replays original result without re-charging. Scoped per api_key_id. Mandatory on exactly 5 operations: POST /v1/orders, POST /v1/orders/bulk, POST /v1/esims/{id}/cancel, POST /v1/esims/{id}/change-plan, POST /v1/esims/{id}/refund-requests.

Pagination: Two Distinct Paradigms

Do not write a unified pagination helper for both: they return different response shapes and follow different termination semantics.

Offset · page / page_size

Used in exactly one endpoint: GET /v1/webhooks/deliveries. Returns page, page_size, total.

Requests exceeding bounds return items: [] + actual total with HTTP 200, not 404. Stop when items is empty or total items have been processed.

Cursor · cursor / next_cursor

Used in GET /v1/catalog/packages, GET /v1/orders, GET /v1/esims, and GET /v1/esims/{id}/plan-options. GET /v1/orders and GET /v1/esims support pull reconciliation via updated_since; catalog and plan-options do not.

Stop when next_cursor is ABSENT or EQUAL to the current cursor. This field may be omitted entirely in data (type is string | null | undefined). Missing this check creates infinite loops.

Why invalid cursors return HTTP 200, and the subtle trap

Malformed cursors do not error: the server silently resets to the head of the list to avoid breaking existing clients during encoding upgrades. The trade-off is that loops checking next_cursor !== null will spin endlessly on page 1 instead of stopping.

javascript
let cursor = null;
const seen = new Set();

while (true) {
  const params = new URLSearchParams({ page_size: "200" });
  if (cursor) params.set("cursor", cursor);

  // callSigned = your helper adding 4 X-TKE-* headers (see HMAC page).
  const page = await callSigned("GET", `/v1/orders?${params}`);
  for (const order of page.data.items) await handle(order);

  const next = page.data.next_cursor;
  if (!next || next === cursor || seen.has(next)) break;
  seen.add(next);
  cursor = next;
}

Catalog

The package catalog: ONE surface for both supply systems. The supply system is a FIELD (`market`: `domestic` | `international`) and a filter (`?market=`), no longer two separate paths. The catalog carries NO stock concept (no figure, no `available`/`low`/`out_of_stock` level): packages simply appear here, and ordering one the supplier cannot fulfill returns a clear error from `POST /v1/orders` (`esim.out_of_stock`…) instead of the catalog announcing it upfront. One cursor pagination scheme. `total` is present only when counting is cheap (filtering to the domestic system); otherwise it is `null`.

GET/v1/catalog/packages

List packages

scope catalog:readget_catalog_packages

Required scope: catalog:read.

Your organization's package catalog: ONE surface for both supply systems, filtered with ?market=.

It only returns packages you can ACTUALLY BUY: published, still distributed by the supplier, within your key's catalog restrictions, AND already priced for your organization. An unpriced package does not appear, not to hide it but because you could not order it.

Cursor pagination: call it first without cursor, then pass next_cursor back verbatim until it is null. A page SHORTER than page_size does NOT mean the end (see the cursor parameter).

capabilities comes from each package's REAL declaration, never from a guess: a package with nothing declared reports every flag as false. We would rather omit a button you could have used than promise an endpoint that is certain to return 422.

The catalog carries NO stock concept: no figure, no in-stock/sold-out level. Packages simply appear here; ordering one the supplier cannot fulfill returns a clear error right at order time from POST /v1/orders (esim.out_of_stock…), instead of making you infer it from a field shown beforehand.

Query Parameters (18)

page_sizeintegeroptionaldefault 50min 1max 100

Records per page. Default 50, capped at 100 — sending a larger value is clamped to 100 (NOT an error).

marketstringoptional

Supply system: domestic | international. A value outside the list returns internal.invalid_filter (400) rather than the whole catalog: one typo must not read as "this filter excludes nothing".

countrystringoptional

Country code (ISO-3166 alpha-2, e.g. VN). Only packages covering this country.

regionstringoptional

Region slug (e.g. chau-a). Packages covering at least one country of that region, plus every global package.

qstringoptional

Free-text search over the public code and the package name (partial, case-insensitive).

tagstringoptional

Filter by ONE commercial tag. Tags are normalized exactly as they were stored (extra spaces removed, lowercased, hyphenated), so Ban Le and ban-le are the same tag.

data_modelstringoptional

Data model: daily | fixed | unlimited. REPLACES the old supports_topup. An unknown value returns internal.invalid_filter (400).

data_min_bytesinteger · int64optional

Minimum data allowance in BYTES. Per-day packages are compared on their PER-DAY allowance; unlimited packages ALWAYS pass a lower bound.

data_max_bytesinteger · int64optional

Maximum data allowance in BYTES. Unlimited packages are EXCLUDED: a data ceiling cannot apply to an unlimited package, and letting it through would hand an unlimited plan to someone looking for the smallest one.

days_mininteger · int64optional

Minimum validity in days.

days_maxinteger · int64optional

Maximum validity in days.

price_mininteger · int64optional

Minimum price (VND), compared against YOUR ORGANIZATION'S price. This filter runs AFTER the server has cut the page, so it can shorten a page without meaning the data ran out.

price_maxinteger · int64optional

Maximum price (VND), same rules as price_min.

network_genstringoptional

Only packages that run on this network generation (2g | 3g | 4g | 5g). An unknown value returns internal.invalid_filter (400).

hotspotstringoptional
Allowed values:truefalse

true = only packages DECLARED to have Wi-Fi tethering; false = only those DECLARED not to. A package with nothing declared (features.hotspot = null) matches NEITHER side — the null rule applies to filters exactly as it does to responses. Accepts only the two strings true/false.

appstringoptional

Only packages DECLARED to run this app on at least one operating system (app=tiktok). A package that declares nothing about that app does NOT match — silence is not a promise. Only tiktok is accepted today; any other code returns internal.invalid_filter (400).

voicestringoptional
Allowed values:truefalse

true = only packages DECLARED to have voice; false = only those DECLARED not to. A package with nothing declared matches neither side. Accepts only the two strings true/false.

cursorstringoptional

Cursor for the next page: pass the previous page's next_cursor back VERBATIM.

Stop when next_cursor is null; do NOT stop on a short page. Three gates run AFTER the server has cut the page (your key's catalog restrictions, the package must be priced for your organization, and the price-range filter), so a page returning 3 items may still have thousands of packages behind it.

The cursor carries a FINGERPRINT of the filters that produced it. Sending it alongside different filters (or sending a corrupted string) returns catalog.cursor_mismatch (400) instead of quietly resuming a different query: stitching two pages from two filter sets together produces a catalog that never existed, and no layer can detect that afterwards.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
itemsobject[]
—
capabilitiesobject

What this package can do AFTER the sale. Read from each package's REAL declaration, never inferred from the supplier type: a package with nothing declared reports every flag as false. We would rather omit a button you could have used than promise an endpoint that is certain to return 422 capability.not_supported.

cancelobject
—
restrictionstring
nullable

code_not_disclosed = cancellation is only possible while NOBODY has seen the activation code (not sent to the customer, not read through the dashboard or through ?include=lpa). This is the REAL condition on the cancel path. It says nothing about whether the customer has installed the eSIM — the system cannot know that, and the old label (unused_only) promised exactly that wrong thing.

code_not_disclosednull
supportedboolean
—
plan_changeobject
—
policyobject
nullable

How TOPPING UP A NEW PLAN works at this supplier. null = the package cannot be topped up, OR we have not measured that supplier's policy. null says "do not assume"; an object of all-false would say "we guarantee nothing gets cancelled", and those are two very different statements.

……

Deeper nested fields: see the JSON example.

supportedboolean
—
usage_checkobject
—
supportedboolean
—
codestring

The package code from YOUR catalog, i.e. the public_sku. IMMUTABLE per package (it does not change when we switch the supplier behind it), so you can safely use it as a reconciliation key. The example value here has the right FORMAT but will NOT work: fetch a real code from GET /v1/catalog/packages.

coverageobject

Where the package works.

countriesstring[]

ISO-3166 alpha-2 codes. Empty for global packages (we do not enumerate 200 countries) and for packages with no declared coverage.

country_countinteger

How many countries the package covers — so you can sort or filter on "multi-country packages" without fetching everything and counting the array yourself.

groupstring

Whether the package covers a list of countries (country), a region (region), or the whole world (global). OPEN ENUM: clients MUST ignore unknown values.

regionstring
nullable

ALWAYS null today: regions are attached to COUNTRIES, not to packages, so any value here would be an inference. Filtering by region still works through the ?region= parameter.

dataobject

The package's data allowance. EVERY figure is in BYTES (not MB/GB — it avoids rounding twice).

daily_bytesinteger · int64
nullable

PER-DAY data allowance, in bytes. null for fixed/unlimited packages.

labelstring

A human-readable label, generated by the same function every one of our screens uses.

modelstring

How the allowance is counted: daily (per-day allowance) | fixed (total for the whole period) | unlimited. OPEN ENUM — clients MUST ignore unknown values.

dailyfixedunlimited
resetobject
nullable

When the daily allowance resets. A per-day package whose reset point is unknown costs a customer who buys at 22:00 almost the whole first day — this is sales information, not a technical detail. null = not declared.

atstring
nullable

Reset time in the local time of timezone (00:00, 00:00:01).

notestring
nullable
—
timezonestring
nullable

Time zone of the reset point (UTC+8, UTC+7).

total_bytesinteger · int64
nullable

Total allowance for the whole period, in bytes. null for daily/unlimited packages: a per-day allowance times the number of days is NOT a figure the supplier commits to.

descriptionstring
nullable

The sales description of the package. null when nothing has been written yet.

description_enstring
nullable

English description. null when not provided yet.

featuresobject

What the package can and cannot do. A TYPED object rather than an array of codes: [] cannot tell "no hotspot" apart from "nobody declared it", whereas false and null can.

appsobject[]

Per-OPERATING-SYSTEM app status.

An app NOT PRESENT in this array means WE HAVE NO INFORMATION, NOT "it works". Only what is listed is something the supplier commits to; today only TikTok is declared.

There is no combined field, and that is deliberate: measured on the real catalog, 2,465 packages run TikTok on iPhone but NOT on Android. A single combined flag flattens them to "no", and you would then be wrong about nearly a third of the catalog.

androidstring

supported (the supplier declares it works) | blocked (declares it does not) | unknown (the declaration does not cover this OS). OPEN ENUM.

supportedblockedunknown
appstring

App code, lowercase (e.g. tiktok).

iosstring

supported (the supplier declares it works) | blocked (declares it does not) | unknown (the declaration does not cover this OS). OPEN ENUM.

supportedblockedunknown
notestring
nullable
—
hotspotboolean
nullable

Wi-Fi tethering for other devices. null = not declared, DIFFERENT from false.

kyc_requiredboolean
nullable

Identity verification (KYC) is required before use. null = not declared.

smsboolean
nullable

SMS. false = SMS is NOT received, which means bank OTPs are NOT received. null = not declared.

voiceboolean
nullable

Voice calls. false = the supplier declares there is NO voice — a data-only line cannot receive calls, and selling it to someone who needs voice is a guaranteed complaint. null = not declared.

fupobject
nullable

What happens after the high-speed allowance runs out. null = the package declares nothing about throttling.

behaviorstring
nullable

throttle (slowed down) | cutoff (disconnected) | null (not declared). OPEN ENUM.

throttlecutoffnull
high_speed_bytesinteger · int64
nullable

BYTES at full speed before throttling kicks in.

notestring
nullable
—
speed_kbpsinteger
nullable

Speed AFTER the high-speed allowance runs out (kbps). null when the line is cut off or nothing is declared. DIFFERENT from network.speed_cap_kbps, which is the cap for the whole package.

marketstring

The package's SUPPLY SYSTEM, an OPEN ENUM (clients MUST ignore unknown values). This is the axis that decides whether a package has stock behind it and which post-sale operations it has: domestic sells kits and supports top-up, cancel and usage lookup; international is ordered from the supplier on request so it has no stock.

domesticinternational
namestring
—
name_enstring
nullable

English name. null when not provided yet.

networkobject

Which networks the package runs on.

apnstring
nullable

The APN that must be entered by hand on some Android devices that do not pick it up automatically. Without it you cannot build an installation guide, and every such case becomes a support call. null = not declared.

carriersobject[]

The carriers this package RUNS ON. This is an eSIM specification (whose radio it uses), not the supplier.

namestring
—
generationsstring[]
nullable

Network generations the package can use (["4g", "5g"]). null = not declared — NOT "2G only".

speed_cap_kbpsinteger
nullable

Speed cap for the WHOLE package (kbps) — different from fup.speed_kbps, which applies after the high-speed allowance is gone.

priceobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
tagsstring[]

Normalized commercial tags (lowercase, hyphenated). They are also what your key's catalog restrictions match on. NO package carries a tag today, so ?tag= returns an empty list until the seller starts tagging.

validityobject

How long the package lasts, and when the clock starts.

daysinteger
—
install_by_daysinteger
nullable

Number of days to scan the QR / install the eSIM after purchase. null = not declared — NOT "install whenever you like".

labelstring
—
reclaim_on_expiryboolean
nullable

The supplier RECLAIMS the subscription when the plan expires — meaning it can no longer be topped up or changed. If you sell renewals you must know that window. null = not declared.

starts_whenstring
nullable

WHEN the clock starts: on_install (when the customer installs the eSIM) | on_purchase (at purchase) | null.

The HEAVIEST field in this block. If the supplier counts from INSTALL and you do not know it, you will quote the wrong expiry date to EVERY customer. null ⇒ do not promise a specific expiry date.

on_installon_purchasenull
unused_valid_daysinteger
nullable

Number of days the package can sit unused on the shelf before it expires. null = not declared, NOT unlimited. Deliberately separate from install_by_days: one is the INSTALL deadline, the other is the SHELF life.

modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_cursorstring
nullable

Pass it back VERBATIM to fetch the next page. null = NO MORE data, and that is the ONLY stop signal: a page shorter than page_size may still have pages after it.

page_sizeinteger
nullable

The MAXIMUM number of records the server used for this page (already clamped to the cap), not the actual number of elements in items.

totalinteger · int64
nullable

Total packages matching the filters. null when the set is large enough that counting is not worth paying for (the international system, or a call with no market filter). This count runs BEFORE the two application-level gates (your key's catalog restrictions and the package having a price for your organization), so it answers "how many packages match in the catalog", not "how many you can buy".

warningsobject[]

WHY the list looks the way it does. Empty = nothing unusual.

code is a CLOSED set: filtered_by_key_package_filters (your key's catalog restrictions), filtered_by_provider_scope (your organization's provider scope), no_price_for_org (your organization has no price yet), price_filter_removed_rows (the price-range filter), provider_disabled (no supplier can deliver it any more), capability_missing (the package has not declared its post-sale capabilities). count is the number of rows involved, null when it cannot be expressed as a number.

codestring
—
filtered_by_key_package_filtersfiltered_by_provider_scopeno_price_for_orgprice_filter_removed_rowsprovider_disabledcapability_missing
countinteger · int64
nullable
—
messagestring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failedinternal.invalid_filtercatalog.cursor_mismatch
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="GET"
REQ_PATH="/v1/catalog/packages"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "items": [
      {
        "capabilities": {
          "cancel": {
            "restriction": "code_not_disclosed",
            "supported": true
          },
          "plan_change": {
            "policy": {
              "note": "<note>",
              "requires_cancel_first": true,
              "stacking": true
            },
            "supported": true
          },
          "usage_check": {
            "supported": true
          }
        },
        "code": "TKE-VN-30D-X3K9F",
        "coverage": {
          "countries": [
            "CN"
          ],
          "country_count": 1,
          "group": "country",
          "region": "<region>"
        },
        "data": {
          "daily_bytes": 1073741824,
          "label": "1GB/ngày",
          "model": "daily",
          "reset": {
            "at": "00:00",
            "note": "<note>",
            "timezone": "UTC+8"
          },
          "total_bytes": null
        },
        "description": "<description>",
        "description_en": "<description_en>",
        "features": {
          "apps": [
            {
              "android": "blocked",
              "app": "tiktok",
              "ios": "supported",
              "note": "<note>"
            }
          ],
          "hotspot": true,
          "kyc_required": false,
          "sms": false,
          "voice": false
        },
        "fup": {
          "behavior": "throttle",
          "high_speed_bytes": 1048576,
          "note": "<note>",
          "speed_kbps": 128
        },
        "market": "international",
        "name": "eSIM Trung Quốc Mainland Multi - 1GB/ngày - 24 ngày",
        "name_en": "<name_en>",
        "network": {
          "apn": "e-ideas",
          "carriers": [
            {
              "name": "China Unicom"
            }
          ],
          "generations": [
            "4g",
            "5g"
          ],
          "speed_cap_kbps": 1
        },
        "price": {
          "amount": 273000,
          "currency": "VND"
        },
        "tags": [],
        "validity": {
          "days": 24,
          "install_by_days": 365,
          "label": "24 ngày",
          "reclaim_on_expiry": true,
          "starts_when": "on_install",
          "unused_valid_days": 1
        }
      }
    ],
    "mode": "live",
    "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0xMVQwODoxMjozMFoifQ",
    "page_size": 50,
    "total": 128,
    "warnings": []
  },
  "ok": true
}
GET/v1/catalog/packages/{code}

Get one package

scope catalog:readget_catalog_packages__code_

Required scope: catalog:read.

Details of EXACTLY ONE package by its public code. data carries the SAME FIELD SET as an element of GET /v1/catalog/packages, plus mode — no field exists only on the detail endpoint, so you write exactly one type for Package.

A code you cannot see → partner.package_not_visible (404). ONE code for every cause (the code does not exist · it is outside your key's catalog restrictions · your organization has no price for it · the package lost its supply link): being able to tell them apart would hand a stranger a way to enumerate the whole catalog.

Path Parameters (1)

codestringrequired

The package code, i.e. the code you see in the catalog (public_sku). IMMUTABLE per package: it does not change when we switch the supplier behind it, so you can store it as a reconciliation key. A code outside your key's catalog restrictions, or a code your organization has no price for, returns 404 partner.package_not_visible exactly like a code that does not exist.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
partner.package_not_visible
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
CODE="THAY-BANG-CODE"

METHOD="GET"
REQ_PATH="/v1/catalog/packages/$CODE"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": null,
  "ok": true
}

Orders

Place orders + reconcile

GET/v1/orders

List orders (pull-based reconciliation)

scope orders:readget_orders

Required scope: orders:read.

Query Parameters (5)

page_sizeintegeroptionaldefault 50min 1max 200

Records per page. Default 50, capped at 200 — sending a larger value is clamped to 200 (NOT an error).

statusstringoptional
Allowed values:completedpending_providerprovider_failedcancelled

Filter by order status. A value outside the list → internal.invalid_filter (400).

external_refstringoptional

Filter by the reference code you sent at commit time (EXACT match).

updated_sincestring · date-timeoptional

ISO-8601 UTC. Returns every record with updated_at >= updated_since, sorted ascending — this is the standard PULL-BASED RECONCILIATION path (§8.5): even a missed webhook can still be reconstructed from state. Wrong format → internal.invalid_filter (400).

cursorstringoptional

Cursor for the next page — pass back the next_cursor value from the previous page VERBATIM. WARNING: a malformed cursor does NOT raise an error; the server just reads from the START of the list again (a deliberate choice so that changing the cursor encoding scheme never breaks a partner's running reconciliation loop). Consequence to know: an automated reconciliation loop that keeps sending a garbage cursor will loop forever on the first page instead of stopping — check that next_cursor differs from the cursor you just sent before looping again.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
itemsobject[]
—
created_atstring · date-time
—
external_refstring
nullable
—
fulfillment_statusstring
—
fulfilledpartially_fulfilledunfulfilled
idstring · uuid
—
item_countinteger · int64
—
netobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
public_refstring
nullable
—
refundedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
statusstring
—
totalobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
updated_atstring · date-time
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_cursorstring
nullable

Pass it back VERBATIM to fetch the next page. null = NO MORE data, and that is the ONLY stop signal: a page shorter than page_size may still have pages after it.

page_sizeinteger
nullable

The MAXIMUM number of records the server used for this page (already clamped to the cap), not the actual number of elements in items.

totalinteger · int64
nullable

How many rows match your filter. null when counting is too expensive — do not use it to tell whether you have reached the end; next_cursor is that signal.

warningsobject[]

WHY the list looks the way it does. Empty = nothing unusual.

code is a CLOSED set: filtered_by_key_package_filters (your key's catalog restrictions), filtered_by_provider_scope (your organization's provider scope), no_price_for_org (your organization has no price yet), price_filter_removed_rows (the price-range filter), provider_disabled (no supplier can deliver it any more), capability_missing (the package has not declared its post-sale capabilities). count is the number of rows involved, null when it cannot be expressed as a number.

codestring
—
filtered_by_key_package_filtersfiltered_by_provider_scopeno_price_for_orgprice_filter_removed_rowsprovider_disabledcapability_missing
countinteger · int64
nullable
—
messagestring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failedinternal.invalid_filter
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="GET"
REQ_PATH="/v1/orders"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "items": [
      {
        "created_at": "2026-07-11T08:12:30Z",
        "external_ref": "po-1001",
        "fulfillment_status": "fulfilled",
        "id": "018f2b00-0000-7000-8000-00000000009a",
        "item_count": 2,
        "net": {
          "amount": 273000,
          "currency": "VND"
        },
        "public_ref": "TKE-DH-2A4C7Q",
        "refunded": {
          "amount": 273000,
          "currency": "VND"
        },
        "status": "completed",
        "total": {
          "amount": 273000,
          "currency": "VND"
        },
        "updated_at": "2026-07-11T09:03:11Z"
      }
    ],
    "mode": "live",
    "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0xMVQwODoxMjozMFoifQ",
    "page_size": 50,
    "total": 128,
    "warnings": []
  },
  "ok": true
}
POST/v1/orders

Create order (direct purchase)

scope orders:writeIdempotency-Key Requiredpost_orders

Required scope: orders:write.

Headers (1)

Idempotency-Keystringrequired

A client-generated string — the same key + body executes exactly once (a retry returns the original result). Scoped by api_key_id (§11): two different keys = two separate commands.

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
external_refstring
nullable

Your own reference code — echoed in the response and in webhooks.

includestring[]
nullable

Ask for extra blocks in the response: ["lpa"] or ["lpa","qr"]. Omitted ⇒ items[].install is null and EVERY eSIM in the order keeps its right to be cancelled.

RETURNING THE CODE IS DISCLOSING IT: asking for lpa here means accepting that the cancellation window closes at the moment the order is placed.

invoice_buyerobject
nullable

Member keys only. Omitted data uses the consumer flow without an organization or Vietnamese legal entity. Supplied data follows the existing personal validator: full_name, a 12-digit personal tax_code, and address/email under checkout rules. Business invoices are requested after purchase. Test mode only validates the snapshot and never issues MISA invoices.

addressstring
nullable
—
emailstring
nullable
—
full_namestring
nullable
—
legal_namestring
nullable
—
tax_codestring
nullable
—
itemsobject[]
required

List of packages and quantities to buy (maximum 50 eSIMs).

package_codestring
required

The package code from YOUR catalog, i.e. the public_sku. IMMUTABLE per package (it does not change when we switch the supplier behind it), so you can safely use it as a reconciliation key. The example value here has the right FORMAT but will NOT work: fetch a real code from GET /v1/catalog/packages.

quantityinteger
required

Quantity of eSIMs to buy for this package (≥ 1).

Response

200Replayed committed order

A replay of a previously committed command: same Idempotency-Key + same body within 24h. The body is IDENTICAL to the first call, money is NOT charged a second time, and the header Idempotent-Replayed: true is added. A NEW commit returns 201 — don't branch on success by status code.

FieldTypeConstraintsDescription
dataobject
required

Partner order.

created_atstring · date-time
—
external_refstring
nullable
—
fulfillment_statusstring

How much was actually delivered — a SEPARATE axis from status. An order of 3 eSIMs where 2 deliver and 1 fails still has status=completed, but fulfillment_status=partially_fulfilled and refunded_amount > 0.

fulfilledpartially_fulfilledunfulfilled
idstring · uuid

The order ID (UUID).

idempotent_replayboolean
nullable

true if this request was an idempotent replay of an earlier success.

invoice_nostring
nullable

Invoice number once issued.

invoice_statusstring

The order's VAT invoice status. none = not issued yet.

nonependingissuingissuedfailedcancelled
itemsobject[]
—
cancellableobject

Whether this eSIM can still be cancelled, and if not, why — read it BEFORE calling POST /v1/esims/{order_item_id}/cancel. This block is ALWAYS present (never null): it is an answer, not optional data.

allowedboolean
—
disclosed_atstring · date-time
nullable

When the code left our systems (ISO-8601 UTC) — null while nobody has seen it. This is the trace you can follow when a customer claims "I never received the code, why can't it be cancelled".

reasonstring
nullable

Why it cannot be cancelled. code_disclosed = the code has been sent to or shown to someone; wrong_status = the eSIM is not in a cancellable state; expired = the plan has expired; not_supported = the package does not declare a cancel capability. null when allowed = true. Same vocabulary as meta.reason on esim.not_cancellable.

code_disclosedwrong_statusexpirednot_supportednull
external_refstring
nullable

Your own order reference (the external_ref you sent when ordering) — matches the eSIM back to your order. null when the order was placed without one.

iccidstring
nullable
—
idstring · uuid

The eSIM id — used in every /v1/esims/{id}/… path (also the order_item_id in webhooks). If you only have the ICCID, call GET /v1/esims?iccid=… to get the id.

installobject
nullable

Everything needed to INSTALL this eSIM. null = you did NOT ASK for it (no include=lpa) — which is not the same as "this eSIM has no code yet"; in that case the block is present with lpa: null.

A response carrying this block always has Cache-Control: no-store.

android_urlstring
nullable
—
apnstring
nullable

The access point name (APN) to enter when the device does not pick up the configuration automatically. ALWAYS null in the current release — null means "unknown", not "not needed".

ios_urlstring
nullable
—
lpastring
nullable

The eSIM activation code (a sellable secret). This string is all you need to render the QR code on your side.

matching_idstring
nullable
—
qr_code_base64string
nullable

PNG data-URI of the QR code. Only present when you ask for include=qr AND call a DETAIL endpoint — lists NEVER carry it (≈11 KB per row, so a 100-row page is ≈1.1 MB); render the QR from lpa instead.

smdp_addressstring
nullable
—
msisdnstring
nullable
—
order_idstring · uuid
—
package_codestring
nullable

The package CODE from the catalog — the key to map this eSIM back to your catalog. null only when the source package has been removed from the catalog.

package_namestring

The display NAME — a human-readable label that can change. Do NOT use it as a reconciliation key.

planobject
nullable

The plan currently applied to this eSIM.

current_codestring
—
expires_atstring · date-time
nullable
—
started_atstring · date-time
nullable
—
smdp_statusstring
nullable
—
statusstring
—
updated_atstring · date-time
—
usageobject
nullable

eSIM data usage statistics. updated_at = null when never polled.

expires_atstring · date-time
nullable
—
remaining_bytesinteger · int64
nullable
—
sourcestring
nullable
—
pollwebhook
total_bytesinteger · int64
nullable
—
updated_atstring · date-time
nullable
—
used_bytesinteger · int64
nullable
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
money_settledboolean

Whether the order's money has settled (no refund still in progress). false ⇒ an invoice cannot be issued yet.

netobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
public_refstring
nullable

A short, human-readable order code — use it when opening a support ticket.

refundedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
statusstring
—
completedpending_providerprovider_failedcancelled
totalobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
okboolean
required
—
true
201Success

Success

FieldTypeConstraintsDescription
dataobject
required

Partner order.

created_atstring · date-time
—
external_refstring
nullable
—
fulfillment_statusstring

How much was actually delivered — a SEPARATE axis from status. An order of 3 eSIMs where 2 deliver and 1 fails still has status=completed, but fulfillment_status=partially_fulfilled and refunded_amount > 0.

fulfilledpartially_fulfilledunfulfilled
idstring · uuid

The order ID (UUID).

idempotent_replayboolean
nullable

true if this request was an idempotent replay of an earlier success.

invoice_nostring
nullable

Invoice number once issued.

invoice_statusstring

The order's VAT invoice status. none = not issued yet.

nonependingissuingissuedfailedcancelled
itemsobject[]
—
cancellableobject

Whether this eSIM can still be cancelled, and if not, why — read it BEFORE calling POST /v1/esims/{order_item_id}/cancel. This block is ALWAYS present (never null): it is an answer, not optional data.

allowedboolean
—
disclosed_atstring · date-time
nullable

When the code left our systems (ISO-8601 UTC) — null while nobody has seen it. This is the trace you can follow when a customer claims "I never received the code, why can't it be cancelled".

reasonstring
nullable

Why it cannot be cancelled. code_disclosed = the code has been sent to or shown to someone; wrong_status = the eSIM is not in a cancellable state; expired = the plan has expired; not_supported = the package does not declare a cancel capability. null when allowed = true. Same vocabulary as meta.reason on esim.not_cancellable.

code_disclosedwrong_statusexpirednot_supportednull
external_refstring
nullable

Your own order reference (the external_ref you sent when ordering) — matches the eSIM back to your order. null when the order was placed without one.

iccidstring
nullable
—
idstring · uuid

The eSIM id — used in every /v1/esims/{id}/… path (also the order_item_id in webhooks). If you only have the ICCID, call GET /v1/esims?iccid=… to get the id.

installobject
nullable

Everything needed to INSTALL this eSIM. null = you did NOT ASK for it (no include=lpa) — which is not the same as "this eSIM has no code yet"; in that case the block is present with lpa: null.

A response carrying this block always has Cache-Control: no-store.

android_urlstring
nullable
—
apnstring
nullable

The access point name (APN) to enter when the device does not pick up the configuration automatically. ALWAYS null in the current release — null means "unknown", not "not needed".

ios_urlstring
nullable
—
lpastring
nullable

The eSIM activation code (a sellable secret). This string is all you need to render the QR code on your side.

matching_idstring
nullable
—
qr_code_base64string
nullable

PNG data-URI of the QR code. Only present when you ask for include=qr AND call a DETAIL endpoint — lists NEVER carry it (≈11 KB per row, so a 100-row page is ≈1.1 MB); render the QR from lpa instead.

smdp_addressstring
nullable
—
msisdnstring
nullable
—
order_idstring · uuid
—
package_codestring
nullable

The package CODE from the catalog — the key to map this eSIM back to your catalog. null only when the source package has been removed from the catalog.

package_namestring

The display NAME — a human-readable label that can change. Do NOT use it as a reconciliation key.

planobject
nullable

The plan currently applied to this eSIM.

current_codestring
—
expires_atstring · date-time
nullable
—
started_atstring · date-time
nullable
—
smdp_statusstring
nullable
—
statusstring
—
updated_atstring · date-time
—
usageobject
nullable

eSIM data usage statistics. updated_at = null when never polled.

expires_atstring · date-time
nullable
—
remaining_bytesinteger · int64
nullable
—
sourcestring
nullable
—
pollwebhook
total_bytesinteger · int64
nullable
—
updated_atstring · date-time
nullable
—
used_bytesinteger · int64
nullable
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
money_settledboolean

Whether the order's money has settled (no refund still in progress). false ⇒ an invoice cannot be issued yet.

netobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
public_refstring
nullable

A short, human-readable order code — use it when opening a support ticket.

refundedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
statusstring
—
completedpending_providerprovider_failedcancelled
totalobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failedinternal.invalid_filter
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
402
wallet.insufficient_balance
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
partner.package_not_visible
409
esim.out_of_stockidempotency.conflict
422
invoice.buyer_invalid
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="POST"
REQ_PATH="/v1/orders"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"external_ref":"po-1001","include":["lpa"],"invoice_buyer":{"address":"<address>","email":"<email>","full_name":"<full_name>","legal_name":"<legal_name>","tax_code":"TKE-VN-30D-X3K9F"},"items":[{"package_code":"TKE-VN-30D-X3K9F","quantity":2}]}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "created_at": "2026-07-11T08:12:30Z",
    "external_ref": "po-1001",
    "fulfillment_status": "fulfilled",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "idempotent_replay": true,
    "invoice_no": "<invoice_no>",
    "invoice_status": "none",
    "items": [
      {
        "cancellable": {
          "allowed": true,
          "disclosed_at": "2026-07-11T08:12:30Z",
          "reason": "code_disclosed"
        },
        "external_ref": "po-1001",
        "iccid": "8984012345678901234",
        "id": "018f2b00-0000-7000-8000-00000000009a",
        "install": {
          "android_url": "https://esimsetup.google.com/esim_qrcode_provisioning?data=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
          "apn": "<apn>",
          "ios_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
          "lpa": "LPA:1$smdp.example.com$MATCH-ABC123",
          "matching_id": "MATCH-ABC123",
          "qr_code_base64": "<PNG base64 — đã rút gọn>",
          "smdp_address": "smdp.example.com"
        },
        "msisdn": "+84900000000",
        "order_id": "018f2b00-0000-7000-8000-00000000009a",
        "package_code": "TKE-VN-30D-X3K9F",
        "package_name": "Tên gói hiển thị",
        "plan": {
          "current_code": "TKE-VN-30D-X3K9F",
          "expires_at": "2026-08-10T00:00:00Z",
          "started_at": "2026-07-11T08:12:30Z"
        },
        "smdp_status": "released",
        "status": "delivered",
        "updated_at": "2026-07-11T09:03:11Z",
        "usage": {
          "expires_at": "2026-08-10T00:00:00Z",
          "remaining_bytes": 5368709120,
          "source": "poll",
          "total_bytes": 5368709120,
          "updated_at": "2026-07-11T09:03:11Z",
          "used_bytes": 5368709120
        }
      }
    ],
    "mode": "live",
    "money_settled": true,
    "net": {
      "amount": 273000,
      "currency": "VND"
    },
    "public_ref": "TKE-DH-2A4C7Q",
    "refunded": {
      "amount": 273000,
      "currency": "VND"
    },
    "status": "completed",
    "total": {
      "amount": 273000,
      "currency": "VND"
    }
  },
  "ok": true
}

Sample Request Body

json
{
  "external_ref": "po-1001",
  "include": [
    "lpa"
  ],
  "invoice_buyer": {
    "address": "<address>",
    "email": "<email>",
    "full_name": "<full_name>",
    "legal_name": "<legal_name>",
    "tax_code": "TKE-VN-30D-X3K9F"
  },
  "items": [
    {
      "package_code": "TKE-VN-30D-X3K9F",
      "quantity": 2
    }
  ]
}
GET/v1/orders/{id}

Order details + eSIMs

scope orders:readget_orders__id_

Required scope: orders:read.

Path Parameters (1)

idstringrequired

The order ID (UUID).

Query Parameters (1)

includestringoptional

Ask for extra blocks, comma-separated. lpa = return the install block (the eSIM activation code); qr = also return install.qr_code_base64 (detail endpoints only).

RETURNING THE CODE IS DISCLOSING IT. Asking for lpa PERMANENTLY closes the cancellation window of every eSIM in the response (cancellable.allowed becomes false, reason = code_disclosed) — once the code has left our systems it cannot be recalled. Ask for it when you deliver to the customer right away; skip it when you order ahead and deliver later, and you keep the right to cancel. An unknown value → internal.invalid_filter (400, field: "include").

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required

Partner order.

created_atstring · date-time
—
external_refstring
nullable
—
fulfillment_statusstring

How much was actually delivered — a SEPARATE axis from status. An order of 3 eSIMs where 2 deliver and 1 fails still has status=completed, but fulfillment_status=partially_fulfilled and refunded_amount > 0.

fulfilledpartially_fulfilledunfulfilled
idstring · uuid

The order ID (UUID).

idempotent_replayboolean
nullable

true if this request was an idempotent replay of an earlier success.

invoice_nostring
nullable

Invoice number once issued.

invoice_statusstring

The order's VAT invoice status. none = not issued yet.

nonependingissuingissuedfailedcancelled
itemsobject[]
—
cancellableobject

Whether this eSIM can still be cancelled, and if not, why — read it BEFORE calling POST /v1/esims/{order_item_id}/cancel. This block is ALWAYS present (never null): it is an answer, not optional data.

allowedboolean
—
disclosed_atstring · date-time
nullable

When the code left our systems (ISO-8601 UTC) — null while nobody has seen it. This is the trace you can follow when a customer claims "I never received the code, why can't it be cancelled".

reasonstring
nullable

Why it cannot be cancelled. code_disclosed = the code has been sent to or shown to someone; wrong_status = the eSIM is not in a cancellable state; expired = the plan has expired; not_supported = the package does not declare a cancel capability. null when allowed = true. Same vocabulary as meta.reason on esim.not_cancellable.

code_disclosedwrong_statusexpirednot_supportednull
external_refstring
nullable

Your own order reference (the external_ref you sent when ordering) — matches the eSIM back to your order. null when the order was placed without one.

iccidstring
nullable
—
idstring · uuid

The eSIM id — used in every /v1/esims/{id}/… path (also the order_item_id in webhooks). If you only have the ICCID, call GET /v1/esims?iccid=… to get the id.

installobject
nullable

Everything needed to INSTALL this eSIM. null = you did NOT ASK for it (no include=lpa) — which is not the same as "this eSIM has no code yet"; in that case the block is present with lpa: null.

A response carrying this block always has Cache-Control: no-store.

android_urlstring
nullable
—
apnstring
nullable

The access point name (APN) to enter when the device does not pick up the configuration automatically. ALWAYS null in the current release — null means "unknown", not "not needed".

ios_urlstring
nullable
—
lpastring
nullable

The eSIM activation code (a sellable secret). This string is all you need to render the QR code on your side.

matching_idstring
nullable
—
qr_code_base64string
nullable

PNG data-URI of the QR code. Only present when you ask for include=qr AND call a DETAIL endpoint — lists NEVER carry it (≈11 KB per row, so a 100-row page is ≈1.1 MB); render the QR from lpa instead.

smdp_addressstring
nullable
—
msisdnstring
nullable
—
order_idstring · uuid
—
package_codestring
nullable

The package CODE from the catalog — the key to map this eSIM back to your catalog. null only when the source package has been removed from the catalog.

package_namestring

The display NAME — a human-readable label that can change. Do NOT use it as a reconciliation key.

planobject
nullable

The plan currently applied to this eSIM.

current_codestring
—
expires_atstring · date-time
nullable
—
started_atstring · date-time
nullable
—
smdp_statusstring
nullable
—
statusstring
—
updated_atstring · date-time
—
usageobject
nullable

eSIM data usage statistics. updated_at = null when never polled.

expires_atstring · date-time
nullable
—
remaining_bytesinteger · int64
nullable
—
sourcestring
nullable
—
pollwebhook
total_bytesinteger · int64
nullable
—
updated_atstring · date-time
nullable
—
used_bytesinteger · int64
nullable
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
money_settledboolean

Whether the order's money has settled (no refund still in progress). false ⇒ an invoice cannot be issued yet.

netobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
public_refstring
nullable

A short, human-readable order code — use it when opening a support ticket.

refundedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
statusstring
—
completedpending_providerprovider_failedcancelled
totalobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failedinternal.invalid_filter
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
internal.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="GET"
REQ_PATH="/v1/orders/$ID"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "created_at": "2026-07-11T08:12:30Z",
    "external_ref": "po-1001",
    "fulfillment_status": "fulfilled",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "idempotent_replay": true,
    "invoice_no": "<invoice_no>",
    "invoice_status": "none",
    "items": [
      {
        "cancellable": {
          "allowed": true,
          "disclosed_at": "2026-07-11T08:12:30Z",
          "reason": "code_disclosed"
        },
        "external_ref": "po-1001",
        "iccid": "8984012345678901234",
        "id": "018f2b00-0000-7000-8000-00000000009a",
        "install": {
          "android_url": "https://esimsetup.google.com/esim_qrcode_provisioning?data=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
          "apn": "<apn>",
          "ios_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
          "lpa": "LPA:1$smdp.example.com$MATCH-ABC123",
          "matching_id": "MATCH-ABC123",
          "qr_code_base64": "<PNG base64 — đã rút gọn>",
          "smdp_address": "smdp.example.com"
        },
        "msisdn": "+84900000000",
        "order_id": "018f2b00-0000-7000-8000-00000000009a",
        "package_code": "TKE-VN-30D-X3K9F",
        "package_name": "Tên gói hiển thị",
        "plan": {
          "current_code": "TKE-VN-30D-X3K9F",
          "expires_at": "2026-08-10T00:00:00Z",
          "started_at": "2026-07-11T08:12:30Z"
        },
        "smdp_status": "released",
        "status": "delivered",
        "updated_at": "2026-07-11T09:03:11Z",
        "usage": {
          "expires_at": "2026-08-10T00:00:00Z",
          "remaining_bytes": 5368709120,
          "source": "poll",
          "total_bytes": 5368709120,
          "updated_at": "2026-07-11T09:03:11Z",
          "used_bytes": 5368709120
        }
      }
    ],
    "mode": "live",
    "money_settled": true,
    "net": {
      "amount": 273000,
      "currency": "VND"
    },
    "public_ref": "TKE-DH-2A4C7Q",
    "refunded": {
      "amount": 273000,
      "currency": "VND"
    },
    "status": "completed",
    "total": {
      "amount": 273000,
      "currency": "VND"
    }
  },
  "ok": true
}
POST/v1/orders/{id}/resend

Resend the order's email/QR

scope orders:writepost_orders__id__resend

Required scope: orders:write.

Path Parameters (1)

idstringrequired

The order ID (UUID).

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
idstring · uuid

ID of the order that was just re-sent.

modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
resent_atstring · date-time
—
resent_tostring

The recipient address, PARTIALLY MASKED — the partner surface never replays the customer's full email.

okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
409
order.not_deliverable
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="POST"
REQ_PATH="/v1/orders/$ID/resend"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "mode": "live",
    "resent_at": "2026-07-11T08:12:30Z",
    "resent_to": "ab***@x.com"
  },
  "ok": true
}
POST/v1/orders/bulk

Place a multi-line batch (one order per line)

scope orders:writeIdempotency-Key Requiredpost_orders_bulk

Required scope: orders:write.

Place a multi-line batch: each line creates ONE independent order through the same quote→commit path as POST /v1/orders.

PARTIAL SUCCESS: failed lines do not block others, and only successful lines are charged. Business errors (stock, balance, unavailable package, abuse limits) appear in lines[].error without changing the batch HTTP status. If the balance runs out, later lines are still attempted because a cheaper line may succeed.

Status is always 200: the response mixes per-line results rather than creating one resource, and the batch finishes within the request, so neither 201 nor 202 plus polling applies.

Retry or interruption: the SAME Idempotency-Key and SAME body reload the old batch, preserve completed line results, and run only remaining pending lines, without another charge. If no line ran, the response adds Idempotent-Replayed: true. Same key with another body returns idempotency.conflict.

Repeating the same cart within 10 minutes: identical batch content from one organization is treated as an accidental repeat, even with different Idempotency-Key values. The old batch is returned with Idempotent-Replayed: true, without another charge. To intentionally order an identical cart for a second customer, change external_ref: it belongs to the signed body, makes the commands distinct, and identifies each order during reconciliation. A completely failed batch (no orders created) is never blocked, and after 10 minutes a new order is allowed.

Per-line external_ref is echoed in lines[].external_ref; the batch reference is echoed at the response root.

The response contains NO LPA/QR. Fetch eSIMs through GET /v1/orders/{order_id} for each order_id.

Headers (1)

Idempotency-Keystringrequired

A client-generated string — the same key + body executes exactly once (a retry returns the original result). Scoped by api_key_id (§11): two different keys = two separate commands.

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
external_refstring
nullable

Reference code for the WHOLE batch — echoed in the batch response.

itemsobject[]
required

Each element creates ONE independent order. The line-count cap is configurable (default 100) — exceeding it → internal.limit_exceeded (422).

external_refstring
nullable

Reference code for THIS line only — echoed verbatim in the line result.

package_codestring
required

The package code from YOUR catalog, i.e. the public_sku. IMMUTABLE per package (it does not change when we switch the supplier behind it), so you can safely use it as a reconciliation key. The example value here has the right FORMAT but will NOT work: fetch a real code from GET /v1/catalog/packages.

quantityinteger
required
—

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required

Result of a batch order. PARTIAL-SUCCESS: failed lines do not block other lines; money is ONLY charged for done lines. Does not carry LPA/QR — retrieve eSIM via GET /v1/orders/{id} by each order_id.

created_atstring · date-time
—
external_refstring
nullable
—
failed_linesinteger
—
idstring · uuid
—
itemsobject[]
—
chargedobject
nullable
—
errorobject
nullable

A real domain.reason error code (e.g. wallet.insufficient_balance, esim.out_of_stock, order.package_not_allowed, wallet.velocity_exceeded).

codestring
—
messagestring
—
external_refstring
nullable
—
line_nointeger

1-based, in the exact order submitted.

order_idstring · uuid
nullable
—
package_codestring
—
quantityinteger
—
statusstring
—
pendingdonefailed
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
statusstring

pending = the batch still has lines that haven't run (interrupted mid-way) — call again with the SAME Idempotency-Key to finish the rest.

pendingcompletedpartialfailed
succeeded_linesinteger
—
total_chargedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
total_linesinteger
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
partner.package_not_visible
409
idempotency.conflict
422
internal.limit_exceeded
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="POST"
REQ_PATH="/v1/orders/bulk"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"external_ref":"batch-2026-001","items":[{"external_ref":"po-1001","package_code":"TKE-VN-30D-X3K9F","quantity":5}]}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "created_at": "2026-07-11T08:12:30Z",
    "external_ref": "batch-2026-001",
    "failed_lines": 1,
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "items": [
      {
        "charged": null,
        "error": {
          "code": "TKE-VN-30D-X3K9F",
          "message": "<message>"
        },
        "external_ref": "po-1001",
        "line_no": 1,
        "order_id": "018f2b00-0000-7000-8000-00000000009a",
        "package_code": "TKE-VN-30D-X3K9F",
        "quantity": 2,
        "status": "pending"
      }
    ],
    "mode": "live",
    "status": "pending",
    "succeeded_lines": 1,
    "total_charged": {
      "amount": 273000,
      "currency": "VND"
    },
    "total_lines": 1
  },
  "ok": true
}

Sample Request Body

json
{
  "external_ref": "batch-2026-001",
  "items": [
    {
      "external_ref": "po-1001",
      "package_code": "TKE-VN-30D-X3K9F",
      "quantity": 5
    }
  ]
}
GET/v1/orders/bulk/{id}

Result of a batch order

scope orders:readget_orders_bulk__id_

Required scope: orders:read.

Re-read per-line results of a placed bulk batch — recovery path when original response is lost without resending body. Batch belonging to other org or other environment (live ⇄ testmode) → 404.

Path Parameters (1)

idstringrequired

The batch ID (UUID) returned by POST /v1/orders/bulk.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required

Result of a batch order. PARTIAL-SUCCESS: failed lines do not block other lines; money is ONLY charged for done lines. Does not carry LPA/QR — retrieve eSIM via GET /v1/orders/{id} by each order_id.

created_atstring · date-time
—
external_refstring
nullable
—
failed_linesinteger
—
idstring · uuid
—
itemsobject[]
—
chargedobject
nullable
—
errorobject
nullable

A real domain.reason error code (e.g. wallet.insufficient_balance, esim.out_of_stock, order.package_not_allowed, wallet.velocity_exceeded).

codestring
—
messagestring
—
external_refstring
nullable
—
line_nointeger

1-based, in the exact order submitted.

order_idstring · uuid
nullable
—
package_codestring
—
quantityinteger
—
statusstring
—
pendingdonefailed
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
statusstring

pending = the batch still has lines that haven't run (interrupted mid-way) — call again with the SAME Idempotency-Key to finish the rest.

pendingcompletedpartialfailed
succeeded_linesinteger
—
total_chargedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
total_linesinteger
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
internal.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="GET"
REQ_PATH="/v1/orders/bulk/$ID"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "created_at": "2026-07-11T08:12:30Z",
    "external_ref": "batch-2026-001",
    "failed_lines": 1,
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "items": [
      {
        "charged": null,
        "error": {
          "code": "TKE-VN-30D-X3K9F",
          "message": "<message>"
        },
        "external_ref": "po-1001",
        "line_no": 1,
        "order_id": "018f2b00-0000-7000-8000-00000000009a",
        "package_code": "TKE-VN-30D-X3K9F",
        "quantity": 2,
        "status": "pending"
      }
    ],
    "mode": "live",
    "status": "pending",
    "succeeded_lines": 1,
    "total_charged": {
      "amount": 273000,
      "currency": "VND"
    },
    "total_lines": 1
  },
  "ok": true
}

eSIMs

eSIM records, usage, top-up, cancellation

GET/v1/esims

List eSIMs (pull-based reconciliation)

scope esims:readget_esims

Required scope: esims:read.

Query Parameters (8)

page_sizeintegeroptionaldefault 50min 1max 200

Records per page. Default 50, capped at 200 — sending a larger value is clamped to 200 (NOT an error).

statusstringoptional
Allowed values:pendingavailablereservedsolddeliveredin_useused_upexpiredsuspendedcancelledprovider_failed

Filter by esim_status. A value outside the list → internal.invalid_filter (400) — it does NOT return an empty list, so a typo (e.g. canceled missing an l) isn't misread as "this customer has no eSIM in that state".

order_idstring · uuidoptional

Only eSIMs belonging to one order.

iccidstringoptional

Filter by ICCID (string, exact match).

external_refstringoptional

Every eSIM of the order(s) carrying this order reference of yours (the external_ref sent when ordering, exact match).

updated_sincestring · date-timeoptional

ISO-8601 UTC. Returns every record with updated_at >= updated_since, sorted ascending — this is the standard PULL-BASED RECONCILIATION path (§8.5): even a missed webhook can still be reconstructed from state. Wrong format → internal.invalid_filter (400).

cursorstringoptional

Cursor for the next page — pass back the next_cursor value from the previous page VERBATIM. WARNING: a malformed cursor does NOT raise an error; the server just reads from the START of the list again (a deliberate choice so that changing the cursor encoding scheme never breaks a partner's running reconciliation loop). Consequence to know: an automated reconciliation loop that keeps sending a garbage cursor will loop forever on the first page instead of stopping — check that next_cursor differs from the cursor you just sent before looping again.

includestringoptional

Ask for extra blocks, comma-separated. lpa = return the install block (the eSIM activation code); qr = also return install.qr_code_base64 (detail endpoints only).

RETURNING THE CODE IS DISCLOSING IT. Asking for lpa PERMANENTLY closes the cancellation window of every eSIM in the response (cancellable.allowed becomes false, reason = code_disclosed) — once the code has left our systems it cannot be recalled. Ask for it when you deliver to the customer right away; skip it when you order ahead and deliver later, and you keep the right to cancel. An unknown value → internal.invalid_filter (400, field: "include").

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
itemsobject[]
—
cancellableobject

Whether this eSIM can still be cancelled, and if not, why — read it BEFORE calling POST /v1/esims/{order_item_id}/cancel. This block is ALWAYS present (never null): it is an answer, not optional data.

allowedboolean
—
disclosed_atstring · date-time
nullable

When the code left our systems (ISO-8601 UTC) — null while nobody has seen it. This is the trace you can follow when a customer claims "I never received the code, why can't it be cancelled".

reasonstring
nullable

Why it cannot be cancelled. code_disclosed = the code has been sent to or shown to someone; wrong_status = the eSIM is not in a cancellable state; expired = the plan has expired; not_supported = the package does not declare a cancel capability. null when allowed = true. Same vocabulary as meta.reason on esim.not_cancellable.

code_disclosedwrong_statusexpirednot_supportednull
external_refstring
nullable

Your own order reference (the external_ref you sent when ordering) — matches the eSIM back to your order. null when the order was placed without one.

iccidstring
nullable
—
idstring · uuid

The eSIM id — used in every /v1/esims/{id}/… path (also the order_item_id in webhooks). If you only have the ICCID, call GET /v1/esims?iccid=… to get the id.

installobject
nullable

Everything needed to INSTALL this eSIM. null = you did NOT ASK for it (no include=lpa) — which is not the same as "this eSIM has no code yet"; in that case the block is present with lpa: null.

A response carrying this block always has Cache-Control: no-store.

android_urlstring
nullable
—
apnstring
nullable

The access point name (APN) to enter when the device does not pick up the configuration automatically. ALWAYS null in the current release — null means "unknown", not "not needed".

ios_urlstring
nullable
—
lpastring
nullable

The eSIM activation code (a sellable secret). This string is all you need to render the QR code on your side.

matching_idstring
nullable
—
qr_code_base64string
nullable

PNG data-URI of the QR code. Only present when you ask for include=qr AND call a DETAIL endpoint — lists NEVER carry it (≈11 KB per row, so a 100-row page is ≈1.1 MB); render the QR from lpa instead.

smdp_addressstring
nullable
—
msisdnstring
nullable
—
order_idstring · uuid
—
package_codestring
nullable

The package CODE from the catalog — the key to map this eSIM back to your catalog. null only when the source package has been removed from the catalog.

package_namestring

The display NAME — a human-readable label that can change. Do NOT use it as a reconciliation key.

planobject
nullable

The plan currently applied to this eSIM.

current_codestring
—
expires_atstring · date-time
nullable
—
started_atstring · date-time
nullable
—
smdp_statusstring
nullable
—
statusstring
—
updated_atstring · date-time
—
usageobject
nullable

eSIM data usage statistics. updated_at = null when never polled.

expires_atstring · date-time
nullable
—
remaining_bytesinteger · int64
nullable
—
sourcestring
nullable
—
pollwebhook
total_bytesinteger · int64
nullable
—
updated_atstring · date-time
nullable
—
used_bytesinteger · int64
nullable
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_cursorstring
nullable

Pass it back VERBATIM to fetch the next page. null = NO MORE data, and that is the ONLY stop signal: a page shorter than page_size may still have pages after it.

page_sizeinteger
nullable

The MAXIMUM number of records the server used for this page (already clamped to the cap), not the actual number of elements in items.

totalinteger · int64
nullable

How many rows match your filter. null when counting is too expensive — do not use it to tell whether you have reached the end; next_cursor is that signal.

warningsobject[]

WHY the list looks the way it does. Empty = nothing unusual.

code is a CLOSED set: filtered_by_key_package_filters (your key's catalog restrictions), filtered_by_provider_scope (your organization's provider scope), no_price_for_org (your organization has no price yet), price_filter_removed_rows (the price-range filter), provider_disabled (no supplier can deliver it any more), capability_missing (the package has not declared its post-sale capabilities). count is the number of rows involved, null when it cannot be expressed as a number.

codestring
—
filtered_by_key_package_filtersfiltered_by_provider_scopeno_price_for_orgprice_filter_removed_rowsprovider_disabledcapability_missing
countinteger · int64
nullable
—
messagestring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failedinternal.invalid_filter
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="GET"
REQ_PATH="/v1/esims"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "items": [
      {
        "cancellable": {
          "allowed": true,
          "disclosed_at": "2026-07-11T08:12:30Z",
          "reason": "code_disclosed"
        },
        "external_ref": "po-1001",
        "iccid": "8984012345678901234",
        "id": "018f2b00-0000-7000-8000-00000000009a",
        "install": {
          "android_url": "https://esimsetup.google.com/esim_qrcode_provisioning?data=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
          "apn": "<apn>",
          "ios_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
          "lpa": "LPA:1$smdp.example.com$MATCH-ABC123",
          "matching_id": "MATCH-ABC123",
          "qr_code_base64": "<PNG base64 — đã rút gọn>",
          "smdp_address": "smdp.example.com"
        },
        "msisdn": "+84900000000",
        "order_id": "018f2b00-0000-7000-8000-00000000009a",
        "package_code": "TKE-VN-30D-X3K9F",
        "package_name": "Tên gói hiển thị",
        "plan": {
          "current_code": "TKE-VN-30D-X3K9F",
          "expires_at": "2026-08-10T00:00:00Z",
          "started_at": "2026-07-11T08:12:30Z"
        },
        "smdp_status": "released",
        "status": "delivered",
        "updated_at": "2026-07-11T09:03:11Z",
        "usage": {
          "expires_at": "2026-08-10T00:00:00Z",
          "remaining_bytes": 5368709120,
          "source": "poll",
          "total_bytes": 5368709120,
          "updated_at": "2026-07-11T09:03:11Z",
          "used_bytes": 5368709120
        }
      }
    ],
    "mode": "live",
    "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0xMVQwODoxMjozMFoifQ",
    "page_size": 50,
    "total": 128,
    "warnings": []
  },
  "ok": true
}
GET/v1/esims/{id}

Get one eSIM

scope esims:readget_esims__id_

Required scope: esims:read.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Query Parameters (1)

includestringoptional

Ask for extra blocks, comma-separated. lpa = return the install block (the eSIM activation code); qr = also return install.qr_code_base64 (detail endpoints only).

RETURNING THE CODE IS DISCLOSING IT. Asking for lpa PERMANENTLY closes the cancellation window of every eSIM in the response (cancellable.allowed becomes false, reason = code_disclosed) — once the code has left our systems it cannot be recalled. Ask for it when you deliver to the customer right away; skip it when you order ahead and deliver later, and you keep the right to cancel. An unknown value → internal.invalid_filter (400, field: "include").

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
cancellableobject

Whether this eSIM can still be cancelled, and if not, why — read it BEFORE calling POST /v1/esims/{order_item_id}/cancel. This block is ALWAYS present (never null): it is an answer, not optional data.

allowedboolean
—
disclosed_atstring · date-time
nullable

When the code left our systems (ISO-8601 UTC) — null while nobody has seen it. This is the trace you can follow when a customer claims "I never received the code, why can't it be cancelled".

reasonstring
nullable

Why it cannot be cancelled. code_disclosed = the code has been sent to or shown to someone; wrong_status = the eSIM is not in a cancellable state; expired = the plan has expired; not_supported = the package does not declare a cancel capability. null when allowed = true. Same vocabulary as meta.reason on esim.not_cancellable.

code_disclosedwrong_statusexpirednot_supportednull
external_refstring
nullable

Your own order reference (the external_ref you sent when ordering) — matches the eSIM back to your order. null when the order was placed without one.

iccidstring
nullable
—
idstring · uuid

The eSIM id — used in every /v1/esims/{id}/… path (also the order_item_id in webhooks). If you only have the ICCID, call GET /v1/esims?iccid=… to get the id.

installobject
nullable

Everything needed to INSTALL this eSIM. null = you did NOT ASK for it (no include=lpa) — which is not the same as "this eSIM has no code yet"; in that case the block is present with lpa: null.

A response carrying this block always has Cache-Control: no-store.

android_urlstring
nullable
—
apnstring
nullable

The access point name (APN) to enter when the device does not pick up the configuration automatically. ALWAYS null in the current release — null means "unknown", not "not needed".

ios_urlstring
nullable
—
lpastring
nullable

The eSIM activation code (a sellable secret). This string is all you need to render the QR code on your side.

matching_idstring
nullable
—
qr_code_base64string
nullable

PNG data-URI of the QR code. Only present when you ask for include=qr AND call a DETAIL endpoint — lists NEVER carry it (≈11 KB per row, so a 100-row page is ≈1.1 MB); render the QR from lpa instead.

smdp_addressstring
nullable
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
msisdnstring
nullable
—
order_idstring · uuid
—
package_codestring
nullable

The package CODE from the catalog — the key to map this eSIM back to your catalog. null only when the source package has been removed from the catalog.

package_namestring

The display NAME — a human-readable label that can change. Do NOT use it as a reconciliation key.

planobject
nullable

The plan currently applied to this eSIM.

current_codestring
—
expires_atstring · date-time
nullable
—
started_atstring · date-time
nullable
—
smdp_statusstring
nullable
—
statusstring
—
updated_atstring · date-time
—
usageobject
nullable

eSIM data usage statistics. updated_at = null when never polled.

expires_atstring · date-time
nullable
—
remaining_bytesinteger · int64
nullable
—
sourcestring
nullable
—
pollwebhook
total_bytesinteger · int64
nullable
—
updated_atstring · date-time
nullable
—
used_bytesinteger · int64
nullable
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failedinternal.invalid_filter
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
internal.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="GET"
REQ_PATH="/v1/esims/$ID"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "cancellable": {
      "allowed": true,
      "disclosed_at": "2026-07-11T08:12:30Z",
      "reason": "code_disclosed"
    },
    "external_ref": "po-1001",
    "iccid": "8984012345678901234",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "install": {
      "android_url": "https://esimsetup.google.com/esim_qrcode_provisioning?data=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
      "apn": "<apn>",
      "ios_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
      "lpa": "LPA:1$smdp.example.com$MATCH-ABC123",
      "matching_id": "MATCH-ABC123",
      "qr_code_base64": "<PNG base64 — đã rút gọn>",
      "smdp_address": "smdp.example.com"
    },
    "mode": "live",
    "msisdn": "+84900000000",
    "order_id": "018f2b00-0000-7000-8000-00000000009a",
    "package_code": "TKE-VN-30D-X3K9F",
    "package_name": "Tên gói hiển thị",
    "plan": {
      "current_code": "TKE-VN-30D-X3K9F",
      "expires_at": "2026-08-10T00:00:00Z",
      "started_at": "2026-07-11T08:12:30Z"
    },
    "smdp_status": "released",
    "status": "delivered",
    "updated_at": "2026-07-11T09:03:11Z",
    "usage": {
      "expires_at": "2026-08-10T00:00:00Z",
      "remaining_bytes": 5368709120,
      "source": "poll",
      "total_bytes": 5368709120,
      "updated_at": "2026-07-11T09:03:11Z",
      "used_bytes": 5368709120
    }
  },
  "ok": true
}
POST/v1/esims/{id}/cancel

Cancel an unused eSIM

scope esims:writeIdempotency-Key Requiredpost_esims__id__cancel

Required scope: esims:write.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Headers (1)

Idempotency-Keystringrequired

A client-generated string — the same key + body executes exactly once (a retry returns the original result). Scoped by api_key_id (§11): two different keys = two separate commands.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
cancelled_atstring · date-time
—
esim_idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
refund_suggestedboolean

true = a refund request is queued for a human to approve; the money has NOT moved yet. Do not read refunded.amount = 0 as "no refund".

refundedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
statusstring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
409
esim.not_cancellableidempotency.conflict
422
capability.not_supported
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="POST"
REQ_PATH="/v1/esims/$ID/cancel"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)"

Sample Response

json
{
  "data": {
    "cancelled_at": "2026-07-11T08:12:30Z",
    "esim_id": "018f2b00-0000-7000-8000-000000000000",
    "mode": "live",
    "refund_suggested": true,
    "refunded": {
      "amount": 273000,
      "currency": "VND"
    },
    "status": "cancelled"
  },
  "ok": true
}
POST/v1/esims/{id}/change-plan

Top up an eSIM

scope esims:writeIdempotency-Key Requiredpost_esims__id__change-plan

Required scope: esims:write.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Headers (1)

Idempotency-Keystringrequired

A client-generated string — the same key + body executes exactly once (a retry returns the original result). Scoped by api_key_id (§11): two different keys = two separate commands.

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
package_codestring
required

The package code from YOUR catalog, i.e. the public_sku. IMMUTABLE per package (it does not change when we switch the supplier behind it), so you can safely use it as a reconciliation key. The example value here has the right FORMAT but will NOT work: fetch a real code from GET /v1/catalog/packages.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required

Result of changing the eSIM plan.

chargedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
current_planobject
—
codestring
—
expires_atstring · date-time
nullable
—
started_atstring · date-time
nullable
—
esim_idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
order_idstring · uuid
—
previous_planobject
nullable
—
cancelled_atstring · date-time
nullable
—
codestring
—
remaining_bytes_forfeitedinteger · int64
nullable
—
warningstring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
402
wallet.insufficient_balance
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
partner.package_not_visible
409
esim.topup_not_supportedesim.topup_provider_mismatchesim.not_eligibleidempotency.conflict
422
capability.not_supported
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="POST"
REQ_PATH="/v1/esims/$ID/change-plan"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"package_code":"TKE-VN-7D-P4M2Q"}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "charged": {
      "amount": 273000,
      "currency": "VND"
    },
    "current_plan": {
      "code": "TKE-VN-30D-X3K9F",
      "expires_at": "2026-08-10T00:00:00Z",
      "started_at": "2026-07-11T08:12:30Z"
    },
    "esim_id": "018f2b00-0000-7000-8000-000000000000",
    "mode": "live",
    "order_id": "018f2b00-0000-7000-8000-00000000009a",
    "previous_plan": {
      "cancelled_at": "2026-07-11T08:12:30Z",
      "code": "TKE-VN-30D-X3K9F",
      "remaining_bytes_forfeited": 2147483648
    },
    "warning": "Dung lượng còn lại của gói cũ KHÔNG được cộng dồn."
  },
  "ok": true
}

Sample Request Body

json
{
  "package_code": "TKE-VN-7D-P4M2Q"
}
GET/v1/esims/{id}/plan-options

Valid top-up packages for this eSIM

scope esims:readget_esims__id__plan-options

Required scope: esims:read.

Return valid top-up packages FOR THIS EXACT eSIM. The server uses id to find the supplier holding it, then returns only that supplier's packages. /v1 never exposes provider_id, so this endpoint performs the filtering for you and makes selecting another supplier's package structurally impossible. Calling POST /v1/esims/{id}/change-plan directly with a package outside this list returns 409 esim.topup_provider_mismatch.

An eSIM with no linked supplier (a plain imported record or missing source data) returns an EMPTY list (total: 0). This is a valid answer, not an error.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Query Parameters (2)

page_sizeintegeroptionaldefault 50min 1max 200

Records per page. Default 50, capped at 200 — sending a larger value is clamped to 200 (NOT an error).

cursorstringoptional

Cursor for the next page — pass back the next_cursor value from the previous page VERBATIM. WARNING: a malformed cursor does NOT raise an error; the server just reads from the START of the list again (a deliberate choice so that changing the cursor encoding scheme never breaks a partner's running reconciliation loop). Consequence to know: an automated reconciliation loop that keeps sending a garbage cursor will loop forever on the first page instead of stopping — check that next_cursor differs from the cursor you just sent before looping again.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required

The plans THIS eSIM can be topped up with — already filtered by the supplier holding it. Every element is a FULL Package, the same shape as the catalog: there is no second schema forcing you to write two type sets for one entity.

policy is the policy of the supplier CURRENTLY HOLDING this eSIM. null = we have not measured that supplier's policy — read it as "do not assume", NOT as "nothing gets cancelled".

itemsobject[]
—
capabilitiesobject

What this package can do AFTER the sale. Read from each package's REAL declaration, never inferred from the supplier type: a package with nothing declared reports every flag as false. We would rather omit a button you could have used than promise an endpoint that is certain to return 422 capability.not_supported.

cancelobject
—
restrictionstring
nullable

code_not_disclosed = cancellation is only possible while NOBODY has seen the activation code (not sent to the customer, not read through the dashboard or through ?include=lpa). This is the REAL condition on the cancel path. It says nothing about whether the customer has installed the eSIM — the system cannot know that, and the old label (unused_only) promised exactly that wrong thing.

code_not_disclosednull
supportedboolean
—
plan_changeobject
—
policyobject
nullable

How TOPPING UP A NEW PLAN works at this supplier. null = the package cannot be topped up, OR we have not measured that supplier's policy. null says "do not assume"; an object of all-false would say "we guarantee nothing gets cancelled", and those are two very different statements.

……

Deeper nested fields: see the JSON example.

supportedboolean
—
usage_checkobject
—
supportedboolean
—
codestring

The package code from YOUR catalog, i.e. the public_sku. IMMUTABLE per package (it does not change when we switch the supplier behind it), so you can safely use it as a reconciliation key. The example value here has the right FORMAT but will NOT work: fetch a real code from GET /v1/catalog/packages.

coverageobject

Where the package works.

countriesstring[]

ISO-3166 alpha-2 codes. Empty for global packages (we do not enumerate 200 countries) and for packages with no declared coverage.

country_countinteger

How many countries the package covers — so you can sort or filter on "multi-country packages" without fetching everything and counting the array yourself.

groupstring

Whether the package covers a list of countries (country), a region (region), or the whole world (global). OPEN ENUM: clients MUST ignore unknown values.

regionstring
nullable

ALWAYS null today: regions are attached to COUNTRIES, not to packages, so any value here would be an inference. Filtering by region still works through the ?region= parameter.

dataobject

The package's data allowance. EVERY figure is in BYTES (not MB/GB — it avoids rounding twice).

daily_bytesinteger · int64
nullable

PER-DAY data allowance, in bytes. null for fixed/unlimited packages.

labelstring

A human-readable label, generated by the same function every one of our screens uses.

modelstring

How the allowance is counted: daily (per-day allowance) | fixed (total for the whole period) | unlimited. OPEN ENUM — clients MUST ignore unknown values.

dailyfixedunlimited
resetobject
nullable

When the daily allowance resets. A per-day package whose reset point is unknown costs a customer who buys at 22:00 almost the whole first day — this is sales information, not a technical detail. null = not declared.

atstring
nullable

Reset time in the local time of timezone (00:00, 00:00:01).

notestring
nullable
—
timezonestring
nullable

Time zone of the reset point (UTC+8, UTC+7).

total_bytesinteger · int64
nullable

Total allowance for the whole period, in bytes. null for daily/unlimited packages: a per-day allowance times the number of days is NOT a figure the supplier commits to.

descriptionstring
nullable

The sales description of the package. null when nothing has been written yet.

description_enstring
nullable

English description. null when not provided yet.

featuresobject

What the package can and cannot do. A TYPED object rather than an array of codes: [] cannot tell "no hotspot" apart from "nobody declared it", whereas false and null can.

appsobject[]

Per-OPERATING-SYSTEM app status.

An app NOT PRESENT in this array means WE HAVE NO INFORMATION, NOT "it works". Only what is listed is something the supplier commits to; today only TikTok is declared.

There is no combined field, and that is deliberate: measured on the real catalog, 2,465 packages run TikTok on iPhone but NOT on Android. A single combined flag flattens them to "no", and you would then be wrong about nearly a third of the catalog.

androidstring

supported (the supplier declares it works) | blocked (declares it does not) | unknown (the declaration does not cover this OS). OPEN ENUM.

supportedblockedunknown
appstring

App code, lowercase (e.g. tiktok).

iosstring

supported (the supplier declares it works) | blocked (declares it does not) | unknown (the declaration does not cover this OS). OPEN ENUM.

supportedblockedunknown
notestring
nullable
—
hotspotboolean
nullable

Wi-Fi tethering for other devices. null = not declared, DIFFERENT from false.

kyc_requiredboolean
nullable

Identity verification (KYC) is required before use. null = not declared.

smsboolean
nullable

SMS. false = SMS is NOT received, which means bank OTPs are NOT received. null = not declared.

voiceboolean
nullable

Voice calls. false = the supplier declares there is NO voice — a data-only line cannot receive calls, and selling it to someone who needs voice is a guaranteed complaint. null = not declared.

fupobject
nullable

What happens after the high-speed allowance runs out. null = the package declares nothing about throttling.

behaviorstring
nullable

throttle (slowed down) | cutoff (disconnected) | null (not declared). OPEN ENUM.

throttlecutoffnull
high_speed_bytesinteger · int64
nullable

BYTES at full speed before throttling kicks in.

notestring
nullable
—
speed_kbpsinteger
nullable

Speed AFTER the high-speed allowance runs out (kbps). null when the line is cut off or nothing is declared. DIFFERENT from network.speed_cap_kbps, which is the cap for the whole package.

marketstring

The package's SUPPLY SYSTEM, an OPEN ENUM (clients MUST ignore unknown values). This is the axis that decides whether a package has stock behind it and which post-sale operations it has: domestic sells kits and supports top-up, cancel and usage lookup; international is ordered from the supplier on request so it has no stock.

domesticinternational
namestring
—
name_enstring
nullable

English name. null when not provided yet.

networkobject

Which networks the package runs on.

apnstring
nullable

The APN that must be entered by hand on some Android devices that do not pick it up automatically. Without it you cannot build an installation guide, and every such case becomes a support call. null = not declared.

carriersobject[]

The carriers this package RUNS ON. This is an eSIM specification (whose radio it uses), not the supplier.

namestring
—
generationsstring[]
nullable

Network generations the package can use (["4g", "5g"]). null = not declared — NOT "2G only".

speed_cap_kbpsinteger
nullable

Speed cap for the WHOLE package (kbps) — different from fup.speed_kbps, which applies after the high-speed allowance is gone.

priceobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
tagsstring[]

Normalized commercial tags (lowercase, hyphenated). They are also what your key's catalog restrictions match on. NO package carries a tag today, so ?tag= returns an empty list until the seller starts tagging.

validityobject

How long the package lasts, and when the clock starts.

daysinteger
—
install_by_daysinteger
nullable

Number of days to scan the QR / install the eSIM after purchase. null = not declared — NOT "install whenever you like".

labelstring
—
reclaim_on_expiryboolean
nullable

The supplier RECLAIMS the subscription when the plan expires — meaning it can no longer be topped up or changed. If you sell renewals you must know that window. null = not declared.

starts_whenstring
nullable

WHEN the clock starts: on_install (when the customer installs the eSIM) | on_purchase (at purchase) | null.

The HEAVIEST field in this block. If the supplier counts from INSTALL and you do not know it, you will quote the wrong expiry date to EVERY customer. null ⇒ do not promise a specific expiry date.

on_installon_purchasenull
unused_valid_daysinteger
nullable

Number of days the package can sit unused on the shelf before it expires. null = not declared, NOT unlimited. Deliberately separate from install_by_days: one is the INSTALL deadline, the other is the SHELF life.

modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_cursorstring
nullable

Pass it back VERBATIM to fetch the next page. null = NO MORE data, and that is the ONLY stop signal: a page shorter than page_size may still have pages after it.

page_sizeinteger

The MAXIMUM number of records the server used for this page (already clamped to the cap), not the actual number of elements in items.

policyobject
nullable

How TOPPING UP A NEW PLAN works at this supplier. null = the package cannot be topped up, OR we have not measured that supplier's policy. null says "do not assume"; an object of all-false would say "we guarantee nothing gets cancelled", and those are two very different statements.

notestring
nullable
—
requires_cancel_firstboolean

The RUNNING plan is cancelled before the new one is placed (this supplier does not stack plans). Tell the customer BEFORE they hit the top-up-a-new-plan button.

stackingboolean

Whether the new plan's validity and allowance add on top of the running one.

totalinteger · int64
nullable

Total packages matching the filters. null when the set is large enough that counting is not worth paying for (the international system, or a call with no market filter). This count runs BEFORE the two application-level gates (your key's catalog restrictions and the package having a price for your organization), so it answers "how many packages match in the catalog", not "how many you can buy".

warningsobject[]

WHY the list looks the way it does. Empty = nothing unusual.

code is a CLOSED set: filtered_by_key_package_filters (your key's catalog restrictions), filtered_by_provider_scope (your organization's provider scope), no_price_for_org (your organization has no price yet), price_filter_removed_rows (the price-range filter), provider_disabled (no supplier can deliver it any more), capability_missing (the package has not declared its post-sale capabilities). count is the number of rows involved, null when it cannot be expressed as a number.

codestring
—
filtered_by_key_package_filtersfiltered_by_provider_scopeno_price_for_orgprice_filter_removed_rowsprovider_disabledcapability_missing
countinteger · int64
nullable
—
messagestring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
internal.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="GET"
REQ_PATH="/v1/esims/$ID/plan-options"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "items": [
      {
        "capabilities": {
          "cancel": {
            "restriction": "code_not_disclosed",
            "supported": true
          },
          "plan_change": {
            "policy": {
              "note": "<note>",
              "requires_cancel_first": true,
              "stacking": true
            },
            "supported": true
          },
          "usage_check": {
            "supported": true
          }
        },
        "code": "TKE-VN-30D-X3K9F",
        "coverage": {
          "countries": [
            "CN"
          ],
          "country_count": 1,
          "group": "country",
          "region": "<region>"
        },
        "data": {
          "daily_bytes": 1073741824,
          "label": "1GB/ngày",
          "model": "daily",
          "reset": {
            "at": "00:00",
            "note": "<note>",
            "timezone": "UTC+8"
          },
          "total_bytes": null
        },
        "description": "<description>",
        "description_en": "<description_en>",
        "features": {
          "apps": [
            {
              "android": "blocked",
              "app": "tiktok",
              "ios": "supported",
              "note": "<note>"
            }
          ],
          "hotspot": true,
          "kyc_required": false,
          "sms": false,
          "voice": false
        },
        "fup": {
          "behavior": "throttle",
          "high_speed_bytes": 1048576,
          "note": "<note>",
          "speed_kbps": 128
        },
        "market": "international",
        "name": "eSIM Trung Quốc Mainland Multi - 1GB/ngày - 24 ngày",
        "name_en": "<name_en>",
        "network": {
          "apn": "e-ideas",
          "carriers": [
            {
              "name": "China Unicom"
            }
          ],
          "generations": [
            "4g",
            "5g"
          ],
          "speed_cap_kbps": 1
        },
        "price": {
          "amount": 273000,
          "currency": "VND"
        },
        "tags": [],
        "validity": {
          "days": 24,
          "install_by_days": 365,
          "label": "24 ngày",
          "reclaim_on_expiry": true,
          "starts_when": "on_install",
          "unused_valid_days": 1
        }
      }
    ],
    "mode": "live",
    "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0xMVQwODoxMjozMFoifQ",
    "page_size": 50,
    "policy": {
      "note": "<note>",
      "requires_cancel_first": true,
      "stacking": true
    },
    "total": 128,
    "warnings": []
  },
  "ok": true
}
GET/v1/esims/{id}/refund-requests

eSIM refund request history (members only)

scope esims:readget_esims__id__refund-requests

Required scope: esims:read.

Authorized live member keys only. Return up to 50 recent refund requests for the member-owned eSIM, newest first; next_cursor and total are null. has_open_request indicates pending review. Organization and test keys receive capability.not_supported.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
has_open_requestboolean
—
itemsobject[]
—
decided_atstring · date-time
nullable
—
decision_notestring
nullable
—
idstring · uuid
—
order_idstring · uuid
—
order_item_idstring · uuid
—
reasonstring
—
refundedobject
nullable
—
requested_atstring · date-time
—
statusstring
—
pendingapprovedrejected
suggestedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_cursorstring
nullable

Pass it back VERBATIM to fetch the next page. null = NO MORE data, and that is the ONLY stop signal: a page shorter than page_size may still have pages after it.

page_sizeinteger
nullable

The MAXIMUM number of records the server used for this page (already clamped to the cap), not the actual number of elements in items.

totalinteger · int64
nullable

How many rows match your filter. null when counting is too expensive — do not use it to tell whether you have reached the end; next_cursor is that signal.

warningsobject[]

WHY the list looks the way it does. Empty = nothing unusual.

code is a CLOSED set: filtered_by_key_package_filters (your key's catalog restrictions), filtered_by_provider_scope (your organization's provider scope), no_price_for_org (your organization has no price yet), price_filter_removed_rows (the price-range filter), provider_disabled (no supplier can deliver it any more), capability_missing (the package has not declared its post-sale capabilities). count is the number of rows involved, null when it cannot be expressed as a number.

codestring
—
filtered_by_key_package_filtersfiltered_by_provider_scopeno_price_for_orgprice_filter_removed_rowsprovider_disabledcapability_missing
countinteger · int64
nullable
—
messagestring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
internal.not_found
422
capability.not_supported
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_member_live_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="GET"
REQ_PATH="/v1/esims/$ID/refund-requests"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "has_open_request": true,
    "items": [
      {
        "decided_at": "2026-07-11T08:12:30Z",
        "decision_note": "<decision_note>",
        "id": "018f2b00-0000-7000-8000-00000000009a",
        "order_id": "018f2b00-0000-7000-8000-00000000009a",
        "order_item_id": "018f2b00-0000-7000-8000-000000000000",
        "reason": "<reason>",
        "refunded": null,
        "requested_at": "2026-07-11T08:12:30Z",
        "status": "pending",
        "suggested": {
          "amount": 273000,
          "currency": "VND"
        }
      }
    ],
    "mode": "live",
    "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0xMVQwODoxMjozMFoifQ",
    "page_size": 50,
    "total": 128,
    "warnings": []
  },
  "ok": true
}
POST/v1/esims/{id}/refund-requests

Create an eSIM refund request (members only)

scope esims:writeIdempotency-Key Requiredpost_esims__id__refund-requests

Required scope: esims:write.

Authorized live member keys only. Submit a refund request for that member’s eSIM with Idempotency-Key; no money moves until admin approval. Organization and test keys receive capability.not_supported. In test mode, cancel an eSIM to exercise a virtual-wallet refund.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Headers (1)

Idempotency-Keystringrequired

A client-generated string — the same key + body executes exactly once (a retry returns the original result). Scoped by api_key_id (§11): two different keys = two separate commands.

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
reasonstring
required

Refund reason, 5 to 1000 characters after trimming leading and trailing whitespace.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
decided_atstring · date-time
nullable
—
decision_notestring
nullable
—
idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
order_idstring · uuid
—
order_item_idstring · uuid
—
reasonstring
—
refundedobject
nullable
—
requested_atstring · date-time
—
statusstring
—
pendingapprovedrejected
suggestedobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
internal.not_found
409
esim.refund_not_eligibleesim.refund_request_pendingidempotency.conflict
422
capability.not_supported
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_member_live_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="POST"
REQ_PATH="/v1/esims/$ID/refund-requests"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"reason":"<reason>"}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "decided_at": "2026-07-11T08:12:30Z",
    "decision_note": "<decision_note>",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "mode": "live",
    "order_id": "018f2b00-0000-7000-8000-00000000009a",
    "order_item_id": "018f2b00-0000-7000-8000-000000000000",
    "reason": "<reason>",
    "refunded": null,
    "requested_at": "2026-07-11T08:12:30Z",
    "status": "pending",
    "suggested": {
      "amount": 273000,
      "currency": "VND"
    }
  },
  "ok": true
}

Sample Request Body

json
{
  "reason": "<reason>"
}
GET/v1/esims/{id}/usage

Data used

scope esims:readget_esims__id__usage

Required scope: esims:read.

Read an eSIM's used and remaining data, with the updated_at timestamp of the figures (the real freshness). For an eSIM from a supplier that is read on demand only (currently SkyFi), this call queues a scan and WAITS for the result for up to about 15 seconds, so the response can take up to ~15 seconds; the figures returned are those of the scan that just finished. If the scan fails, is within the 15-second spacing between scans of the same eSIM, or the wait times out, NO error is returned: the most recently stored figures are returned, so check updated_at. Other suppliers return stored figures that are refreshed periodically.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
esim_idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
usageobject
nullable

eSIM data usage statistics. updated_at = null when never polled.

expires_atstring · date-time
nullable
—
remaining_bytesinteger · int64
nullable
—
sourcestring
nullable
—
pollwebhook
total_bytesinteger · int64
nullable
—
updated_atstring · date-time
nullable
—
used_bytesinteger · int64
nullable
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
422
capability.not_supported
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="GET"
REQ_PATH="/v1/esims/$ID/usage"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "esim_id": "018f2b00-0000-7000-8000-000000000000",
    "mode": "live",
    "usage": {
      "expires_at": "2026-08-10T00:00:00Z",
      "remaining_bytes": 5368709120,
      "source": "poll",
      "total_bytes": 5368709120,
      "updated_at": "2026-07-11T09:03:11Z",
      "used_bytes": 5368709120
    }
  },
  "ok": true
}

Wallet

Wallet balance

GET/v1/wallet/balance

Wallet balance

scope wallet:readget_wallet_balance

Required scope: wallet:read.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
availableobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
balanceobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
credit_limitobject

An amount of money. ONE shape everywhere on /v1 — no field ever switches between an object and a bare number.

amountinteger · int64

Integer, in the smallest unit of currency (VND has no minor unit).

currencystring
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="GET"
REQ_PATH="/v1/wallet/balance"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "available": {
      "amount": 273000,
      "currency": "VND"
    },
    "balance": {
      "amount": 273000,
      "currency": "VND"
    },
    "credit_limit": {
      "amount": 273000,
      "currency": "VND"
    },
    "mode": "live"
  },
  "ok": true
}

Webhooks

Webhook configuration + delivery log + simulator

POST/v1/testmode/webhooks/fail-next

Simulate next webhook failure (test keys only)

scope webhook:managepost_testmode_webhooks_fail-next

Required scope: webhook:manage.

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
countinteger
—

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
endpoint_idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
testmode_fail_nextinteger
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspendedtestmode.live_key_forbidden
404
internal.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="POST"
REQ_PATH="/v1/testmode/webhooks/fail-next"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"count":1}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "endpoint_id": "018f2b00-0000-7000-8000-000000000000",
    "mode": "live",
    "testmode_fail_next": 1
  },
  "ok": true
}

Sample Request Body

json
{
  "count": 1
}
POST/v1/testmode/webhooks/fire

Fire a test webhook event (test keys only)

scope webhook:managepost_testmode_webhooks_fire

Required scope: webhook:manage.

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
dataobject

Partially override the data field of the sample payload.

event_typestring
required

Webhook event type to simulate firing.

order.completedorder.failedesim.deliveredesim.activatedesim.usage_thresholdesim.expiringwallet.low_balancecatalog.updated

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
delivery_idstring · uuid
—
event_idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspendedtestmode.live_key_forbidden
404
webhook.not_configuredinternal.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="POST"
REQ_PATH="/v1/testmode/webhooks/fire"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"data":{},"event_type":"order.completed"}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "delivery_id": "018f2b00-0000-7000-8000-000000000000",
    "event_id": "018f3c00-0000-7000-8000-0000000000b0",
    "mode": "live"
  },
  "ok": true
}

Sample Request Body

json
{
  "data": {},
  "event_type": "order.completed"
}
GET/v1/webhooks

List webhook endpoints

scope webhook:manageget_webhooks

Required scope: webhook:manage.

The webhook endpoints of the API key you are calling with. A key NEVER sees another key's endpoint — not even two keys of the same organization (§5.11: an endpoint is bound to an api_key_id; test and live never share one).

Not configured yet → 200 with an EMPTY array, not 404: the collection always exists, it just has no elements right now.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
itemsobject[]
—
activeboolean

false = the endpoint has stopped accepting new events. That only happens when your organization pauses it from the dashboard, or when the API key is revoked (the cascade shuts the outbound channel down in the same transaction). A PUT /v1/webhooks/{id} that passes CHECK_HEALTH turns it back on.

created_atstring · date-time
—
eventsstring[]

Empty = receive everything.

idstring · uuid

The endpoint's ID — what you put into /v1/webhooks/{id}. A NEW one is minted on every create: deleting and re-creating gives you a different endpoint, not the old one brought back to life.

last_delivery_atstring · date-time
nullable

The last time we DELIVERED through this endpoint. null = never delivered — a different thing from "delivery failed", and the first thing to check when a partner reports receiving no webhooks.

secret_last4string

The last four characters of the webhook secret — enough for you to check "is the secret I hold the one the system signs with?" without any endpoint handing the secret back. The full secret is shown ONCE, at creation.

urlstring
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_cursorstring
nullable

Pass it back VERBATIM to fetch the next page. null = NO MORE data, and that is the ONLY stop signal: a page shorter than page_size may still have pages after it.

page_sizeinteger
nullable

The MAXIMUM number of records the server used for this page (already clamped to the cap), not the actual number of elements in items.

totalinteger · int64
nullable

How many rows match your filter. null when counting is too expensive — do not use it to tell whether you have reached the end; next_cursor is that signal.

warningsobject[]

WHY the list looks the way it does. Empty = nothing unusual.

code is a CLOSED set: filtered_by_key_package_filters (your key's catalog restrictions), filtered_by_provider_scope (your organization's provider scope), no_price_for_org (your organization has no price yet), price_filter_removed_rows (the price-range filter), provider_disabled (no supplier can deliver it any more), capability_missing (the package has not declared its post-sale capabilities). count is the number of rows involved, null when it cannot be expressed as a number.

codestring
—
filtered_by_key_package_filtersfiltered_by_provider_scopeno_price_for_orgprice_filter_removed_rowsprovider_disabledcapability_missing
countinteger · int64
nullable
—
messagestring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="GET"
REQ_PATH="/v1/webhooks"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "items": [
      {
        "active": true,
        "created_at": "2026-07-11T08:12:30Z",
        "events": [
          "order.completed"
        ],
        "id": "018f2b00-0000-7000-8000-00000000009a",
        "last_delivery_at": "2026-07-11T08:12:30Z",
        "secret_last4": "a3f9",
        "url": "https://partner.example.com/hooks/tke"
      }
    ],
    "mode": "live",
    "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0xMVQwODoxMjozMFoifQ",
    "page_size": 50,
    "total": 128,
    "warnings": []
  },
  "ok": true
}
POST/v1/webhooks

Create a webhook endpoint

scope webhook:managepost_webhooks

Required scope: webhook:manage.

Create a webhook endpoint for the API key you are calling with. Before saving, the server POSTs a signed ping event to the URL you sent (CHECK_HEALTH): no 2xx means NOTHING is saved and you get 422 webhook.health_check_failed. The URL must be public https — pointing at an internal / metadata IP → 422 webhook.url_not_allowed.

The webhook_secret in the response is shown exactly once: no endpoint can read it back. Lose it and you must rotate from the dashboard (the old secret stays valid in parallel for 24h, §8.4).

The key already has an endpoint → 409 webhook.already_configured, even when that endpoint is paused (is_active: false). A paused endpoint is still the key's endpoint — its url, secret and log are all intact. To start over, DELETE /v1/webhooks/{id} and then create a new one.

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
eventsstring[]

Empty = receive everything. An unknown value → 400 internal.validation_failed (field events).

urlstring · uri
required
—

Response

201Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
activeboolean

false = the endpoint has stopped accepting new events. That only happens when your organization pauses it from the dashboard, or when the API key is revoked (the cascade shuts the outbound channel down in the same transaction). A PUT /v1/webhooks/{id} that passes CHECK_HEALTH turns it back on.

created_atstring · date-time
—
eventsstring[]

Empty = receive everything.

health_checkstring
—
passed
idstring · uuid

The endpoint's ID — what you put into /v1/webhooks/{id}. A NEW one is minted on every create: deleting and re-creating gives you a different endpoint, not the old one brought back to life.

last_delivery_atstring · date-time
nullable

The last time we DELIVERED through this endpoint. null = never delivered — a different thing from "delivery failed", and the first thing to check when a partner reports receiving no webhooks.

modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
secret_last4string

The last four characters of the webhook secret — enough for you to check "is the secret I hold the one the system signs with?" without any endpoint handing the secret back. The full secret is shown ONCE, at creation.

urlstring
—
webhook_secretstring

Shown ONLY ONCE, at creation time (prefix whsec_).

okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
409
webhook.already_configured
422
webhook.health_check_failedwebhook.url_not_allowed
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="POST"
REQ_PATH="/v1/webhooks"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"events":["order.completed"],"url":"https://partner.example.com/hooks/tke"}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "active": true,
    "created_at": "2026-07-11T08:12:30Z",
    "events": [
      "order.completed"
    ],
    "health_check": "passed",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "last_delivery_at": "2026-07-11T08:12:30Z",
    "mode": "live",
    "secret_last4": "a3f9",
    "url": "https://partner.example.com/hooks/tke",
    "webhook_secret": "<webhook secret — chỉ hiện đúng một lần>"
  },
  "ok": true
}

Sample Request Body

json
{
  "events": [
    "order.completed"
  ],
  "url": "https://partner.example.com/hooks/tke"
}
GET/v1/webhooks/{id}

Get one webhook endpoint

scope webhook:manageget_webhooks__id_

Required scope: webhook:manage.

Path Parameters (1)

idstringrequired

The webhook endpoint ID (the id returned by POST /v1/webhooks or GET /v1/webhooks).

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
activeboolean

false = the endpoint has stopped accepting new events. That only happens when your organization pauses it from the dashboard, or when the API key is revoked (the cascade shuts the outbound channel down in the same transaction). A PUT /v1/webhooks/{id} that passes CHECK_HEALTH turns it back on.

created_atstring · date-time
—
eventsstring[]

Empty = receive everything.

idstring · uuid

The endpoint's ID — what you put into /v1/webhooks/{id}. A NEW one is minted on every create: deleting and re-creating gives you a different endpoint, not the old one brought back to life.

last_delivery_atstring · date-time
nullable

The last time we DELIVERED through this endpoint. null = never delivered — a different thing from "delivery failed", and the first thing to check when a partner reports receiving no webhooks.

modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
secret_last4string

The last four characters of the webhook secret — enough for you to check "is the secret I hold the one the system signs with?" without any endpoint handing the secret back. The full secret is shown ONCE, at creation.

urlstring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
webhook.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="GET"
REQ_PATH="/v1/webhooks/$ID"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "active": true,
    "created_at": "2026-07-11T08:12:30Z",
    "events": [
      "order.completed"
    ],
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "last_delivery_at": "2026-07-11T08:12:30Z",
    "mode": "live",
    "secret_last4": "a3f9",
    "url": "https://partner.example.com/hooks/tke"
  },
  "ok": true
}
PUT/v1/webhooks/{id}

Update a webhook endpoint

scope webhook:manageput_webhooks__id_

Required scope: webhook:manage.

Path Parameters (1)

idstringrequired

The webhook endpoint ID (the id returned by POST /v1/webhooks or GET /v1/webhooks).

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
eventsstring[]

Empty = receive everything. An unknown value → 400 internal.validation_failed (field events).

urlstring · uri
required
—

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
activeboolean

false = the endpoint has stopped accepting new events. That only happens when your organization pauses it from the dashboard, or when the API key is revoked (the cascade shuts the outbound channel down in the same transaction). A PUT /v1/webhooks/{id} that passes CHECK_HEALTH turns it back on.

created_atstring · date-time
—
eventsstring[]

Empty = receive everything.

idstring · uuid

The endpoint's ID — what you put into /v1/webhooks/{id}. A NEW one is minted on every create: deleting and re-creating gives you a different endpoint, not the old one brought back to life.

last_delivery_atstring · date-time
nullable

The last time we DELIVERED through this endpoint. null = never delivered — a different thing from "delivery failed", and the first thing to check when a partner reports receiving no webhooks.

modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
secret_last4string

The last four characters of the webhook secret — enough for you to check "is the secret I hold the one the system signs with?" without any endpoint handing the secret back. The full secret is shown ONCE, at creation.

urlstring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
webhook.not_found
422
webhook.health_check_failedwebhook.url_not_allowed
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="PUT"
REQ_PATH="/v1/webhooks/$ID"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"events":["order.completed"],"url":"https://partner.example.com/hooks/tke"}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "active": true,
    "created_at": "2026-07-11T08:12:30Z",
    "events": [
      "order.completed"
    ],
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "last_delivery_at": "2026-07-11T08:12:30Z",
    "mode": "live",
    "secret_last4": "a3f9",
    "url": "https://partner.example.com/hooks/tke"
  },
  "ok": true
}

Sample Request Body

json
{
  "events": [
    "order.completed"
  ],
  "url": "https://partner.example.com/hooks/tke"
}
DELETE/v1/webhooks/{id}

Remove a webhook endpoint (stop receiving new events)

scope webhook:managedelete_webhooks__id_

Required scope: webhook:manage.

Remove the endpoint: stop receiving any new events on the API key you are calling with.

What you lose: the webhook_secret goes with the deleted endpoint. Re-creating via POST /v1/webhooks mints a NEW id and a NEW secret — your verify function must be updated. If you need a temporary stop that KEEPS the secret, use the "Pause" button in the dashboard, not this endpoint.

What you keep: the full delivery log stays for reconciliation. But those deliveries belong to a removed endpoint, so they can no longer be replayed.

Events waiting on retry: per §8.5, once an endpoint is removed its pending deliveries stop with reason endpoint_disabled and remain in the log.

An id that does not belong to the calling key, or one already removed → 404 webhook.not_found (never a silent 200).

Path Parameters (1)

idstringrequired

The webhook endpoint ID (the id returned by POST /v1/webhooks or GET /v1/webhooks).

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
deleted_atstring · date-time
—
idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
webhook.not_found
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="DELETE"
REQ_PATH="/v1/webhooks/$ID"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "deleted_at": "2026-07-11T08:12:30Z",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "mode": "live"
  },
  "ok": true
}
GET/v1/webhooks/deliveries

Webhook delivery log

scope webhook:manageget_webhooks_deliveries

Required scope: webhook:manage.

Query Parameters (5)

pageintegeroptionaldefault 1min 1

1-based page number. A page past the end of the data returns items: [] + the real total (200, not 404).

page_sizeintegeroptionaldefault 50min 1max 200

Records per page. Default 50, capped at 200 — sending a larger value is clamped to 200 (NOT an error).

event_typestringoptional

Filter by event type (see the webhook events catalog).

statusstringoptional
Allowed values:pendingdeliveredfailed

Filter by delivery status. ?status=failed is the filter used for pull-based webhook reconciliation (§13). An unknown value does NOT raise an error — the filter is simply IGNORED and the full log is returned.

event_idstring · uuidoptional

Every delivery attempt (including replays) of ONE event.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
itemsobject[]
—
attemptsinteger
—
created_atstring · date-time
—
event_idstring · uuid

The EVENT identifier. A replay creates a NEW delivery carrying the SAME event_id — keep deduplicating on event_id, not on the delivery id.

event_typestring
—
idstring · uuid
—
last_attempt_atstring · date-time
nullable
—
next_retry_atstring · date-time
nullable
—
response_codeinteger
nullable
—
statusstring
—
pendingdeliveredfailed
webhook_idstring · uuid
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_cursorstring
nullable

Pass it back VERBATIM to fetch the next page. null = NO MORE data, and that is the ONLY stop signal: a page shorter than page_size may still have pages after it.

page_sizeinteger
nullable

The MAXIMUM number of records the server used for this page (already clamped to the cap), not the actual number of elements in items.

totalinteger · int64
nullable

How many rows match your filter. null when counting is too expensive — do not use it to tell whether you have reached the end; next_cursor is that signal.

warningsobject[]

WHY the list looks the way it does. Empty = nothing unusual.

code is a CLOSED set: filtered_by_key_package_filters (your key's catalog restrictions), filtered_by_provider_scope (your organization's provider scope), no_price_for_org (your organization has no price yet), price_filter_removed_rows (the price-range filter), provider_disabled (no supplier can deliver it any more), capability_missing (the package has not declared its post-sale capabilities). count is the number of rows involved, null when it cannot be expressed as a number.

codestring
—
filtered_by_key_package_filtersfiltered_by_provider_scopeno_price_for_orgprice_filter_removed_rowsprovider_disabledcapability_missing
countinteger · int64
nullable
—
messagestring
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
webhook.not_configured
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="GET"
REQ_PATH="/v1/webhooks/deliveries"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "items": [
      {
        "attempts": 3,
        "created_at": "2026-07-11T08:12:30Z",
        "event_id": "018f3c00-0000-7000-8000-0000000000b0",
        "event_type": "order.completed",
        "id": "018f2b00-0000-7000-8000-00000000009a",
        "last_attempt_at": "2026-07-11T09:05:00Z",
        "next_retry_at": "2026-07-11T09:10:00Z",
        "response_code": 200,
        "status": "pending",
        "webhook_id": "018f2b00-0000-7000-8000-000000000000"
      }
    ],
    "mode": "live",
    "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0xMVQwODoxMjozMFoifQ",
    "page_size": 50,
    "total": 128,
    "warnings": []
  },
  "ok": true
}
POST/v1/webhooks/deliveries/{id}/replay

Replay a webhook

scope webhook:managepost_webhooks_deliveries__id__replay

Required scope: webhook:manage.

Path Parameters (1)

idstringrequired

The ID of one webhook delivery (items[].id from GET /v1/webhooks/deliveries).

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
attemptsinteger
—
created_atstring · date-time
—
event_idstring · uuid

The EVENT identifier. A replay creates a NEW delivery carrying the SAME event_id — keep deduplicating on event_id, not on the delivery id.

event_typestring
—
idstring · uuid
—
last_attempt_atstring · date-time
nullable
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
next_retry_atstring · date-time
nullable
—
response_codeinteger
nullable
—
statusstring
—
pendingdeliveredfailed
webhook_idstring · uuid
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspended
404
webhook.delivery_not_foundwebhook.not_configured
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="POST"
REQ_PATH="/v1/webhooks/deliveries/$ID/replay"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "attempts": 3,
    "created_at": "2026-07-11T08:12:30Z",
    "event_id": "018f3c00-0000-7000-8000-0000000000b0",
    "event_type": "order.completed",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "last_attempt_at": "2026-07-11T09:05:00Z",
    "mode": "live",
    "next_retry_at": "2026-07-11T09:10:00Z",
    "response_code": 200,
    "status": "pending",
    "webhook_id": "018f2b00-0000-7000-8000-000000000000"
  },
  "ok": true
}

Test Mode

Use test keys tke_test_ or tke_member_test_ on the api-testing host only. Simulate the owner’s test eSIMs and data without live funds or suppliers. Member test mode does not support webhooks, bulk orders or refund-review requests.

POST/v1/testmode/esims/{id}/simulate

Simulate an eSIM test mode lifecycle (test keys only)

scope esims:writepost_testmode_esims__id__simulate

Required scope: esims:write.

Test keys tke_test_ or tke_member_test_ only. Apply a scenario to the owner’s test eSIM: used data, installation state, expiry, throttling or simulated provider rejection of the next operation. The body must contain at least one field; an empty body returns 422 testmode.invalid_scenario. Read the result through GET /v1/esims/{id} and /usage as in live mode. Plan-change and cancellation policies are shared with live mode; no live supplier is called.

Path Parameters (1)

idstringrequired

The stable ID of a sold eSIM (NOT the ICCID — an ICCID can be reused).

Request bodyrequiredapplication/json

FieldTypeConstraintsDescription
data_used_mbinteger · int64

Data ALREADY USED (MB, binary 1 MiB units). A value above the package allowance is clamped to the allowance — that is the ran-out-of-data scenario, not an error.

expire_atstring · date-time

Set a specific expiry timestamp (ISO-8601 UTC).

expire_nowboolean

true = expire the plan right now (esim_status becomes expired).

provider_rejectboolean

Simulate provider rejection on the next operation (top-up/cancel).

statusstring

The MACHINE lifecycle of the eSIM profile (the provider's vocabulary). DIFFERENT from the esim_status returned by the eSIM endpoints (the commercial vocabulary).

releasedinstalledenableddisabled
throttledboolean

true = the line is throttled (an unlimited plan past its high-speed threshold).

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
cancellableobject

Whether this eSIM can still be cancelled, and if not, why — read it BEFORE calling POST /v1/esims/{order_item_id}/cancel. This block is ALWAYS present (never null): it is an answer, not optional data.

allowedboolean
—
disclosed_atstring · date-time
nullable

When the code left our systems (ISO-8601 UTC) — null while nobody has seen it. This is the trace you can follow when a customer claims "I never received the code, why can't it be cancelled".

reasonstring
nullable

Why it cannot be cancelled. code_disclosed = the code has been sent to or shown to someone; wrong_status = the eSIM is not in a cancellable state; expired = the plan has expired; not_supported = the package does not declare a cancel capability. null when allowed = true. Same vocabulary as meta.reason on esim.not_cancellable.

code_disclosedwrong_statusexpirednot_supportednull
external_refstring
nullable

Your own order reference (the external_ref you sent when ordering) — matches the eSIM back to your order. null when the order was placed without one.

iccidstring
nullable
—
idstring · uuid

The eSIM id — used in every /v1/esims/{id}/… path (also the order_item_id in webhooks). If you only have the ICCID, call GET /v1/esims?iccid=… to get the id.

installobject
nullable

Everything needed to INSTALL this eSIM. null = you did NOT ASK for it (no include=lpa) — which is not the same as "this eSIM has no code yet"; in that case the block is present with lpa: null.

A response carrying this block always has Cache-Control: no-store.

android_urlstring
nullable
—
apnstring
nullable

The access point name (APN) to enter when the device does not pick up the configuration automatically. ALWAYS null in the current release — null means "unknown", not "not needed".

ios_urlstring
nullable
—
lpastring
nullable

The eSIM activation code (a sellable secret). This string is all you need to render the QR code on your side.

matching_idstring
nullable
—
qr_code_base64string
nullable

PNG data-URI of the QR code. Only present when you ask for include=qr AND call a DETAIL endpoint — lists NEVER carry it (≈11 KB per row, so a 100-row page is ≈1.1 MB); render the QR from lpa instead.

smdp_addressstring
nullable
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
msisdnstring
nullable
—
order_idstring · uuid
—
package_codestring
nullable

The package CODE from the catalog — the key to map this eSIM back to your catalog. null only when the source package has been removed from the catalog.

package_namestring

The display NAME — a human-readable label that can change. Do NOT use it as a reconciliation key.

planobject
nullable

The plan currently applied to this eSIM.

current_codestring
—
expires_atstring · date-time
nullable
—
started_atstring · date-time
nullable
—
simulatedobject

The simulated state AFTER the scenario has been applied.

esim_statestring

The MACHINE lifecycle of the test eSIM. On live this comes from smdp_status, but a test eSIM has no SM-DP+ profile to read — so it lives here, next to the other test-mode-only keys.

releasedinstalledenableddisabled
last_viastring

The flow that TOUCHED it most recently.

open_apisimulateseed
viastring

The flow that CREATED this test eSIM.

open_apisimulateseed
smdp_statusstring
nullable
—
statusstring
—
updated_atstring · date-time
—
usageobject
nullable

eSIM data usage statistics. updated_at = null when never polled.

expires_atstring · date-time
nullable
—
remaining_bytesinteger · int64
nullable
—
sourcestring
nullable
—
pollwebhook
total_bytesinteger · int64
nullable
—
updated_atstring · date-time
nullable
—
used_bytesinteger · int64
nullable
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspendedtestmode.live_key_forbidden
404
internal.not_found
422
testmode.invalid_scenario
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"
ID="THAY-BANG-ID"

METHOD="POST"
REQ_PATH="/v1/testmode/esims/$ID/simulate"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"data_used_mb":4096,"expire_at":"2026-07-11T08:12:30Z","expire_now":true,"provider_reject":true,"status":"enabled","throttled":true}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "cancellable": {
      "allowed": true,
      "disclosed_at": "2026-07-11T08:12:30Z",
      "reason": "code_disclosed"
    },
    "external_ref": "po-1001",
    "iccid": "8984012345678901234",
    "id": "018f2b00-0000-7000-8000-00000000009a",
    "install": {
      "android_url": "https://esimsetup.google.com/esim_qrcode_provisioning?data=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
      "apn": "<apn>",
      "ios_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24smdp.example.com%24MATCH-ABC123",
      "lpa": "LPA:1$smdp.example.com$MATCH-ABC123",
      "matching_id": "MATCH-ABC123",
      "qr_code_base64": "<PNG base64 — đã rút gọn>",
      "smdp_address": "smdp.example.com"
    },
    "mode": "live",
    "msisdn": "+84900000000",
    "order_id": "018f2b00-0000-7000-8000-00000000009a",
    "package_code": "TKE-VN-30D-X3K9F",
    "package_name": "Tên gói hiển thị",
    "plan": {
      "current_code": "TKE-VN-30D-X3K9F",
      "expires_at": "2026-08-10T00:00:00Z",
      "started_at": "2026-07-11T08:12:30Z"
    },
    "simulated": {
      "esim_state": "released",
      "last_via": "open_api",
      "via": "open_api"
    },
    "smdp_status": "released",
    "status": "delivered",
    "updated_at": "2026-07-11T09:03:11Z",
    "usage": {
      "expires_at": "2026-08-10T00:00:00Z",
      "remaining_bytes": 5368709120,
      "source": "poll",
      "total_bytes": 5368709120,
      "updated_at": "2026-07-11T09:03:11Z",
      "used_bytes": 5368709120
    }
  },
  "ok": true
}

Sample Request Body

json
{
  "data_used_mb": 4096,
  "expire_at": "2026-07-11T08:12:30Z",
  "expire_now": true,
  "provider_reject": true,
  "status": "enabled",
  "throttled": true
}
POST/v1/testmode/reset

Reset test mode data (test keys only)

scope orders:writepost_testmode_reset

Required scope: orders:write.

Headers (1)

Idempotency-Keystringoptional

Request bodyoptionalapplication/json

FieldTypeConstraintsDescription
reasonstring
—

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
esim_states_deletedinteger · int64
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
order_items_deletedinteger · int64
—
orders_deletedinteger · int64
—
wallet_balanceinteger · int64

Test wallet balance AFTER the reset — always 0. The ledger is NOT deleted: the wallet is zeroed by an offsetting entry.

okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspendedtestmode.live_key_forbidden
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="POST"
REQ_PATH="/v1/testmode/reset"   # them query thi noi thang vao day TRUOC khi ky
BODY='{"reason":"Reset test data"}'

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Sample Response

json
{
  "data": {
    "esim_states_deleted": 1,
    "mode": "live",
    "order_items_deleted": 1,
    "orders_deleted": 1,
    "wallet_balance": 1
  },
  "ok": true
}

Sample Request Body

json
{
  "reason": "Reset test data"
}
POST/v1/testmode/seed

Seed test mode sample data (test keys only)

scope orders:writepost_testmode_seed

Required scope: orders:write.

Headers (1)

Idempotency-Keystringoptional

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
already_seededboolean
—
esims_createdinteger
—
modestring

The calling key environment, matching the X-TKE-Mode header. Organization keys use tke_live_/tke_test_; member keys use tke_member_live_/tke_member_test_. Live and testing hosts enforce the key mode.

testlive
orders_createdinteger
—
seededboolean
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspendedtestmode.live_key_forbidden
422
testmode.no_sellable_package
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="POST"
REQ_PATH="/v1/testmode/seed"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG" \
  -H "Idempotency-Key: $(uuidgen)"

Sample Response

json
{
  "data": {
    "already_seeded": true,
    "esims_created": 1,
    "mode": "live",
    "orders_created": 1,
    "seeded": true
  },
  "ok": true
}
GET/v1/testmode/status

Test mode overview (test keys only)

scope esims:readget_testmode_status

Required scope: esims:read.

Response

200Success

Success

FieldTypeConstraintsDescription
dataobject
required
—
base_url_testingstring

Test-mode host. Use tke_test_ or tke_member_test_ keys only; calling the live host returns 403 partner.wrong_host.

capabilitiesobject
—
bulk_ordersboolean
—
cancel_refundboolean
—
refund_requestsboolean
—
webhooksboolean
—
checklistobject[]

Integration milestones are derived from actual API traces for the same owner and test mode. The webhook step applies to organizations only.

doneboolean
—
labelstring
—
last_atstring · date-time
nullable
—
stepstring
—
driftobject

The simulated usage loop: enabled state, interval and last run. Threshold webhooks are emitted only for organizations with webhook support.

enabledboolean
—
interval_secsinteger · int64
—
last_run_atstring · date-time
nullable
—
modestring
—
testlive
readyboolean

Whether the key owner has completed every supported checklist step. Members do not need a webhook test.

sample_countsobject
—
test_esimsinteger · int64
—
test_ordersinteger · int64
—
seededboolean

Whether the key owner already has sample test orders and eSIMs.

test_key_countinteger · int64
—
wallet_balanceinteger · int64

Test wallet balance in virtual VND for the key owner, isolated from the live wallet.

webhookobject
—
endpoint_urlstring
nullable
—
fail_nextinteger
—
statusstring
nullable
—
okboolean
required
—
true

Error Responses

All failure branches return the envelope { ok: false, error } with the corresponding HTTP status code, never 200 for errors. See the Error Codes page for code details.

400
internal.validation_failed
401
partner.invalid_keypartner.invalid_signaturepartner.timestamp_expiredpartner.nonce_reused
403
partner.ip_not_allowedpartner.scope_deniedpartner.key_suspendedpartner.key_revokedorg.suspendedtestmode.live_key_forbidden
429
partner.rate_limited
503
partner.endpoint_disabledinternal.service_unavailable

Sample Invocation

All TKE-VN-30D-X3K9F values are placeholders you must supply. Query real package codes via GET /v1/catalog/packages.

bash
#!/usr/bin/env bash
# Can: bash + openssl + uuidgen.

KEY_ID="tke_test_THAY-BANG-KEY-ID-CUA-BAN"
SECRET="THAY-BANG-SECRET-CUA-BAN"

METHOD="GET"
REQ_PATH="/v1/testmode/status"   # them query thi noi thang vao day TRUOC khi ky
BODY=""

TS="$(date +%s000)"                 # epoch milliseconds
NONCE="$(uuidgen)"
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
SIGNING="$METHOD"$'\n'"$REQ_PATH"$'\n'"$TS"$'\n'"$NONCE"$'\n'"$BODY_HASH"
SIG="$(printf '%s' "$SIGNING" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"

curl -sS -X "$METHOD" "https://api-testing.tongkhoesim.com$REQ_PATH" \
  -H "Accept-Language: en" \
  -H "X-TKE-Key: $KEY_ID" \
  -H "X-TKE-Timestamp: $TS" \
  -H "X-TKE-Nonce: $NONCE" \
  -H "X-TKE-Signature: $SIG"

Sample Response

json
{
  "data": {
    "base_url_testing": "<base_url_testing>",
    "capabilities": {
      "bulk_orders": true,
      "cancel_refund": true,
      "refund_requests": true,
      "webhooks": true
    },
    "checklist": [
      {
        "done": true,
        "label": "<label>",
        "last_at": "2026-07-11T08:12:30Z",
        "step": "<step>"
      }
    ],
    "drift": {
      "enabled": true,
      "interval_secs": 1,
      "last_run_at": "2026-07-11T08:12:30Z"
    },
    "mode": "test",
    "ready": true,
    "sample_counts": {
      "test_esims": 1,
      "test_orders": 1
    },
    "seeded": true,
    "test_key_count": 1,
    "wallet_balance": 1,
    "webhook": {
      "endpoint_url": "https://partner.example.com/hooks/tke",
      "fail_next": 1,
      "status": "completed"
    }
  },
  "ok": true
}