Kaka eSIMAPIv1

API Reference

Overview

This API lets you browse destinations and data packages, place eSIM orders, track usage and cancel eSIMs that have not been installed yet — all from your own code. Every order is charged against a balance held on your account, so there is no checkout step and no card handling on your side.

Two kinds of accounts can call it: approved resellers with a partner key, and ordinary customers with a personal key they generate themselves. The endpoints, the request format and the response shapes are identical for both — only the price and the wallet being charged differ. See the next section.

Base URL
https://kakaesim.com/api/v1

All endpoints below are relative to that base URL. Requests and responses are JSON over HTTPS. Plain HTTP is not supported.

Two kinds of keys

The prefix of your key decides how you are billed. You never mix the two: a key belongs to exactly one account, and an account only ever sees its own orders and its own eSIMs.

 Partner keyPersonal key
Prefixmk_live_…uk_live_…
How to get oneApply with our team. We create the merchant account and hand you the key once.Generate it yourself under Account → Settings → Developer. Instant, no approval.
PricingYour contracted wholesale rate, agreed per account.The public retail price — exactly what the website shows.
Charged toA separate prepaid merchant balance, topped up by invoice.Your normal wallet balance, the same one the website spends.
Where orders appearOnly through this API, under your own out_trade_no.In “My orders” on the website, alongside orders you placed by hand.
Cancellation refundsCredited back to your balance immediately.Marked refund_pending, then paid back to your wallet once the refund is confirmed — same as cancelling on the website.
IP allowlistOptional, configured by support.Not available.

Personal keys have two conditions attached. Your wallet balance must be at least $10.00 at the moment you create a key (spending it afterwards is fine — only creation is gated), and personal API access can be switched off site-wide, in which case every uk_live_ key stops working and returns error 1005. Partner keys are never affected by either.

Everything else in this document — authentication headers, response envelope, error codes, rate limits, idempotency, the endpoint list — applies to both key types unchanged.

Authentication

A merchant account gets a single partner key that looks like mk_live_…; a customer can hold several personal keys that look like uk_live_…. Either way the key is shown once, at creation, and stored only as a hash on our side — we cannot show it to you again, so keep it somewhere safe. Send it on every request in either of these two headers:

Headers
# Partner key
Authorization: Bearer mk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or
X-API-Key: mk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# Personal key — the same two headers, different prefix
Authorization: Bearer uk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

For partner keys we can optionally pin your account to a list of source IP addresses. When an allowlist is configured, requests from any other address are rejected with error 1003. Tell support which addresses your servers use, and remember to update the list before you migrate servers.

If a partner key leaks, ask support to reset it. If a personal key leaks, revoke it yourself under Account → Settings → Developer and create a new one. Both take effect immediately: the old key stops working on the very next call.

Keep the key in a server-side environment variable. Never ship it in frontend code or a mobile app, and never commit it — anyone holding it can spend your balance.

Response format

Every response uses the same envelope. A successful call always returns HTTP 200 with code set to 0 and the payload under data.

Success
{
  "code": 0,
  "data": { ... }
}

A failure returns a non-zero code, a human readable English message, and a matching HTTP status. Some errors add extra top level fields, such as retry_after on rate limiting or provider_status on provisioning failures.

Failure
{
  "code": 3001,
  "message": "Insufficient balance. Please top up your merchant account."
}

Always branch on code rather than on the message text. Messages are meant for your logs and may be reworded without notice.

Error codes

code comes from a fixed table: 1xxx is about identity and quota, 2xxx about the request itself, 3xxx about money, and 5xxx means something broke on our side or on the provisioning network.

