{
  "envelope": {
    "description": "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`.",
    "fields": {
      "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."
    }
  },
  "health_check": {
    "data_fields": [
      "message"
    ],
    "event_type": "ping",
    "note": "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`.",
    "when": "Every `POST /v1/webhooks` and `PUT /v1/webhooks/{id}` checks health before saving the endpoint"
  },
  "items": [
    {
      "data_fields": [
        "order_id",
        "external_ref",
        "items[] (order_item_id, iccid)"
      ],
      "event_type": "order.completed",
      "note": "",
      "when": "Order committed; eSIMs are ready"
    },
    {
      "data_fields": [
        "order_id",
        "external_ref",
        "reason"
      ],
      "event_type": "order.failed",
      "note": "",
      "when": "Order did not complete (provisioning failure, etc.)"
    },
    {
      "data_fields": [
        "order_item_id",
        "order_id",
        "external_ref",
        "iccid",
        "lpa"
      ],
      "event_type": "esim.delivered",
      "note": "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}`.",
      "when": "An eSIM was provisioned with an installation payload"
    },
    {
      "data_fields": [
        "order_item_id",
        "order_id",
        "external_ref",
        "iccid",
        "activated_at"
      ],
      "event_type": "esim.activated",
      "note": "Use `order_id` and `external_ref` (your order reference sent at purchase) to match this eSIM to your order.",
      "when": "The eSIM was installed and activated (smdp enabled/in_use)"
    },
    {
      "data_fields": [
        "order_item_id",
        "order_id",
        "external_ref",
        "threshold (75/90)",
        "used_bytes",
        "data_bytes"
      ],
      "event_type": "esim.usage_threshold",
      "note": "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.",
      "when": "Usage reached the 75% / 90% threshold"
    },
    {
      "data_fields": [
        "order_item_id",
        "order_id",
        "external_ref",
        "days_remaining (3/1)",
        "expires_at"
      ],
      "event_type": "esim.expiring",
      "note": "Use `order_id` and `external_ref` (your order reference sent at purchase) to match this eSIM to your order.",
      "when": "3 days / 1 day before expiry"
    },
    {
      "data_fields": [
        "threshold (25/10)",
        "balance",
        "currency"
      ],
      "event_type": "wallet.low_balance",
      "note": "`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`.",
      "when": "Wallet reached the 25% / 10% warning threshold"
    },
    {
      "data_fields": [
        "changes[] (package_code, change: price|availability|package, occurred_at)",
        "change_count"
      ],
      "event_type": "catalog.updated",
      "note": "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.",
      "when": "Catalog changed: package added, edited or hidden, base price changed, or out of stock"
    }
  ]
}
