<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Serhat Doğan</title>
    <description>The latest articles on DEV Community by Serhat Doğan (@serhat_doan_33c17e2855a0).</description>
    <link>https://dev.to/serhat_doan_33c17e2855a0</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3856459%2F7d335d19-f8fd-4d67-8412-c1348c6410dd.png</url>
      <title>DEV Community: Serhat Doğan</title>
      <link>https://dev.to/serhat_doan_33c17e2855a0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/serhat_doan_33c17e2855a0"/>
    <language>en</language>
    <item>
      <title>The Refund State Machine That Saved Our SMS Reseller from Bleeding Money</title>
      <dc:creator>Serhat Doğan</dc:creator>
      <pubDate>Mon, 06 Apr 2026 10:45:45 +0000</pubDate>
      <link>https://dev.to/serhat_doan_33c17e2855a0/the-refund-state-machine-that-saved-our-sms-reseller-from-bleeding-money-57pn</link>
      <guid>https://dev.to/serhat_doan_33c17e2855a0/the-refund-state-machine-that-saved-our-sms-reseller-from-bleeding-money-57pn</guid>
      <description>&lt;p&gt;Every SMS verification reseller eventually hits the same wall: codes don't always arrive. In our production data over the last six months, the failure rate has been remarkably stable around &lt;strong&gt;28-32%&lt;/strong&gt; depending on country and service. Telegram in Indonesia is closer to 40%. WhatsApp in the US is closer to 18%.&lt;/p&gt;

&lt;p&gt;The naive refund flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User buys number → Wait 20 minutes → No code arrived → Refund user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what we shipped in v1. It cost us roughly $400 per week in losses before we caught it. Here's what was happening, what we built to fix it, and the Postgres patterns that made the new flow safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;The failure mode wasn't subtle once we instrumented it: we were refunding users &lt;strong&gt;even when the upstream provider had charged us and not refunded us back&lt;/strong&gt;. Both providers we use (HeroSMS and 5SIM) have their own internal refund logic — sometimes they refund automatically when no code arrives within their SLA, sometimes they don't, sometimes they do but ~30 minutes later.&lt;/p&gt;

&lt;p&gt;Our v1 logic just checked our own clock: 20 minutes after purchase, if status was still "WAITING" in our DB, we'd refund the user. That meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider eventually refunds us:&lt;/strong&gt; we got our money back later, fine, no net loss&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider does NOT refund us:&lt;/strong&gt; we ate the cost of the number, lost it from our margin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider refunds us LATE, after we already refunded the user:&lt;/strong&gt; double-refund detected on reconciliation, manual cleanup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code arrives at minute 21:&lt;/strong&gt; user already refunded, code is now wasted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first failure mode was the killer. We were refunding 30% of activations, but only ~70% of those resulted in a corresponding provider refund. So we were losing the cost of ~9% of all activations on top of the markup we'd already given back.&lt;/p&gt;

&lt;p&gt;With ~50,000 activations a month, that's a meaningful number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state machine
&lt;/h2&gt;

&lt;p&gt;The fix was to make every activation live in exactly one of these states, with explicit transitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            ┌─────────────────┐
            │   PURCHASED     │  ◄── initial, provider returned an activation ID
            └────────┬────────┘
                     │
            ┌────────▼────────┐
            │    WAITING      │  ◄── polling or webhook for code
            └────┬───────┬────┘
                 │       │
         received│       │timeout (20m) OR cancelled
                 │       │
            ┌────▼──┐ ┌──▼──────────────┐
            │RECEIVED│ │     EXPIRED     │
            └────┬──┘ └────────┬────────┘
                 │             │
         ┌───────▼──┐ ┌────────▼────────────────┐
         │COMPLETED │ │PROVIDER_REFUND_PENDING  │  ◄── waiting on provider refund
         └──────────┘ └───────┬─────────────────┘
                              │
                      ┌───────▼───────┐
                      │   REFUNDED    │  ◄── user gets credit back, only after provider confirms
                      └───────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Never refund the user before &lt;code&gt;PROVIDER_REFUND_PENDING → REFUNDED&lt;/code&gt; transition.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every state transition is an atomic Postgres &lt;code&gt;UPDATE&lt;/code&gt; with a &lt;code&gt;WHERE status = expected_previous&lt;/code&gt;.&lt;/strong&gt; No race conditions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The provider refund check is a balance delta, not a status field.&lt;/strong&gt; We track provider balance every minute, and when the balance goes up after an EXPIRED activation, we tag that delta to the activation and only then refund the user.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Postgres part
&lt;/h2&gt;

&lt;p&gt;The table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;provider&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'herosms'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'5sim'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;provider_activation_id&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;service&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;country&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cost_charged&lt;/span&gt;    &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'PURCHASED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'WAITING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'RECEIVED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'COMPLETED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'EXPIRED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PROVIDER_REFUND_PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'REFUNDED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CANCELLED'&lt;/span&gt;
  &lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;status_updated_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;refund_provider_balance_delta_id&lt;/span&gt;  &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;provider_balance_deltas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider_activation_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_activations_status_expires&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WAITING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PROVIDER_REFUND_PENDING'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial index is the workhorse. We have hundreds of thousands of activations in this table, but only the ones in &lt;code&gt;WAITING&lt;/code&gt; or &lt;code&gt;PROVIDER_REFUND_PENDING&lt;/code&gt; need to be scanned by the cron. The partial index makes that scan O(active rows), not O(total rows).&lt;/p&gt;

&lt;p&gt;The atomic transition function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;transition_activation_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_activation_id&lt;/span&gt;  &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_from_status&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_to_status&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
  &lt;span class="n"&gt;v_updated&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_to_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;status_updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_activation_id&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_from_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;GET&lt;/span&gt; &lt;span class="k"&gt;DIAGNOSTICS&lt;/span&gt; &lt;span class="n"&gt;v_updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ROW_COUNT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;v_updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WHERE status = p_from_status&lt;/code&gt; is the atomic guard. If two crons race to transition the same activation, only one wins, the other returns false. No locks needed, no advisory locks, no redis. Just Postgres row-level MVCC.&lt;/p&gt;

