TL;DR: To receive a verification SMS programmatically on VirtualSMS: buy a number with
POST /customer/purchase, then choose one of three receive surfaces, pollGET /customer/order/{id}every 2-5 seconds, subscribe to the WebSocket atwss://virtualsms.io/ws/orders, or register a webhook. Webhooks are signed with HMAC-SHA256 in theX-Signatureheader; verify against the raw request body before parsing. Every number sits on a real carrier-issued SIM (Vodafone, O2, T-Mobile, Lebara, not VoIP), so it returns “mobile” in the HLR checks WhatsApp, Telegram, OpenAI, and Binance run. 145+ countries, 2500+ services, auto-refund if no SMS arrives within 20 minutes. Runnable Node, Python, and Go code below.
You need your code, not your users, to receive a verification SMS from a third-party platform. That is a different problem from what Twilio, Telnyx, or Bandwidth solve, and getting it wrong is the reason so many “receive SMS API” integrations quietly fail at the line-type check. This is the working pipeline: buy a real-SIM number, receive the OTP over a signed webhook (or WebSocket, or polling), and release the number, with reference implementations in Node, Python, and Go you can paste and run.
Key Takeaways
- The receive pipeline is three calls:
POST /customer/purchase→ wait via webhook/WebSocket/poll →POST /customer/cancel/{id}.- Webhooks are signed HMAC-SHA256 in
X-Signature, verify against the raw body bytes, in constant time, before parsing.- WebSocket (
wss://virtualsms.io/ws/orders) pushes the OTP 5-15s after it hits the SIM; polling default limit is 60 req/min per key.- Idempotency has three layers:
Idempotency-Keyon purchase, persistedorder_id, and retry-safe cancel.- Twilio Verify is outbound, it cannot register on WhatsApp/OpenAI and forward their code. Real-SIM inbound is what makes this work.
What Is a Real-SIM OTP Receive Pipeline?
- Real-SIM OTP receive pipeline (definition)
- A programmatic flow that buys temporary access to a number backed by a physical carrier SIM, waits for a third-party platform (WhatsApp, Telegram, OpenAI, Binance, and 2500+ others) to deliver a one-time code to that SIM, and returns the parsed code to your application over a webhook, WebSocket, or REST poll, then releases the number back to the pool.
The pipeline has exactly three moving parts:
- Purchase,
POST /customer/purchasereserves a number on a real SIM for a specific service and country, and returns anorder_idplus the number. - Receive, the SIM receives the SMS from the platform you are registering on; the VirtualSMS platform parses the code and surfaces it to you over your chosen surface (webhook, WebSocket, or poll).
- Release,
POST /customer/cancel/{order_id}returns the number to the pool. If no SMS arrived, the activation auto-refunds inside the 20-minute window.
Everything else in this guide, signatures, idempotency, backoff, is production hardening around those three calls.
MCP vs REST API for SMS verification →Citation Capsule: A real-SIM OTP receive pipeline on VirtualSMS is three REST calls:
POST /customer/purchase(buy a number on a physical carrier SIM), a receive step (webhook, WebSocket, or poll ofGET /customer/order/{id}), andPOST /customer/cancel/{order_id}(release). Every number is a carrier-issued SIM on operators like Vodafone, O2, T-Mobile, or Lebara, not a VoIP number, so it returns “mobile” in HLR line-type checks and passes verification on WhatsApp, Telegram, OpenAI, Binance, and Cash App. Verifications have a 20-minute lifetime with auto-refund when no SMS arrives, and the same product is reachable over REST, a WebSocket atwss://virtualsms.io/ws/orders, and an MCP server for AI agents.
How Do I Verify a Webhook Signature on Incoming SMS Events?
VirtualSMS signs every webhook with HMAC-SHA256 over the raw request body using your account webhook secret, and sends the hex digest in the X-Signature header. The rule that trips people up: recompute the digest over the raw body bytes, not the parsed-then-reserialized JSON, reserialization changes whitespace and the digests will never match.
Verify in constant time, reject before parsing, and reject stale events (older than five minutes by received_at) to defend against replay.
// Node.js (Express), verify VirtualSMS webhook
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.VSMS_WEBHOOK_SECRET;
// capture the RAW body, do not let JSON middleware run first
app.post("/webhooks/vsms", express.raw({ type: "*/*" }), (req, res) => {
const sig = req.get("X-Signature") || "";
const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const ok =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
if (Date.now() - new Date(event.received_at).getTime() > 5 * 60_000) {
return res.status(400).send("stale event");
}
// event.code holds the parsed OTP
handleOtp(event.order_id, event.code);
res.sendStatus(200);
});
# Python (Flask), verify VirtualSMS webhook
import hmac, hashlib, time
from datetime import datetime, timezone
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["VSMS_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/vsms")
def vsms_webhook():
raw = request.get_data() # raw bytes, BEFORE any JSON parsing
sig = request.headers.get("X-Signature", "")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json(force=True)
received = datetime.fromisoformat(event["received_at"])
if (datetime.now(timezone.utc) - received).total_seconds() > 300:
abort(400)
handle_otp(event["order_id"], event["code"])
return "", 200
// Go (net/http), verify VirtualSMS webhook
func vsmsWebhook(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body) // raw bytes first
mac := hmac.New(sha256.New, []byte(os.Getenv("VSMS_WEBHOOK_SECRET")))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(r.Header.Get("X-Signature")), []byte(expected)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var ev struct {
OrderID string `json:"order_id"`
Code string `json:"code"`
ReceivedAt time.Time `json:"received_at"`
}
_ = json.Unmarshal(raw, &ev)
if time.Since(ev.ReceivedAt) > 5*time.Minute {
http.Error(w, "stale event", http.StatusBadRequest)
return
}
handleOtp(ev.OrderID, ev.Code)
w.WriteHeader(http.StatusOK)
}
The three implementations differ only in language; the security contract is identical, raw body, constant-time compare, freshness check, then parse.
Should I Poll, Use WebSocket, or Register a Webhook?
All three receive the same OTP; they differ in latency, cost, and how many concurrent activations you run. Pick by workload.
| Surface | Best for | Latency | Cost / caveat |
|---|---|---|---|
Polling GET /customer/order/{id} | 1-20 activations, simple scripts, cron jobs | 2-5s cadence | 60 req/min per key; honor Retry-After on 429 |
WebSocket wss://virtualsms.io/ws/orders | 20+ concurrent, latency-sensitive UIs | 5-15s after SMS hits SIM | Holds an open socket; same x-api-key auth |
| Webhook (signed) | Serverless, background agents, fan-in | Push, sub-second dispatch | You host an HTTPS endpoint + verify HMAC |
For a handful of activations, polling is the least code. A safe cadence is one request every 2-5 seconds, backing off exponentially after ~30 seconds of no SMS. The instant you cross ~20 concurrent activations, stop fanning out polls, you will hit the 60 req/min limit, and switch to the WebSocket or a webhook.
# Python, minimal polling receive loop with backoff
import time, requests
API = "https://virtualsms.io/customer"
H = {"x-api-key": os.environ["VSMS_API_KEY"]}
def receive_otp(order_id, timeout=1200):
deadline, delay = time.time() + timeout, 2
while time.time() < deadline:
r = requests.get(f"{API}/order/{order_id}", headers=H)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 5)))
continue
data = r.json()
if data.get("code"):
return data["code"]
time.sleep(delay)
delay = min(delay * 1.5, 15) # exponential backoff, capped
return None # window expired → auto-refund applies
See live service and country availability →Citation Capsule: VirtualSMS exposes three OTP-receive surfaces on the same
x-api-keyauth: REST polling ofGET /customer/order/{id}(default limit 60 requests/minute, HTTP 429 withRetry-Afterwhen exceeded, recommended 2-5s cadence with exponential backoff), a WebSocket atwss://virtualsms.io/ws/ordersthat pushes a JSON event 5-15 seconds after an SMS is parsed, and signed webhooks (HMAC-SHA256,X-Signatureheader) preferred for serverless and background agents. Server-Sent Events are not offered because WebSocket covers the push case. For 20 or more concurrent activations, WebSocket or webhooks replace polling to stay under the rate limit.
How Do I Make the Pipeline Idempotent Across Crashes and Retries?
A verification job that buys a second number every time your process restarts burns money. Idempotency is three layers, and you want all three:
Idempotency-Keyon purchase. Send the header on everyPOST /customer/purchase. Identical keys inside a 24-hour window return the same order instead of creating a duplicate, so a retried purchase after a network blip does not double-buy.- Persist
order_idbefore polling. Write the returnedorder_idto durable storage first, then start the receive loop. A restart resumes against the existing order rather than buying again. - Retry-safe cancel.
POST /customer/cancel/{id}returns the same shape whether the order is active, already cancelled, or expired. That means you can fire cleanup from afinallyblock with no branching, and no fear of a double-cancel error.
// Go, buy once (idempotent) then release in a finally-equivalent defer
func VerifyOnce(service, country, idemKey string) (string, error) {
order, err := purchase(service, country, idemKey) // sends Idempotency-Key
if err != nil {
return "", err
}
persistOrderID(order.ID) // durable before we wait
defer cancel(order.ID) // retry-safe release, always runs
return waitForCode(order.ID, 20*time.Minute)
}
Because cancel auto-refunds when no SMS arrived, the defer costs nothing on the failure path, the activation charge reverses inside the 20-minute window.
Why Can’t I Just Use Twilio, Telnyx, or Bandwidth for This?
Because those providers sell numbers you own and operate, and third-party platforms refuse to deliver verification codes to them.
Twilio, Telnyx, Bandwidth, Plivo, Vonage, Sinch, and MessageBird are excellent for numbers your own users or systems message. But when you try to register one of those numbers on WhatsApp, Telegram, OpenAI, or Binance, the platform runs a line-type check, sees VoIP, and never sends the code. Twilio Verify makes this worse to reason about: it is an outbound product, you tell Twilio to send a code to a user you control, and it webhooks you the delivery status. It cannot act as a temporary inbound number that receives a third party’s OTP.
| Twilio / Telnyx / Bandwidth | VirtualSMS | |
|---|---|---|
| Number type | You own & operate (VoIP line-type) | Per-activation on real carrier SIM |
| Registers on WhatsApp/OpenAI/Binance | No, flagged VoIP, code not sent | Yes, passes HLR “mobile” check |
| Inbound third-party OTP | Not the product | The product |
| Developer surface | REST + webhooks | REST + WebSocket + webhooks + MCP |
| Failed-verification cost | You pay for the number | Auto-refund inside 20 min |
The developer ergonomics are nearly identical, REST, webhooks, HMAC signatures. What differs is the SIM underneath, and that difference is the entire reason a receive pipeline built on a real SIM actually delivers the code.
Real-SIM API for Discord verification →Citation Capsule: Twilio, Telnyx, Bandwidth, Plivo, Vonage, Sinch, and MessageBird sell numbers the developer owns and operates; those numbers are classified as VoIP by line-type intelligence, so WhatsApp, Telegram, OpenAI, and Binance refuse to deliver verification SMS to them. Twilio Verify is an outbound product that reports delivery status for codes you send to your own users, it cannot receive a third party’s OTP on a temporary number. VirtualSMS sells single-activation access to real carrier-issued SIM numbers that return “mobile” in HLR checks and pass verification across 2500+ services, exposing the same REST + WebSocket + webhook surface plus an MCP server for AI agents.
How Does This Fit AI Agents and Automation Frameworks?
If an agent needs to create an account mid-workflow, wrap the whole buy → wait → release flow as a single tool and hand it over:
- LangGraph: register
receive_smsas a tool node returning a typed dict; the agent calls it from any state transition that needs a code. - n8n: use the HTTP Request node with
x-api-keyauth across the three endpoints, or wire the webhook straight into a trigger node. - CrewAI: bind the function as a
custom_toolon the verification crew member. - Claude / MCP: the VirtualSMS MCP server collapses all three calls into a single
buy_number → wait_for_codeprompt, so there is zero wrapper code on your side.
For agent workloads specifically, the webhook or MCP surface beats polling: a background agent should not hold an open socket or burn its rate limit spinning on GET.
When Should I Rent a Number Instead of Using a Single Activation?
A single activation is the right default for the receive pipeline above, one code, one account, number released. But some workflows need the same number to keep receiving SMS over days: a persistent test account, a service you re-authenticate against, or a dedicated inbox for an integration. VirtualSMS has two rental tiers for that:
Full Access Rental: an entire real-SIM number rented exclusively to you for 1, 3, 7, 14, or 30 days. Every SMS from any service routes to your private inbox, on a local SIM that accepts any service. Right for: developers building SMS-integrated workflows, or teams testing across multiple services at once.
Platform Rental: a partner/global number locked to one specific service for 1, 3, or 7 days, with the same 20-minute auto-refund if no SMS arrives. Right for: managing a single account (one WhatsApp, one Telegram) over multiple sessions without paying for the whole SIM.
Both use real carrier-issued SIM cards and pass the same HLR “mobile” checks as single activations. Compare Full Access and Platform Rental →
Frequently Asked Questions
How do I do webhook signature verification on incoming SMS events?
VirtualSMS signs every webhook with HMAC-SHA256 over the raw request body using your account webhook secret, then sends the hex digest in the X-Signature header. Recompute the digest on the raw body bytes, not the parsed-then-reserialized JSON, since reserialization changes whitespace and breaks the match, and compare in constant time with crypto.timingSafeEqual (Node), hmac.compare_digest (Python), or hmac.Equal (Go). Reject any request whose signature does not match before parsing, and reject any event older than five minutes by checking received_at to defend against replay.
What is the maximum polling rate against the VirtualSMS REST API?
The default rate limit is 60 requests per minute per API key, returning HTTP 429 with a Retry-After header when exceeded. A safe cadence is one GET /customer/order/{id} every 2 to 5 seconds, backing off exponentially after about 30 seconds with no SMS. For 20 or more concurrent activations, subscribe to the WebSocket at wss://virtualsms.io/ws/orders or register a webhook rather than fanning out polls. Hitting the limit briefly is safe as long as your client honors Retry-After and resumes.
Can I receive OTP events over WebSocket or Server-Sent Events instead of polling?
WebSocket is supported today at wss://virtualsms.io/ws/orders with the same x-api-key auth. After buying a number, subscribe to its order_id and receive a JSON event the instant the platform parses an inbound SMS, typically 5 to 15 seconds after the SMS lands on the SIM. SSE is not currently offered because WebSocket already covers the push case and works behind most reverse proxies. For background or serverless agents where holding an open socket is expensive, webhooks are the better surface.
Can I use Twilio Verify as a webhook to receive OTP from third-party services?
No. Twilio Verify is an outbound product, your server tells Twilio to send a code to a user you own, then Twilio webhooks you the delivery status. It does not provide a temporary inbound number that can register on WhatsApp, Telegram, Binance, or OpenAI and forward the OTP that platform sends. For inbound third-party verification, you need a real-SIM provider whose hardware actually receives the SMS, which is what VirtualSMS exposes via REST, WebSocket, and webhooks.
How do I make my OTP pipeline idempotent across crashes and retries?
Three layers. (1) Send an Idempotency-Key on every POST /customer/purchase; identical keys within a 24-hour window return the same order instead of creating a duplicate. (2) Persist the order_id to durable storage before polling, so a process restart resumes against the same order rather than buying a second number. (3) Treat POST /customer/cancel/{id} as safe to retry, the server returns the same shape whether the order was active, already cancelled, or expired, so cleanup can fire from a finally block without branching.
How does the VirtualSMS API differ from Twilio, Telnyx, and Bandwidth for receiving OTP?
Twilio, Telnyx, Bandwidth, Plivo, Vonage, Sinch, and MessageBird sell numbers you own and operate; third-party platforms detect them as VoIP via line-type intelligence and refuse to deliver verification SMS. VirtualSMS sells single-activation access to numbers on real carrier-issued SIM cards that pass HLR “mobile” checks across WhatsApp, Telegram, OpenAI, Binance, Cash App, and 2500+ services. The developer surface is nearly identical (REST + WebSocket + webhooks); the SIM underneath is what differs.
What happens to a number after the OTP arrives or the activation times out?
Verifications have a 20-minute lifetime. If you confirm receipt or the window expires with no SMS, the number returns to the pool and the activation auto-refunds for the no-SMS case, you are never charged for failed verifications. POST /customer/cancel/{order_id} releases immediately and also auto-refunds if no SMS arrived. Numbers are not reused for the same service inside their cooldown window, which is why per-activation real-SIM numbers consistently pass the anti-reuse checks that recycled-pool marketplaces fail.
How do I integrate this pipeline with LangGraph, n8n, or CrewAI?
Wrap the buy → wait → release flow as a single tool function and expose it to the agent. LangGraph: register receive_sms as a tool node returning a typed dict. n8n: use the HTTP Request node with x-api-key auth on the three endpoints. CrewAI: bind the function as a custom_tool on the verification crew member. For Claude-driven agents, the VirtualSMS MCP server collapses all three calls into a single buy_number → wait_for_code prompt, zero wrapper code on your side.
The Bottom Line
Receiving a verification SMS in code is three calls, purchase, receive, release, wrapped in the hardening that keeps it correct under load: HMAC-verified webhooks, a WebSocket or backed-off poll for the wait, Idempotency-Key on purchase, and a retry-safe cancel in a finally block. Every language binds to the same contract; the Node, Python, and Go snippets above differ only in syntax.
What makes the whole pipeline work is the SIM underneath. Twilio and its peers sell numbers you own, and WhatsApp, Telegram, OpenAI, and Binance flag those as VoIP and never send the code. VirtualSMS activations sit on real carrier-issued SIM cards across 145+ countries and 2500+ services, pass the HLR “mobile” check, and auto-refund if no SMS arrives in 20 minutes.
Buy a real-SIM activation → · See pricing → · MCP vs API for verification →