Back to the blog
Engineering

How we route a purchase across four upstream pools

A buy request touches one offer table, up to three providers and one price formula. This is the whole path, including what happens when the first pool lies about its stock.

by Tom Okafor·Infrastructure·Published 13 May 2025·7 min read

We operate one pool of our own and resell four others: 5SIM, SMS-Man, SMS-Activate and SMSHub. From the outside there is one catalogue and one price. Inside, a purchase is a short, unglamorous pipeline that has to survive upstreams disagreeing with themselves about what they have in stock.

The offer table

Nothing queries an upstream on the user's request path. A cron job pulls each provider's whole catalogue and rewrites its rows in one table: provider, country, service, upstream cost in cents, our retail price, units available, trailing success rate in basis points. Roughly 310,000 rows, refreshed on a rolling schedule so no single sync can stall the site.

That table is the only thing a purchase reads. It is also the only thing the pricing page reads, which is why the price you see and the price you pay are the same number rather than an estimate.

Ordering

server/providers/index.ts
where: {
  countryId,
  serviceId,
  available: { gt: -num">0 },
  provider: { enabled: -kw">true, healthy: -kw">true },
  ...(maxPriceCents !== -kw">undefined ? { priceCents: { lte: maxPriceCents } } : {}),
},
orderBy: [
  { priceCents: "asc" },      // cheapest first
  { successRateBp: "desc" },  // ties broken by recent delivery
  { available: "desc" },      // then by depth, to spread load
],

Cheapest first is a deliberate choice and it is occasionally the wrong one. A pool that is two cents cheaper and eight points worse on delivery is a bad trade for the customer even though it wins the sort. We handle that with the health flag rather than by weighting the sort: a provider whose trailing success rate for a pair falls below the floor stops being a candidate for that pair entirely, and comes back when the next sync says otherwise.

The fallback chain

Stock counts are a snapshot and upstreams are optimistic about them. A pool that reported 240 free Indonesian numbers four minutes ago may have none now. So we take the three cheapest candidates and walk them.

server/activations.ts
-kw">const candidates = -kw">await candidateOffers(countryId, serviceId, {
  maxPriceCents: request.maxPriceCents,
  limit: -num">3,
});

-kw">for (-kw">const offer -kw">of candidates) {
  -kw">const provider = getProvider(offer.providerKey);
  -kw">try {
    -kw">const order = -kw">await provider.buy({ countryId, serviceId, maxCostCents: offer.costCents });
    -kw">return finalise(order, offer);
  } -kw">catch (error) {
    -kw">if (error instanceof ProviderError && error.retryable) {
      -kw">await markOfferStale(offer);  // zeroes the row until the next sync
      continue;                     // fall through to the next-cheapest pool
    }
    -kw">throw error;                    // insufficient funds, bad pair: ours to surface
  }
}

-kw">throw -kw">new BusinessError("no_stock", "No pool had a usable number for this pair.");

Two rules keep this honest. The customer is charged the retail price of the offer that actually succeeded, not the one we tried first — and because candidates are sorted by price ascending, a fallback can only ever cost more than the first attempt, so we cap the walk at the price you were quoted plus nothing. If the second candidate is dearer than the quote, the purchase fails with no_stock instead of quietly costing more. The second rule: we never substitute the country. A request for Portugal that cannot be filled in Portugal is a failure, not an opportunity.

What the pools look like

PoolCountriesMedian cost7-day successShare of purchases
VirtualSMSNumbers Pool31€0.0993.1 %22 %
5SIM96€0.1389.4 %31 %
SMS-Man84€0.1287.8 %24 %
SMS-Activate190€0.1586.2 %17 %
SMSHub71€0.1184.9 %6 %

Our own pool is the smallest by coverage and the best by delivery, which is the usual shape: owning the SIMs means knowing when a range gets filtered. It is also why we have not tried to own all 190 countries — the long tail is genuinely uneconomic to hold, and reselling it honestly beats holding it badly.

Price

ts
-kw">export -kw">function retailPrice(costCents: number, marginBp: number, minMarginCents: number): number {
  -kw">const withMargin = Math.ceil(costCents * (-num">1 + marginBp / 10_000));
  -kw">return Math.max(withMargin, costCents + minMarginCents, -num">3);
}

Default margin is 3,500 basis points. The two floors matter more than the percentage: on a three-cent upstream cost, 35 % is one cent, which does not pay for the request, so a minimum absolute margin applies, and nothing is ever priced below three cents. Rounding is always up to the whole cent, and we do not round differently per pool — if the cheapest pool moves, the shelf price moves.

Staleness, and how wrong the table is allowed to be

Each provider is resynced on its own cadence — the two large ones every four minutes, the smaller ones every fifteen, and the whole catalogue once nightly to catch rows that quietly stopped being returned at all. Between syncs the table is wrong in a bounded way, and the bound is the interesting number: for a busy pair, stock can go from 200 to zero inside one sync interval.

We do not try to fix that with a live lookup. Making the buy path depend on four upstream APIs being awake means adopting the worst latency and the worst uptime among them, on every purchase, including the ones we could have filled from a pool that was healthy. The fallback chain is the cheaper answer: be occasionally wrong about the first candidate, and recover in one extra call.

A stale row is zeroed the moment a purchase against it fails, so the second customer to hit an emptied pool takes a different path than the first. That is also why the stock figure on a busy pair sometimes drops by more than your purchase — you did not buy 40 numbers, you found out that 40 had already gone.

Idempotency across a fallback

If your request carried an Idempotency-Key, the key covers the whole walk, not a single attempt. A retry after a timeout returns whichever activation the original walk ended up creating — first candidate or third — with Idempotent-Replay: true. There is no path where a retry produces two numbers because the first attempt fell through to a different pool.

The part that is not automated

Health thresholds and the enabled flag are human decisions. Twice in 2024 a pool passed every automated check while returning numbers that received the SMS and then got the account banned within an hour — delivery looked perfect and the outcome was worthless. That pattern is not visible in a success rate. It arrived as four support tickets in a morning, and it was fixed by switching a provider off by hand.

Articles are published in English.