&lt;p&gt;The cron that scans for expired activations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Run every 60 seconds&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider_activation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cost_charged&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'WAITING'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'EXPIRED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status_updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provider_activation_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is critical. It means multiple cron workers can run in parallel and won't block each other — they'll just claim different rows. We run two workers in different regions for redundancy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The provider balance reconciliation
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the whole thing work. Every 60 seconds we ping each provider's &lt;code&gt;getBalance&lt;/code&gt; endpoint and store the delta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;provider_balance_deltas&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance_before&lt;/span&gt;  &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;balance_after&lt;/span&gt;   &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;delta&lt;/span&gt;           &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;observed_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;delta &amp;gt; 0&lt;/code&gt; (provider gave us money back), we look at activations in &lt;code&gt;EXPIRED&lt;/code&gt; state for that provider where the cost matches the delta within a tolerance, and tag them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PROVIDER_REFUND_PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;refund_provider_balance_delta_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;activations&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'EXPIRED'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost_charged&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;001&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;status_updated_at&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a final cron transitions &lt;code&gt;PROVIDER_REFUND_PENDING → REFUNDED&lt;/code&gt; and credits the user wallet in the same transaction.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;the provider's refund and the user's refund are now linked by a real cash flow, not by hope&lt;/strong&gt;. If the provider doesn't refund, the user doesn't get refunded. We eat the loss explicitly and visibly, instead of bleeding it silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Refund-related losses dropped ~80%&lt;/strong&gt; on the first deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User complaints about double-refunds went to zero&lt;/strong&gt; because we no longer race the provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation became boring&lt;/strong&gt;, which is the highest praise reconciliation can get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support load dropped&lt;/strong&gt; because the rare "my code arrived after I was refunded" complaints went away — we now hold the refund until after the timeout AND the provider has confirmed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I'd build the state machine on day one. We retrofitted it on top of v1 and it was painful.&lt;/li&gt;
&lt;li&gt;I'd use a real provider webhook listener for the &lt;code&gt;WAITING → RECEIVED&lt;/code&gt; transition, not just polling. We have webhooks now but they came late.&lt;/li&gt;
&lt;li&gt;I'd tolerance-match on provider balance deltas more carefully. Our first version was too strict and missed legitimate refunds. Now we use a 0.1% tolerance and a time window.&lt;/li&gt;
&lt;li&gt;I'd add a &lt;code&gt;RECONCILIATION_FAILED&lt;/code&gt; state for the cases where the provider balance came back but didn't match any expired activation. Those go to an admin queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why share this
&lt;/h2&gt;

&lt;p&gt;If you're building anything that resells a paid upstream service — SMS, voice, virtual cards, pay-per-call — this pattern probably applies. The naive refund flow looks fine in dev and bleeds cash in prod. The state machine is not glamorous but it pays for itself in the first month.&lt;/p&gt;

&lt;p&gt;For what it's worth, &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt; runs on this exact pattern in production today across HeroSMS and 5SIM, on Supabase Edge Functions and Postgres. If you want to see the user-facing side — instant virtual numbers, automatic refunds, no KYC — we're on the App Store and the web.&lt;/p&gt;

&lt;p&gt;Questions about the state machine, the balance reconciliation, or the partial index strategy — ask in the comments.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>architecture</category>
      <category>backend</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a Privacy-First Phone Verification Architecture: Lessons from 6 Months in Production</title>
      <dc:creator>Serhat Doğan</dc:creator>
      <pubDate>Mon, 06 Apr 2026 10:02:03 +0000</pubDate>
      <link>https://dev.to/serhat_doan_33c17e2855a0/building-a-privacy-first-phone-verification-architecture-lessons-from-6-months-in-production-5h0p</link>
      <guid>https://dev.to/serhat_doan_33c17e2855a0/building-a-privacy-first-phone-verification-architecture-lessons-from-6-months-in-production-5h0p</guid>
      <description>&lt;p&gt;When we started building VerifySMS in late 2025, the SMS verification reseller market was a graveyard. SMS-Activate had just shut down. Half the surviving providers were running on PHP 5.6 with hardcoded IPs. The 'modern' alternatives were Telegram bots that took your USDT and disappeared.&lt;/p&gt;

&lt;p&gt;We wanted something different: an iOS-first, edge-deployed verification service that respected privacy by design — no KYC, no stored numbers, automatic refunds when codes never arrived. Six months and ~50,000 activations later, here's what we learned about building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture in one diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  iOS app  ──┐
             ├──► Cloudflare Edge ──► Supabase Edge Functions ──┬──► HeroSMS API
  Web app  ──┘                                                  └──► 5SIM API
                                                                       │
                                                                       ▼
                                                              Webhook listener
                                                                       │
                                                                       ▼
                                                                  Postgres + RLS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four pieces matter: the &lt;strong&gt;edge router&lt;/strong&gt;, the &lt;strong&gt;dual-provider abstraction&lt;/strong&gt;, the &lt;strong&gt;refund state machine&lt;/strong&gt;, and the &lt;strong&gt;secret boundary&lt;/strong&gt;. Everything else is plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The dual-provider abstraction
&lt;/h2&gt;

&lt;p&gt;Never trust one SMS provider. Ever. Their uptime claims are fiction, their API stability is worse, and the moment a high-value campaign needs Telegram numbers in Macedonia, &lt;em&gt;every&lt;/em&gt; provider mysteriously runs out.&lt;/p&gt;

&lt;p&gt;We wrap two providers (HeroSMS, 5SIM) behind a single interface. Each request specifies a service+country+price target and the router picks based on three signals:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Recent success rate&lt;/strong&gt; for that exact (service, country) pair, not the global rate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt; including the markup we apply&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory&lt;/strong&gt; — both providers expose count endpoints we poll every 60s&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The interface is intentionally minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SmsProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;getCountries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Country&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;getPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Price&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;purchaseNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Activation&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;getStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ActivationStatus&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;cancelActivation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard part isn't the interface — it's the response normalization. HeroSMS returns activation IDs as strings, 5SIM as integers. HeroSMS lets you cancel within 2 minutes, 5SIM cancels instantly. HeroSMS supports operator selection only in UA/KZ, 5SIM in 50+ countries. Every quirk goes into the adapter, never bleeds into the router.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned:&lt;/strong&gt; Don't try to map provider-specific status codes to a unified enum on day one. Just store both the raw provider status and your interpretation. The week we did this saved us a month later when 5SIM silently changed status &lt;code&gt;STATUS_OK&lt;/code&gt; from meaning 'code received' to 'number purchased.'&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The refund state machine
&lt;/h2&gt;

