Developer Portal/Guides & Recipes

Guides & Recipes

Practical implementation guides for common eSIM products. Each recipe is a self-contained walkthrough with working code.

API Reference

Pay-As-You-Go (PAYG) eSIM

Your customer pays you upfront; you fund their SIM with that amount.

Customer pays you $40 for a travel eSIM. You keep $10, fund the SIM with $30, and the SIM drains as they use data at the rates in /rates. When the wallet hits $0, the SIM cuts off on its own — you're never charged again for that SIM after the fund call.

1. Provision eSIM and fund it after customer pays
// After collecting payment on your side (e.g., customer paid $40, you fund $30):
const esim = await fetch('https://citrusmobile.com/api/v2/reseller/esim/provision', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer rsk_your_api_key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    end_user_reference: 'customer_456',
    label: 'Maria G. - PAYG Plan',
  }),
}).then(r => r.json());

// Fund the SIM's wallet. Your reseller account is debited once, right here.
// Data usage drains the SIM wallet — your account is NOT charged again.
await fetch(`https://citrusmobile.com/api/v2/reseller/esim/${esim.iccid}/fund`, {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer rsk_your_api_key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ amount: 30 }),
});
// esim.qr_code → base64 PNG to display/email
// esim.direct_install_url → iOS 17.4+ one-tap install link
2. Send QR code to customer
// Email the QR code and install link
await sendEmail({
  to: customer.email,
  subject: 'Your eSIM is ready',
  html: `
    <p>Scan this QR code in Settings > Cellular > Add eSIM:</p>
    <img src="${esim.qr_code}" />
    <p>Or tap to install: <a href="${esim.direct_install_url}">Install eSIM</a></p>
  `,
});
3. Subscribe to per-SIM balance events so you can notify your customer
// esim.balance_low fires once when the SIM wallet crosses below $5.
// esim.balance_depleted fires when it hits $0 and the network cuts the SIM off.
// balance.low / balance.depleted are for YOUR reseller top-up account.
await fetch('https://citrusmobile.com/api/v2/reseller/webhooks', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer rsk_your_api_key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://your-server.com/webhooks/citrus',
    events: [
      'esim.balance_low',      // "Your SIM is running low — top up?"
      'esim.balance_depleted', // "Your SIM ran out — top up to restore"
      'balance.low',           // Your reseller account is running low on top-up funds
      'balance.depleted',
    ],
  }),
});

// Then in your webhook handler:
app.post('/webhooks/citrus', (req, res) => {
  const e = req.body;
  if (e.event === 'esim.balance_low') {
    const order = await db.orders.findOne({ iccid: e.data.iccid });
    await sendEmail({
      to: order.customer_email,
      subject: 'Your eSIM is running low',
      html: `Only $${e.data.wallet_balance_usd} left. Top up to stay connected.`,
    });
  }
  res.sendStatus(200);
});

Your margin is customer_paid − fund_amount. Data usage drains the SIM's wallet at the rates in /rates; your reseller account is only debited once, at fund time. To top a customer up mid-trip, just call /fund again with the additional amount.

Unlimited Data with Fair-Use Throttling

Sell a flat-rate "unlimited" plan: full speed for the first 2 GB/day, then throttled to 512 Kbps.

Customer pays you a flat monthly fee (say $25). You provision an eSIM and fund it with a generous ceiling (say $50) that's high enough to cover a month of reasonable usage but bounded so a runaway customer can't bleed you out. A throttle cron caps their daily burn at 2 GB of full-speed data; after that they stay connected at 512 Kbps. When the SIM wallet hits $0 or the plan period ends, the SIM cuts off.

1. Provision and fund the eSIM
// Provision
const esim = await citrus.post('/esim/provision', {
  end_user_reference: 'customer_789',
  label: 'Unlimited EU Roaming - 30 days',
});

// Fund with a generous ceiling — high enough to feel unlimited,
// bounded enough to cap your worst-case exposure on this one SIM.
await citrus.post(`/esim/${esim.iccid}/fund`, { amount: 50 });

