Receive SMS in a Webhook: Real-SIM OTP Pipeline (Node, Python, Go)

4.7 Updated 25 June 2026 Published 25 June 2026
Outlined terminal icon on a waves purple gradient background

TL;DR: To receive a verification SMS programmatically on VirtualSMS: buy a number with POST /customer/purchase, then choose one of three receive surfaces, poll GET /customer/order/{id} every 2-5 seconds, subscribe to the WebSocket at wss://virtualsms.io/ws/orders, or register a webhook. Webhooks are signed with HMAC-SHA256 in the X-Signature header; 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-Key on purchase, persisted order_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:

  1. Purchase, POST /customer/purchase reserves a number on a real SIM for a specific service and country, and returns an order_id plus the number.
  2. 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).
  3. 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.

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 of GET /customer/order/{id}), and POST /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 at wss://virtualsms.io/ws/orders, and an MCP server for AI agents.

MCP vs REST API for SMS verification →

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.

SurfaceBest forLatencyCost / caveat
Polling GET /customer/order/{id}1-20 activations, simple scripts, cron jobs2-5s cadence60 req/min per key; honor Retry-After on 429
WebSocket wss://virtualsms.io/ws/orders20+ concurrent, latency-sensitive UIs5-15s after SMS hits SIMHolds an open socket; same x-api-key auth
Webhook (signed)Serverless, background agents, fan-inPush, sub-second dispatchYou 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

Citation Capsule: VirtualSMS exposes three OTP-receive surfaces on the same x-api-key auth: REST polling of GET /customer/order/{id} (default limit 60 requests/minute, HTTP 429 with Retry-After when exceeded, recommended 2-5s cadence with exponential backoff), a WebSocket at wss://virtualsms.io/ws/orders that pushes a JSON event 5-15 seconds after an SMS is parsed, and signed webhooks (HMAC-SHA256, X-Signature header) 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.

See live service and country availability →

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:

  1. Idempotency-Key on purchase. Send the header on every POST /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.
  2. Persist order_id before polling. Write the returned order_id to durable storage first, then start the receive loop. A restart resumes against the existing order rather than buying again.
  3. 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 a finally block 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.

See per-activation pricing →

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 / BandwidthVirtualSMS
Number typeYou own & operate (VoIP line-type)Per-activation on real carrier SIM
Registers on WhatsApp/OpenAI/BinanceNo, flagged VoIP, code not sentYes, passes HLR “mobile” check
Inbound third-party OTPNot the productThe product
Developer surfaceREST + webhooksREST + WebSocket + webhooks + MCP
Failed-verification costYou pay for the numberAuto-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.

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.

Real-SIM API for Discord verification →

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_sms as 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-key auth across the three endpoints, or wire the webhook straight into a trigger node.
  • CrewAI: bind the function as a custom_tool on the verification crew member.
  • Claude / MCP: the VirtualSMS MCP server collapses all three calls into a single buy_number → wait_for_code prompt, 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.

Claude + VirtualSMS MCP workflow → Virtual numbers for developer QA & testing →

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 →

James Foster avatar

Written by

Developer Relations & API

4.7

James covers the developer side: the REST API, the SDKs (npm, PyPI, Packagist, RubyGems, NuGet), and the MCP server that lets AI agents run verification flows without a dashboard. His posts are the ones with actual request and response snippets, webhook payloads, and the auth header you actually need. He cares about the boring parts, idempotency, rate limits, what a 429 means and how to back off, because that's where integrations break in production. If you're wiring VirtualSMS into a signup flow or an agent, his write-ups are meant to get you from "it works in Postman" to "it works at 2am under load".

from $0.05
Get verified