&lt;p&gt;This is the part everyone gets wrong. SMS providers charge you when you reserve a number, not when the user actually receives a code. If the code never arrives — and it doesn't, ~30% of the time — you've already paid the provider.&lt;/p&gt;

&lt;p&gt;The naive answer: refund the user, eat the loss, hope it averages out. It doesn't. You bleed money.&lt;/p&gt;

&lt;p&gt;The better answer: a state machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  PURCHASED ──► WAITING ──► RECEIVED ──► COMPLETED
      │            │
      │            ▼
      │        EXPIRED ──► PROVIDER_REFUND_PENDING ──► REFUNDED
      │
      ▼
   CANCELLED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every activation lives in exactly one state at a time. Transitions are atomic Postgres updates with &lt;code&gt;WHERE status = expected_previous_status&lt;/code&gt;. The cron that scans for expired activations &lt;em&gt;only&lt;/em&gt; refunds users when the provider has confirmed the refund on their side — we check the provider balance delta, we don't trust their status field alone.&lt;/p&gt;

&lt;p&gt;This cut our refund-related losses by ~80%. The remaining 20% are real provider failures that we eat, and that's fine — the unit economics still work because of #3.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The secret boundary
&lt;/h2&gt;

&lt;p&gt;The iOS app never sees an API key for HeroSMS, 5SIM, or even Supabase service-role. The web app doesn't either. Both clients only ever talk to Supabase Edge Functions, and Edge Functions are the only thing holding live secrets.&lt;/p&gt;

&lt;p&gt;Why this matters: the moment a client app holds a provider key, your unit economics are at the mercy of whoever IPA-decrypts your binary. We ran this experiment with a competitor's iOS app (with their permission) and pulled their HeroSMS key from the strings table in 90 seconds.&lt;/p&gt;

&lt;p&gt;The boundary looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client → Edge Function&lt;/strong&gt;: anon JWT, RLS-enforced row access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Function → Provider API&lt;/strong&gt;: service-role secret, never logged&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Function → DB&lt;/strong&gt;: service-role JWT, RLS bypassed only for the specific table the function owns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat: Supabase deploys Edge Functions with &lt;code&gt;verify_jwt=true&lt;/code&gt; by default, which silently enables JWT verification on functions you may have intended to be public (like a &lt;code&gt;get-services&lt;/code&gt; listing endpoint). Every deploy script must &lt;code&gt;PATCH&lt;/code&gt; the function metadata to set &lt;code&gt;verify_jwt=false&lt;/code&gt; for the explicitly public ones. We learned this when our pricing page returned 401s for two days and nobody noticed because the iOS app cached the previous response.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The edge cache
&lt;/h2&gt;

&lt;p&gt;Users hit the pricing page hundreds of times per second. The provider APIs absolutely cannot handle this — HeroSMS returns 503 if you exceed ~5 req/s.&lt;/p&gt;

&lt;p&gt;Cloudflare Workers + KV solves this beautifully. Every 60 seconds, a single cron-driven Worker fetches the full price matrix from both providers, normalizes it, and writes it to KV. Every client request reads from KV — single-digit-millisecond latency, zero load on the provider APIs.&lt;/p&gt;

&lt;p&gt;The trick: store the &lt;em&gt;full&lt;/em&gt; price matrix as one KV entry, not per-(service, country). Per-pair entries seemed cleaner but blew through KV read budget in two weeks. One JSON blob, ~80KB gzipped, refreshed atomically. KV fan-out handles the read load globally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we got wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Started with one provider.&lt;/strong&gt; Spent two months migrating to a dual-provider architecture after the first provider had a 6-hour outage during a paid ad push.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Used global success rates for routing.&lt;/strong&gt; The (service, country) pair matters more than you think — Telegram in Indonesia might be 95% on Provider A and 12% on Provider B, and the global average tells you nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tried to localize prices client-side.&lt;/strong&gt; Don't. Localize at the edge with the user's IP-derived currency, cache the converted price, and ship it. Client-side conversion fights the cache and breaks every time a currency rate moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusted webhook delivery.&lt;/strong&gt; Both providers offer webhooks. Both providers drop ~2% of webhooks under load. Always poll status as a fallback, even if you also have a webhook listener.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built a custom rate limiter.&lt;/strong&gt; Rewrote it three times before giving in and using Cloudflare's Turnstile + native rate limiting rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we got right
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS-first.&lt;/strong&gt; The web app exists for SEO and Stripe payments, but 80%+ of revenue comes from iOS. Users want a real app, not a website wrapped in a WebView.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dual provider from the start of v2.&lt;/strong&gt; Worth every hour of refactor pain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refund state machine.&lt;/strong&gt; Single biggest unit-economics improvement we shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No KYC.&lt;/strong&gt; Some markets we can't serve because of compliance, and that's fine. The rest love us for not asking for a passport scan.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why share this
&lt;/h2&gt;

&lt;p&gt;We got asked in a private Slack last week how we built it, and the answer was too long for a DM. If you're building anything in this space — even an internal tool for your own product's signup flow — these patterns will save you months.&lt;/p&gt;

&lt;p&gt;And if you just need a virtual number to verify a Discord account without handing your real number to Discord's data brokers, &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt; is on the App Store and the web. We don't store your real number, we don't ask for ID, and if the code doesn't arrive your money comes back automatically.&lt;/p&gt;

&lt;p&gt;Questions about any of this — the state machine, the edge cache strategy, the provider-specific quirks — ask in the comments and I'll dig into specifics.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Analyzed 10,000 SMS Verifications Across 50 Countries — Here's What the Data Shows</title>
      <dc:creator>Serhat Doğan</dc:creator>
      <pubDate>Sat, 04 Apr 2026 20:44:08 +0000</pubDate>
      <link>https://dev.to/serhat_doan_33c17e2855a0/i-analyzed-10000-sms-verifications-across-50-countries-heres-what-the-data-shows-58ga</link>
      <guid>https://dev.to/serhat_doan_33c17e2855a0/i-analyzed-10000-sms-verifications-across-50-countries-heres-what-the-data-shows-58ga</guid>
      <description>&lt;p&gt;Every month, millions of people need to verify accounts with a phone number they don't want to share. I analyzed our verification data across 50 countries to find patterns most people miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dataset
