EN

Developer API — Sell Turnaa eSIMs

Turnaa B2B API

Sell Turnaa travel eSIMs from your own platform. You buy at wholesale from a prepaid wallet; we provision real eSIMs through our carrier network and return the QR / activation payload to you in seconds — over a simple REST API.

https://esim.turnaa.cloud/wp-json/turnaa/v1
Get your API key →

Quickstart

Three steps from zero to your first eSIM:

  1. Get a key — apply for a B2B account. You start in sandbox, so you can build the whole integration before funding anything.
  2. Call the API — every request carries your key as a Bearer token (below).
  3. Go live — once we approve live access and your wallet has funds, generate a second key from the dashboard. Both keys stay valid side by side: the same code provisions real eSIMs when it calls with the live key, and keeps testing freely with the sandbox one.
shellKEY="tb2b_sk_xxxxxxxxxxxxxxxxxxxx"

# list Japan plans with your wholesale price
curl -s -H "Authorization: Bearer $KEY" \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/catalog?country=jp&days=7"

Conventions

Item Value
Base URL https://esim.turnaa.cloud/wp-json/turnaa/v1
Protocol HTTPS only · REST · JSON request & response bodies
Auth Authorization: Bearer <api_key> on every request
Money All amounts are USD, decimal. currency is always "USD".
Country ISO 3166-1 alpha-2, lowercase (e.g. jp, it, us)
Mode sandbox or live — decided by which key you called with, not by an account setting. /balance echoes back the mode of the key you used.
ICCID The identifier of an issued eSIM (19–20 digits). Sandbox ICCIDs begin with SANDBOX.
IDs plan_id is a stable integer; use it for catalog lookups and orders

Authentication

Send your API key as a Bearer token on every request. A key is shown once, at the moment it is created, and stored only as a hash — after that your dashboard shows its last four characters so you can tell one from the other. Roll a key anytime; the old one stops working immediately.

You hold up to two keys, tb2b_sk_sandbox_… and tb2b_sk_live_…, and they are independent: rolling one leaves the other alone, and a sandbox key can never move real money or act on a real eSIM. Keep them server-side — a key in browser or app code is a key anyone can spend.

httpAuthorization: Bearer tb2b_sk_xxxxxxxxxxxxxxxxxxxx
Failure HTTP When
unauthorized 401 Missing or invalid Bearer key
forbidden 403 Key valid but the account is paused, or is not a B2B API account
suspended 403 The account is suspended — both keys stop working until it is lifted
sandbox_key 403 A sandbox key was used against a real eSIM

Errors

Errors use standard HTTP status codes and a JSON body with a machine-readable code:

json{ "code": "insufficient_funds",
  "message": "Wallet balance too low — top up required",
  "data": { "status": 402 } }
HTTP code Meaning
400 invalid_plan Unknown plan_id, or the plan is not orderable
400 missing_country A required parameter was not supplied
401 unauthorized Missing or invalid API key
402 insufficient_funds Wallet balance below the order total — top up
400 invalid_package Unknown package_code, or that package is not offered for this eSIM
400 confirm_required revoke was called without confirm=true
403 forbidden Partner account paused or not a B2B API account
403 suspended Account suspended — contact support
403 sandbox_key Sandbox key used on a real eSIM — call with your live key
404 not_found Order or eSIM not found, or not owned by your account
500 supplier_unavailable Supplier client not reachable — nothing charged
502 supplier_error Carrier-side failure — the order is refunded automatically

Rate limits & fair use

Poll an order at most once every 3 seconds. The catalog changes only a few times a day — cache it on your side for ~6 hours rather than fetching per page view. Bulk orders should send one request per plan_id with a qty rather than many parallel calls.

Usage for a single eSIM is cached for 3 minutes per ICCID, so calling GET /esims/{iccid} in a tight loop returns the same numbers and buys you nothing; a top-up clears that cache immediately. To show a customer dashboard, read GET /esims — it comes from our own records and never touches the carrier.

GET /b2b/catalog

Every orderable plan with your wholesale price and the suggested retail price.