// Save the "starting wallet" so your throttle cron knows today's baseline.
// wallet_balance_usd rounds DOWN, so the fund_baseline may be ~1-5¢ below
// the amount you sent — that's expected.
const fresh = await citrus.get(`/esim/${esim.iccid}`);
await db.unlimitedPlans.insert({
  iccid: esim.iccid,
  started_at: new Date(),
  fund_baseline_usd: fresh.wallet_balance_usd,
  day_start_baseline_usd: fresh.wallet_balance_usd,
});
// Deliver QR code to customer
2. Throttle cron — poll each SIM and cap daily burn (run every 10 min)
// Pull the SIM's current wallet_balance_usd and compare it against the
// baseline we stored at the start of today. Difference = usage this day.
const DAILY_CAP_USD = 5.00; // ~2 GB in most EU countries at the rates in /rates
const THROTTLE_SPEED = 'SPEED_500_KBPS'; // 512 Kbps after cap

async function checkAndThrottle(iccid) {
  const res = await fetch(
    `https://citrusmobile.com/api/v2/reseller/esim/${iccid}`,
    { headers: { 'Authorization': 'Bearer rsk_your_api_key' } }
  );
  const esim = await res.json();

  // How much the SIM wallet has drained since today's baseline.
  const plan = await db.unlimitedPlans.findOne({ iccid });
  const usedToday = plan.day_start_baseline_usd - esim.wallet_balance_usd;

  if (usedToday >= DAILY_CAP_USD) {
    await fetch(`https://citrusmobile.com/api/v2/reseller/esim/${iccid}/throttle`, {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer rsk_your_api_key',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ speed: THROTTLE_SPEED }),
    });
    console.log(`Throttled ${iccid} after $${usedToday.toFixed(2)} burn today`);
  }
}
3. Reset throttles and record new daily baseline at midnight
// Run at 00:00 in the customer's timezone
async function resetDailyBaselines() {
  const res = await fetch(
    'https://citrusmobile.com/api/v2/reseller/esim/list?status=active',
    { headers: { 'Authorization': 'Bearer rsk_your_api_key' } }
  );
  const { esims } = await res.json();

  for (const esim of esims) {
    // Un-throttle
    await fetch(
      `https://citrusmobile.com/api/v2/reseller/esim/${esim.iccid}/throttle`,
      {
        method: 'POST',
        headers: {
          'Authorization': 'Bearer rsk_your_api_key',
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ speed: 'NO_LIMIT' }),
      }
    );
    // Snapshot current wallet as today's new starting point
    await db.unlimitedPlans.update(
      { iccid: esim.iccid },
      { day_start_baseline_usd: esim.wallet_balance_usd }
    );
  }
  console.log(`Reset ${esims.length} eSIMs to full speed`);
}
4. Terminate at the end of the plan period
// Schedule 30 days out from provision
setTimeout(async () => {
  await fetch(
    `https://citrusmobile.com/api/v2/reseller/esim/${esim.iccid}/terminate`,
    {
      method: 'POST',
      headers: { 'Authorization': 'Bearer rsk_your_api_key' },
    }
  );
}, 30 * 24 * 60 * 60 * 1000); // 30 days

// Also listen for esim.balance_depleted — if the customer drains their $50
// ceiling before 30 days are up, the network will cut the SIM off and fire the
// event. You can either terminate early or offer them a top-up.

Economics: At 512 Kbps throttled speed, a user can consume ~5 MB/hour max. Even if they stay connected 24/7 after the cap, that's only ~120 MB/day of throttled usage — costing you pennies at the rates returned by /rates. Your worst case on any single SIM is the fund ceiling you set in step 1, full stop.

Country-Specific eSIMs

Sell eSIMs targeted at specific travel destinations with localized pricing.

Build a storefront where customers pick their destination country, see the per-GB rate, and purchase. The eSIM works globally but you display country-specific pricing.

1. Fetch rates and build your country catalog
// Fetch all rates (cache for 1 hour)
const res = await fetch('https://citrusmobile.com/api/v2/reseller/rates', {
  headers: { 'Authorization': 'Bearer rsk_your_api_key' },
});
const { countries } = await res.json();

// Build your product catalog with your markup
const YOUR_MARKUP = 1.4; // 40% margin
const catalog = countries
  .filter(c => c.has_data)
  .map(c => ({
    name: c.name,
    flag: c.flag,
    iso2: c.iso2,
    cost_per_gb: c.cheapest_per_gb_usd,
    your_retail_per_gb: +(c.cheapest_per_gb_usd * YOUR_MARKUP).toFixed(2),
    networks: c.networks,
  }));