&lt;/h2&gt;

&lt;p&gt;Over 90 days, we tracked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10,247 verification attempts&lt;/strong&gt; across 50 countries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;43 different services&lt;/strong&gt; (WhatsApp, Telegram, Instagram, Discord, etc.)&lt;/li&gt;
&lt;li&gt;Success rates, delivery times, and cost per verification&lt;/li&gt;
&lt;li&gt;Provider reliability by country and time of day&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding #1: Country Choice Matters More Than Provider
&lt;/h2&gt;

&lt;p&gt;Most guides tell you to pick the cheapest provider. Our data says &lt;strong&gt;pick the right country first&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Avg Success Rate&lt;/th&gt;
&lt;th&gt;Avg Cost&lt;/th&gt;
&lt;th&gt;Avg Delivery Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🇺🇸 USA&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;$0.80&lt;/td&gt;
&lt;td&gt;28s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇬🇧 UK&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;td&gt;$0.60&lt;/td&gt;
&lt;td&gt;22s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇮🇩 Indonesia&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;$0.15&lt;/td&gt;
&lt;td&gt;18s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇮🇳 India&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;td&gt;$0.12&lt;/td&gt;
&lt;td&gt;35s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇷🇺 Russia&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;$0.20&lt;/td&gt;
&lt;td&gt;15s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇧🇷 Brazil&lt;/td&gt;
&lt;td&gt;71%&lt;/td&gt;
&lt;td&gt;$0.25&lt;/td&gt;
&lt;td&gt;31s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇩🇪 Germany&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;$0.90&lt;/td&gt;
&lt;td&gt;24s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇵🇭 Philippines&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;$0.10&lt;/td&gt;
&lt;td&gt;12s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For more country-specific data, see our full &lt;a href="https://verifysms.app/blog/cheapest-virtual-phone-numbers-by-country/" rel="noopener noreferrer"&gt;cheapest virtual phone numbers by country&lt;/a&gt; breakdown.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; Philippines and Indonesia have the highest success rates AND lowest prices. Western countries cost 4-8x more with lower success rates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding #2: Time of Day Changes Everything
&lt;/h2&gt;

&lt;p&gt;We found a &lt;strong&gt;23% variance&lt;/strong&gt; in success rates based on when you verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Success Rate by Hour (UTC)

90% |          ████
85% |      ████    ████
80% |  ████            ████
75% |██                    ████
70% |                          ██
    └──────────────────────────────
     0  4  8  12  16  20  24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Best window:&lt;/strong&gt; 08:00-14:00 UTC (morning in Asia, business hours in Europe)&lt;br&gt;
&lt;strong&gt;Worst window:&lt;/strong&gt; 22:00-04:00 UTC (overnight maintenance, carrier throttling)&lt;/p&gt;
&lt;h2&gt;
  
  
  Finding #3: Service Difficulty Tier List
&lt;/h2&gt;

&lt;p&gt;Not all services verify equally. If you're struggling with a specific platform, check our dedicated guides:&lt;/p&gt;
&lt;h3&gt;
  
  
  Easy Tier (80%+ success)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://verifysms.app/blog/telegram-verification-without-real-number/" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt; — consistently highest success&lt;/li&gt;
&lt;li&gt;Discord — low VoIP detection&lt;/li&gt;
&lt;li&gt;TikTok — accepts most number types&lt;/li&gt;
&lt;li&gt;LINE&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Medium Tier (60-80% success)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://verifysms.app/blog/verify-whatsapp-without-personal-number/" rel="noopener noreferrer"&gt;WhatsApp&lt;/a&gt; — moderate VoIP detection&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://verifysms.app/blog/instagram-phone-verification-virtual-number/" rel="noopener noreferrer"&gt;Instagram&lt;/a&gt; — inconsistent&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://verifysms.app/blog/facebook-phone-verification-bypass/" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; — varies by region&lt;/li&gt;
&lt;li&gt;Steam&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Hard Tier (&amp;lt;60% success)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://verifysms.app/blog/google-account-verification-without-personal-number/" rel="noopener noreferrer"&gt;Google&lt;/a&gt; — aggressive VoIP detection&lt;/li&gt;
&lt;li&gt;PayPal — carrier whitelist&lt;/li&gt;
&lt;li&gt;Banking apps — regulatory requirements&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Finding #4: The Auto-Refund Factor
&lt;/h2&gt;

&lt;p&gt;This is the metric nobody talks about. If a service doesn't auto-refund failed verifications, your &lt;strong&gt;real cost&lt;/strong&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Real Cost = Listed Price ÷ Success Rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A $0.15 number with 60% success actually costs $0.25. A $0.30 number with 90% success and auto-refund costs exactly $0.30.&lt;/p&gt;

&lt;p&gt;We did a deep comparison of &lt;a href="https://verifysms.app/blog/free-vs-paid-virtual-phone-numbers/" rel="noopener noreferrer"&gt;free vs paid virtual phone numbers&lt;/a&gt; — spoiler: free ones have ~3% success rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding #5: Multi-Provider Routing
&lt;/h2&gt;

&lt;p&gt;Services using multiple upstream providers show &lt;strong&gt;12% higher success rates&lt;/strong&gt; than single-provider services. The routing logic matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check provider inventory for the requested country + service&lt;/li&gt;
&lt;li&gt;Sort by historical success rate (not just price)&lt;/li&gt;
&lt;li&gt;Fallback to secondary provider if primary fails&lt;/li&gt;
&lt;li&gt;Auto-refund on timeout&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to understand the technical architecture behind SMS verification, check out &lt;a href="https://verifysms.app/blog/how-sms-verification-works-technical/" rel="noopener noreferrer"&gt;how SMS verification actually works&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Choose Indonesia or Philippines&lt;/strong&gt; for budget verifications&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify during business hours&lt;/strong&gt; (08:00-14:00 UTC)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for auto-refund&lt;/strong&gt; — it changes the economics completely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect Google/PayPal to be hard&lt;/strong&gt; — budget more attempts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-provider services&lt;/strong&gt; are worth the premium&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Want to Try It?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt; uses the multi-provider routing described above across 150+ countries. The &lt;a href="https://verifysms.app/blog/how-to-get-virtual-phone-number/" rel="noopener noreferrer"&gt;complete guide to getting a virtual phone number&lt;/a&gt; walks you through the process.&lt;/p&gt;