Query param Type Description
country string optional ISO alpha-2 filter (e.g. jp). Omit for the full catalog.
days integer optional Only plans whose validity is ≥ this many days
limit integer optional Max rows (default & cap 500)
shellcurl -s -H "Authorization: Bearer $KEY" \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/catalog?country=jp&days=7"
json[
  {
    "plan_id": 3732,
    "name": "Japan · Unlimited · 1 day",
    "data_mb": 3072,
    "duration_days": 1,
    "unlimited": true,
    "partner_price": 3.54,
    "retail_price": 9.45,
    "currency": "USD"
  }
]
Field Type Description
plan_id integer Stable plan identifier — pass to /orders
name string Human-readable plan name
data_mb integer Data allowance in MB (per-day for unlimited plans)
duration_days integer Validity in days
unlimited boolean true for daily-unlimited (fair-use) plans
partner_price number Your wholesale price per unit (debited from your wallet)
retail_price number Suggested retail — your margin is the difference

GET /b2b/suggest

Trip-aware plan picker: give a destination and the trip dates (or a day count) and we return the plans that fit, cheapest first, each flagged fits_trip.

Query param Type Description
country string required ISO alpha-2 destination
days integer optional Trip length in days
start, end date optional YYYY-MM-DD — used to compute trip days (inclusive) when days is absent
shellcurl -s -H "Authorization: Bearer $KEY" \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/suggest?country=it&start=2026-08-01&end=2026-08-09"
json{
  "trip_days": 9,
  "fit": [
    { "plan_id": 812, "name": "Italy · 3 GB · 15 days",
      "duration_days": 15, "partner_price": 4.92, "retail_price": 11.99,
      "unlimited": false, "currency": "USD", "fits_trip": true }
  ],
  "all": [ /* every plan for the country, fits_trip true/false */ ]
}

GET /b2b/balance

Your prepaid wallet balance and the current mode of your key.

json{ "balance": 250.00, "currency": "USD", "mode": "live" }

POST /b2b/orders

Order one or more eSIMs for a plan. The unit price is debited from your wallet; the response carries the eSIM activation payload.

Body field Type Description
plan_id integer required From the catalog
qty integer optional 1–50 (default 1)
idempotency_key string recommended ≤ 64 chars — replaying the same key returns the original order instead of charging again
customer_email string optional Stored on the order for your reconciliation
shellcurl -s -X POST \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"plan_id":3732,"qty":1,"idempotency_key":"order-abc-123"}' \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/orders"
json{
  "id": 4821,
  "status": "completed",
  "mode": "live",
  "unit_price": 3.54,
  "total": 3.54,
  "currency": "USD",
  "balance": 246.46,
  "payload": {
    "esims": [
      {
        "iccid": "89371034000059xxxxx",
        "activation_code": "LPA:1$rsp.example.com$XXXXXXXX",
        "qr_note": "Encode activation_code as a QR for the customer"
      }
    ]
  }
}
When the carrier needs a moment, the order returns "status": "processing" with an empty esims array — poll GET /orders/{id} every few seconds (usually < 30 s) until it is completed. If allocation ultimately fails, the order becomes failed and the full amount is refunded to your wallet automatically.
esims[] field Type Description
iccid string The eSIM’s ICCID — the handle for every call under /esims
activation_code string Full LPA string — render it as a QR for the traveler
smdp_address string SM-DP+ server, for customers who install by hand instead of scanning
matching_id string Activation code for that same manual install
qr_url string Hosted QR image, if you would rather not render one
short_url string Hosted install page you can send straight to the traveler
expires_at string ISO-8601 expiry, or null before first use
esim_tran_no, imsi,
confirmation_code, apn,
ios_install_url, smdp_status
string Carrier extras. Present only when that carrier supplies them — treat every one as optional and never block an install on a missing value.
A manual install needs smdp_address and matching_id separately — handing the customer the whole LPA string does not work in the iOS or Android dialog. Both are derived from the activation code, so they are there whenever it is well formed. eSIMs sold before 23 September 2026 carry only iccid, activation_code and qr_url.

GET /b2b/orders/{id}

Fetch a single order (only your own). Use it to poll a processing order until the payload.esims array is populated.

shellcurl -s -H "Authorization: Bearer $KEY" \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/orders/4821"

