All articles
4 August 2026 VoxioTelecom Team

Automating SMS Verification: A Developer's Guide to the OTP Flow

The activation lifecycle behind an SMS verification API — renting a number, polling for the code, handling timeouts and refunds — and how to build a client that does not lose money or leak numbers.

Every SMS verification API, whatever the provider, implements roughly the same state machine. Once you understand it, integrating any of them is a couple of hours of work. Getting the error handling right is what separates an integration that quietly loses money from one that does not.

This is the flow, the failure modes, and the client design that survives contact with production.

The activation lifecycle

An activation is one rented number, scoped to one service, for one time-limited session.

  request number
        |
        v
   [ WAITING ] --- code arrives ---> [ CODE RECEIVED ]
        |                                   |
        | timeout / cancel                  | close
        v                                   v
   [ REFUNDED ]                        [ COMPLETED ]

Four things to internalise:

  1. You are charged when the number is issued, not when the code arrives. That is what makes the refund path essential rather than cosmetic.
  2. The window is finite — typically around twenty minutes. After that the number returns to the pool and any later SMS is lost.
  3. Cancellation is usually rate-limited at the start. Most providers block cancellation for the first minute or two, to stop clients from churning through inventory.
  4. The same number can receive more than one code while the activation is open, which matters when a platform re-sends.

Step 1: check stock before you rent

Do not rent blind. Query availability for the specific service-and-country pair you want, and treat a low number as a signal rather than a technicality.

type Availability = { service: string; country: number; count: number; priceUsd: number };

async function pickCountry(service: string, preferred: number[]): Promise<Availability | null> {
  const stock = await api.getAvailability(service);
  for (const country of preferred) {
    const match = stock.find((s) => s.country === country && s.count > 5);
    if (match) return match;
  }
  // fall back to the deepest stock anywhere
  return stock.sort((a, b) => b.count - a.count)[0] ?? null;
}

The count > 5 threshold is deliberate. A pool down to one or two numbers is usually a pool where acceptance is already failing, and you will pay for a rental that never receives anything.

Step 2: always send a price ceiling

Prices move with inventory. If the API accepts a maximum price, send it — otherwise a demand spike between your quote and your request silently charges the higher figure.

const activation = await api.rentNumber({
  service: "wa",
  country: 187,
  maxPriceUsd: quoted * 1.1, // tolerate 10% drift, no more
});

Skipping this is the most common way an integration ends up spending more than the operator expected.

Step 3: poll with backoff, not a tight loop

Codes usually arrive within seconds, but not always. A one-second loop for twenty minutes is 1,200 requests per activation and will get you rate-limited.

async function waitForCode(id: string, deadline: number): Promise<string | null> {
  let delay = 2000;
  while (Date.now() < deadline) {
    const status = await api.getStatus(id);
    if (status.state === "code_received") return status.code;
    if (status.state === "cancelled" || status.state === "expired") return null;
    await sleep(delay);
    delay = Math.min(delay * 1.5, 15000); // 2s -> 3s -> 4.5s ... cap at 15s
  }
  return null;
}

Exponential backoff with a ceiling gives you fast delivery when the code is quick and sane request volume when it is not.

Step 4: guarantee the release path

This is where money leaks. If your process dies between renting a number and releasing it, nobody cancels the activation and nobody triggers the refund. Wrap the whole thing so the release always runs.

async function verify(service: string, country: number) {
  const activation = await api.rentNumber({ service, country });
  const deadline = Date.now() + 19 * 60 * 1000; // stop just inside the provider window
  try {
    const code = await waitForCode(activation.id, deadline);
    if (!code) {
      await api.cancel(activation.id); // no code -> full refund
      return { ok: false as const, reason: "no_code" };
    }
    await api.complete(activation.id);
    return { ok: true as const, phone: activation.phone, code };
  } catch (err) {
    await api.cancel(activation.id).catch(() => {});
    throw err;
  }
}

Two details matter. The catch block cancels and swallows its own error, so a failing cancel does not mask the original exception. And the deadline sits just inside the provider's window rather than exactly on it, so you cancel before expiry instead of racing it.

Belt and braces: persist every activation id with its state before you start polling, and run a reconciliation job that cancels anything left waiting past its deadline. Process crashes are not hypothetical.

Step 5: distinguish the failure modes

Not every failure deserves a retry, and retrying the wrong one wastes inventory.

| Failure | Meaning | Correct response | | --- | --- | --- | | No stock | Nothing available for that pair | Try another country, do not retry same | | Rented, no code | Number likely filtered by the platform | Cancel, retry in a different country | | Insufficient balance | Wallet is empty | Fail loudly; never retry in a loop | | Rate limited | Polling too aggressively | Back off, do not open new activations | | Price above ceiling | Inventory cost moved | Re-quote, then decide |

The one to be most careful with is insufficient balance. A naive retry loop against a failing charge can hammer the API hundreds of times per minute for no benefit.

Idempotency and concurrency

If you rent numbers from multiple workers, two rules will save you:

Key every activation to a business object. Store your own reference (user id, signup attempt id) alongside the provider's activation id, so a retried job finds the existing activation instead of renting a second number.

Cap concurrent activations per account. Inventory is finite and shared. Ten workers each renting on demand will exhaust a country's pool and drive your own success rate down.

Security notes

  • Never expose provider credentials to the browser. All calls belong on the server. A key in client-side JavaScript is a key someone else is now using.
  • Treat the received SMS text as untrusted input. It is attacker-influenceable content. Escape it before rendering, and parse the code with a strict pattern (/\b\d{4,8}\b/) rather than displaying the raw body.
  • Log activation ids, never full SMS bodies. Codes are credentials with a short life; they do not belong in your log retention.
  • Rate-limit per end user, not just globally. Otherwise one abusive account drains the pool for everyone.

Testing without burning inventory

Use the generic "any service" option and a low-cost country for integration tests, and mock the provider entirely for unit tests. Build a fake that can produce each terminal state — code received, cancelled, expired, insufficient balance — because those paths are exactly the ones that break in production and never get exercised against the real API.

Summary

The happy path is trivial. The value is in the rest: check stock before renting, send a price ceiling, poll with backoff, guarantee the cancel path with try/finally plus a reconciliation job, and treat each failure mode differently.

VoxioTelecom's SMS verification exposes this lifecycle over the same account as our voice and DID services, with automatic refunds on expired or cancelled activations so an unhandled edge case does not become a bill.

See the live rate deck

Create an account to browse the full A-Z deck in your portal — Premium CLI and Standard clearly labelled, searchable by prefix or destination.

Create account