Skip to content

Build it, start to finish

Every step below runs against the sandbox with a tz_test_ key, so nothing costs real money until you swap in a live key. The code is plain Node.js with fetch: no SDK to install.

01

Set up a client

Every request is HTTPS to one base URL with your API key as a bearer token. The key decides the mode: tz_test_… talks to the sandbox, tz_live_… moves real money. The URL is the same for both.

Call Tizon from your server, not from your mobile app, so the key never ships to a device.

tizon.js
// tizon.js: a tiny client. Node 18+ has fetch built in,
// so there's no SDK to install.
const BASE_URL = 'https://api.tizon.mobile';

export async function tizon(method, path, body, options = {}) {
  const res = await fetch(BASE_URL + path, {
    method,
    headers: {
      // tz_test_… in the sandbox, tz_live_… in production.
      Authorization: `Bearer ${process.env.TIZON_API_KEY}`,
      'Content-Type': 'application/json',
      ...(options.idempotencyKey && {
        'Idempotency-Key': options.idempotencyKey,
      }),
    },
    body: body && JSON.stringify(body),
  });

  const data = await res.json();
  if (!res.ok) {
    // Every error is { error: { type, code, message, request_id } }
    throw Object.assign(new Error(data.error.message), data.error);
  }
  return data;
}
.env
# .env: your sandbox key.
# Keep it on your server, never in a mobile app.
TIZON_API_KEY=tz_test_51b3f0c2a7d94e8a9c6e2f1d

02

Get your payment addresses

There’s no balance to prefund. Your account comes with its own payment addresses: a Lightning address, stablecoin addresses for USDT and USDC, and a bank account for transfers.

Each purchase is paid to one of them at the moment you buy, so you only ever spend what your users spend.

  • GET /v1/wallet/addresses
  • USDT on Tron (trc20), BNB Chain (bep20) and Base; USDC on Base and BNB Chain.
  • Lightning is in preview: its fields may change before it ships.
addresses.js
// No balance to prefund. Your account comes with its own
// payment addresses; each purchase is paid to one of them.
const { data } = await tizon('GET', '/v1/wallet/addresses');

