$10$20Get the same amount as a giftOn top-ups of $10 or more, up to $125, valid 90 days. Ends Oct 25, 2026 · 11 days leftTop up nowTop up
Login Register
API v3 · REST · JSON

Sell travel eSIMs from your own app

The eSIMCenter Partner API gives you our full catalog — 200+ destinations, plans from several providers — and lets you buy an eSIM from your prepaid wallet with a single POST. The QR code comes back in the same response.

Environment

Test key, fake eSIMs, no charges. Start here. Live key: real orders, charged to your wallet.

Sandbox URL
https://sandbox-api.esimcenter.com/v3selected
Live URL
https://api.esimcenter.com/v3selected
Auth header
X-API-Key: esk_test_YOUR_SANDBOX_KEY

JSON over HTTPS, UTF-8 · 360–900 requests / minute per key · the same endpoints on both addresses.

Get an API key

Sign in and create your own key from the developer panel — no waiting on us. Want a scheduled-expiry key instead of the default no-expiry one? ask us.

Getting started

Three steps from nothing to a live integration. Start in the sandbox: the same API on its own address, with a test key, fake eSIMs and no charges.

Step 1 — Get a key

Sign in to the developer panel and pick Sandbox in the key form. A sandbox key starts with esk_test_. The full key is shown once; we keep only a hash, so a lost key cannot be recovered, only replaced.

Step 2 — Make your first request

Export the address and key once. Every shell sample on this page uses these two variables.

Shell
export BASE="https://sandbox-api.esimcenter.com/v3"
export KEY="esk_test_YOUR_SANDBOX_KEY"

List the plans for one country. A 200 with "result": "ok" means your key and address are right.

cURL
curl -s "https://sandbox-api.esimcenter.com/v3/catalog/products?country=tr" \
  -H "X-API-Key: esk_test_YOUR_SANDBOX_KEY" \
  -H "Accept: application/json"
The same request in PHP and JavaScript
PHP
<?php
$ch = curl_init("https://sandbox-api.esimcenter.com/v3/catalog/products?country=tr");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        "X-API-Key: esk_test_YOUR_SANDBOX_KEY",
        "Accept: application/json",
    ],
]);
$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$data = json_decode($body, true);
if ($status !== 200 || ($data["result"] ?? "") !== "ok") {
    // Branch on $data["error"] - it is a stable machine code, never a sentence.
    throw new RuntimeException("API error: " . ($data["error"] ?? "unknown"));
}
foreach ($data["products"] as $p) {
    printf("%-34s %6s %3sd  $%s\n", $p["id"], $p["data"], $p["day"], $p["price"]);
}
JavaScript (Node 18+)
const res = await fetch("https://sandbox-api.esimcenter.com/v3/catalog/products?country=tr", {
  headers: { "X-API-Key": "esk_test_YOUR_SANDBOX_KEY", Accept: "application/json" },
});

const data = await res.json();
if (!res.ok || data.result !== "ok") {
  // data.error is a machine code: "invalid_api_key", "rate_limited", ...
  throw new Error(`API error: ${data.error}`);
}
for (const p of data.products) {
  console.log(p.id, p.data, `${p.day}d`, `$${p.price}`);
}

Then buy a test eSIM. Take any id from the catalog answer. The order is a sandbox order: your balance stays at 1000.00 and the eSIM it returns is fake. Pick the outcome with sandbox_scenario and the usage figures with sandbox_usage — see Sandbox.

curl -s -X POST "$BASE/purchase/buy" \
  -H "X-API-Key: $KEY" \
  -d "product_id=PRODUCT_ID_FROM_THE_CATALOG" \
  -d "idempotency_key=$(uuidgen)"

Step 3 — Go live

When the sandbox run is clean, switch both values. Nothing else in your code changes:

Shell · live
export BASE="https://api.esimcenter.com/v3"
export KEY="esk_YOUR_LIVE_KEY"

Create the live key in the same developer panel. A mixed-up pair fails loudly instead of spending money — see Environments. Ready-made Postman and OpenAPI files are under Downloads.

Environments

Two addresses, same endpoints and field names. What makes a request a sandbox request is the key, never a field in the request: no body or query value can turn a sandbox key into a live one or the other way round. Click an address to copy it.

EnvironmentBase URLKeyWhat happens
Sandboxhttps://sandbox-api.esimcenter.com/v3esk_test_ + 40 hex charactersNo money moves, no provider is called, the eSIM is fake. Every response carries "livemode": false.
Livehttps://api.esimcenter.com/v3esk_ + 40 hex charactersReal orders, charged to your wallet or credit line.

The key must match the host. A wrong pair is refused before anything else happens:

You sentToYou get
A live key (esk_)sandbox-api.esimcenter.com401 live_key_on_sandbox_host
A sandbox key (esk_test_)api.esimcenter.com401 sandbox_key_on_live_host
No key, an unknown key or a revoked keyeither401 api_key_required or 401 invalid_api_key
  • Both addresses serve the API only. Any other path answers a GET or HEAD with a 301 to esimcenter.com and every other method with 404.
  • The old address https://esimcenter.com/api/v3 still works and accepts both kinds of key; the key decides the mode there, as before. Existing integrations keep running.
  • You can hold up to 2 active sandbox keys. They do not count towards your live key limit.

Authentication

Every request needs your key in the X-API-Key header. Keys look like esk_ followed by 40 hex characters (sandbox: esk_test_), and we store only a hash of them.

X-API-Key: esk_3a4ef148ea886cbf51c5e2b1441e2b0de4b6ff66
Accept: application/json

Only the header is accepted. A request that carries the key as ?api_key= in the URL, or as an api_key field in the body, gets 401 api_key_header_required — even when the header is also present. Query strings end up in access logs, browser history and Referer headers, so treat a key that travelled in a URL as leaked and replace it from the developer panel.

  • One key belongs to one partner account. The wallet, the orders and the rate-limit quota all follow the account.
  • Each key has its own quota, so you can give staging and production separate keys without splitting your balance.
  • Create your own key in the developer panel — no waiting on us, and it never expires. For a key that expires on a schedule, ask us.

Locking a key to your own servers

Each key can carry an allow-list of source addresses, which you set yourself in the developer panel. Single addresses and CIDR blocks are accepted, IPv4 and IPv6, comma or newline separated — for example 203.0.113.7, 2001:db8::/32, 198.51.100.0/24. An empty list means no restriction, which is how every key starts.

A request from an address that is not on the list is refused with 403 ip_not_allowed before anything else happens — before the rate limiter, before the wallet, before any order. The refused address and the time are recorded on the key, so you can see in the panel whether the block was your own staging box or someone using a leaked key.

No key at all, a key we do not know, or a key whose account is closed — each is its own code, so you can tell a configuration mistake from a suspended account:

Request
curl -s "$BASE/account/balance" \
  -H "X-API-Key: $KEY"
Response · HTTP 401
{
    "result": "fail",
    "error": "api_key_required",
    "code": "401",
    "title": "Unsuccessful",
    "api_version": 3
}

Response envelope

Every response is JSON with the same envelope, and the HTTP status always matches the code field.

Success
{
  "result": "ok",
  "code": "200",
  "balance": 42.16,
  "title": "Successful",
  "api_version": 3
}
Failure
{
  "result": "fail",
  "error": "product_not_found",
  "code": "404",
  "title": "Unsuccessful",
  "api_version": 3
}
  • result is ok or fail, and code is the real HTTP status — you can branch on either.
  • error is a stable machine code matching ^[a-z0-9_]+$. It is never translated and never a sentence. Branch on this, not on title, which is a human label we may reword at any time.
  • Timestamps are YYYY-MM-DD HH:MM:SS in UTC. A date that is not set comes back as null, never as a zero date.
  • Money is a decimal number in USD. Data volumes are integers in MB.
  • New fields can appear in a response at any time — ignore what you do not know. Fields are never removed or retyped inside v3; a breaking change would ship as v4 with notice.

Errors

The complete list, grouped by where you meet them. Anything not on it is a bug on our side — please report it.

Retry policy we recommend. Retry 429 after the next minute boundary (or after Retry-After). Retry 500 once with the same idempotency_key. Never retry any other 4xx — the request itself is wrong and will fail again. The two 409 codes charge_in_progress and charge_incomplete are the exception: retry them with the same key.

Keys, hosts and access