CodeNameHTTPMeaning
1001UNAUTHORIZED401API key is missing, invalid or revoked.
1002SUSPENDED403Merchant account is suspended. Contact support.
1003IP_NOT_ALLOWED403Request IP is not in the merchant IP allowlist.
1004RATE_LIMITED429Rate limit exceeded. The body carries retry_after (seconds) when the limit is per account.
1005USER_API_DISABLED403Personal API access is switched off site-wide. Only personal keys (uk_live_) can hit this; partner keys are unaffected.
2001BAD_REQUEST400Malformed body or invalid/missing parameter.
2002PACKAGE_NOT_FOUND404Package slug or destination does not exist.
2003ORDER_NOT_FOUND404No order with that out_trade_no under your account.
2004ESIM_NOT_FOUND404eSIM does not exist, or does not belong to your account.
2005ORDER_NOT_CANCELABLE400 / 409Order status does not allow cancellation, or the order contains more than one eSIM.
2006ESIM_NOT_CANCELABLE400The eSIM is already installed, in use, expired or canceled.
2007DUPLICATE_ORDER409Duplicate out_trade_no that could not be resolved to the original order (very rare).
3001INSUFFICIENT_BALANCE402Balance is too low. No order is created and nothing is charged.
3002PURCHASE_FAILED_REFUNDED502Provisioning failed. The order is marked failed and fully refunded to your balance.
5001PROVIDER_ERROR502The provisioning network returned an error or timed out.
5002SERVICE_UNAVAILABLE503Ordering is temporarily disabled on our side.
5000INTERNAL_ERROR500Unexpected internal error.

Rate limits

Limits are counted in a rolling 60 second window. When a limit is hit you get HTTP 429 with code 1004 and, for per account limits, a retry_after value in seconds.

FieldTypeDescription
All endpoints120 / 60sPer account, across every endpoint.
POST /orders30 / 60sExtra limit on order creation, on top of the global one.
POST /esims/{id}/cancel30 / 60sExtra limit on cancellation, on top of the global one.
Failed authentication30 / 60sCounted per source IP address, to block key guessing.

Partner and personal keys use separate counter buckets, so throttling on one side never affects the other.

Poll order status at a sane interval. A few seconds between polls is plenty, and package data can safely be cached on your side for minutes.

Amounts and units

FieldTypeDescription
MoneystringEvery monetary value is a USD decimal string with at least two decimals, for example 12.34. Values that need more precision keep up to four decimals, for example 1.2345, so nothing is ever rounded away from your statement. Parse with a decimal type, not a float, if you do your own bookkeeping.
currencystringAlways USD. There is no multi currency settlement on this API.
volumeintegerData allowance in bytes. 0 means unlimited.
data_usage / total_dataintegerUsage counters in bytes, as reported by the network.
duration / duration_unitinteger / stringValidity once activated, for example 7 plus DAY.
TimestampsstringUTC date-time strings. Treat them as opaque and format on your side.

Partner prices are calculated from the rate agreed for your account; personal keys simply see the retail price. Either way, the price in /packages is exactly what gets deducted from your balance.

Endpoints

Unless stated otherwise, every endpoint below behaves identically for both key types — only the price and the account being charged differ.

Get account balance

GET/api/v1/balance

Returns the live balance and the current state of your account. Call it before a batch of orders to make sure you will not run dry halfway through.

Request
curl -s https://kakaesim.com/api/v1/balance \
  -H "Authorization: Bearer $API_KEY"
Response (partner key)
{
  "code": 0,
  "data": {
    "merchant_code": "acme-travel",
    "balance": "1420.50",
    "currency": "USD",
    "markup": 1.5,
    "status": "active"
  }
}

With a personal key the same endpoint reports your wallet balance — the very same number the website shows — and leaves out the merchant-only fields.

Response (personal key)
{
  "code": 0,
  "data": {
    "account_type": "user",
    "email": "[email protected]",
    "balance": "42.00",
    "currency": "USD",
    "status": "active"
  }
}

List destinations

GET/api/v1/destinations

All countries, regions and global plans we sell, each with the lowest price available to your account. Use the returned slug to fetch packages.

Query parameters

FieldTypeDescription
sortstringpopular (default) or alphabetical. Any other value falls back to popular.
Request
curl -s "https://kakaesim.com/api/v1/destinations?sort=alphabetical" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "code": 0,
  "data": {
    "destinations": [
      {
        "slug": "japan",
        "name": "Japan",
        "location_name": "Japan",
        "type": "country",
        "from": "0.68"
      }
    ],
    "total": 186
  }
}