// Example: Japan → $1.38/GB cost → your price $1.93/GB
2. Show a specific country's rates on a landing page
// For your "Japan eSIM" page:
const res = await fetch(
  'https://citrusmobile.com/api/v2/reseller/rates?country=JP',
  { headers: { 'Authorization': 'Bearer rsk_your_api_key' } }
);
const { countries } = await res.json();
const japan = countries[0];
// japan.networks → [{ operator: "KDDI Corporation", per_gb_usd: 1.38 }, ...]
3. Provision on purchase AND fund the SIM
// Customer bought your "Japan - 5GB" product for $27.90 (5 GB × $5.58)
// You want to fund the SIM with $7.50 so 5 GB at $1.38/GB (Japan KDDI) lands
// comfortably inside the wallet. Your margin is $27.90 - $7.50 = $20.40.
const esim = await fetch('https://citrusmobile.com/api/v2/reseller/esim/provision', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer rsk_your_api_key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    end_user_reference: `order_${orderId}`,
    label: `${customerName} - Japan`,
  }),
}).then(r => r.json());

// Fund the SIM — without this step the SIM has a $0 wallet and
// cannot pass any data. The eSIM works in Japan AND everywhere else,
// at the per-country rates returned by /rates.
await fetch(`https://citrusmobile.com/api/v2/reseller/esim/${esim.iccid}/fund`, {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer rsk_your_api_key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ amount: 7.50 }),
});

Travel eSIM Bundles (Groups)

Provision eSIMs for tour groups, corporate trips, or event attendees.

Batch-provision eSIMs for a group of travelers and track each one by name or employee ID.

Batch provision and fund for a group
const travelers = [
  { name: 'Alice Chen',   email: 'alice@acme.com',   id: 'EMP-001' },
  { name: 'Bob Mueller',  email: 'bob@acme.com',     id: 'EMP-002' },
  { name: 'Carlos Silva', email: 'carlos@acme.com',  id: 'EMP-003' },
];

const API = 'https://citrusmobile.com/api/v2/reseller';
const headers = {
  'Authorization': 'Bearer rsk_your_api_key',
  'Content-Type': 'application/json',
};

// How much to put on each traveler's SIM for the whole trip
const PER_TRAVELER_FUND_USD = 18; // rounds cleanly with no drift

// Provision AND fund one eSIM per traveler
const results = [];
for (const traveler of travelers) {
  // 1. Provision
  const provRes = await fetch(`${API}/esim/provision`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      end_user_reference: traveler.id,
      label: `${traveler.name} - Berlin Trip`,
    }),
  });
  const esim = await provRes.json();

  // 2. Fund — without this, the SIM has $0 wallet and won't pass data
  await fetch(`${API}/esim/${esim.iccid}/fund`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ amount: PER_TRAVELER_FUND_USD }),
  });

  results.push({ ...traveler, iccid: esim.iccid, qr: esim.qr_code });
}

// Email each traveler their QR code
for (const r of results) {
  await sendEmail({
    to: r.email,
    subject: `Your ${r.name.split(' ')[0]}-trip eSIM`,
    qrCode: r.qr,
  });
}
Monitor the group during the trip
// Check activation status of all eSIMs
const res = await fetch(`${API}/esim/list?status=active`, { headers });
const { esims } = await res.json();
console.log(`${esims.length} of ${travelers.length} eSIMs activated`);

// After the trip: terminate all
for (const esim of esims) {
  await fetch(`${API}/esim/${esim.iccid}/terminate`, {
    method: 'POST', headers
  });
}

White-Label eSIM Storefront

Build a fully branded eSIM website. Your customers never see Citrus Mobile.

Integrate the API into your checkout flow and deliver eSIMs under your own brand.

Complete checkout integration (Node.js / Express)
const express = require('express');
const app = express();
const CITRUS_API = 'https://citrusmobile.com/api/v2/reseller';
const API_KEY = process.env.CITRUS_API_KEY; // rsk_...