HTTPerrorWhen
401api_key_requiredThe X-API-Key header is missing or empty.
401api_key_header_requiredThe request carried an api_key in the URL (?api_key=) or in the body. Keys are accepted only in the X-API-Key header, so the request is refused even when that header is also present.
401invalid_api_keyUnknown, revoked or expired key.
401live_key_on_sandbox_hostA live key (esk_) was sent to sandbox-api.esimcenter.com. The sandbox host accepts sandbox keys only; use your live key on api.esimcenter.com.
401sandbox_key_on_live_hostA sandbox key (esk_test_) was sent to api.esimcenter.com. Use it on sandbox-api.esimcenter.com, or create a live key for production. The old address esimcenter.com/api/v3 still accepts both kinds.
403ip_not_allowedThe key has a source-address allow-list and this request did not come from it.
403partner_not_foundThe key is valid but its partner account no longer exists.
403partner_disabledThe partner account has been suspended.
404unknown_endpointNo such resource under /api/v3.
404unknown_methodThe resource exists but has no such action.
405method_not_allowedWrong HTTP verb. The response carries an "allow" field.
429rate_limitedOver the per-minute ceiling of this endpoint's tier (see Rate limits). The response carries "limit_per_minute", "tier" and "retry_after", and the Retry-After header says how many seconds to wait.

Catalog

HTTPerrorWhen
400country_requiredGET /catalog/products without ?country=.
400invalid_countryMalformed country or region code.
404country_not_foundThe code is well formed but matches no country or region we sell. A country we do sell but have no plans for right now answers 200 with an empty products list instead.
400invalid_usage_meteredThe usage_metered filter was neither 0 nor 1.
400product_id_requiredMissing product id.
400invalid_productproduct_id could not be resolved, or a raw internal id was sent.
404product_not_foundUnknown product id, or the product is no longer on sale.

Purchase and billing

HTTPerrorWhen
400invalid_priceThe product has no sellable price.
402insufficient_balancePrepaid mode: wallet balance below the price. The response carries "balance" and "required".
403credit_limit_exceededPostpaid mode: this purchase would push your outstanding balance past your credit limit. The response carries "outstanding", "credit_limit", "available" and "required".
403partner_account_suspendedYour partner account is suspended: every purchase is refused in both billing modes, before any order is created or any money moves. Balance, statement and order history stay readable. The response carries "billing_mode" and "status".
402not_postpaidA postpaid-only operation on an account that runs prepaid.
409charge_in_progressAnother charge on your account is still being written. The order was NOT marked failed - retry with the same idempotency_key.
409charge_incompleteA previous charge stopped half-way and is being reconciled. The order was NOT marked failed - retry with the same idempotency_key.
409idempotency_key_conflictThat idempotency_key was already used for a different product.
400idempotency_key_too_longLonger than 191 characters. We reject rather than truncate: two long keys sharing their first 191 characters would collapse two purchases into one order. A UUID is 36 characters.
400invalid_idempotency_keyNot a plain string (an array or object), not valid UTF-8, or it contains a refused character: a 4-byte character such as an emoji, a double quote ("), a single quote ('), a backslash (\), <, >, or a control character (tab, line break, any other byte below 0x20, or 0x7F) inside the key; leading and trailing whitespace is trimmed first. Use letters, digits and - _ . : (a UUID or ULID fits; base64 with + / = works too).
500sale_insert_failedThe order row could not be created. Retry with the same idempotency_key.

Orders and account

HTTPerrorWhen
400order_id_requiredMissing order id.
404order_not_foundNo such order under your account.
500order_read_failedGET /order/get or /order/detail/{id} could not read your orders this time. Nothing was changed; retry the same request later. Treat it as unknown, not as empty: when you really have no orders the answer is 200 with an empty "order", and a missing order is 404 order_not_found.
400iccid_requiredMissing ICCID.
404usage_not_availableThe ICCID is not yours, has no usage record, or the provider did not answer.
404partner_account_not_foundGET /account/statement when we have never opened a billing account for you at all. Having one does not mean you are on invoice terms: a prepaid partner with an account gets 200 and zeros. Either way /account/balance, /account/history and /account/profitshare are the endpoints for a prepaid wallet.
500history_read_failedGET /account/history could not read your wallet movements this time. Nothing was changed; retry the same request later. Treat it as unknown, not as zero: when you really have no movements the answer is 200 with an empty "ledger".
400amount_requiredMissing or non-positive top-up amount.
400amount_out_of_rangeTop-up amount outside the accepted range.
403topup_not_availableCard top-up is not enabled for this account.
400payment_key_requiredMissing payment key on confirm.
404topup_not_foundNo pending top-up for that payment key.
400topup_failedThe payment provider rejected the intent.
400topup_confirm_failedThe payment could not be confirmed.

Webhooks

HTTPerrorWhen
404no_subscriptionPOST /webhook/test with no webhook endpoint registered for your account.
404subscription_not_foundThat subscription_id is not yours.
409no_active_subscriptionThe subscription exists but is paused or disabled.
409mode_immutableA subscription's mode (live or sandbox) is fixed when it is created and cannot be changed.
400probe_blockedPOST /webhook/probe to a URL that resolves to a private or link-local address. Refused before any request is opened; the response carries request_sent: false and a machine-readable reason.
500test_enqueue_failedThe test event could not be queued.
500webhook_read_failedGET /webhook/list, GET /webhook/deliveries (the list or a single ?id=) or POST /webhook/test could not read your webhook data this time. Nothing was changed and no test event was queued; retry the same request later. Treat it as unknown, not as empty: when you really have no subscriptions or deliveries the answer is 200 with an empty list, and a test without any subscription is 404 no_subscription.

Sandbox only

HTTPerrorWhen
400sandbox_only_actionPOST /account/sandboxReset with a live key. Resetting test data exists for sandbox keys only; nothing was deleted.
400sandbox_unsupported_actionA sandbox key called an action the sandbox does not simulate (see the behaviour table under Sandbox).
400invalid_sandbox_scenariosandbox_scenario was not empty, success, deferred or failed.
429sandbox_queue_fullYour account has reached the limit of sandbox webhook deliveries waiting to go out. POST /webhook/test is refused and the response carries "pending" and "limit"; a sandbox purchase still creates the order, but its events are not queued and the response carries "warnings": ["sandbox_queue_full"]. Space frees up as waiting deliveries go out. Live events are never limited.
500sandbox_webhook_read_failedThe same as webhook_read_failed, for a sandbox key. GET /webhook/list, GET /webhook/deliveries or POST /webhook/test could not read your sandbox webhook data this time. Nothing was changed and no sandbox event was queued; retry the same request later. The response carries "livemode": false.

Rate limits

Limits are per key and per tier, not one number for the whole API. Reading the catalog is cheap for us, so it gets a wide limit; spending money and making us call an address you chose get narrow ones. The table is generated from the limiter's own configuration.

TierLimitEndpoints
catalog
Catalog reads
900 / mincatalog/countries catalog/products catalog/product
read
Order & account reads
360 / minorder/get order/detail order/usage account/balance account/history account/statement account/profitshare webhook/list webhook/deliveries
purchase
Purchase
150 / minpurchase/buy account/sandboxReset
money
Money movement
30 / minaccount/topup account/topupConfirm
outbound
Outbound calls we make for you
18 / minwebhook/test webhook/probe

The window slides: we weigh the previous minute in proportion to how much of it is still inside the last 60 seconds, so you cannot get a double burst by waiting for a clock minute to tick over. Every response carries your position:

X-RateLimit-Limit: 360
X-RateLimit-Remaining: 287
X-RateLimit-Reset: 46

X-RateLimit-Reset is seconds until the window rolls. Over the limit you get HTTP 429 with a Retry-After header and this body:

{
  "result": "fail",
  "error": "rate_limited",
  "code": "429",
  "tier": "read",
  "limit_per_minute": 360,
  "retry_after": 33,
  "title": "Unsuccessful",
  "api_version": 3
}

The catalog changes a few times a day, not a few times a minute — cache /catalog/countries and /catalog/products on your side and you will never come close to the limit. Prices move with the daily refresh, so read the price at purchase time rather than storing it for weeks. If you need a higher quota for a launch, tell us in advance.

Idempotency

HTTP clients retry on timeout by default. Without an anchor, a retry is a second purchase — selling the same plan twice is legitimate for a reseller, so we cannot guess which one you meant. Send idempotency_key with every POST /purchase/buy: the key is unique per account at the database level, so even two requests arriving in the same millisecond collapse into one order and one deduction.

You sendYou get
Same key, same product_idThe recorded outcome, replayed: 200 when delivered, 202 while still deferred, 200 with "delivery": "failed" when it ended in failure. Nothing is charged or provisioned a second time.
Same key, different product_id409 idempotency_key_conflict. Generate a fresh key per intended purchase.
A new key after a failureA second purchase, charged as one. Repeating the old key reads the failure back; it never retries.
No keyNo protection. Two POSTs mean two eSIMs and two deductions.
  • Use a UUID. Keys are compared byte for byte: ABC and abc, or 0123 and 123, are different keys.
  • Allowed: letters, digits and - _ . :, up to 191 characters. Longer keys are rejected (idempotency_key_too_long), never truncated; refused characters give invalid_idempotency_key.
  • A 402 insufficient_balance still consumes the key you sent. After topping up, retry with a new key.
  • A 202 is "paid, arriving", not an error. Retrying it without your key is how resellers end up selling one customer two eSIMs.

Webhooks

An order can be paid now and delivered minutes later, so you should not have to poll. Register one HTTPS endpoint and we POST every state change of your own orders to it, signed. Register it yourself in the developer panel; the signing secret is shown once at creation, and a lost one can only be replaced, never recovered.

Events

EventWhen it firesstatus
order.createdThe order row exists, payment not settled yet.3
order.paidPayment (or the credit charge) went through; the eSIM is not provisioned yet.4
order.deliveredThe eSIM is ready. iccid and qr_code are filled in.1
order.deferredPaid, delivery postponed. delivery_reason says why; we keep retrying.4
order.failedThe order will not be delivered.2
order.testYou called /webhook/test. Does not correspond to a real order.test

The request we send

POST https://your-endpoint.example.com/esimcenter
Content-Type: application/json
User-Agent: esimcenter-webhook/1.0
X-Webhook-Event: order.delivered
X-Webhook-Id: 41207
X-Webhook-Timestamp: 1790000000
X-Signature: sha256=9f86d0818...

{
  "event": "order.delivered",
  "event_id": "order.delivered:899991072:1",
  "order_id": 899991072,
  "status": "1",
  "delivery": "delivered",
  "delivery_reason": "",
  "iccid": "8931088123456789012",
  "qr_code": "LPA:1$rsp.truphone.com$QR-G-5C-1S-1TT7ETZ",
  "occurred_at": "2026-09-25T04:12:07+03:00"
}
  • delivery and delivery_reason carry the same values as the purchase response (see POST /purchase/buy) — one vocabulary for both. event and event_id are in the body as well as the headers, so a proxy that strips headers cannot make the payload ambiguous.
  • You only receive events for orders placed with your own key. qr_code is a bearer credential — whoever installs it first owns the line — so keep it out of your logs too.

Verifying the signature

The signature covers the timestamp and the raw body, joined by a dot: sha256=hex(hmac_sha256(secret, timestamp + "." + body)). Hash the bytes you received — not a re-encoded version of the parsed JSON — and compare in constant time. Reject anything whose timestamp is more than five minutes off your clock: without that check a captured body could be replayed forever with its original signature.

PHP
<?php
$secret = getenv("ESIMCENTER_WEBHOOK_SECRET");
$body   = file_get_contents("php://input");
$ts     = $_SERVER["HTTP_X_WEBHOOK_TIMESTAMP"] ?? "";
$got    = $_SERVER["HTTP_X_SIGNATURE"] ?? "";

if (abs(time() - (int) $ts) > 300) {
    http_response_code(400);          // too old or too far in the future
    exit;
}
$want = "sha256=" . hash_hmac("sha256", $ts . "." . $body, $secret);
if (!hash_equals($want, $got)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// Idempotent: remember $event["event_id"] and ignore one you have seen.
http_response_code(200);              // 2xx = delivered, anything else is retried
JavaScript (Node, Express)
// Express: the RAW body is required, so mount this route before any JSON parser.
app.post("/esimcenter",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts  = req.get("X-Webhook-Timestamp") || "";
    const got = req.get("X-Signature") || "";
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

    const want = "sha256=" + crypto
      .createHmac("sha256", process.env.ESIMCENTER_WEBHOOK_SECRET)
      .update(Buffer.concat([Buffer.from(ts + "."), req.body]))
      .digest("hex");

    const a = Buffer.from(want), b = Buffer.from(got);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString("utf8"));
    // Idempotent: event_id is the key.
    res.sendStatus(200);
  });