type is one of country, region or global.

List packages for a destination

GET/api/v1/packages

Query parameters

FieldTypeDescription
destinationrequiredstringDestination slug from /destinations, for example japan. Missing or empty returns error 2001; unknown returns 2002.
Request
curl -s "https://kakaesim.com/api/v1/packages?destination=japan" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "code": 0,
  "data": {
    "destination": "japan",
    "packages": [
      {
        "slug": "japan-3gb-7days",
        "package_code": "CKH123",
        "name": "Japan 3GB / 7 Days",
        "price": "4.35",
        "currency": "USD",
        "volume": 3221225472,
        "duration": 7,
        "duration_unit": "DAY",
        "location": "JP",
        "data_type": 1,
        "sms_support": false,
        "speed": "5G",
        "unused_valid_days": 180,
        "support_topup": true,
        "description": "Data only eSIM for Japan.",
        "fup_policy": null,
        "networks": [
          { "location_name": "Japan", "operators": ["NTT DOCOMO", "SoftBank"] }
        ]
      }
    ],
    "total": 12
  }
}

Package fields

FieldTypeDescription
slugstringStable package identifier. This is what you send when placing an order.
package_codestringProvider side product code, useful for support tickets.
pricestringYour price for one eSIM, in USD.
volumeintegerData allowance in bytes, 0 for unlimited.
data_typeinteger1 total data, 2 daily data, 3 unlimited, 4 daily unlimited.
sms_supportbooleanWhether the plan carries SMS on top of data.
unused_valid_daysintegerHow long the eSIM stays usable before it is first activated.
support_topupbooleanWhether the eSIM can be topped up after the plan is used up.
fup_policystring | nullFair usage policy text for unlimited plans, when the provider states one.
networksarrayCovered locations with their carrier names.

Get one package

GET/api/v1/packages/{slug}

Same object as one entry of the list above, wrapped in package. Handy to refresh the price of a single plan right before you charge your own customer.

Request
curl -s https://kakaesim.com/api/v1/packages/japan-3gb-7days \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "code": 0,
  "data": {
    "package": { "slug": "japan-3gb-7days", "price": "4.35", "...": "..." }
  }
}

Prices here may be served from a short cache. The amount that is actually charged is always re-read live at order time, so treat this value as indicative and read order.amount from the order response as the source of truth.

Place an order

POST/api/v1/orders

Creates an order, deducts your balance and provisions the eSIM in a single call. The response comes back only after provisioning has been attempted, so expect it to take a few seconds.

Body (application/json)

FieldTypeDescription
out_trade_norequiredstringYour own order number, 1 to 64 characters matching A-Z a-z 0-9 - _ .. Unique per account and used as the idempotency key.
package_slugrequiredstringPackage slug to buy.
countintegerHow many eSIMs to buy, 1 to 10. Defaults to 1.
Request
curl -s -X POST https://kakaesim.com/api/v1/orders \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "out_trade_no": "SHOP-20260807-0001",
    "package_slug": "japan-3gb-7days",
    "count": 1
  }'
Response
{
  "code": 0,
  "data": {
    "order": {
      "out_trade_no": "SHOP-20260807-0001",
      "package_slug": "japan-3gb-7days",
      "package_name": "Japan 3GB / 7 Days",
      "count": 1,
      "amount": "4.35",
      "status": "paid",
      "esim_id": "e7c1f0b2a1d4",
      "created_at": "2026-08-07 09:12:44",
      "esim": {
        "id": "e7c1f0b2a1d4",
        "status": "GOT_RESOURCE",
        "qr_code_url": "https://.../qr/e7c1f0b2a1d4.png",
        "ac": "LPA:1$rsp.example.com$K2-XXXX-XXXX",
        "short_url": "https://.../s/abcd12",
        "data_usage": 0,
        "total_data": 3221225472,
        "expired_time": null,
        "last_usage_update": null
      }
    }
  }
}

