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.
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.
The architecture in one diagram
iOS app ──┐
├──► Cloudflare Edge ──► Supabase Edge Functions ──┬──► HeroSMS API
Web app ──┘ └──► 5SIM API
│
▼
Webhook listener
│
▼
Postgres + RLS
Four pieces matter: the edge router, the dual-provider abstraction, the refund state machine, and the secret boundary. Everything else is plumbing.
1. The dual-provider abstraction
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, every provider mysteriously runs out.
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:
- Recent success rate for that exact (service, country) pair, not the global rate
- Cost including the markup we apply
- Inventory — both providers expose count endpoints we poll every 60s
The interface is intentionally minimal:
interface SmsProvider {
getCountries(service: string): Promise<Country[]>
getPrice(service: string, country: string): Promise<Price | null>
purchaseNumber(service: string, country: string): Promise<Activation>
getStatus(activationId: string): Promise<ActivationStatus>
cancelActivation(activationId: string): Promise<void>
}
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.
Lesson learned: 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 STATUS_OK from meaning 'code received' to 'number purchased.'
2. The refund state machine
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.
The naive answer: refund the user, eat the loss, hope it averages out. It doesn't. You bleed money.
The better answer: a state machine.
PURCHASED ──► WAITING ──► RECEIVED ──► COMPLETED
│ │
│ ▼
│ EXPIRED ──► PROVIDER_REFUND_PENDING ──► REFUNDED
│
▼
CANCELLED
Every activation lives in exactly one state at a time. Transitions are atomic Postgres updates with WHERE status = expected_previous_status. The cron that scans for expired activations only 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.
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.
3. The secret boundary
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.
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.
The boundary looks like:
- Client → Edge Function: anon JWT, RLS-enforced row access
- Edge Function → Provider API: service-role secret, never logged
- Edge Function → DB: service-role JWT, RLS bypassed only for the specific table the function owns
One caveat: Supabase deploys Edge Functions with verify_jwt=true by default, which silently enables JWT verification on functions you may have intended to be public (like a get-services listing endpoint). Every deploy script must PATCH the function metadata to set verify_jwt=false 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.
4. The edge cache
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.
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.
The trick: store the full 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.
What we got wrong
- Started with one provider. Spent two months migrating to a dual-provider architecture after the first provider had a 6-hour outage during a paid ad push.
- Used global success rates for routing. 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.
- Tried to localize prices client-side. 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.
- Trusted webhook delivery. 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.
- Built a custom rate limiter. Rewrote it three times before giving in and using Cloudflare's Turnstile + native rate limiting rules.
What we got right
- iOS-first. 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.
- Dual provider from the start of v2. Worth every hour of refactor pain.
- Refund state machine. Single biggest unit-economics improvement we shipped.
- No KYC. Some markets we can't serve because of compliance, and that's fine. The rest love us for not asking for a passport scan.
Why share this
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.
And if you just need a virtual number to verify a Discord account without handing your real number to Discord's data brokers, VerifySMS 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.
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.
Top comments (0)