Python (Flask)
# Flask: request.get_data() returns the RAW bytes, which is what the signature covers.
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/esimcenter")
def esimcenter():
    secret = os.environ["ESIMCENTER_WEBHOOK_SECRET"].encode()
    body   = request.get_data()
    ts     = request.headers.get("X-Webhook-Timestamp", "")
    got    = request.headers.get("X-Signature", "")

    try:
        age = abs(time.time() - int(ts))
    except ValueError:
        abort(400)
    if age > 300:
        abort(400)                    # too old or too far in the future

    want = "sha256=" + hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(want, got):
        abort(401)

    event = request.get_json(force=True)
    # Idempotent: remember event["event_id"] and ignore one you have seen.
    return "", 200                    # 2xx = delivered, anything else is retried

Retries and at-least-once

Any 2xx counts as delivered. Anything else — including a timeout — is retried on a fixed schedule:

AttemptWait before the next one
11 minute
25 minutes
330 minutes
42 hours
512 hours
  • Deduplicate on event_id. Delivery is at-least-once: a response we never saw (your 200 lost in a timeout) is sent again. The same event_id always describes the same state change, so storing it and ignoring repeats is the whole of the work.
  • Order is not guaranteed. A retried order.paid can land after order.delivered. Use status / delivery in the payload as the truth, not the arrival order.
  • Answer fast. We wait 10 seconds. Queue the event and return 200; do not do downstream work inside the request.
  • Repeated failure pauses you. After 5 failed attempts the event goes to a dead-letter queue we can re-queue by hand, and after 10 consecutive failures the endpoint is disabled so we stop hammering a dead host. Fixing the endpoint and telling us resets the counter; one success also resets it.
  • HTTPS with a valid certificate, public host. We do not follow redirects and we refuse private or link-local addresses, so a tunnel to localhost will not work — use a public test endpoint.

Checking your setup

I want to…Call
See my endpoints and the valid event namesGET /webhook/list — status is 1 active, 2 paused, 3 disabled after repeated failures. The secret is never returned.
Get a real signed test eventPOST /webhook/test — queues an order.test on the real path (real signature, headers, retries). Expect it within a few minutes.
Find out why a webhook did not arriveGET /webhook/deliveries — every attempt with the HTTP code we got, the duration and the start of your reply. With ?id= you also get the exact body we signed.
Try a URL before saving itPOST /webhook/probe — one signed request to any public URL, nothing saved, the whole exchange returned so you can reproduce the HMAC by hand.

Outbound calls have their own, much smaller quota: webhook/test and webhook/probe share 18 requests per minute, because they make us open connections to addresses you choose. A probe the gate refuses still counts.

Request
curl -s "$BASE/webhook/deliveries?id=27" \
  -H "X-API-Key: $KEY"
Response · HTTP 200
{
    "result": "ok",
    "code": "200",
    "delivery": {
        "id": "27",
        "subscription_id": "10",
        "event_id": "order.test:1655:1790594355",
        "event_type": "order.test",
        "sale_id": "0",
        "status": "sent",
        "attempts": "1",
        "max_attempts": "5",
        "http_code": "200",
        "duration_ms": "17",
        "response_body": "{\"ok\":true,\"received_bytes\":263,\"event\":\"order.test\"}",
        "error": "",
        "sent_at": "2026-09-28 14:20:05",
        "date": "2026-09-28 14:19:15"
    },
    "payload": "{\"order_id\":0,\"status\":\"test\",\"delivery\":\"delivered\",\"delivery_reason\":\"\",\"iccid\":\"\",\"qr_code\":\"\",\"occurred_at\":\"2026-09-28T14:19:15+03:00\",\"note\":\"deneme olayi — gercek bir siparise karsilik gelmez\",\"event\":\"order.test\",\"event_id\":\"order.test:1655:1790594355\"}",
    "payload_pruned": false,
    "signature_rule": "X-Signature = 'sha256=' + HMAC_SHA256(<X-Webhook-Timestamp> + '.' + <gövdenin tamamı>, abonelik sırrı)",
    "title": "Successful",
    "api_version": 3
}

Sandbox

Build and test your integration without real orders or charges. A sandbox key calls the same endpoints with the same field names on https://sandbox-api.esimcenter.com/v3. Every response the sandbox produces carries "livemode": false — errors included. Check it in your code before you trust a response. Authentication, method checks and rate limits work exactly as they do for live keys.

No money moves, no provider is called. A sandbox purchase does not touch your wallet or your credit line, does not create a real order, does not send email and does not earn revenue share. The eSIM it returns is fake and cannot be installed on a phone. Sandbox ICCIDs start with 89000, and activation codes point at sandbox.esimcenter.invalid.

What each endpoint does with a sandbox key