Order fields

FieldTypeDescription
statusstringpending being provisioned, paid delivered, failed provisioning failed and refunded, refunded canceled and refunded (personal keys pass through refund_pending first).
amountstringTotal charged for the order, unit price multiplied by count.
esim_idstring | nullIdentifier of the delivered eSIM, used by the usage and cancel endpoints.
esimobject | nullDelivery details. Null when the eSIM cannot be read from the provider at that moment.
esim.acstring | nullLPA activation string for manual installation, when the provider supplies one.
esim.qr_code_urlstring | nullReady made QR image to show to your customer.
esim.statusstring | nullProvider status, passed through as is. Common values are GOT_RESOURCE, RELEASED, NEW, IN_USE, USED_UP, EXPIRED, CANCEL.

For multi eSIM orders only the first eSIM identifier is attached to the order. In rare cases the identifier cannot be matched right away and esim_id comes back null even though the order is paid; poll the order a few seconds later, or contact support if it stays empty.

Query an order

GET/api/v1/orders/{out_trade_no}

Looks up one of your own orders by your order number and returns the same object as the create call, including live eSIM status and usage. Orders belonging to anyone else are never visible.

Request
curl -s https://kakaesim.com/api/v1/orders/SHOP-20260807-0001 \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "code": 0,
  "data": {
    "order": {
      "out_trade_no": "SHOP-20260807-0001",
      "status": "paid",
      "amount": "4.35",
      "esim_id": "e7c1f0b2a1d4",
      "esim": { "status": "IN_USE", "data_usage": 154140672, "...": "..." },
      "...": "..."
    }
  }
}

Remember to URL encode the order number if it contains characters such as a dot at the end of a path segment.

Query eSIM usage

GET/api/v1/esims/{id}/usage

Lightweight usage lookup for a single eSIM. The eSIM must belong to one of your orders, otherwise the call returns 2004.

Request
curl -s https://kakaesim.com/api/v1/esims/e7c1f0b2a1d4/usage \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "code": 0,
  "data": {
    "esim_id": "e7c1f0b2a1d4",
    "out_trade_no": "SHOP-20260807-0001",
    "data_usage": 154140672,
    "total_data": 3221225472,
    "last_usage_update": "2026-08-07 11:02:10"
  }
}

Counters are in bytes and are refreshed by the network on its own schedule, so last_usage_update can lag behind real time by a few minutes.

Cancel an unused eSIM

POST/api/v1/esims/{id}/cancel

Cancels an eSIM that has never been installed and refunds the full order amount. There is no request body.

With a partner key the refund lands on your merchant balance immediately and status comes back as refunded. With a personal key the order moves to refund_pending instead: the money is paid back to your wallet once the refund is confirmed, exactly like cancelling from the website. In that case refunded is "0.00" and the amount awaiting payout is in pending_refund.

Request
curl -s -X POST https://kakaesim.com/api/v1/esims/e7c1f0b2a1d4/cancel \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "code": 0,
  "data": {
    "esim_id": "e7c1f0b2a1d4",
    "out_trade_no": "SHOP-20260807-0001",
    "status": "refunded",
    "refunded": "4.35",
    "balance": "1424.85"
  }
}

If the cancellation fails you get 5001 and no refund is issued, so the eSIM stays valid and you can retry later. The state transition is atomic, so retrying a cancellation that already went through can never refund you twice.

Ordering rules

Idempotency

out_trade_no is unique per account (per merchant for partner keys, per customer for personal keys) and is the idempotency key. Submitting the same order number twice never charges you twice: the second call skips provisioning entirely and returns the original order with its current status. This holds for concurrent retries as well, so it is safe to retry on a network timeout with the exact same order number.

Generate the order number on your side before the first attempt and store it. Never reuse an order number for a different package.

Insufficient balance