for (const a of data) {
  console.log(a.type, a.asset ?? '', a.address ?? a.bank_name);
}
// lightning_address       [email protected]
// crypto_address     USDT T…your-usdt-trc20-address
// bank_account            Example Bank
Response · 200
{
  "object": "list",
  "data": [
    // Preview: Lightning isn't in the API yet; names may change.
    {
      "object": "funding_destination",
      "type": "lightning_address",
      "address": "[email protected]"
    },
    {
      "id": "addr_01JAC0B7D9F2H4K6M8N1Q3S5TV",
      "object": "funding_destination",
      "type": "crypto_address",
      "asset": "USDT",
      "network": "trc20",
      "address": "T…your-usdt-trc20-address",
      "memo": null,
      "status": "active"
    },
    {
      "id": "vac_01JAC0B7E2G4J6K8M1P3R5T7VX",
      "object": "funding_destination",
      "type": "bank_account",
      "bank_name": "Example Bank",
      "account_number": "0123456789",
      "account_name": "Tizon / Your Company"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

03

Find a plan

An offer is something your users can buy: an eSIM data plan for a country, or a mobile top-up. Filter by category and country to build your store screen.

price is what you pay for each purchase. Charge your users whatever you like on top: the difference is your margin.

  • GET /v1/offers
  • Add display_currency=NGN (or any currency) to get an indicative local price for display.
  • Lists are paged: pass limit and cursor, and follow next_cursor while has_more is true.
offers.js
// eSIM plans that work in Japan.
// `country` is an ISO code: NG, KE, GB, JP…
const offers = await tizon(
  'GET', '/v1/offers?category=esim&country=JP',
);

for (const offer of offers.data) {
  const usd = (offer.price.amount / 100).toFixed(2);
  console.log(offer.id, offer.name, `$${usd}`);
}
// off_jp_5gb_30d  Japan 5GB 30 days  $9.50
Response · 200
{
  "object": "list",
  "data": [
    {
      "id": "off_jp_5gb_30d",
      "object": "offer",
      "category": "esim",
      "name": "Japan 5GB 30 days",
      "coverage": { "type": "country", "countries": ["JP"] },
      "data": { "amount": 5, "unit": "GB", "unlimited": false },
      "validity_days": 30,
      "price": { "amount": 950, "currency": "USD" },
      "requires_destination": false,
      "status": "active"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

04

Buy it, pay as you go

Buying is one request: a quote for the offer with how you’ll pay, lightning, usdt, usdc or bank_transfer. Nothing is charged up front. The quote comes back awaiting_funds with the exact amount to send and where to send it.

Send that amount and the quote pays itself: it turns fulfilled and an order starts delivering the eSIM. If nothing arrives before expires_at, the quote expires and nothing happens.

  • POST /v1/quotes
  • Send an Idempotency-Key: a retried request replays the first quote instead of creating a second.
buy.js
// Pay per purchase: 'lightning', 'usdt', 'usdc' or 'bank_transfer'.
// Nothing is charged up front, and nothing needs to be prefunded.
const quote = await tizon('POST', '/v1/quotes', {
  offer_id: 'off_jp_5gb_30d',
  payment_source: 'usdt',
  metadata: { user_id: 'usr_8812' }, // your own data, echoed back
}, { idempotencyKey: purchase.id }); // retries never buy twice

// The quote says exactly what to send, and where.
const { asset_amount, destinations } = quote.payment;
const to = destinations.find((d) => d.network === 'trc20');

// Send it from your own treasury or exchange account.
await treasury.sendUsdt({ to: to.address, amount: asset_amount });
Response · 200 · USDT
{
  "id": "qt_01JAB4C8M2Y5E7H9K3N6Q1S4VX",
  "object": "quote",
  "status": "awaiting_funds",
  "offer_id": "off_jp_5gb_30d",
  "payment_source": "usdt",
  "price": { "amount": 950, "currency": "USD" },
  "wallet": null,
  "payment": {
    "amount": { "amount": 950, "currency": "USD" },
    "asset": "USDT",
    "asset_amount": "9.50",
    "destinations": [
      {
        "id": "addr_01JAC0B7D9F2H4K6M8N1Q3S5TV",
        "object": "funding_destination",
        "type": "crypto_address",
        "asset": "USDT",
        "network": "trc20",
        "address": "T…your-usdt-trc20-address",
        "memo": null,
        "status": "active"
      }
    ]
  },
  "expires_at": "2026-09-19T11:24:07Z",
  "order_id": null,
  "metadata": { "user_id": "usr_8812" },
  "created_at": "2026-09-19T10:24:07Z"
}
Preview · Lightning
// Preview: Lightning isn't in the API yet; names may change.
// The same request with "payment_source": "lightning":
{
  "status": "awaiting_funds",
  "payment_source": "lightning",
  "payment": {
    "amount": { "amount": 950, "currency": "USD" },
    "asset": "BTC",
    "asset_amount": "0.00015323",
    "destinations": [
      {
        "type": "lightning_address",
        "address": "[email protected]"
      },
      {
        "type": "lightning_invoice",
        "invoice": "lnbc153230n1p…"
      }
    ]
  }
  // …the rest of the Quote
}

05

Pay it in the sandbox

In the sandbox you don’t send real money: a test helper pretends the payment arrived at your address. The waiting quote is paid, and its order_id is set.

In production the same thing happens when your payment lands, and a quote.fulfilled webhook tells you.

  • POST /v1/test_helpers/deposits
  • GET /v1/quotes/{quote_id}
  • Amounts are integer cents in USD: 950 is $9.50.
pay.js
import { randomUUID } from 'node:crypto';

// Sandbox only: pretend the 9.50 USDT arrived at your address.
// Amounts are integer cents, so 950 is $9.50.
await tizon('POST', '/v1/test_helpers/deposits',
  { amount: 950, asset: 'USDT', network: 'trc20' },
  { idempotencyKey: randomUUID() },
);

// The payment settles the waiting quote and starts the order.
const paid = await tizon('GET', `/v1/quotes/${quote.id}`);
console.log(paid.status, paid.order_id);
// fulfilled  ord_01JAB4C8N7F2G6J8M1P4R9T3WY
Response · 200
// GET /v1/quotes/qt_01JAB4C8M2Y5E7H9K3N6Q1S4VX, once paid
{
  "id": "qt_01JAB4C8M2Y5E7H9K3N6Q1S4VX",
  "object": "quote",
  "status": "fulfilled",
  "payment_source": "usdt",
  "price": { "amount": 950, "currency": "USD" },
  "payment": null,
  "order_id": "ord_01JAB4C8N7F2G6J8M1P4R9T3WY"
  // …
}

06

Collect the eSIM

The order does the delivery. When its status is fulfilled, deliverable holds the eSIM profile: its iccid and the lpa string the phone needs to install it.

If delivery fails, the order is failed with a failure_reason, and what you paid is credited to your account for your next purchase.

  • GET /v1/orders/{order_id}
order.js
// Fulfilment runs in the background, usually for a few seconds.
// In production, wait for the order.fulfilled webhook (step 8).
// While you're building, polling is fine:
let order = await tizon('GET', `/v1/orders/${quote.order_id}`);

while (order.status === 'pending') {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  order = await tizon('GET', `/v1/orders/${quote.order_id}`);
}

if (order.status === 'failed') {
  // What you paid is credited to your account and counts
  // towards your next purchase.
  throw new Error(order.failure_reason);
}

// { type: 'esim_profile', iccid, lpa, activation_code }
const esim = order.deliverable;
Response · 200
{
  "id": "ord_01JAB4C8N7F2G6J8M1P4R9T3WY",
  "object": "order",
  "quote_id": "qt_01JAB4C8M2Y5E7H9K3N6Q1S4VX",
  "offer_id": "off_jp_5gb_30d",
  "status": "fulfilled",
  "price": { "amount": 950, "currency": "USD" },
  "deliverable": {
    "type": "esim_profile",
    "iccid": "8981100022334455667",
    "lpa": "LPA:1$smdp.example.com$K2-1XYZ7-ABC123",
    "activation_code": "K2-1XYZ7-ABC123"
  },
  "failure_reason": null,
  "upstream": { "transaction_id": "qt_01JAB4C8M2Y5E7H9K3N6Q1S4VX" },
  "created_at": "2026-09-19T10:24:07Z",
  "resolved_at": "2026-09-19T10:24:11Z"
}

07

Put it on your user’s phone

This is the only part that touches the phone, and Tizon doesn’t do it for you: your app hands the user the lpa string, and their phone downloads the eSIM.

Offer a QR code to scan, a one-tap install link on iPhone, and the raw values for manual entry. Most users will only need the first two.

  • The phone needs an internet connection (Wi-Fi is fine) while it installs.
  • A profile usually installs only once. If your user deletes it, it can’t be reinstalled.
install.js
import QRCode from 'qrcode'; // npm install qrcode

// 1. A QR code. The user scans it with the phone that gets the eSIM
//    (Settings → Mobile → Add eSIM), so show it on another screen.
const qrDataUrl = await QRCode.toDataURL(esim.lpa);

// 2. One-tap install on the iPhone itself (iOS 17.4+):
//    open Apple's eSIM setup link with the LPA string.
const iosInstallUrl =
  'https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=' +
  esim.lpa;

// 3. Manual entry, when neither works: the user types these two.
const [, smdpAddress, activationCode] = esim.lpa.split('$');

// Keep the ICCID with your user: top-ups target it (step 9).
await db.esims.insert({ userId: 'usr_8812', iccid: esim.iccid });
Anatomy of an LPA string
LPA:1 $ smdp.example.com $ K2-1XYZ7-ABC123
  │         │                  │
  │         │                  └─ activation code: this one profile
  │         └─ SM-DP+ address: the server the phone downloads from
  └─ format version

Treat it like a password. Most profiles install once:
show it only to the user who bought it.

08

Listen for webhooks

Instead of polling, register an HTTPS endpoint and Tizon will POST an event when something happens: a payment settles a quote, an order is fulfilled or fails.

Every delivery is signed. Check the Tizon-Signature header against your endpoint’s secret before trusting the body.

  • POST /v1/webhook_endpoints
  • Delivery is at-least-once: the same event id can arrive twice, so dedupe on it.
  • The URL must be public HTTPS, even in the sandbox. Use a tunnel such as ngrok while developing.
webhooks.js
import crypto from 'node:crypto';
import express from 'express';

// Once: register the endpoint and store the secret it returns.
// It's shown only in this response.
// const { secret } = await tizon('POST', '/v1/webhook_endpoints', {
//   url: 'https://api.yourapp.com/tizon/webhooks',
//   enabled_events: [
//     'quote.fulfilled', 'order.fulfilled', 'order.failed',
//   ],
// });

const app = express();
const raw = express.raw({ type: 'application/json' });

app.post('/tizon/webhooks', raw, async (req, res) => {
  // Tizon-Signature: t=1758277451,v1=5f2b…
  // v1 is the HMAC-SHA256 of "<t>.<raw body>" with your secret.
  const header = req.get('Tizon-Signature') ?? '';
  const { t, v1 = '' } = Object.fromEntries(
    header.split(',').map((part) => part.split('=')),
  );
  const expected = crypto
    .createHmac('sha256', process.env.TIZON_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`)
    .digest('hex');

  const valid = v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  // Reject anything older than five minutes.
  const recent = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  if (!valid || !recent) return res.sendStatus(400);

  // Deliveries can repeat: skip events you've already handled.
  const event = JSON.parse(req.body);
  if (await db.events.seen(event.id)) return res.sendStatus(200);

  if (event.type === 'order.fulfilled') {
    // event.data.object is the Order, with deliverable.lpa
    await sendEsimToUser(event.data.object);
  }

  // Answer 2xx within ten seconds, or the delivery is retried.
  res.sendStatus(200);
});
What Tizon sends
// POST https://api.yourapp.com/tizon/webhooks
{
  "id": "evt_01JAB4C9A3D5F7H9K2M4P6R8TV",
  "object": "event",
  "type": "order.fulfilled",
  "created_at": "2026-09-19T10:24:11Z",
  "livemode": false,
  "data": {
    "object": {
      "id": "ord_01JAB4C8N7F2G6J8M1P4R9T3WY",
      "object": "order",
      "status": "fulfilled",
      "deliverable": {
        "type": "esim_profile",
        "iccid": "8981100022334455667",
        "lpa": "LPA:1$smdp.example.com$K2-1XYZ7-ABC123",
        "activation_code": "K2-1XYZ7-ABC123"
      }
      // …the rest of the Order
    }
  }
}

09

Top up data and airtime

Both are the same pay-as-you-go quote. To add data to an eSIM your user already installed, pass its iccid. To send airtime to a phone number, buy a mobile_topup offer with a destination.

To know when your user needs more, read the eSIM’s usage: each plan with the GB it started with, what is left, and when it ends. It is measured when you ask, so call it when your user opens their eSIM rather than on a timer.

  • GET /v1/esims/{iccid}/usage
  • GET /v1/offers
  • POST /v1/quotes
topups.js
// How much is left? Each plan on the eSIM, measured when you ask.
const usage = await tizon('GET', `/v1/esims/${esim.iccid}/usage`);
const plan = usage.plans.find((p) => p.status === 'active');
// plan.data: { unit: 'GB', total: 5, remaining: 0.4, used: 4.6 }

// More data on an eSIM your user already has: buy a plan for the
// same country and pass its ICCID instead of issuing a new eSIM.
await tizon('POST', '/v1/quotes', {
  offer_id: 'off_jp_5gb_30d',
  payment_source: 'lightning', // or 'usdt', 'usdc', 'bank_transfer'
  iccid: esim.iccid,
}, { idempotencyKey: dataTopUp.id });

// Airtime for a phone number: mobile_topup offers need a
// destination in international (E.164) form.
const { data: [airtime] } = await tizon(
  'GET', '/v1/offers?category=mobile_topup&country=NG',
);

await tizon('POST', '/v1/quotes', {
  offer_id: airtime.id,
  payment_source: 'usdt',
  destination: '+2348012345678',
}, { idempotencyKey: airtimeTopUp.id });
Airtime receipt
// GET /v1/orders/{order_id} for the airtime purchase
{
  "object": "order",
  "status": "fulfilled",
  "deliverable": {
    "type": "topup_receipt",
    "destination": "+2348012345678",
    "reference": "TPU-7Q2K9M"
  }
  // …
}

10

Go live

Swap the test key for a live one. Your live account has its own Lightning, stablecoin and bank addresses: fetch them with GET /v1/wallet/addresses and pay live quotes there.

Test and live are completely separate, so register your webhook endpoint again with the live key.

  • GET /v1/wallet/addresses
  • Test helpers answer 403 test_mode_only to a live key.
.env.production
# Production: swap the key. The URL stays the same;
# the key picks the mode.
TIZON_API_KEY=tz_live_…

# Test and live are fully separate: fetch your live payment
# addresses and register your webhook endpoint again.
TIZON_WEBHOOK_SECRET=whsec_…

The rules every endpoint follows

  • Money

    Prices are integer USD cents. What to send over Lightning or on-chain comes as an exact asset_amount string on the quote.

    { "amount": 1450, "currency": "USD" }  // $14.50
  • Idempotency

    Send an Idempotency-Key with every POST that moves money. A retry returns the original response with Idempotent-Replayed: true.

    Idempotency-Key: purchase_7f3c9a
  • Errors

    One shape everywhere. Branch on code; quote request_id when you ask for help.

    { "error": { "type": "invalid_request_error", "code": "offer_unavailable",
      "message": "…", "request_id": "req_…" } }
  • Pagination

    Lists take limit (up to 100) and cursor, and return has_more and next_cursor.

    GET /v1/offers?category=esim&limit=50&cursor=…

Request API Access

Create an account and your tz_test_ sandbox key is ready straight away. Going live needs a short business review.

All rights reserved. Tizon Mobile © 2026

Tizon is an eSIM and mobile top-ups API for the freedom economy, not a telco. It’s built to make it easy for mobile and web app builders to offer eSIMs and top-ups to their customers. No prefunded account or minimum balance is needed: you settle in stablecoins, Lightning or local bank transfers. We support global, regional and local eSIMs.