GET /b2b/esims

Every eSIM you have sold, newest first, with the install payload we stored at the time of sale. It is served from our own records, so paging through it is cheap and the carrier is never contacted.

Query param Type Description
limit integer optional Orders to scan (default 50, cap 200)
offset integer optional Skip this many orders
shellcurl -s -H "Authorization: Bearer $KEY" \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/esims?limit=20"
json{
  "esims": [
    {
      "iccid": "8937103400005912345",
      "order_id": 4821,
      "plan": "Japan \· Unlimited \· 1 day",
      "mode": "live",
      "customer_email": "traveler@example.com",
      "created_at": "2026-07-15T09:12:04+00:00",
      "activation_code": "LPA:1$rsp-eu.simlessly.com$32B863FF...A8A9",
      "smdp_address": "rsp-eu.simlessly.com",
      "matching_id": "32B863FF...A8A9",
      "qr_url": "https://.../qr/8937103400005912345.png"
    }
  ],
  "count": 1
}

Paging is over orders, not eSIMs: an order with qty: 3 contributes three rows, so count can exceed limit. Fields the carrier did not supply are left out of the row rather than sent empty — check for presence, do not compare against "".

GET /b2b/esims/{iccid}

Live usage for one eSIM, merged with its install payload — one call answers both “how much is left” and “how do I install it”, so a customer-facing screen needs no second request.

shellcurl -s -H "Authorization: Bearer $KEY" \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/esims/8937103400005912345"
json{
  "iccid": "8937103400005912345",
  "status": "active",
  "data_total_mb": 3072,
  "data_used_mb": 812,
  "data_left_mb": 2260,
  "expires_at": "2026-08-14T00:00:00+00:00",
  "topups_used": 0,
  "topups_left": 9,
  "plan": "Japan \· Unlimited \· 1 day",
  "order_id": 4821,
  "activation_code": "LPA:1$rsp-eu.simlessly.com$32B863FF...A8A9",
  "smdp_address": "rsp-eu.simlessly.com",
  "matching_id": "32B863FF...A8A9"
}
status Meaning
active Issued and usable — whether or not the traveler has installed it yet
suspended You suspended it. Validity keeps running; only data is blocked.
expired Data exhausted or validity over
cancelled Cancelled before first use and refunded
revoked Permanently killed — cannot be brought back

Data is reported in MB. topups_left counts down from nine: the carrier accepts at most nine top-ups per profile, ever, and there is no way to raise it. expires_at is null until the profile is first used.

GET /b2b/esims/{iccid}/topups

What can be added to this eSIM, priced for you. The list is carrier-specific — do not cache it across ICCIDs and do not assume a plan from the catalog can be topped up onto an unrelated profile.

json{
  "packages": [
    { "code": "TOPUP_JP_1GB_7D", "name": "Japan 1 GB / 7 days",
      "data_mb": 1024, "days": 7, "price": 2.46, "currency": "USD",
      "needs_days": false }
  ]
}

price is what leaves your wallet, priced by the same wholesale rule as the catalog. When needs_days is true the package extends validity and you must send a days value with the purchase — there is no sensible default for us to pick.

POST /b2b/esims/{iccid}/topup

Add data to an eSIM the traveler already installed. No new QR, nothing for them to do — the allowance lands on the existing profile.

Body field Type Description
package_code string required code from the endpoint above
days integer conditional Required when the package reports needs_days
idempotency_key string recommended ≤ 64 chars — see the note below, this one matters more than usual
shellcurl -s -X POST \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"package_code":"TOPUP_JP_1GB_7D","idempotency_key":"topup-abc-123"}' \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/esims/8937103400005912345/topup"
json{ "id": 4907, "status": "completed", "mode": "live",
  "iccid": "8937103400005912345", "package": "TOPUP_JP_1GB_7D",
  "total": 2.46, "currency": "USD", "balance": 244.00 }
Always send an idempotency_key here. We pass it to the carrier as the transaction id, and the carrier treats a repeated id as the same top-up rather than a new one — so a retry after a timeout is safe end to end, not just on our side. Without it, a retry is a second purchase. If the carrier refuses, your wallet is refunded before the error reaches you.

