Simbase API maze and how to avoid it
richardevcom

richardevcom @richardevcom

About: Full-stack 💩- coder. Wannabe dev blogger. Aspiring tech-troll.

Location:
Latvia
Joined:
Aug 3, 2022

Simbase API maze and how to avoid it

Publish Date: Jul 8
5 0

A few months ago, our client decided to use Simbase for managing custom SIM cards in OBD devices. Little did I know I was about to dive headfirst into a debugging nightmare.

Too lazy to read it all? This post is mostly a rant - feel free to skip ahead to
👉 Epilogue: Why I'll Never Use Simbase Again.


Issue #1 - Inefficient data retrieval

At first, everything looked fine - API permissions were set up, IP whitelisting in place, and even webhook triggers (though they never fired as promised). Managing hundreds of SIM cards through their API seemed straightforward, so I thought, "I'll figure it out as I go."

Then reality hit. I discovered a major flaw: zero granular filtering. Our SIMs live in OBD devices and are tracked by IMEI. Need to fetch a SIM by IMEI? Forget it - you can only fetch by ICCID. That means pulling every single SIM and hunting for one needle in a haystack. I expected a direct lookup; instead, I got endless paginated requests. Multiply that by 100+ clients, and you're guaranteed to hit those absurd rate limits. Uff! 🤦‍♂️

The "Solution"

I had no choice but to fetch, cache, and store all SIMs locally - and keep everything in sync. This extra layer was a ticking time bomb for our production environment.

import axios from 'axios';
import { getLocalSims, saveLocalSims } from './localDatabase';

interface SimCard {
  iccid: string;
  imei: string;
  // ... other fields
}

interface SimcardsResponse {
  simcards: SimCard[];
  cursor: string | null;
}

export async function getSyncedSims(): Promise<SimCard[]> {
  const allSims: SimCard[] = [];
  let cursor: string | null = null;

  // keep pulling pages until cursor is null
  do {
    const { data } = await axios.get<SimcardsResponse>(
      'https://api.simbase.com/v2/simcards',
      {
        headers: { Authorization: 'Bearer YOUR_SECRET_TOKEN' },
        params: { cursor },
      }
    );
    allSims.push(...data.simcards);
    cursor = data.cursor;
  } while (cursor);

  await saveLocalSims(allSims);
  return allSims;
}

export async function getSyncedSimByImei(
  imei: string
): Promise<SimCard | undefined> {
  const sims = await getSyncedSims();
  return sims.find(sim => sim.imei === imei);
}
Enter fullscreen mode Exit fullscreen mode

Issue #2: Absurd rate limits

By the time I built our local SIM-management layer, I quickly ran into ridiculous Simbase's limits:

  • 10 calls per second;
  • 5,000 calls per day;

Juggling data for 100+ (expected therefor simulated) clients, every extra API call felt like pouring gasoline on a fire. 🤯

Syncing hundreds of SIMs? Torture. The only workaround was a homemade throttle + batch scheduler - more code, more delays, more potential headaches.

The "Solution"

I wrapped my fetch in a tiny rate-limiter class and batched the updates. It's not pretty, but at least we don't smash the API - or our sanity.

// ...

const delay = (ms: number) => new Promise(res => setTimeout(res, ms));

class RateLimiter {
  private calls = 0;
  private windowStart = Date.now();

  constructor(private limit = 10, private windowMs = 1000) {}

  async throttle() {
    const now = Date.now();

    if (now - this.windowStart >= this.windowMs) {
      this.windowStart = now;
      this.calls = 0;
    }

    if (this.calls >= this.limit) {
      await delay(this.windowMs - (now - this.windowStart));
      this.windowStart = Date.now();
      this.calls = 0;
    }

    this.calls++;
  }
}

export async function getSyncedSims(): Promise<SimCard[]> {
  const limiter = new RateLimiter(10, 1000); // 10 calls/sec
  const allSims: SimCard[] = [];
  let cursor: string | null = null;

  do {
    await limiter.throttle();

    const { data } = await axios.get<SimcardsResponse>(
      'https://api.simbase.com/v2/simcards',
      {
        headers: { Authorization: 'Bearer YOUR_SECRET_TOKEN' },
        params: { cursor },
      }
    );

    allSims.push(...data.simcards);
    cursor = data.cursor;
  } while (cursor);

  await saveLocalSims(allSims);
  return allSims;
}

// ...
Enter fullscreen mode Exit fullscreen mode

Issue #3 - Inconsistent field naming

Next up - small but annoying inconvenience - inconsistent field names. Some endpoints return cost as current_month_cost; others use current_month_costs. It's like the dev team flipped a coin every time-so you end up adding boilerplate just to keep your code sane.

The "Solution"

I wrote a tiny (unnecessary) normalizer that standardizes both variants into one cost property. No more guessing which field you'll get.

export function normalizeSimCard(sim: SimCardRaw): SimCardNormalized {
  return {
    iccid: sim.iccid,
    imei: sim.imei,
    cost: sim.current_month_cost || sim.current_month_costs || "0",
    // ... normalize other fields as needed
  };
}
Enter fullscreen mode Exit fullscreen mode

Issue #4 - Sparse error messaging

Debugging became painful when the API started returning vague responses. One wrong call returned a vague 429 error and left me thinking: "Was it the per-second cap or the daily limit?" The message didn't say.

The "Solution"

I wrapped every request in a helper that logs the error, reads retry_after, and automatically retries-or bails if I've truly hit the daily ceiling. With this in place, every 429 comes with clear context and an automatic pause - no more head-scratching over "Which limit did I hit?"

// ...
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));

/**
 * Fetch with built-in retry for 429 errors.
 * Logs context, waits the suggested time, and retries once.
 */
