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

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

Batch-provision eSIMs for a party of travelers and track each one by name or employee ID. Each traveler gets their own wallet, which is what you want when they are each paying for their own trip.

If instead they are all spending from one company budget, do not fund them one by one. Use Always-On Team Data and give them a single shared balance that refills itself.

Batch provision and fund for a group
const travelers = [
  { name: 'Alice Chen',   email: '[email protected]',   id: 'EMP-001' },
  { name: 'Bob Mueller',  email: '[email protected]',     id: 'EMP-002' },
  { name: 'Carlos Silva', email: '[email protected]',  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
  });
}

Always-On Team Data

Give a whole team worldwide data that never stops, from one shared balance.

A company puts 40 employees on eSIMs and wants them online in any country without anyone ever topping anything up. Funding 40 separate wallets does not work here: the heavy travellers run dry while money sits stranded on the ones at their desk, and somebody has to watch all 40.

A group replaces those 40 wallets with one. Every eSIM in the group draws from the same balance, so whoever is travelling this week simply uses more of it. You wire up one webhook, and from then on the balance refills itself and nobody in the company thinks about data again.

The loop, once it is running: the group balance drops below your threshold, we send you group.balance_low, and your server moves money from your account into the group. That is the whole thing. No employee action, no manual step.

Keeping your own account funded is a separate switch. Turn on auto-refill in the developer dashboard with a card on file, and if your balance ever cannot cover a refill we charge that card first and hold the notification until the credit lands, so the transfer your server makes always has funds behind it.

1. Create the group and set its refill policy
const API = 'https://citrusmobile.com/api/v2/reseller';
const headers = { Authorization: `Bearer ${process.env.CITRUS_API_KEY}`, 'Content-Type': 'application/json' };

// One group per team or per cost centre. Groups are permanent and you get 10,
// so name them for the team, not for a single trip.
const res = await fetch(`${API}/groups`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    name: 'Field Sales EMEA',
    // Tell us when to nudge you. Size this to roughly a day of the team's burn
    // so you are never reacting with only minutes of balance left.
    low_balance_threshold_usd: 200,
    // How much you intend to move in when we nudge. Sent back as suggested_amount_usd.
    refill_amount_usd: 500,
  }),
});

const group = await res.json();
await db.teams.update({ name: 'Field Sales EMEA' }, { citrus_group_id: group.id });
2. Fund it once
// One transfer for the whole team, not one per employee.
await fetch(`${API}/groups/${group.id}/fund`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ amount: 500 }),
});
3. Issue one eSIM per employee, straight into the group
const employees = [
  { name: 'Ana Ruiz',   email: '[email protected]',   staff_id: 'EMP-1041' },
  { name: 'Tom Baker',  email: '[email protected]',   staff_id: 'EMP-1042' },
  // ...38 more
];

for (const person of employees) {
  const res = await fetch(`${API}/esim/provision`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      group_id: group.id,           // <- the whole trick is this one field
      label: person.name,
      end_user_reference: person.staff_id,
    }),
  });

  const esim = await res.json();     // $1.75 each, charged to your account
  await sendOnboardingEmail(person.email, esim.qr_code, esim.direct_install_url);
}

// Note there is no funding call in this loop. Each eSIM has no wallet of its
// own; it draws from the group. Calling /esim/{iccid}/fund on one of these
// returns ESIM_IN_GROUP. Fund the group instead.
4. Subscribe to the group events
// IMPORTANT: subscriptions are an explicit list of event names. If you
// registered your webhook before shared balance groups existed, you are NOT
// subscribed to the group events, and step 5 will simply never fire.
//
// Check what you have with GET /webhooks, then widen it:
await fetch(`${API}/webhooks/${webhookId}`, {
  method: 'PUT',                      // note: PUT, not PATCH
  headers,
  body: JSON.stringify({ events: ['*'] }),
});