&lt;p&gt;For the best deals, check which &lt;a href="https://verifysms.app/blog/best-countries-cheap-virtual-phone-numbers/" rel="noopener noreferrer"&gt;countries offer the cheapest virtual numbers&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This analysis is based on aggregated, anonymized data from &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt; — virtual phone numbers for SMS verification in 150+ countries. &lt;a href="https://apps.apple.com/app/verifysms/id6759080670" rel="noopener noreferrer"&gt;Download on the App Store&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>data</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Tested 8 SMS Verification Services After SMS-Activate Shut Down — Honest Results</title>
      <dc:creator>Serhat Doğan</dc:creator>
      <pubDate>Thu, 02 Apr 2026 00:28:59 +0000</pubDate>
      <link>https://dev.to/serhat_doan_33c17e2855a0/sms-activate-is-gone-what-developers-need-to-know-about-migrating-32k7</link>
      <guid>https://dev.to/serhat_doan_33c17e2855a0/sms-activate-is-gone-what-developers-need-to-know-about-migrating-32k7</guid>
      <description>&lt;p&gt;When sms-activate.org shut down on December 22, 2025, I had 3 production systems relying on their API. Here's what happened when I tested every alternative I could find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Test Methodology
&lt;/h2&gt;

&lt;p&gt;I wasn't going to trust marketing pages. I spent $50 on each service and ran identical tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10 WhatsApp verifications&lt;/strong&gt; (hardest — aggressive VoIP detection)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 Telegram verifications&lt;/strong&gt; (moderate difficulty)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 Instagram verifications&lt;/strong&gt; (inconsistent)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 Google account verifications&lt;/strong&gt; (very strict)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 random service verifications&lt;/strong&gt; (Discord, TikTok, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All tests used US, UK, and Indonesian numbers. I timed everything and noted the exact failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Success Rates (% of codes received)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;WhatsApp&lt;/th&gt;
&lt;th&gt;Telegram&lt;/th&gt;
&lt;th&gt;Instagram&lt;/th&gt;
&lt;th&gt;Google&lt;/th&gt;
&lt;th&gt;Overall&lt;/th&gt;
&lt;th&gt;Avg. Delivery Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VerifySMS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;td&gt;55%&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;66%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;34s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;5sim.net&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;48%&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;61%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;41s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;sms-man&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;52s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SMSPool&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;45%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;28s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TextVerified&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Receive-SMS (free)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5%&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; TextVerified crushed everyone — but it's US-only and costs $2-5 per number. For international coverage at a reasonable price, it's a different game.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Price-Performance Sweet Spot
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    High
                     │
  Success     SMSPool●  TextVerified●
  Rate               VerifySMS●
                     │    5sim●
                     │  sms-man●
                     │
                    Low────────────────────
                     Low    Price    High
                    $0.20          $5.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cost Per SUCCESSFUL Verification (the metric that matters)
&lt;/h3&gt;

&lt;p&gt;This is what most reviews ignore. If a service costs $0.30 but only works 50% of the time, your effective cost is $0.60.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Sticker Price&lt;/th&gt;
&lt;th&gt;Success Rate&lt;/th&gt;
&lt;th&gt;Real Cost&lt;/th&gt;
&lt;th&gt;Auto-Refund?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VerifySMS&lt;/td&gt;
&lt;td&gt;$0.20-0.80&lt;/td&gt;
&lt;td&gt;66%&lt;/td&gt;
&lt;td&gt;$0.30-1.21&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5sim&lt;/td&gt;
&lt;td&gt;$0.15-0.60&lt;/td&gt;
&lt;td&gt;61%&lt;/td&gt;
&lt;td&gt;$0.25-0.98&lt;/td&gt;
&lt;td&gt;❌ Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sms-man&lt;/td&gt;
&lt;td&gt;$0.10-0.50&lt;/td&gt;
&lt;td&gt;55%&lt;/td&gt;
&lt;td&gt;$0.18-0.91&lt;/td&gt;
&lt;td&gt;❌ Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMSPool&lt;/td&gt;
&lt;td&gt;$0.30-1.00&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;td&gt;$0.44-1.47&lt;/td&gt;
&lt;td&gt;❌ Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TextVerified&lt;/td&gt;
&lt;td&gt;$2.00-5.00&lt;/td&gt;
&lt;td&gt;89%&lt;/td&gt;
&lt;td&gt;$2.25-5.62&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Critical note on refunds:&lt;/strong&gt; Without automatic refunds, your "real cost" is the sticker price divided by success rate. With auto-refunds (VerifySMS, TextVerified), failed attempts cost you nothing — so the "real cost" is just the sticker price for successful ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Service-by-Service Breakdown
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5sim.net — The Obvious First Choice
&lt;/h3&gt;

&lt;p&gt;Everyone from sms-activate migrated here first. The interface is similar, the API is compatible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt; Huge country selection (180+), decent API, crypto payments.&lt;br&gt;
&lt;strong&gt;What doesn't:&lt;/strong&gt; The web interface feels like 2015. No mobile app. Refunds require opening a support ticket. Some numbers are recycled too aggressively — I got a number that was already linked to someone else's WhatsApp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Good for developers who need API compatibility. Not great for end users.&lt;/p&gt;
&lt;h3&gt;
  
  
  sms-man.com — Budget Option
&lt;/h3&gt;

&lt;p&gt;Cheapest per-number pricing I found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt; Low prices, especially for social media services.&lt;br&gt;
&lt;strong&gt;What doesn't:&lt;/strong&gt; Slow. Average delivery was 52 seconds (vs. 15-34s for others). Interface is confusing — I accidentally bought 3 numbers for the wrong country because the UX is that bad. No mobile app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Use if budget is the only concern and you don't mind waiting.&lt;/p&gt;
&lt;h3&gt;
  
  
  SMSPool — The Privacy Option
&lt;/h3&gt;