POST /b2b/esims/{iccid}/{action}

Four actions on one shape — suspend, unsuspend, cancel, revoke. They differ in what they cost you and whether you can undo them.

action Reversible Money Use it when
suspend Yes None Blocking data temporarily — a suspected-fraud hold, or a customer dispute. Validity keeps running while suspended; the plan is still burning days.
unsuspend — None Lifting that hold
cancel No Refunded to your wallet The eSIM was never used and the customer wants out. Only works while the profile is untouched; once it has been installed and used the carrier refuses and you get supplier_error.
revoke No No refund Killing a used profile for good — abuse, chargeback, a lost device. Requires confirm=true.
shell# refundable: unused profile only
curl -s -X POST -H "Authorization: Bearer $KEY" \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/esims/$ICCID/cancel"

# permanent, no money back — the flag is deliberate
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"confirm":true}' \
  "https://esim.turnaa.cloud/wp-json/turnaa/v1/b2b/esims/$ICCID/revoke"
json{ "iccid": "8937103400005912345", "action": "cancel",
  "status": "completed", "refunded": 3.54, "balance": 249.00,
  "note": null }

refunded is the unit price that eSIM was sold at — not the order total, since one order can carry several. It is 0 for every action except cancel. The refund lands in your ledger as a refund row the moment the carrier confirms.

GET /b2b/ledger

Your wallet statement — every movement, newest first.

Query param Type Description
limit integer optional Rows to return (default 20, cap 100)
json[
  { "id": 5, "type": "sale", "amount": -3.54, "balance_after": 246.46,
    "note": "API order #4821", "order_id": 4821, "created_at": "2026-07-15 09:12:04" }
]
type Sign Meaning
topup + Wallet loaded — by card on our checkout, or by us after a bank transfer
sale − An order or a top-up charged to your wallet
refund + Money back: a failed or timed-out order, or an eSIM you cancelled
adjustment ± Manual correction by us — the note says why
Charges carry a negative amount. The ledger sums to the balance as-is, so add the rows — do not subtract by type. balance_after is the balance immediately following that row, which is usually the easier thing to reconcile against.

Order lifecycle

status Meaning
completed eSIMs allocated — payload.esims holds ICCID + activation code
processing Carrier is allocating — poll the order
failed Allocation failed — wallet refunded automatically
sandbox_completed Sandbox order — test payload, no charge

Top-ups are recorded as orders too, so they appear in your ledger with their own id and move through the same statuses.

After the sale

Selling the eSIM is the easy half. Most support load lands in the week that follows, and each question maps to one call:

The customer says You call
“How much data is left?” GET /esims/{iccid} — usage, expiry, status in one response
“The QR will not scan.” Nothing. You already hold smdp_address and matching_id — show them for a manual install.
“I am running out.” GET .../topups then POST .../topup — no new QR, nothing to reinstall
“I never used it, refund me.” POST .../cancel — your wallet is credited back
“My phone was stolen.” POST .../suspend now; revoke only if it is never coming back

Two things are worth building into your own refund policy rather than discovering later. Cancel only works on an untouched profile — the minute the traveler installs and uses it, the carrier will not take it back, so a “30-day money back” promise costs you real money after first use. And suspending does not pause the clock: a 30-day plan suspended for a week still expires on day 30.

Idempotency

Send a unique idempotency_key with every POST /orders and every POST /esims/{iccid}/topup. If a request is retried — network blip, timeout, a queue that redelivers — the same key returns the original order, flagged "idempotent": true, and never charges twice. Use one key per intended purchase, such as your own order id.

On top-ups the key goes further: we hand it to the carrier as the transaction id, and the carrier collapses a repeat of the same id onto the first top-up. So the protection covers the whole chain, not just our side of it. That only holds if the key is stable across retries — generating a fresh one each attempt defeats the entire mechanism.

Sandbox & live

You get two keys, not one switch. Each request runs in the mode of the key that carried it, so staging and production can run the same code at the same time and never collide — and going live never costs you your test environment.

Sandbox key Live key
Issued As soon as your account is approved Once we approve live access and your wallet has funds
Wallet Never charged Charged per order and per top-up
Carrier Never contacted Real profiles, real activation
ICCIDs Begin with SANDBOX Real 19–20 digit ICCIDs