// POST /checkout — called after customer pays on your site
app.post('/checkout', async (req, res) => {
  const { customer_email, customer_name, order_id, plan_fund_usd } = req.body;

  // 1. Provision eSIM via Citrus API
  const esimRes = await fetch(`${CITRUS_API}/esim/provision`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      end_user_reference: order_id,
      label: customer_name,
    }),
  });
  const esim = await esimRes.json();

  // 2. Fund the SIM from your reseller balance. plan_fund_usd is whatever
  // portion of what the customer paid you want to credit to the SIM —
  // anything less than what you charged is your margin. WITHOUT this step
  // the SIM has a $0 wallet and your customer can't use any data.
  await fetch(`${CITRUS_API}/esim/${esim.iccid}/fund`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ amount: plan_fund_usd }),
  });

  // 3. Save to your database
  await db.orders.create({
    order_id,
    customer_email,
    iccid: esim.iccid,
    qr_code: esim.qr_code,
    status: 'delivered',
  });

  // 4. Send branded email with QR code
  await sendBrandedEmail({
    to: customer_email,
    subject: 'Your eSIM from YourBrand',
    qr_code: esim.qr_code,
    install_url: esim.direct_install_url,
  });

  res.json({ success: true, iccid: esim.iccid });
});

// GET /dashboard/:order_id — customer checks their eSIM status
app.get('/dashboard/:order_id', async (req, res) => {
  const order = await db.orders.findOne({ order_id: req.params.order_id });
  const statusRes = await fetch(
    `${CITRUS_API}/esim/${order.iccid}`,
    { headers: { 'Authorization': `Bearer ${API_KEY}` } }
  );
  const status = await statusRes.json();
  res.json({ status: status.status, activated: !!status.activated_at });
});
Handle activation webhook
// Webhook endpoint — Citrus notifies you when eSIM activates
app.post('/webhooks/citrus', (req, res) => {
  // Verify signature (see Webhooks section in the API Reference)
  const event = req.body;

  if (event.event === 'esim.activated') {
    // Update your customer's dashboard
    db.orders.update(
      { iccid: event.data.iccid },
      { status: 'active', activated_at: event.data.activated_at }
    );
    // Optionally notify your customer
    sendPushNotification(customer, 'Your eSIM is now active!');
  }
  res.sendStatus(200);
});

IoT / M2M Connectivity

Connect devices — GPS trackers, kiosks, sensors, vending machines.

Provision eSIMs for devices, control connectivity remotely, and throttle to save costs on low-bandwidth devices.

1. Provision, fund, and throttle a GPS tracker
// GPS trackers only need ~50 KB per location ping. A $9 fund typically
// lasts months at low-usage IoT workloads — pick whatever matches your
// expected burn rate. ($9 rounds cleanly with zero display drift.)
const esim = await citrus.post('/esim/provision', {
  label: 'Tracker-Fleet-042',
  end_user_reference: 'vehicle_plate_ABC123',
});

// Fund the SIM — without this the device can't connect at all.
await fetch(`${API}/esim/${esim.iccid}/fund`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ amount: 9 }),
});

// Immediately throttle to minimum speed (saves on per-session data)
await fetch(`${API}/esim/${esim.iccid}/throttle`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ speed: 'SPEED_100_KBPS' }),
});

// Subscribe to esim.balance_low so you can top the tracker up before
// it hits zero and goes offline:
//   POST /webhooks  { events: ['esim.balance_low', 'esim.balance_depleted'] }
2. Fleet management — enable/disable by schedule
// Disable all devices overnight to save data costs
async function disableFleetOvernight() {
  const { esims } = await fetch(`${API}/esim/list?status=active`, { headers })
    .then(r => r.json());

  for (const esim of esims) {
    await fetch(`${API}/esim/${esim.iccid}/disable`, {
      method: 'POST', headers
    });
  }
}

// Re-enable in the morning
async function enableFleetMorning() {
  const { esims } = await fetch(`${API}/esim/list`, { headers })
    .then(r => r.json());

  for (const esim of esims.filter(e => e.status === 'active' || e.status === 'pending')) {
    await fetch(`${API}/esim/${esim.iccid}/enable`, {
      method: 'POST', headers
    });
  }
}
3. Monitor fleet and prevent balance depletion
// Register webhook to get alerted before your fleet goes offline
await fetch(`${API}/webhooks`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    url: 'https://your-server.com/webhooks/citrus',
    events: ['balance.low', 'balance.depleted'],
  }),
});

// In your webhook handler:
app.post('/webhooks/citrus', (req, res) => {
  if (req.body.event === 'balance.low') {
    // Auto-top-up or alert ops team
    alertOpsTeam(`Citrus balance low: $${req.body.data.balance_usd}`);
  }
  res.sendStatus(200);
});

Ready to go deeper?

See every endpoint with request/response schemas and try it live.

Open API Reference