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.
Quickstart
Three steps from zero to your first eSIM:
- Get a key — apply for a B2B account. You start in sandbox, so you can build the whole integration before funding anything.
- Call the API — every request carries your key as a Bearer token (below).
- 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"
}
]
}
}
"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. |
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 }
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 |
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.
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);
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.
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.
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 accountThen come back here — this box turns into the application form.