Every endpoint behaves the same in both modes and the response shapes match field for field, so going live is a key change and not a code change. Sandbox orders return an obviously fake payload and charge nothing:

json{
  "status": "sandbox_completed",
  "payload": { "esims": [
    { "iccid": "SANDBOX1234567890123",
      "activation_code": "LPA:1$sandbox.turnaa.cloud$TEST-XXXXXXXXXXXX",
      "qr_note": "SANDBOX — no real eSIM issued, no wallet charge" }
  ]}
}

Usage, top-ups and the management actions all work on sandbox eSIMs: usage numbers are generated (stable per ICCID, so they do not jump between calls), and the top-up catalogue is the fixed set SANDBOX-TOPUP-1GB-7D, SANDBOX-TOPUP-3GB-30D, SANDBOX-TOPUP-5GB-30D, priced through the same wholesale rule live uses (on fixed test costs), so your margin maths is exercised too.

A sandbox key used against a real ICCID is refused with 403 sandbox_key — it never silently no-ops. You find a mis-wired environment variable in staging instead of wondering why a customer’s eSIM did nothing. The reverse is allowed: a live key can read and manage sandbox eSIMs.

If you branch on mode, read it from /balance at boot and assert it matches the environment you think you are in.

Webhooks

Set an HTTPS webhook URL from your dashboard (Developer tab). We POST a signed JSON event so you don’t have to poll:

event Fired when
order.completed eSIMs allocated — data includes the full esims array
order.failed Allocation failed — amount already refunded
balance.low A sale dropped your balance below the threshold
esim.topup.completed Data added — data carries iccid, package_code, total
esim.topup.failed The carrier refused the top-up — refunded is already back in your wallet
esim.suspend · esim.unsuspend An eSIM was held or resumed
esim.cancel An unused eSIM was cancelled — refunded holds the amount returned
esim.revoke An eSIM was permanently killed — refunded is 0

eSIM events fire whichever way the action was taken — your API call, or one of our staff acting on a support ticket — so your records stay in step with ours either way.

jsonPOST your-webhook-url
X-Turnaa-Event: order.completed
X-Turnaa-Signature: sha256=9f86d081...

{ "event": "order.completed",
  "created": "2026-07-15T09:12:04+00:00",
  "data": { "order_id": 4821, "status": "completed", "mode": "live",
            "total": 3.54, "currency": "USD", "esims": [ ... ] } }

Verify every delivery — compute HMAC-SHA256(raw_body, signing_secret) and compare it to the X-Turnaa-Signature header (constant-time). The signing secret is shown in your dashboard when you save the URL.

php$raw = file_get_contents('php://input');
$sig = 'sha256=' . hash_hmac('sha256', $raw, $signing_secret);
if (!hash_equals($sig, $_SERVER['HTTP_X_TURNAA_SIGNATURE'] ?? '')) {
    http_response_code(400); exit;
}
$event = json_decode($raw, true);
nodeconst crypto = require('crypto');
const sig = 'sha256=' + crypto.createHmac('sha256', SIGNING_SECRET)
                              .update(rawBody).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(req.get('X-Turnaa-Signature')||'')))
  return res.sendStatus(400);
Webhooks are a convenience — GET /orders/{id} is always the source of truth. Return 2xx quickly; do slow work asynchronously.

Prefer no code? WordPress plugin

Running WordPress? Skip the raw API — install our reseller plugin, paste your key, and drop a shortcode anywhere:

shortcode

It renders live plans with your prices and blends into your theme. In WooCommerce mode, the Buy buttons run through your own checkout — when the customer pays, the eSIM is ordered from your Turnaa wallet and emailed automatically (QR + activation code) and the order auto-completes. Fully testable in sandbox.

Download the plugin (.zip)

Get access

A B2B account ties your API key, wallet and order history to one Turnaa login. Approval typically takes one business day, and you build entirely in sandbox until you are ready.

Step 1 — create your account

Partner applications are tied to a Turnaa account, so you can track status and manage your API key and wallet from one dashboard.

Sign in / Create account

Then come back here — this box turns into the application form.