EndpointSandbox behaviour
catalog/*The real catalog, same as live. Read only.
account/pricingYour real partner prices. Read only.
account/balanceA fixed balance of 1000.00. Purchases do not lower it.
account/history, statement, profitshareSame fields as live, empty lists. The sandbox account is always prepaid.
account/topup, topupConfirmSimulated, see Simulated top-up. No card is charged.
account/sandboxResetDeletes your sandbox orders and their delivery records. See Resetting your sandbox data.
purchase/buyCreates a sandbox order. Pick the outcome with sandbox_scenario and the usage figures with sandbox_usage.
order/get, detailOnly your sandbox orders. Failed sandbox orders are left out of the list, as failed live orders are.
order/usage/{iccid}Only the ICCID of your own delivered sandbox order. The figures follow the sandbox_usage you chose; the default is not_active, 1024 MB total, 0 MB used.
webhook/list, deliveries, testOnly your sandbox subscriptions and their deliveries. Live subscriptions are not visible.
webhook/probeWorks as live: one signed request to the URL you name, nothing saved. The body says "livemode": false.
Anything else400 sandbox_unsupported_action

Choosing the outcome of a purchase

purchase/buy runs the same checks as live — the product must exist and the price is your real partner price — then creates a sandbox order instead of charging you. Send sandbox_scenario to pick what happens next. Live keys ignore the field.

sandbox_scenarioHTTPdeliveryWebhook events
empty or success200deliveredorder.created, order.paid, order.delivered
deferred202deferred (awaiting_profile)order.created, order.paid, order.deferred, then order.delivered 60 seconds later
failed200failedorder.created, order.failed
anything else400invalid_sandbox_scenario—
Request
curl -s -X POST "$BASE/purchase/buy" \
  -H "X-API-Key: $KEY" \
  -d "product_id=esc_1da490735cdc625a" \
  -d "idempotency_key=1f7c0c9e-7b3a-4a6e-9f11-9a5b2d7c4e01" \
  -d "sandbox_scenario=deferred"
  • A deferred order becomes delivered the first time you read it after its 60 seconds are up — through order/detail, order/get or a retry with the same idempotency key. The order.delivered event is held back until the same moment.
  • Idempotency works as it does live: the same key with the same product returns the same sandbox order and queues no new events; the same key with a different product is 409 idempotency_key_conflict.

Choosing the usage of an eSIM

To test a usage screen or a low-data alert you need an eSIM in every state. Add sandbox_usage to purchase/buy; order/usage/{iccid} of that order then reports the matching figures. Optional, defaults to not_active.

sandbox_usageWhat order/usage reports
not_active (default)1024 MB total, 0 MB used. The eSIM has not been installed yet.
active1024 MB total, 512 MB used.
depleted1024 MB total, 1024 MB used. Nothing is left.
expiredThe validity period is already over.
Request
curl -s -X POST "$BASE/purchase/buy" \
  -H "X-API-Key: $KEY" \
  -d "product_id=esc_1da490735cdc625a" \
  -d "idempotency_key=7d5f0c64-52a1-4c0b-8c1e-3b8f9a2e6d10" \
  -d "sandbox_usage=depleted"

Simulated top-up

With a sandbox key, POST /v3/account/topup and POST /v3/account/topupConfirm run the same two-step flow as live, so you can build and test it end to end. Nothing reaches Stripe, your wallet or the orders table, and the sandbox balance stays 1000.00.

CallHTTPWhat you get
account/topup200A fake payment intent: id starting with sbx_topup_, client_secret starting with sbx_secret_, status requires_confirmation, "livemode": false. The secret is not a Stripe secret — do not hand it to Stripe's SDK.
account/topupConfirm200status succeeded, "livemode": false.

Resetting your sandbox data

POST /v3/account/sandboxReset deletes the sandbox orders of your account together with their delivery records, so a test run can start from an empty list. It takes no parameters, only touches data of the key's own account, and never touches live orders. It counts against the purchase tier in Rate limits. A live key is refused with 400 sandbox_only_action and nothing is deleted.

Request
curl -s -X POST "$BASE/account/sandboxReset" \
  -H "X-API-Key: $KEY"
Response · HTTP 200
{
  "result": "ok",
  "code": "200",
  "deleted_orders": 4,
  "livemode": false
}

Sandbox data is not wiped on its own. Sandbox orders belong to your account, not to one key: revoking a sandbox key does not delete them, and your next sandbox key lists them again. Live keys never see them.

Sandbox webhooks

In the developer panel, pick Sandbox when you add a webhook endpoint. A sandbox subscription receives only the events of your sandbox orders and sandbox webhook/test calls; a live subscription never receives them. Sandbox events are signed and retried exactly like live ones, carry "livemode": false and have an event_id starting with sbx_.

  • A subscription's mode is fixed when you create it: 409 mode_immutable if you try to change it. Add a second subscription in the other mode instead — the same URL may hold one live and one sandbox subscription.
  • The limit of 5 subscriptions applies to each mode separately.
  • The private-address gate applies to sandbox subscriptions too: an internal or link-local URL is refused when you save it and again at delivery time.

Billing & revenue share

Every partner account runs in one of two modes. You do not choose it in the request — we set it on your account, and every purchase response tells you which one you are in via billing_mode.

billing_modeHow a purchase is paidNot enough room
prepaidThe price leaves your wallet balance immediately. This is the default.402 insufficient_balance with balance and required. Nothing was charged, no order exists.
postpaidNo money moves. The price is written to your account as outstanding debt and settled against an invoice. balance does not change.403 credit_limit_exceeded with outstanding, credit_limit, available and required.
  • A suspended partner account is refused in both modes with 403 partner_account_suspended, before any order is created or any money moves. Reading endpoints (balance, statement, orders) keep working.
  • The limit check is a single atomic database write, so two purchases arriving in the same millisecond cannot both slip under the same remaining credit.
  • Revenue share on unused data: on plans where our own cost follows what is actually consumed, part of the saving on data your customer never used goes back to you. It is calculated per sale, paid automatically and never claimed or invoiced. Your rate is set on your account by agreement — it is never a field you send.

Two postpaid codes need the opposite reaction from every other error. 409 charge_in_progress and 409 charge_incomplete mean a charge on your account is half-written and we do not yet know whether the money moved. We deliberately do not mark the order failed — a false "failed" stamp would shut the recovery paths that fix it. Retry the same call with the same idempotency_key; you get either the finished order or a clean error.

Revenue share in detail: calculation, eligibility, payout

How one share is calculated

unused_mb    = plan_mb - used_mb
unit_cost    = (our wholesale cost for that plan) / plan_mb
unused_cost  = unused_mb * unit_cost
share_amount = unused_cost * share_pct / 100

The share is computed on our cost of the unused data, not on what you charged your customer. plan_mb is the provider's own reported total when we have it, the catalog amount otherwise; unit_cost comes from the same total as unused_mb, so the share can never exceed what the plan cost us. All money is USD, and unused_mb is never negative. Every row carries the rate that was applied in share_pct, frozen at calculation time — changing a rate later never rewrites a row already paid.

Which sales can produce a share

Unlimited plans never do (there is no bundle size to share against). For the rest, a sale produces a share only when all of these hold at settlement time:

ConditionWhy
The plan is not unlimitedNo bundle size means no "unused data".
The provider reports usage for this planSome plans never expose usage. No data means no defensible used_mb, so we skip rather than guess.
We have received a usage reading for this eSIMSkipped, not zeroed, until it arrives.
The plan size is known and greater than zeroOtherwise unused_mb cannot be computed.
Our wholesale cost for the plan is greater than zeroNo cost, no base to share.
Unused MB works out positiveIf as much data was used as the bundle held, nothing was left unused.

A sale that fails one of these is skipped, not recorded as a zero row; it stays a candidate and is picked up again once the missing piece shows up. The catalog flag is_usage_metered is only a rough, catalog-level hint, not this gate — use it to narrow a list (?usage_metered=1), never to predict a payout.

When it is paid, and where the money lands

A share accrues only after the eSIM's expires_at has passed and a settlement grace window (default 3 days) has also run out. The delay is deliberate: providers report usage late, and an under-reported used_mb would inflate the share. The share is never paid out as cash or by bank transfer — it lands in your site wallet or reduces what you owe us. The channel follows what you owed at settlement time, not your billing mode by itself:

Your situationchannelsettle_debtsettle_wallet
Prepaidwallet0the whole share
Postpaid, debt ≥ sharedebtthe whole share0
Postpaid, 0 < debt < sharesplitup to the current outstandingwhatever is left
Postpaid, debt already zerowallet0the whole share
Share rounds to zeronone00

A sale produces at most one share, ever; an interrupted run completes only the missing part. Until the money has moved, the row's status is shadow and channel is an empty string; afterwards status is settled and channel is one of the four values above.

Reading it from the API

EndpointUse it ifWhat it answers
GET /v3/account/historyYou are prepaidWallet movements. A paid share is a credit with type profit_share.
GET /v3/account/statementYou are postpaidCredit limit, outstanding debt, remaining credit and the debt ledger. A paid share is a row with type profit_share.
GET /v3/account/profitshareEvery partnerOne row per sale: plan MB, used MB, unused MB, unit cost, the rate applied, the amount and where it was paid. The only endpoint that explains how an amount came about.

Revenue share not switched on is not an error: /account/profitshare answers 200 with profit_share_enabled 0 and an empty items. Branch on the flag, never on the HTTP status. To get it enabled or the terms changed, talk to us.

Request
curl -s "$BASE/account/profitshare?limit=3" \
  -H "X-API-Key: $KEY"
Response · HTTP 200 (list trimmed to 3 rows)
{
    "result": "ok",
    "code": "200",
    "profit_share_enabled": 1,
    "items": [],
    "count": 0,
    "total_share": "0.00",
    "total_unused_mb": 0,
    "currency": "USD",
    "title": "Successful",
    "api_version": 3
}
Partner pricing in detail

Catalog prices are list prices. Your partner price is a per-product number derived from the list price, your share of our margin and the rules configured for your account, and it is the price your wallet is actually charged. The same catalog call with a different key can return different numbers.

FieldMeaning
price, sale_priceList price, what an end customer pays. Both stay equal to the list price for backwards compatibility.
partner_priceWhat your wallet is charged for this product. Never negative and never below our cost; if our margin on the product is zero or unknown it equals the list price.
partner_discountThe list price minus partner_price. "0.00" means no discount on this product, not "failed" or "missing".
partner_margin_share_pctThe percentage of our margin turned into your discount on this row. "0.00" means the resolved share is zero.
margin        = catalog_price - cost_price        # OUR margin on this product
discount      = max(0, margin) * share_pct / 100  # your share of that margin
partner_price = list_price - discount              # what your wallet is charged

The discount is a percentage of our margin, not a flat percentage off the list price, so partner_price cannot fall below cost_price. The per-product percentage therefore varies; the average_discount_pct figure (with is_average: true) is a measurement, not a commitment — never quote an average to your customers as a guarantee.

Which rule wins — the most specific one, and source on the row says which: product (a fixed price for one product), then provider, country, region, then the account default, then none (no share configured; partner price equals list price). A fixed product price below the floor our margin rules permit is ignored at read time and resolution continues down the levels.

Total cap. Your account carries one cap, total_share_cap_pct (default 60%): the maximum share of our margin on a single product that can leave our side across both channels combined — the upfront discount here plus the unused-data share later on the same sale. When the cap binds, the unused-data share is clipped, never the upfront discount, and the revenue share row carries capped: 1.

On the sale row, product_price is the list price at purchase time and sale_price is the partner price actually charged; their difference is your upfront discount on that sale.

Catalog

Countries and plans. Read-only; identical in both modes.

GET/catalog/countries

List countries and regions

Every destination we sell, one row per country code, sorted by code. Regions appear in the region field and can be passed to /catalog/products as a country value. Rate tier catalog: 900 requests per minute per key.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/catalog/countries" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "count": 230,
    "countries": [
        {
            "country_code": "AD",
            "title": "Andorra",
            "slug": "andorra",
            "image": "https://esimcenter.com/cdn/flags/ad.png",
            "region": "europe"
        },
        {
            "country_code": "AE",
            "title": "United Arab Emirates",
            "slug": "united-arab-emirates",
            "image": "https://esimcenter.com/cdn/flags/ae.png",
            "region": "middle-east"
        },
        {
            "country_code": "AG",
            "title": "Antigua And Barbuda",
            "slug": "antigua-and-barbuda",
            "image": "https://esimcenter.com/cdn/flags/ag.png",
            "region": "americas"
        },
        "<227 oge daha — ornek kisaltildi, gercek yanit tamamini doner>"
    ],
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 405 429

GET/catalog/products

List the plans of one destination

The same set, in the same order, that a customer sees on the website country page. By default one winning plan per country, days and data group is returned and filters run after that choice; pass all_variants=1 to filter across every variant. At most 500 plans per request; truncated is true when more matched. Every invalid filter value is refused with invalid_<name> instead of being ignored. Rate tier catalog: 900 requests per minute per key.

Parameters

NameInTypeRequiredDescription
countryquerystringrequiredISO country code (de, tr, us) or region code/slug (europe, global, middle-east). Errors: country_required, invalid_country, country_not_found.
typequerystringoptionalOptional catalog group filter, passed through to the selector the website uses.
include_smallquerystringone of 0, 1 · default 0optional1 also returns packages under 1 GB. Off by default.
all_variantsquerystringone of 0, 1 · default 0optional0: one winner per data/day group. 1: every variant.
usage_meteredquerystringone of 0, 1optional1 only plans whose cost follows measured usage, 0 only the others, empty both. Error: invalid_usage_metered.
unlimitedquerystringone of 0, 1optional1 only unlimited plans, 0 only capped plans, empty both. Error: invalid_unlimited.
network_typequerystringone of 4G, 5GoptionalAdvertised network generation, case-insensitive. Error: invalid_network_type.
min_data_mbqueryintegermin 0 · max 10485760optionalMinimum bundled data in MB (1024 = 1 GB). Unlimited plans report amount 0, so any min_data_mb excludes them. Error: invalid_min_data_mb.
max_data_mbqueryintegermin 0 · max 10485760optionalMaximum bundled data in MB. Error: invalid_max_data_mb.
dayqueryintegermin 1 · max 3650optionalExact validity in days. Error: invalid_day.
min_dayqueryintegermin 1 · max 3650optionalMinimum validity in days. Error: invalid_min_day.
max_dayqueryintegermin 1 · max 3650optionalMaximum validity in days. Error: invalid_max_day.
min_pricequerynumbermin 0 · max 100000optionalMinimum price you pay, in USD. Error: invalid_min_price.
max_pricequerynumbermin 0 · max 100000optionalMaximum price you pay, in USD. Error: invalid_max_price.
sortquerystringone of price, price_per_gb, data, day, title · default priceoptionalSort field. Plans with no per-GB value are placed last in both directions when sorting by price_per_gb. Error: invalid_sort.
orderquerystringone of asc, desc · default ascoptionalSort direction. Error: invalid_order.
limitqueryintegermin 1 · max 500 · default 500optionalHow many plans to return. Error: invalid_limit.
offsetqueryintegermin 0 · max 999999 · default 0optionalHow many matched plans to skip. Error: invalid_offset.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/catalog/products?country=tr" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "country_code": "tr",
    "country": {
        "country_code": "TR",
        "title": "Turkey",
        "slug": "turkey",
        "image": "https://esimcenter.com/cdn/flags/tr.png",
        "region": ""
    },
    "include_small": false,
    "all_variants": false,
    "total": 132,
    "matched": 132,
    "count": 132,
    "truncated": false,
    "limit": 500,
    "offset": 0,
    "sort": "price",
    "order": "asc",
    "filters": [],
    "products": [
        {
            "id": "esc_0dde4d49725f9e60",
            "title": "Turkey 1GB 7Days",
            "country_code": "tr",
            "country_title": "Turkey",
            "country_image": "https://esimcenter.com/cdn/flags/tr.png",
            "region": "country",
            "continent": "",
            "data": "1 GB",
            "amount": 1024,
            "day": 7,
            "is_unlimited": "",
            "price": 1.4299999999999999378275106209912337362766265869140625,
            "sale_price": 1.4299999999999999378275106209912337362766265869140625,
            "price_per_gb": 1.4299999999999999378275106209912337362766265869140625,
            "operator_title": "",
            "network_type": "5G",
            "coverage_codes": [
                "TR"
            ],
            "coverage_names_en": [
                "Turkey"
            ],
            "fup_kind": "unknown",
            "fup_daily_mb": 0,
            "fup_speed_kbps": 0,
            "fair_use_text": "",
            "is_usage_metered": 0
        },
        {
            "id": "esc_e7c9a2a773dcccba",
            "title": "Turkey 3GB 15Days Premium",
            "country_code": "tr",
            "country_title": "Turkey",
            "country_image": "https://esimcenter.com/cdn/flags/tr.png",
            "region": "country",
            "continent": "",
            "data": "3 GB",
            "amount": 3072,
            "day": 15,
            "is_unlimited": "",
            "price": 1.6599999999999999200639422269887290894985198974609375,
            "sale_price": 1.6599999999999999200639422269887290894985198974609375,
            "price_per_gb": 0.5500000000000000444089209850062616169452667236328125,
            "operator_title": "",
            "network_type": "5G",
            "coverage_codes": [
                "TR"
            ],
            "coverage_names_en": [
                "Turkey"
            ],
            "fup_kind": "unknown",
            "fup_daily_mb": 0,
            "fup_speed_kbps": 0,
            "fair_use_text": "",
            "is_usage_metered": 0
        },
        {
            "id": "esc_93054d1eddb4d3e2",
            "title": "Greece Cyprus Turkey 2GB 15d",
            "country_code": "europe",
            "country_title": "Europe",
            "country_image": "https://esimcenter.com/cdn/flags/europe.png",
            "region": "regional",
            "continent": "Europe",
            "data": "2 GB",
            "amount": 2048,
            "day": 15,
            "is_unlimited": "",
            "price": 2.680000000000000159872115546022541821002960205078125,
            "sale_price": 2.680000000000000159872115546022541821002960205078125,
            "price_per_gb": 1.3400000000000000799360577730112709105014801025390625,
            "operator_title": "",
            "network_type": "5G",
            "coverage_codes": [
                "CY",
                "GR",
                "TR"
            ],
            "coverage_names_en": [
                "Cyprus",
                "Greece",
                "Turkey"
            ],
            "fup_kind": "unknown",
            "fup_daily_mb": 0,
            "fup_speed_kbps": 0,
            "fair_use_text": "",
            "is_usage_metered": 1
        },
        "<129 oge daha — ornek kisaltildi, gercek yanit tamamini doner>"
    ],
    "title": "Successful",
    "api_version": 3
}

Status codes 200 400 401 403 404 405 429

GET/catalog/product/{id}

Get one plan

One plan under a product key, same fields as the list. Works for variants that grouping hides from the list. Accepts only the opaque alias (esc_ + 16 hex); a raw internal id and an unknown alias both answer 404 product_not_found. Rate tier catalog: 900 requests per minute per key.

Parameters

NameInTypeRequiredDescription
idpathstringpattern ^esc_[0-9a-f]{16}$requiredOpaque product alias, esc_ + 16 lowercase hex characters.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/catalog/product/esc_0dde4d49725f9e60" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "product": {
        "id": "esc_0dde4d49725f9e60",
        "title": "Turkey 1GB 7Days",
        "country_code": "tr",
        "country_title": "Turkey",
        "country_image": "https://esimcenter.com/cdn/flags/tr.png",
        "region": "country",
        "continent": "",
        "data": "1 GB",
        "amount": 1024,
        "day": 7,
        "is_unlimited": "",
        "price": 1.4299999999999999378275106209912337362766265869140625,
        "sale_price": 1.4299999999999999378275106209912337362766265869140625,
        "price_per_gb": 1.4299999999999999378275106209912337362766265869140625,
        "operator_title": "",
        "network_type": "5G",
        "coverage_codes": [
            "TR"
        ],
        "coverage_names_en": [
            "Turkey"
        ],
        "fup_kind": "unknown",
        "fup_daily_mb": 0,
        "fup_speed_kbps": 0,
        "fair_use_text": "",
        "is_usage_metered": 0
    },
    "title": "Successful",
    "api_version": 3
}

Status codes 200 400 401 403 404 405 429

Purchase

The only endpoint that spends money (live) or creates a fake order (sandbox).

POST/purchase/buy

Buy an eSIM

Live key: checks the price, charges your wallet (prepaid) or credit line (postpaid) and orders the eSIM. Sandbox key: same input checks and your real partner price, then creates a sandbox order instead of charging you (balance stays 1000.00).

The response never carries the activation payload; read esim from GET /order/detail/{id}.

200 delivered: the eSIM exists. 202 deferred: paid, arriving later, do NOT buy again; wait for the order.delivered webhook or poll order/detail. 200 failed: the order will not be delivered.

Send your own unique idempotency_key per intended purchase. The same key with the same product returns the recorded result and moves no money; with another product it is 409. To retry a failed order, send a NEW key.

Rate tier purchase: 150 requests per minute per key.

Parameters

NameInTypeRequiredDescription
product_idbody (form)stringpattern ^esc_[0-9a-f]{16}$requiredOpaque, stable, irreversible product alias. Raw internal ids are refused.
idempotency_keybody (form)stringmax length 191optionalStrongly recommended. At most 191 bytes after trimming; no quotes, backslash, < >, control characters or 4-byte characters. A UUID fits. Errors: idempotency_key_too_long, invalid_idempotency_key.
sandbox_scenariobody (form)stringone of success, deferred, failedoptionalSandbox keys only; live keys ignore it. Empty means success. Any other value is 400 invalid_sandbox_scenario.
sandbox_usagebody (form)stringone of not_active, active, depleted, expired · default not_activeoptionalSandbox keys only. Decides what order/usage/{iccid} reports for the fake eSIM of this order.

Request

curl -s -X POST "https://sandbox-api.esimcenter.com/v3/purchase/buy" \
  -H "X-API-Key: esk_test_YOUR_KEY" \
  -d "product_id=esc_1da490735cdc625a" \
  -d "idempotency_key=1f7c0c9e-7b3a-4a6e-9f11-9a5b2d7c4e01"

Response

HTTP 200 example

{
    "result": "ok",
    "code": "200",
    "id": "2470",
    "order_id": 2470,
    "billing_mode": "prepaid",
    "balance": 3.939999999999999946709294817992486059665679931640625,
    "delivery": "delivered",
    "delivery_reason": "",
    "provision": "ok",
    "order": {
        "id": "2470",
        "product_id": "esc_1da490735cdc625a",
        "product_title": "Turkey 100MB 7Days",
        "product_price": "1.06",
        "sale_price": "1.06",
        "coupon_code": "",
        "product_type": "esim",
        "date": "2026-09-28 13:06:09",
        "status": "1",
        "api_sale_id": "B26092810060019",
        "api_sale_code": "26092810060022"
    },
    "title": "Successful",
    "api_version": 3
}

Status codes 200 202 400 401 402 403 404 405 409 429 500

Orders

Order list, order detail with activation data, and data usage.

GET/order/get

List your orders

Your orders, newest first, under the singular key order. Sandbox keys see only their own sandbox orders; failed sandbox orders are left out, as failed live orders are. Rate tier read: 360 requests per minute per key.

Parameters

NameInTypeRequiredDescription
limitqueryintegerdefault 10optionalPage size.
startqueryintegermin 1 · default 1optional1-based page number.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/order/get" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "order": [
        {
            "id": "2471",
            "product_id": "esc_e4c66b73d7954ed2",
            "product_title": "Algeria 100MB 7Days",
            "title_display": "100 MB · 7 days",
            "product_price": "1.06",
            "sale_price": "1.06",
            "product_type": "esim",
            "date": "2026-09-28 13:06:58",
            "status": "1",
            "api_sale_id": "B26092810070001",
            "api_sale_code": "26092810070001",
            "esim": {
                "qrcode_url": "",
                "iccid": "<maskelendi>",
                "lpa": "<maskelendi>",
                "smdpAddress": "<maskelendi>",
                "activationCode": "<maskelendi>",
                "matching_id": null,
                "direct_apple_installation_url": null
            },
            "data": "100 MB",
            "day": "7",
            "is_unlimited": "",
            "country_code": "dz",
            "country_image": "https://esimcenter.com/cdn/flags/dz.png",
            "country_name": "Algeria",
            "fair_use_text": "",
            "coupon_code": ""
        }
    ],
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 405 429 500

GET/order/detail/{id}

Get one order with activation data

One order in full. The esim block carries the activation details (lpa, smdpAddress, activationCode, matching_id); treat it as a secret. An id that is not yours answers 404 order_not_found, the same as an id that does not exist. Rate tier read: 360 requests per minute per key.

Parameters

NameInTypeRequiredDescription
idpathintegermin 1requiredOrder id (the id / order_id returned by purchase/buy).

Request

curl -s "https://sandbox-api.esimcenter.com/v3/order/detail/2470" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "order": {
        "id": "2471",
        "product_id": "esc_e4c66b73d7954ed2",
        "product_title": "Algeria 100MB 7Days",
        "title_display": "100 MB · 7 days",
        "product_price": "1.06",
        "sale_price": "1.06",
        "coupon_code": "",
        "date": "2026-09-28 13:06:58",
        "status": "1",
        "api_sale_id": "B26092810070001",
        "api_sale_code": "26092810070001",
        "data": "100 MB",
        "day": "7",
        "is_unlimited": false,
        "voice": "",
        "text": "",
        "short_info": "Algeria 100MB 7Days",
        "fair_use_text": "",
        "fup_kind": "unknown",
        "fup_daily_mb": 0,
        "fup_speed_kbps": 0,
        "operator_title": "",
        "operator_image": "https://esimcenter.com/cdn/assets/images/providers/<maskelendi>.jpg",
        "country_code": "dz",
        "country_image": "https://esimcenter.com/cdn/flags/dz.png",
        "country_name": "Algeria"
    },
    "esim": {
        "qrcode": null,
        "qrcode_url": "",
        "iccid": "<maskelendi>",
        "lpa": "<maskelendi>",
        "matching_id": null,
        "smdpAddress": "<maskelendi>",
        "activationCode": "<maskelendi>",
        "confirmation_code": null,
        "msisdn": null,
        "apn": "plus",
        "apn_type": null,
        "apn_value": null,
        "is_roaming": null
    },
    "operator_meta": {
        "activation_policy": null,
        "plan_type": "data",
        "esim_type": "Prepaid",
        "is_roaming": null,
        "is_kyc_verify": null,
        "rechargeability": null,
        "apn_type": null,
        "apn_value": null,
        "info": null,
        "other_info": null,
        "warning": null
    },
    "title": "Successful",
    "api_version": 3
}

Status codes 200 400 401 403 404 405 429 500

GET/order/usage/{iccid}

Get data usage of an eSIM

Remaining data. Cache it for a minute or two. 404 usage_not_available means the ICCID is not yours, has no usage record yet, or the provider did not answer; it is not an integration error. With a sandbox key only the ICCID of your own delivered sandbox order works, and the figures follow the sandbox_usage chosen at purchase: not_active (default) 1024 MB total and 0 used, active 1024 total and 512 used, depleted 1024 of 1024 used, expired with an expiry date in the past. Rate tier read: 360 requests per minute per key.

Parameters

NameInTypeRequiredDescription
iccidpathstringrequiredICCID of the eSIM. Sandbox ICCIDs start with 89000.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/order/usage/8900000000000000001" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "usage": {
        "iccid": "<maskelendi>",
        "sale_id": 2470,
        "title": "Turkey 100MB 7Days",
        "status": "not_active",
        "tone": "ok",
        "total_mb": 100,
        "used_mb": 0,
        "remaining_mb": 100,
        "percent_remaining": 100,
        "is_unlimited": false,
        "fair_use_text": "",
        "activated_at": null,
        "expires_at": null,
        "days_left": null,
        "fetched_at": "2026-09-28 14:00:03"
    },
    "title": "Successful",
    "api_version": 3
}

Status codes 200 400 401 403 404 405 429

Account

Wallet, statement, revenue share, pricing and top-up.

GET/account/balance

Get wallet balance

Spendable balance in USD. Sandbox keys always see 1000.00, and purchases do not reduce it. On invoice terms read account/statement instead. Rate tier read: 360 requests per minute per key.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/account/balance" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "balance": 0,
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 405 429

GET/account/history

List wallet movements

Wallet movements, newest first, each with the balance that resulted. Sandbox keys get an empty ledger. A read failure is 500 history_read_failed, which means unknown, not empty. Rate tier read: 360 requests per minute per key.

Parameters

NameInTypeRequiredDescription
limitqueryintegermin 1 · max 500 · default 50optionalHow many movements. Values above 500 are clamped.
startqueryintegermin 1 · default 1optional1-based page number.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/account/history" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "ledger": [],
    "balance": 0,
    "balances": {
        "balance": 0,
        "promo": 0,
        "cash": 0,
        "debt": 0
    },
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 405 429 500

GET/account/statement

Get invoice-terms statement

Billing mode, credit limit, outstanding debt, remaining credit (available may be negative) and credit-line movements. 404 partner_account_not_found when no billing account was ever opened for you. Sandbox keys see a prepaid account with zeros and an empty ledger. Rate tier read: 360 requests per minute per key.

Parameters

NameInTypeRequiredDescription
limitqueryintegerdefault 50optionalHow many movements per page. Default 50, at most 500.
startqueryintegermin 1 · default 1optional1-based page number.
typequerystringone of charge, payment, adjust, refund, profit_shareoptionalOptional movement type filter: charge, payment, adjust, refund or profit_share. Any other value is ignored.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/account/statement" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "billing_mode": "postpaid",
    "credit_limit": "5.00",
    "outstanding": "1.06",
    "available": "3.94",
    "profit_share_enabled": 1,
    "status": 1,
    "currency": "USD",
    "ledger": [
        {
            "id": "1",
            "type": "charge",
            "amount": "1.06",
            "balance_after": "1.06",
            "note": "eSIM purchase #2471",
            "date": "2026-09-28 13:06:58"
        }
    ],
    "count": 1,
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 404 405 429

GET/account/profitshare

List revenue share on unused data

Revenue share lines plus page-independent totals. profit_share_enabled: 0 is not an error; history stays visible. Sandbox keys get profit_share_enabled: 0, an empty list and total_share 0.00. Rate tier read: 360 requests per minute per key.

Parameters

NameInTypeRequiredDescription
limitqueryintegerdefault 50optionalHow many lines per page. Default 50, at most 500.
startqueryintegermin 1 · default 1optional1-based page number.
statusquerystringone of shadow, settled, noneoptionalOptional status filter: shadow, settled or none. Any other value is ignored.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/account/profitshare" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "profit_share_enabled": 1,
    "items": [],
    "count": 0,
    "total_share": "0.00",
    "total_unused_mb": 0,
    "currency": "USD",
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 405 429

GET/account/pricing

Get your pricing summary

Your contract numbers: margin share, total share cap, number of active special price rules, and a measured average discount. average_discount_pct is a measurement, not a promise: null means it could not be measured, 0 means measured with no discount. Sandbox keys get the same live answer. Rate tier read: 360 requests per minute per key.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/account/pricing" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Status codes 200 401 403 405 429

POST/account/topup

Start a wallet top-up

Live key: creates a Stripe payment intent; you complete the payment with Stripe's own SDK using the returned client_secret, then call topupConfirm with the payment_key. Sandbox key: returns a fake intent (id starting sbx_topup_, client_secret starting sbx_secret_, status: requires_confirmation, livemode: false); no Stripe call is made and the balance stays 1000.00. Rate tier money: 30 requests per minute per key.

Parameters

NameInTypeRequiredDescription
amountbody (form)number> 0requiredAmount credited to the wallet, in USD. Outside the accepted range: 400 amount_out_of_range (carries min and max).

Request

curl -s -X POST "https://sandbox-api.esimcenter.com/v3/account/topup" \
  -H "X-API-Key: esk_test_YOUR_KEY" \
  -d "amount=50"

Status codes 200 400 401 403 405 429

POST/account/topupConfirm

Confirm a wallet top-up

Verifies the payment and credits the wallet. Idempotent: confirming twice credits once. Sandbox key: returns succeeded without touching any wallet. Rate tier money: 30 requests per minute per key.

Parameters

NameInTypeRequiredDescription
payment_keybody (form)stringrequiredThe payment_key returned by topup. Sandbox keys return keys that start with sbx_topup_.

Request

curl -s -X POST "https://sandbox-api.esimcenter.com/v3/account/topupConfirm" \
  -H "X-API-Key: esk_test_YOUR_KEY" \
  -d "payment_key=sbx_topup_0123456789abcdef01234567_5000"

Status codes 200 400 401 403 404 405 429

Webhooks

Read your webhook subscriptions and delivery log, and test an address. Subscriptions are created in the developer panel.

GET/webhook/list

List your webhook subscriptions

Your subscriptions of the key's own mode: sandbox keys see only sandbox subscriptions, live keys only live ones. The signing secret is never returned, only secret_mask. Subscriptions are created in the developer panel. Rate tier read: 360 requests per minute per key.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/webhook/list" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "subscriptions": [
        {
            "id": "10",
            "url": "https://esimcenter.com/bin/olc/wh-alici.php?t=db4e55c7ea6a9a8b80e050e828731a02&p=p2",
            "secret_mask": "402c11...",
            "events_list": [
                "order.created",
                "order.paid",
                "order.delivered",
                "<3 oge daha — ornek kisaltildi, gercek yanit tamamini doner>"
            ],
            "status": "1",
            "fail_count": "0",
            "date": "2026-09-28 12:44:26",
            "updated_at": "2026-09-28 13:39:09"
        }
    ],
    "count": 1,
    "events": [
        "order.created",
        "order.paid",
        "order.delivered",
        "<3 oge daha — ornek kisaltildi, gercek yanit tamamini doner>"
    ],
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 405 429 500

GET/webhook/deliveries

Read the webhook delivery log

Without id the latest deliveries (without body). With id one delivery with the exact body we sent in payload and the signature_rule. Sandbox keys see only deliveries of sandbox subscriptions. Rate tier read: 360 requests per minute per key.

Parameters

NameInTypeRequiredDescription
idqueryintegermin 1optionalDelivery id. When present, the single-delivery mode is used and the other filters are ignored.
statusquerystringoptionalDelivery status filter. The accepted values are returned in statuses; others answer 400 invalid_status.
eventquerystringoptionalEvent filter, one of events; others answer 400 invalid_event.
subscription_idquerystringpattern ^\d{1,20}$optionalDigits only; otherwise 400 invalid_subscription_id.
limitqueryintegermin 1 · max 200 · default 50optional1 to 200. Otherwise 400 invalid_limit.

Request

curl -s "https://sandbox-api.esimcenter.com/v3/webhook/deliveries" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "count": 3,
    "limit": 3,
    "statuses": [
        "pending",
        "sent",
        "failed",
        "<1 oge daha — ornek kisaltildi, gercek yanit tamamini doner>"
    ],
    "events": [
        "order.created",
        "order.paid",
        "order.delivered",
        "<3 oge daha — ornek kisaltildi, gercek yanit tamamini doner>"
    ],
    "deliveries": [
        {
            "id": "27",
            "subscription_id": "10",
            "event_id": "order.test:1655:1790594355",
            "event_type": "order.test",
            "sale_id": "0",
            "status": "sent",
            "attempts": "1",
            "max_attempts": "5",
            "http_code": "200",
            "duration_ms": "17",
            "response_body": "{\"ok\":true,\"received_bytes\":263,\"event\":\"order.test\"}",
            "error": "",
            "sent_at": "2026-09-28 14:20:05",
            "date": "2026-09-28 14:19:15"
        },
        {
            "id": "26",
            "subscription_id": "10",
            "event_id": "order.test:1655:1790594353",
            "event_type": "order.test",
            "sale_id": "0",
            "status": "sent",
            "attempts": "1",
            "max_attempts": "5",
            "http_code": "200",
            "duration_ms": "12",
            "response_body": "{\"ok\":true,\"received_bytes\":263,\"event\":\"order.test\"}",
            "error": "",
            "sent_at": "2026-09-28 14:20:04",
            "date": "2026-09-28 14:19:13"
        },
        {
            "id": "25",
            "subscription_id": "10",
            "event_id": "order.test:1655:1790594270",
            "event_type": "order.test",
            "sale_id": "0",
            "status": "sent",
            "attempts": "1",
            "max_attempts": "5",
            "http_code": "200",
            "duration_ms": "20",
            "response_body": "{\"ok\":true,\"received_bytes\":263,\"event\":\"order.test\"}",
            "error": "",
            "sent_at": "2026-09-28 14:20:04",
            "date": "2026-09-28 14:17:50"
        }
    ],
    "title": "Successful",
    "api_version": 3
}

Status codes 200 400 401 403 404 405 429 500

POST/webhook/test

Queue an order.test event

Queues a signed order.test event to your registered subscriptions of the key's mode. Delivery happens asynchronously; read the result from webhook/deliveries. Sandbox keys: only sandbox subscriptions, event id starts with sbx_; when your sandbox queue is full the answer is 429 sandbox_queue_full. Rate tier outbound: 18 requests per minute per key.

Parameters

NameInTypeRequiredDescription
subscription_idbody (form)integeroptionalTarget one subscription. Omit to target all your active subscriptions. Not yours: 404 subscription_not_found.

Request

curl -s -X POST "https://sandbox-api.esimcenter.com/v3/webhook/test" \
  -H "X-API-Key: esk_test_YOUR_KEY" \
  -d "subscription_id=10"

Response

HTTP 200 real captured response

{
    "result": "ok",
    "code": "200",
    "event": "order.test",
    "event_id": "order.test:1655:1790594918",
    "queued": 1,
    "subscriptions": 1,
    "delivery_mode": "queued",
    "title": "Successful",
    "api_version": 3
}

Status codes 200 401 403 404 405 409 429 500

POST/webhook/probe

Test an address without saving it

Sends one signed request to the given address right now and returns the HTTP status, latency and a trimmed response. Nothing is saved. A fresh single-use secret is generated and returned as probe_secret so you can test your signature check. Internal and private addresses are refused with 400 probe_blocked and no request is sent. Same behavior for both key kinds; the livemode field in the sent body follows the key's mode. Rate tier outbound: 18 requests per minute per key.

Parameters

NameInTypeRequiredDescription
urlbody (form)stringmax length 500requiredhttps address to test. Errors: url_required, url_too_long, probe_blocked.
eventbody (form)stringdefault order.testoptionalEvent name to simulate; one of the events returned by webhook/list. Otherwise 400 invalid_event.

Request

curl -s -X POST "https://sandbox-api.esimcenter.com/v3/webhook/probe" \
  -H "X-API-Key: esk_test_YOUR_KEY" \
  -d "url=https://esimcenter.com/" \
  -d "event=order.paid"

Response

HTTP 200 example

{
    "result": "ok",
    "code": "200",
    "request_sent": true,
    "saved": false,
    "url": "https://esimcenter.com/",
    "event": "order.paid",
    "event_id": "probe.order.paid:1001:1790594918",
    "http_status": 200,
    "accepted": true,
    "duration_ms": 120,
    "response_body": "<!DOCTYPE html>\n<html lang=\"en\" dir=\"ltr\">\n<head><meta charset=\"utf-8\">…",
    "payload": "{\"order_id\":0,\"status\":\"test\",\"delivery\":\"delivered\",\"delivery_reason\":\"\",\"iccid\":\"\",\"qr_code\":\"\",\"occurred_at\":\"2026-09-28T14:28:38+03:00\",\"event\":\"order.paid\",\"event_id\":\"probe.order.paid:1001:1790594918\"}",
    "timestamp": 1790594918,
    "signature": "<redacted>",
    "probe_secret": "<redacted>",
    "signature_rule": "X-Signature = 'sha256=' + HMAC_SHA256(timestamp + '.' + payload, probe_secret)",
    "title": "Successful",
    "api_version": 3,
    "error": ""
}

Status codes 200 400 401 403 405 429 500

Sandbox

Endpoints and fields that exist only for sandbox keys.

POST/account/sandboxReset

Delete your sandbox orders

Sandbox keys only. Deletes the sandbox orders and delivery records of your account so you can start a test run from a clean state. A live key gets 400 sandbox_only_action and nothing is touched. Rate tier purchase: 150 requests per minute per key.

Request

curl -s -X POST "https://sandbox-api.esimcenter.com/v3/account/sandboxReset" \
  -H "X-API-Key: esk_test_YOUR_KEY"

Response

HTTP 200 example

{
    "result": "ok",
    "code": "200",
    "deleted_orders": 4,
    "livemode": false
}

Status codes 200 400 401 403 405 429

Downloads

Import the collection and one environment into Postman, or feed the OpenAPI file to your client generator. The sandbox environment points at https://sandbox-api.esimcenter.com/v3, the live one at https://api.esimcenter.com/v3; switch between them with the environment picker, and put your key into the environment's key variable.

Notes and limits

  • Webhooks are the recommended path, polling still works. If you would rather not run an endpoint, poll /order/detail/{id} after any purchase that came back 202. Do not poll faster than once every 30 seconds — it counts against your rate limit and nothing changes that quickly.
  • Registering a webhook endpoint is self-service. Save the URL from the developer panel; it hands you the signing secret once, and you can check the endpoint from /webhook/list afterwards.
  • Test with a sandbox key, not with real money. A key starting with esk_test_ runs the same endpoints without charging you or calling a provider — see Sandbox.
  • Unused eSIMs. Refunds follow our standard policy and are handled by support, not by the API.
  • Versioning. New fields can appear in a response at any time — ignore what you do not know. Fields are never removed or retyped inside v3; a breaking change would ship as v4 with notice.
  • Prices move. We resell several providers and the catalog is refreshed daily. Read the price at purchase time rather than storing it for weeks.

Need a key, a higher rate limit, or a field we do not return?

Write to us with what you are building and the volume you expect. We answer integration questions from engineers directly.

Contact us
Secure payment
  • Your QR code arrives by e-mail right after payment
4.5/5 Trustpilot 100% refund if incompatible WhatsApp support
Payment Failed
Payment is Successful
Your eSIM is being prepared
View My eSIMs
Need help with installation? Contact Support

How was your experience?

Review us on Trustpilot

Chat with us on WhatsApp