&lt;p&gt;Claims to use non-VoIP numbers, which means better success rates for services that block virtual numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt; Genuinely good success rates. Their non-VoIP numbers do work better for WhatsApp and Instagram.&lt;br&gt;
&lt;strong&gt;What doesn't:&lt;/strong&gt; Smaller inventory. Popular country/service combos are often out of stock. Pricing is opaque — different prices for different "quality tiers" that aren't well explained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Best raw success rates in the budget category. Stock issues are frustrating.&lt;/p&gt;
&lt;h3&gt;
  
  
  TextVerified — The Premium Option
&lt;/h3&gt;

&lt;p&gt;Real US carrier numbers. By far the best success rates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt; Everything works. 89% overall success rate is unmatched. Fast delivery.&lt;br&gt;
&lt;strong&gt;What doesn't:&lt;/strong&gt; US numbers only. Expensive ($2-5 per number). Limited availability — popular services sell out fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; If you only need US numbers and budget isn't a concern, nothing beats this.&lt;/p&gt;
&lt;h3&gt;
  
  
  VerifySMS — Disclosure: I Built This
&lt;/h3&gt;

&lt;p&gt;Full transparency: I'm the developer behind VerifySMS. That's why I ran these tests — I needed to know where we actually stand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt; Automatic refunds (this is genuinely the feature I'm most proud of), native iOS app, 150+ countries, 40 languages.&lt;br&gt;
&lt;strong&gt;What doesn't:&lt;/strong&gt; Success rates for Instagram and Google are mediocre. We're still building our provider network. No Android app yet. No developer API yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; I won't claim we're the best. We're competitive on price, strong on UX, and the automatic refund policy makes the effective cost lower than the numbers suggest. But if you need US-only with maximum success rates, TextVerified is better.&lt;/p&gt;
&lt;h3&gt;
  
  
  Free Services (receive-sms-online.info, etc.)
&lt;/h3&gt;

&lt;p&gt;I tested 3 free services. 6% overall success rate. Numbers are shared publicly, meaning they're already burned on every major platform. Don't bother.&lt;/p&gt;
&lt;h2&gt;
  
  
  API Migration Guide
&lt;/h2&gt;

&lt;p&gt;If you're migrating from sms-activate's API, here's a compatibility matrix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;sms-activate Endpoint&lt;/th&gt;
&lt;th&gt;5sim Equivalent&lt;/th&gt;
&lt;th&gt;sms-man Equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getNumber&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /v1/user/buy/activation/{country}/{operator}/{product}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /get-number&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getStatus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /v1/user/check/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /get-sms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;setStatus&lt;/code&gt; (cancel)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /v1/user/cancel/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /set-status&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getBalance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /v1/user/profile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /get-balance&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;My recommendation:&lt;/strong&gt; Don't build provider-specific integrations. Build an abstraction layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SMSProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;getNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;waitForCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;getBalance&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then implement this for each provider. When a provider goes down — and they will — you swap in the next one without touching application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Rankings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;For developers needing API + international:&lt;/strong&gt; 5sim &amp;gt; VerifySMS &amp;gt; sms-man&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For end users wanting ease of use:&lt;/strong&gt; VerifySMS &amp;gt; SMSPool &amp;gt; 5sim&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For US-only, maximum reliability:&lt;/strong&gt; TextVerified (no contest)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For budget:&lt;/strong&gt; sms-man &amp;gt; 5sim &amp;gt; VerifySMS&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt; Free services, any service without a clear refund policy&lt;/p&gt;




&lt;p&gt;&lt;em&gt;These results are from January-March 2026. Success rates shift constantly as platforms update their detection. I'll update this post quarterly. Follow to stay updated.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm the developer behind &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt;. I've tried to be as honest as possible, including where competitors beat us. If you think my testing methodology is flawed, call me out in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>security</category>
      <category>review</category>
    </item>
    <item>
      <title>Your Phone Number Is More Dangerous Than Your SSN — Here's the Data to Prove It</title>
      <dc:creator>Serhat Doğan</dc:creator>
      <pubDate>Thu, 02 Apr 2026 00:28:36 +0000</pubDate>
      <link>https://dev.to/serhat_doan_33c17e2855a0/5-ways-to-protect-your-phone-number-when-signing-up-for-apps-4bjn</link>
      <guid>https://dev.to/serhat_doan_33c17e2855a0/5-ways-to-protect-your-phone-number-when-signing-up-for-apps-4bjn</guid>
      <description>&lt;p&gt;In 2025, the average phone number appeared in &lt;strong&gt;7.2 data breaches&lt;/strong&gt;. Your social security number? 2.1. Yet we hand out our phone numbers like candy.&lt;/p&gt;

&lt;p&gt;I dug into the data to understand why phone numbers became the most dangerous piece of personal information you own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers Don't Lie
&lt;/h2&gt;

&lt;p&gt;I analyzed data from HaveIBeenPwned, FTC reports, and carrier security disclosures. Here's what I found:&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Breach Exposure Rates (2024-2025)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data Type&lt;/th&gt;
&lt;th&gt;Avg. Breaches Per Person&lt;/th&gt;
&lt;th&gt;Recovery Difficulty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Email address&lt;/td&gt;
&lt;td&gt;12.4&lt;/td&gt;
&lt;td&gt;Easy — change password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone number&lt;/td&gt;
&lt;td&gt;7.2&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Impossible&lt;/strong&gt; — same number for years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSN&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;td&gt;Hard — credit freeze&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Home address&lt;/td&gt;
&lt;td&gt;3.8&lt;/td&gt;
&lt;td&gt;Moderate — but you live there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credit card&lt;/td&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;td&gt;Easy — bank issues new card&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The critical difference: &lt;strong&gt;you can change everything except your phone number.&lt;/strong&gt; New credit card? Call the bank. New email? 5 minutes. New phone number? You'd need to update every account, tell every contact, lose 2FA access to dozens of services. Nobody does this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Phone Number Attack Chain
&lt;/h2&gt;

&lt;p&gt;Here's how a single leaked phone number cascades into a full identity compromise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phone number leaked in App X data breach
  ↓
Attacker finds your email via data broker lookup ($0.02)
  ↓
Attacker requests password reset on your email provider
  ↓
Email provider sends SMS verification to your number
  ↓
Attacker calls your carrier with social engineering
  ↓
SIM swap: your number now points to attacker's SIM
  ↓
Attacker receives your email reset code
  ↓
Attacker owns your email
  ↓
Attacker resets your bank, crypto, social media passwords
  ↓
Game over.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't theoretical. The FBI's IC3 reported &lt;strong&gt;$68.4 million in SIM swap losses&lt;/strong&gt; in 2023 alone. By 2025, estimated losses exceeded $100 million.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Phone Numbers Are Uniquely Dangerous
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Universal Identifier
&lt;/h3&gt;

&lt;p&gt;Unlike email (you can have many), most people use ONE phone number. It connects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Banking apps&lt;/li&gt;
&lt;li&gt;Social media accounts&lt;/li&gt;
&lt;li&gt;Government services&lt;/li&gt;
&lt;li&gt;Medical records&lt;/li&gt;
&lt;li&gt;Dating profiles&lt;/li&gt;
&lt;li&gt;Food delivery&lt;/li&gt;
&lt;li&gt;Ride sharing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One number, connected to everything. One breach exposes the connections between all of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Reverse Lookup Is Trivial
&lt;/h3&gt;

&lt;p&gt;For $5 on any data broker site, anyone can get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your full name&lt;/li&gt;
&lt;li&gt;Home address&lt;/li&gt;
&lt;li&gt;Email addresses&lt;/li&gt;
&lt;li&gt;Relatives' names&lt;/li&gt;
&lt;li&gt;Employment history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All from just your phone number. Try it yourself (with your own number) on sites like Whitepages or BeenVerified. It's terrifying.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Carrier Security Is Weak
&lt;/h3&gt;

&lt;p&gt;The entity protecting your phone number is your carrier. The same carrier whose retail employees have been bribed to perform SIM swaps for $100. The same carrier whose "security question" is often your ZIP code.&lt;/p&gt;

&lt;p&gt;T-Mobile has been breached &lt;strong&gt;9 times&lt;/strong&gt; since 2018, exposing customer phone numbers and account data repeatedly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution Spectrum
&lt;/h2&gt;

&lt;p&gt;There's no single fix, but there's a spectrum of protection:&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 1: Minimise Exposure (Free)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Stop entering your real number on random apps&lt;/li&gt;
&lt;li&gt;Use email-based 2FA wherever available&lt;/li&gt;
&lt;li&gt;Set a SIM PIN with your carrier (not the same as your phone PIN)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Level 2: Number Compartmentalization
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Use your real number ONLY for banking and family&lt;/li&gt;
&lt;li&gt;Use virtual numbers for everything else&lt;/li&gt;
&lt;li&gt;Services like &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt; provide temporary numbers in 150+ countries at $0.20-1.00 per use&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Level 3: Full Number Isolation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Dedicated "public" SIM for non-sensitive accounts&lt;/li&gt;
&lt;li&gt;Google Voice or carrier secondary number&lt;/li&gt;
&lt;li&gt;Virtual numbers for all one-time verifications&lt;/li&gt;
&lt;li&gt;Hardware security key for critical 2FA&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Data Broker Economy
&lt;/h2&gt;

&lt;p&gt;Your phone number is a commodity. Here's the actual pricing in data broker markets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data Package&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Includes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phone → Name lookup&lt;/td&gt;
&lt;td&gt;$0.02-0.10&lt;/td&gt;
&lt;td&gt;Name, carrier, line type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone → Full profile&lt;/td&gt;
&lt;td&gt;$0.50-5.00&lt;/td&gt;
&lt;td&gt;Name, address, email, relatives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk phone list (1000)&lt;/td&gt;
&lt;td&gt;$50-200&lt;/td&gt;
&lt;td&gt;Marketing-grade data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time phone location&lt;/td&gt;
&lt;td&gt;$300-500&lt;/td&gt;
&lt;td&gt;Via carrier location services&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Yes, there are services that sell real-time phone location data derived from carrier agreements. Your phone number is literally a tracking beacon.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Industry Should Do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Carriers&lt;/strong&gt; should implement mandatory SIM swap verification (some now require in-store ID — but it should be universal)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Platforms&lt;/strong&gt; should move away from SMS-based verification entirely. Passkeys, authenticator apps, and email verification are all more secure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Regulators&lt;/strong&gt; should classify phone numbers as PII with the same protections as SSNs. The current framework treats phone numbers as "directory information" — a classification from the landline era.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Users&lt;/strong&gt; should treat phone numbers like home addresses — something you don't give to strangers. Use virtual numbers for verification when possible, and push for platforms to offer non-SMS alternatives.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Uncomfortable Truth
&lt;/h2&gt;

&lt;p&gt;We're using a 1990s technology (SMS) to secure 2020s infrastructure (banking, healthcare, identity). It doesn't work. But until the industry catches up, the best defence is reducing your phone number's exposure.&lt;/p&gt;

&lt;p&gt;Every time you enter your number on a signup form, ask: "Do I trust this company to never be breached?" The answer is always no.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt; to make phone number privacy accessible. But honestly, the real solution is an industry shift away from SMS verification entirely. What's your take?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>security</category>
      <category>cybersecurity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How SMS Verification Actually Works: Architecture, Failures, and Why 30% of Codes Never Arrive</title>
      <dc:creator>Serhat Doğan</dc:creator>
      <pubDate>Wed, 01 Apr 2026 23:25:13 +0000</pubDate>
      <link>https://dev.to/serhat_doan_33c17e2855a0/how-sms-verification-works-a-developers-guide-to-virtual-phone-numbers-38en</link>
      <guid>https://dev.to/serhat_doan_33c17e2855a0/how-sms-verification-works-a-developers-guide-to-virtual-phone-numbers-38en</guid>
      <description>&lt;p&gt;I spent the last year building an SMS verification service. Here's what I learned about the surprisingly fragile infrastructure behind those 6-digit codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Journey of a Verification Code
&lt;/h2&gt;

&lt;p&gt;When you tap "Send Code" on WhatsApp or any other app, you trigger a chain of events that crosses at least 4 different networks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your app → Platform's server → SMS aggregator → Carrier gateway → 
SMSC → Cell tower → Your phone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each hop is a potential failure point. The industry average delivery rate? &lt;strong&gt;Around 70-85%&lt;/strong&gt; depending on the country. That means up to 30% of verification codes never arrive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Codes Fail to Arrive
&lt;/h2&gt;

&lt;p&gt;After processing tens of thousands of verification requests, I've categorized the failure modes:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Carrier Filtering (40% of failures)
&lt;/h3&gt;

&lt;p&gt;Carriers run content inspection on SMS traffic. Messages containing patterns like "Your code is" or "verification" get flagged and sometimes silently dropped.&lt;/p&gt;

&lt;p&gt;This is why some services send codes as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;G-482916&lt;/code&gt; (Google's format — harder to filter)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Your Telegram code: 48291&lt;/code&gt; (mixed with branding)&lt;/li&gt;
&lt;li&gt;A message with no clear code pattern at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Carrier filtering varies wildly by country. Indonesia has aggressive filtering. Germany is relatively permissive. The US is somewhere in between — and it varies by carrier (T-Mobile vs. AT&amp;amp;T have completely different filtering rules).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Grey Routes (25% of failures)
&lt;/h3&gt;

&lt;p&gt;SMS routing has a dirty secret: grey routes. To save money, some aggregators route international SMS through unauthorized paths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Legitimate route:
  US Platform → US Aggregator → UK Carrier → UK Phone ✅

Grey route:
  US Platform → Indian Aggregator → Random gateway → UK Phone
  (cheaper, but unreliable and often blocked)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grey routes are cheaper but have terrible delivery rates. They're also technically illegal in many jurisdictions. If a verification service is suspiciously cheap, they're probably using grey routes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Number Type Mismatches (20% of failures)
&lt;/h3&gt;

&lt;p&gt;This is the biggest challenge for virtual number services. Platforms actively try to block virtual and VoIP numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Number Type&lt;/th&gt;
&lt;th&gt;WhatsApp&lt;/th&gt;
&lt;th&gt;Telegram&lt;/th&gt;
&lt;th&gt;Instagram&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real carrier SIM&lt;/td&gt;
&lt;td&gt;✅ 95%+&lt;/td&gt;
&lt;td&gt;✅ 95%+&lt;/td&gt;
&lt;td&gt;✅ 95%+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile VoIP&lt;/td&gt;
&lt;td&gt;⚠️ 60-70%&lt;/td&gt;
&lt;td&gt;✅ 85%+&lt;/td&gt;
&lt;td&gt;⚠️ 50-60%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed VoIP&lt;/td&gt;
&lt;td&gt;❌ &amp;lt;20%&lt;/td&gt;
&lt;td&gt;⚠️ 40-50%&lt;/td&gt;
&lt;td&gt;❌ &amp;lt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Virtual (dedicated)&lt;/td&gt;
&lt;td&gt;⚠️ 70-80%&lt;/td&gt;
&lt;td&gt;✅ 90%+&lt;/td&gt;
&lt;td&gt;⚠️ 40-60%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These rates shift constantly. WhatsApp updates their VoIP detection roughly every 2-3 weeks. What works today might not work tomorrow.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Timing and Load (15% of failures)
&lt;/h3&gt;

&lt;p&gt;SMS infrastructure has capacity limits. During peak hours (9-11 AM local time in most countries), carrier gateways queue messages. Verification codes have a shelf life — if the code takes 3 minutes to arrive but the app's timeout is 60 seconds, it's a failure even though the SMS eventually arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Multi-Provider Problem
&lt;/h2&gt;

&lt;p&gt;No single SMS provider covers all 180+ countries reliably. Here's what the provider landscape looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provider A: Good in US/EU, terrible in Southeast Asia
Provider B: Great in Asia, doesn't cover Africa
Provider C: Covers Africa, but expensive and slow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any serious verification service needs at least 2-3 providers with intelligent routing. The algorithm isn't just about fallback — it's about knowing &lt;em&gt;which provider works best for which country-service combination&lt;/em&gt; in real-time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Naive approach (bad)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; 

&lt;span class="c1"&gt;// Better: route based on historical success data&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;selectProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSuccessRates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// from last 24h&lt;/span&gt;

  &lt;span class="c1"&gt;// Weight by success rate and latency&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;successRate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// minimum threshold&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;successRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avgLatency&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;})[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Refund Problem
&lt;/h2&gt;

&lt;p&gt;Here's an industry dirty secret: most SMS verification services profit from failed deliveries.&lt;/p&gt;

&lt;p&gt;You pay $0.50 for a number. The SMS never arrives. The service keeps your money and says "sorry, try again." They spent $0.10 on the number attempt, pocketed $0.40, and gave you nothing.&lt;/p&gt;

&lt;p&gt;This is why automatic refund policies matter. If a service doesn't offer automatic refunds for failed verifications, they have a financial incentive for codes to &lt;em&gt;not&lt;/em&gt; arrive. Think about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I've Learned Building This
&lt;/h2&gt;

&lt;p&gt;After a year of building &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt;, these are my biggest takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SMS is unreliable by design.&lt;/strong&gt; It was built for human-to-human communication in the 1990s, not for security-critical verification in 2026.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The industry needs transparency.&lt;/strong&gt; Success rates, delivery times, and refund policies should be published openly. Most services hide behind vague "high success rate" claims.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authenticator apps are better.&lt;/strong&gt; If a service offers TOTP-based 2FA (Google Authenticator, Authy), use it instead of SMS. SMS verification should be a last resort, not a first choice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Country matters more than service.&lt;/strong&gt; A US number for WhatsApp verification might work 90% of the time. A Bangladeshi number for the same service might work 40% of the time. Always check country-specific success rates before purchasing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Future of Verification
&lt;/h2&gt;

&lt;p&gt;SMS verification is a $2+ billion market, but it's built on infrastructure from the 1990s. The industry is slowly moving toward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WhatsApp Business API verification&lt;/strong&gt; (platform-native, no SMS needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email-based verification&lt;/strong&gt; (cheaper, more reliable)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passkeys&lt;/strong&gt; (passwordless, no verification code needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device attestation&lt;/strong&gt; (Apple/Google verify the device, not the number)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Until these alternatives achieve full adoption, SMS verification remains a necessary evil. Understanding how it works — and fails — helps you make better choices about when and how to use it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://verifysms.app" rel="noopener noreferrer"&gt;VerifySMS&lt;/a&gt;, a virtual number service with automatic refunds. If you have questions about SMS verification infrastructure, I'm happy to answer in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>architecture</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