// Or name them explicitly. group.balance_low is the one the loop depends on:
//   group.balance_low, group.funded, group.balance_depleted, group.defunded,
//   esim.group_assigned, esim.group_removed
5. Close the loop: refill on group.balance_low
// This handler is the only always-on code you need. Everything else was setup.
app.post('/webhooks/citrus', async (req, res) => {
  res.sendStatus(200);                // ack fast, then work
  const evt = req.body;

  if (evt.event === 'group.balance_low') {
    const { group_id, name, balance_usd, suggested_amount_usd, account_underfunded } = evt.data;

    // Normally your account already covers this and the transfer just works.
    // If it does not, and you have auto-refill enabled with a card on file, we
    // charge that card first and delay this event until the credit lands. This
    // flag is set only when we could not get your account funded in time.
    if (account_underfunded) {
      await alertOps(`Citrus card declined; group ${name} cannot be refilled`);
      return;
    }

    await fetch(`${API}/groups/${group_id}/fund`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ amount: suggested_amount_usd }),
    });

    await db.ledger.insert({ team: name, topped_up_usd: suggested_amount_usd, at: new Date() });
  }

  // Safety net. If this ever fires, the team is offline and the loop above
  // did not keep up: widen the threshold or the refill amount.
  if (evt.event === 'group.balance_depleted') {
    await pageOnCall(`Citrus group ${evt.data.name} hit $0, ${evt.data.esim_count} eSIMs offline`);
  }
});

// group.balance_low fires ONCE per crossing, not once per sync cycle. It will
// not repeat while the group sits low; it re-arms after the balance recovers
// above your threshold. Do not build a poller that expects repeats.

Fund on group.balance_low, never on a top-up event

The handler above triggers on group.balance_low, which fires once per crossing and only when the group actually needs money. Triggering the same transfer on balance.topped_up or balance.auto_refill_succeeded instead looks equivalent and is not. Those fire whenever your account is credited, including by your own auto-refill, so the transfer runs on money that just arrived and empties the account again.

The shape to avoid
// DO NOT DO THIS. Every top-up drains straight back into the group.
if (evt.event === 'balance.topped_up') {
  await fundGroup(groupId, account.balance);   // no check of the group's level
}

We now refuse to auto-refill your card in response to a group or eSIM transfer, precisely so this cannot spin, and we cap auto-refill at 10 charges in 24 hours. Treat both as backstops rather than as permission: if you do fund on a top-up event, always compare the group's current balance_usd against its threshold first and transfer only the shortfall.

6. Moving people in and out as the team changes
// New hire who already has one of your standalone eSIMs:
const move = await fetch(`${API}/groups/${group.id}/esims`, {
  method: 'POST', headers, body: JSON.stringify({ iccid }),
});

// 200 means it is done. 202 means the eSIM still had its own balance, so we
// are returning that to your account first and will finish the move in about
// 15 minutes. A 202 is NOT a failure. Wait for esim.group_assigned.
if (move.status === 202) {
  await db.pendingMoves.insert({ iccid, group_id: group.id });
}

// Someone leaves the team: back to standalone at a $0 balance.
await fetch(`${API}/groups/${group.id}/esims/${iccid}`, { method: 'DELETE', headers });

// Winding the team down entirely: return whatever is left to your account.
// Data pauses on every member, and it settles in about 15 minutes.
await fetch(`${API}/groups/${group.id}/defund`, { method: 'POST', headers });

Worth knowing before you build this

  • Existing webhooks do not receive group.* events until you resubscribe. This is the most common reason the loop never starts.
  • Auto-refill on your account is opt-in and off by default. It needs a saved card and both an auto-refill threshold and amount set; leaving either blank means no card is ever charged. Without it, a group refill will fail once your account balance runs out.
  • Moving money into a group or a SIM wallet never triggers an auto-refill charge. Only real spend does, such as provisioning an eSIM, or a group that is low while your account cannot cover it. We also email you on every auto-refill charge and stop after 10 in 24 hours.
  • Groups cannot be deleted, and you can have 10. Name them per team, and reuse an emptied one by renaming it. Contact support if you need more; the limit is only there to deter abuse.
  • eSIMs in a group cannot be funded individually. That is the point, but it will surprise code written against the per-SIM flow.
  • Balances you read back are retail, and our upstream network takes a few minutes to reflect a change. A balance that looks stale right after funding is normal.
  • Set your threshold against how fast the team actually burns data, not against a round number. Forty people roaming can move through a low threshold between two checks.

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