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       acmepay@pay.tizon.mobile
// 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": "acmepay@pay.tizon.mobile"
    },
    {
      "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": "acmepay@pay.tizon.mobile"
      },
      {
        "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.

  • GET /v1/offers
  • POST /v1/quotes
topups.js
// 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: '8981100022334455667',
}, { 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

Tizon is in early access. Tell us what you’re building and we’ll send you tz_test_ keys, then help you through your first integration.

All rights reserved. Tizon Mobile © 2026

Tizon is a prepayment technology API, 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.