Webhooks
The system delivers events to URLs registered via POST /v1/webhooks (updated via PUT /v1/webhooks/{id}; your endpoint must return 2xx for CHECK_HEALTH upon creation). Webhook dispatch is an outbound push channel signed with a dedicated secret, independent of your API key secret.
Webhooks are a convenience stream, not the source of truth. REST is the ultimate source of truth: even if your webhook receiver drops an event, GET /v1/orders?updated_since=… fully reconstructs accurate system state. Design your architecture with this guarantee in mind.
webhook-events.en.jsonEvent Catalog
| event_type | When triggered | Key data fields |
|---|---|---|
order.completed | Order committed; eSIMs are ready | order_idexternal_refitems[] (order_item_id, iccid) |
order.failed | Order did not complete (provisioning failure, etc.) | order_idexternal_refreason |
esim.delivered | An eSIM was provisioned with an installation payload Match by `order_id` and `external_ref`. `lpa` is decrypted just before POST, never stored as plaintext in deliveries. No QR image URL is sent: generate QR from `lpa` or fetch `qr_code_base64` through `GET /v1/esims/{id}`. | order_item_idorder_idexternal_reficcidlpa |
esim.activated | The eSIM was installed and activated (smdp enabled/in_use) Use `order_id` and `external_ref` (your order reference sent at purchase) to match this eSIM to your order. | order_item_idorder_idexternal_reficcidactivated_at |
esim.usage_threshold | Usage reached the 75% / 90% threshold Match the eSIM to your order using `order_id` and `external_ref`. Only the highest newly crossed threshold is emitted (0→95% sends only 90). `threshold` means at least this level has been exceeded. | order_item_idorder_idexternal_refthreshold (75/90)used_bytesdata_bytes |
esim.expiring | 3 days / 1 day before expiry Use `order_id` and `external_ref` (your order reference sent at purchase) to match this eSIM to your order. | order_item_idorder_idexternal_refdays_remaining (3/1)expires_at |
wallet.low_balance | Wallet reached the 25% / 10% warning threshold `threshold` is a percentage (25/10), included only when a real percentage is known; otherwise omitted (compare `balance` yourself). Never send raw VND under `threshold`. | threshold (25/10)balancecurrency |
catalog.updated | Catalog changed: package added, edited or hidden, base price changed, or out of stock No price is included because prices differ by organization. Call `GET /v1/catalog/packages` for your prices. Changes within a short window (default 10 minutes) are merged into one `changes[]` webhook; each package appears once. A sync of hundreds of packages does not send hundreds of webhooks. Applies to test and live modes. | changes[] (package_code, change: price|availability|package, occurred_at)change_count |
event_type: "ping"Every `POST /v1/webhooks` and `PUT /v1/webhooks/{id}` checks health before saving the endpoint.
Cannot subscribe or unsubscribe through `events` (`ping` there returns `internal.validation_failed`); every endpoint receives it. Uses the real envelope and signature (`X-TKE-Webhook-Signature`, no `-Old` header), so use it to verify signing. Your handler must return 2xx for unknown `event_type`; 4xx makes endpoint creation/update fail with `webhook.health_check_failed` (422), saving nothing. `ping` is absent from `GET /v1/webhooks/deliveries`.
Ensure your webhook handler recognizes this event type to prevent registration failures.
{
"event_id": "018f3c00-0000-7000-8000-0000000000c1",
"event_type": "ping",
"occurred_at": "2026-07-11T07:29:00Z",
"is_testmode": true,
"data": {
"message": "TongKho eSIM webhook health check"
}
}Webhook Envelope & Signature Headers
All webhooks use one envelope. Sign `HMAC-SHA256(webhook_secret, timestamp\nraw_body)` in `X-TKE-Webhook-Signature` (lowercase hex). During the 24-hour rotation window also send `X-TKE-Webhook-Signature-Old`. At-least-once delivery; deduplicate by `event_id`.
{
"event_id": "018f3c00-0000-7000-8000-0000000000b0",
"event_type": "order.completed",
"occurred_at": "2026-07-11T07:30:00Z",
"is_testmode": false,
"data": {
"order_id": "018f2b00-0000-7000-8000-00000000009a",
"external_ref": "partner-cart-9931",
"items": [
{
"order_item_id": "018f2b00-0000-7000-8000-000000000001",
"iccid": "8984012345678901234"
}
]
}
}| Field | Description |
|---|---|
data | Payload by event type (see below). |
event_id | Stable UUID unique to each event (deduplication). |
event_type | Event type. |
is_testmode | true if generated by a test key. |
occurred_at | ISO-8601 UTC. |
Catalog Sync: catalog.updated
Emitted when packages change: new/edited/hidden packages, base price changes, or out-of-stock. Payload contains an aggregated changes[] array: hundreds of package updates result in one delivery per endpoint.
{
"event_id": "018f3c00-0000-7000-8000-0000000000c1",
"event_type": "catalog.updated",
"occurred_at": "2026-07-11T08:10:00Z",
"is_testmode": false,
"data": {
"changes": [
{
"package_code": "THAY-BANG-MA-GOI-CUA-BAN",
"change": "price",
"occurred_at": "2026-07-11T08:02:11Z"
},
{
"package_code": "VN_10GB_30D",
"change": "availability",
"occurred_at": "2026-07-11T08:04:52Z"
},
{
"package_code": "JP_3GB_7D",
"change": "package",
"occurred_at": "2026-07-11T08:09:30Z"
}
],
"change_count": 3
}
} Selling prices are organization-specific . Emitting a single rate would be both inaccurate and a confidentiality leak. This event is a trigger : call GET /v1/catalog/packages?market=… to refresh your pricing.
change accepts three values: price (base cost change), availability (in/out of stock), package (new / updated / hidden). If an unexpected value appears, treat it as a trigger to sync.
Signing, retry/backoff, and dedupe by event_id match standard events. Applies across both test and live environments, separated by is_testmode.
How to Verify Webhook Signatures
Every outgoing webhook carries an X-TKE-Signature header computed using HMAC-SHA256 over the webhook_secret and the raw request body bytes.
signing_string = timestamp + "\n" + raw_body # timestamp = header X-TKE-Webhook-Timestamp
signature = HMAC_SHA256(webhook_secret, signing_string) → hex lowercase
# Verify freshness (±300s) to prevent replay.
# Sign over exact received raw bytes. DO NOT re-serialize JSON.Retry Backoff Policy
If your receiver fails (non-2xx response) or times out after 5 seconds, the system retries exponentially over a 24 giờ window: 1 phút → 5 phút → 30 phút → 2 giờ → 6 giờ.
4 Critical Webhook Guidelines
Field Notes & Behaviors
esim.delivered.lpa is decrypted right before POST dispatch (not stored plaintext). Payload does not include QR image URL: construct QR from LPA or fetch via GET /v1/esims/{id}.
esim.usage_threshold: fires highest exceeded threshold only (jump from 0→95% emits threshold=90 only).
wallet.low_balance.threshold is a percentage (25/10), not a raw VND amount.
catalog.updated contains no pricing or granular inventory: only package code + change type.