Order creation and the balance deduction happen in one transaction. If the balance would go negative the whole thing is rolled back: you get HTTP 402 with code 3001, no order is recorded and nothing is charged. Top up and submit the same order number again.

Provisioning failure

If provisioning fails after we have already charged you, the order is marked failed and the full amount is refunded to your balance automatically. The call returns HTTP 502 with code 3002. Because that order number is now consumed by a failed order, retry with a new one.

Cancellation and refunds

An eSIM can be canceled only when all of these hold:

  • the order is in status paid;
  • the order contains exactly one eSIM, so orders placed with count above 1 must be handled by support;
  • the provider still reports the eSIM as not installed, that is GOT_RESOURCE, RELEASED or NEW.

Once the eSIM has been installed or activated it cannot be canceled and the call returns 2006. With a partner key a successful cancellation refunds the full order amount to your prepaid balance right away, moves the order to refunded, and appears on your balance statement; with a personal key the order goes to refund_pending first and the wallet is credited once the refund is confirmed. Partial refunds are not supported either way.

No callbacks

The API does not push webhooks today. Order results are returned synchronously by the create call, and any later change in eSIM status or usage should be picked up by polling GET /orders/{out_trade_no} or GET /esims/{id}/usage.

Node.js example

A complete order flow with authentication, error handling and a safe retry. Runs on Node 18 or newer, no dependencies.

order.mjs
const BASE_URL = 'https://kakaesim.com/api/v1'
const API_KEY = process.env.KAKA_API_KEY

class ApiError extends Error {
  constructor(code, message, status) {
    super(message)
    this.code = code
    this.status = status
  }
}

async function call(path, { method = 'GET', body } = {}) {
  const res = await fetch(BASE_URL + path, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  })

  let payload
  try {
    payload = await res.json()
  } catch {
    throw new ApiError(-1, `Malformed response (HTTP ${res.status})`, res.status)
  }

  // code === 0 is the only success signal; the HTTP status is just a category
  if (payload.code !== 0) {
    const err = new ApiError(payload.code, payload.message, res.status)
    err.retryAfter = payload.retry_after ?? null
    throw err
  }
  return payload.data
}

async function placeOrder(outTradeNo, packageSlug, count = 1) {
  try {
    const { order } = await call('/orders', {
      method: 'POST',
      body: { out_trade_no: outTradeNo, package_slug: packageSlug, count },
    })
    return order
  } catch (err) {
    if (!(err instanceof ApiError)) {
      // Network failure: the order may or may not exist. Retrying with the
      // same out_trade_no is safe and returns the original order if there is one.
      return call(`/orders/${encodeURIComponent(outTradeNo)}`)
        .then((d) => d.order)
        .catch(() => { throw err })
    }
    switch (err.code) {
      case 3001: // insufficient balance, nothing was charged
        throw new Error('Top up your balance and retry the same order number.')
      case 3002: // provisioning failed, fully refunded — use a NEW order number
        throw new Error('Provisioning failed and was refunded. Retry with a new order number.')
      case 1004: // rate limited
        throw new Error(`Rate limited, retry in ${err.retryAfter ?? 60}s.`)
      default:
        throw err
    }
  }
}

async function main() {
  const { balance } = await call('/balance')
  console.log('Balance:', balance, 'USD')

  const { packages } = await call('/packages?destination=japan')
  const pick = packages[0]
  console.log('Buying:', pick.name, pick.price, 'USD')

  const outTradeNo = `SHOP-${Date.now()}`
  const order = await placeOrder(outTradeNo, pick.slug, 1)

  console.log('Order:', order.out_trade_no, order.status, order.amount)
  console.log('eSIM:', order.esim_id)
  console.log('Activation:', order.esim?.ac ?? order.esim?.qr_code_url ?? 'pending')
}

main().catch((err) => {
  console.error('Failed:', err.code ?? '', err.message)
  process.exitCode = 1
})

Support

Questions about onboarding, IP allowlisting, balance top ups, key resets or a stuck order? Send us the merchant code (or your account email for personal keys) and the out_trade_no involved, and we will take a look.