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 — top up your wallet and we flip your key to live; the same calls now provision real eSIMs.
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 — a property of your key, returned by /balance |
| 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. Keys are shown once in your dashboard and stored only as a hash — roll a new one anytime (the old key stops working immediately).
httpAuthorization: Bearer tb2b_sk_xxxxxxxxxxxxxxxxxxxx
| Failure | HTTP | When |
|---|---|---|
unauthorized |
401 | Missing or invalid Bearer key |
forbidden |
403 | Key valid but the partner account is paused, or is not a B2B API account |
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 |
| 403 | forbidden |
Partner account paused or not a B2B API account |
| 404 | not_found |
Order 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.
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 |
activation_code |
string | LPA activation string — render it as a QR for the traveler |
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/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": "debit", "amount": 3.54, "balance_after": 246.46,
"note": "Order #4821", "order_id": 4821, "created_at": "2026-07-15 09:12:04" }
]
| type | Meaning |
|---|---|
credit |
Wallet top-up |
debit |
An order charge |
refund |
A failed order returned to your wallet |
adjustment |
Manual correction by Turnaa |
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 |
Idempotency
Send a unique idempotency_key with every POST /orders. If a request is retried (network blip, timeout) with the same key, we return the original order — flagged "idempotent": true — and never charge twice. Use one key per intended purchase (e.g. your own order id).
Sandbox & live
Every account starts in sandbox. All endpoints behave identically, but orders return a clearly-fake payload and nothing is charged:
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" }
]}
}
Build and test the full flow against sandbox. When you top up your wallet we switch your key to live — the same code then provisions real eSIMs. Check mode from /balance at runtime if you branch on it.
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 |
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.