export async function fetchWithRetry<T>(
  url: string,
  options: Record<string, any> = {}
): Promise<T> {
  try {
    const { data } = await axios.get<T>(url, options);
    return data;
  } catch (err) {
    if (axios.isAxiosError(err) && err.response) {
      const { status, data } = err.response as { status: number; data: any };
      console.error(`Error ${status}:`, data.message || err.message);

      if (status === 429) {
        const waitSec = typeof data.retry_after === 'number' ? data.retry_after : 10;
        if (waitSec >= 86400) {
          console.error('Daily rate limit hit-stopping retries for today.');
          throw err;
        }
        console.log(`Rate limit hit. Waiting ${waitSec}s before retrying…`);
        await delay(waitSec * 1000);
        return fetchWithRetry(url, options);
      }
    }
    throw err; // non-retryable or non-Axios error
  }
}

// ...
Enter fullscreen mode Exit fullscreen mode

Issue #5 - Bulk operations

I finally had a basic API client working and decided to add bulk CRUD operations. In unit tests, it felt like playing Russian roulette - one failed SIM, and the whole batch imploded. What should have been a single call turned into a spaghetti of errors and wasted time.

The "Solution"

Use a "bulk-first" strategy with recursive split-on-failure. Try registering all SIMs at once. If it fails, split the list in half, retry each half, and keep recursing until only the bad record is isolated. That way, most SIMs sail through in efficient bulk calls, and only the failed ones get singled out - no need to fall back to one-by-one for every SIM.

// ...

interface BulkSim {
  iccid: string;
  // ... other fields
}

async function registerBulk(sims: BulkSim[]): Promise<void> {
  await axios.post('https://api.simbase.com/v2/simcards/register', sims, {
    headers: { Authorization: `Bearer ${'YOUR_SECRET_TOKEN'}` },
  });
  console.log(`✔️ Registered ${sims.length} SIMs in bulk`);
}

export async function safeBulkRegister(sims: BulkSim[]): Promise<void> {
  try {
    await registerBulk(sims);
  } catch (err: any) {
    if (sims.length === 1) {
      console.error(
        `❌ SIM ${sims[0].iccid} failed:`,
        err.response?.data?.message || err.message
      );
      return;
    }
    const mid = Math.floor(sims.length / 2);
    await safeBulkRegister(sims.slice(0, mid));
    await safeBulkRegister(sims.slice(mid));
  }
}

// ...
Enter fullscreen mode Exit fullscreen mode

Issue #6 - Flaky webhook callbacks

Simbase promised real-time updates via webhooks; in practice these callbacks were as reliable as a paper umbrella in a storm - irregular, delayed, or vanishing into thin air. 🤷‍♂️ Without guarantees, you'll miss critical events and have no clue why.

The "Solution"

Add a polling fallback that only fetches new events. Use a since cursor (timestamp or event ID) so each poll grabs just what you haven't seen. Webhooks do the heavy lifting when they arrive; polling picks up anything they miss.

interface SimEvent {
  id: string;
  timestamp: string;
  type: string;
  payload: any;
}

interface EventsResponse {
  events: SimEvent[];
}

const POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes

async function pollEvents(lastTimestamp: string | null): Promise<string | null> {
  try {
    const params = lastTimestamp ? { since: lastTimestamp } : {};
    const { data } = await axios.get<EventsResponse>('https://api.simbase.com/v2/events', {
      headers: { Authorization: `Bearer ${'YOUR_SECRET_TOKEN'}` },
      params,
    });

    if (data.events.length) {
      console.log('✔️ Polled events:', data.events);
      // Return the newest event timestamp for next poll
      return data.events[data.events.length - 1].timestamp;
    }
  } catch (err: any) {
    console.error('Polling error:', err.response?.data?.message || err.message);
  }
  return lastTimestamp;
}

// Kick off polling
let lastTs: string | null = null;
(async () => {
  lastTs = await pollEvents(lastTs);
  setInterval(async () => {
    lastTs = await pollEvents(lastTs);
  }, POLL_INTERVAL);
})();
Enter fullscreen mode Exit fullscreen mode

Additional inconvenience - Outdated documentation & glacial support

The official Simbase docs feel like relics from the API Stone Age - packed with abstract examples, missing endpoints, and inconsistent field names. I was lucky enough to start on v2, but if you've ever been stuck with v1, you'll know it's like navigating a maze.
When you finally need help - a quick rate-limit bump or clarification on a disappearing webhook event callback - their support won't help you. My first email - ghosted, and the one follow-up reply to client e-mail took another week to arrive.


Epilogue: Why I'll Never Use Simbase API Again

After getting lost in Simbase's maze, I learned that peace of mind beats their inconvenient API features. Here's why this turned into a nonstop debugging nightmare:

  • Inefficient data retrieval: No proper filters. Need a SIM by IMEI? - You pull every SIM by ICCID and hunt it down yourself;
  • Absurd rate limits: 10 calls/sec, 5000 calls/day - constant throttling hacks and extra code to cope;
  • Inconsistent field naming: current_month_cost here, current_month_costs there - pick one, please;
  • Sparse error messaging: Vague codes forced me to build custom error handlers and retry loops;
  • Bulk operations gone wild: One bad SIM sinks the entire batch;
  • Flaky webhook callbacks: Promised live updates that almost never arrive without a polling backup;
  • Outdated Documentation & Slow Support: Documentation that's more confusing than helpful, paired with glacial support.

I wish we had chosen a better alternative at the time, but this was my first - and last - SIM management project with them.

In the end, this is more than a rant - it's a cautionary tale. Save yourself the trouble, skip the endless maze, and steer clear of Simbase.

Thanks for reading this far and happy coding! 🙏

Comments 0 total

    Add comment