TL;DR — AI agents that register for real platforms need real phone numbers. VoIP numbers get line-type-checked and rejected before any OTP sends. VirtualSMS provides a REST API and MCP server: one call gets a real carrier SIM number, one call polls for the incoming code, and your agent has a verified account. Under 30 lines per framework — LangChain, OpenAI Assistants, CrewAI, Anthropic Claude tool-use. Real SIM cards on Vodafone, O2, T-Mobile, and other carriers across 145+ countries. Consistently high delivery. Auto-refund in 20 minutes. From $0.05 per code.
Building an AI agent that signs up for accounts, runs QA against real services, or automates workflows that touch any live platform exposes a universal blocker: the phone verification step. WhatsApp, Telegram, Google, Discord, and most fintech apps require a real carrier-issued phone number before they send an OTP. VoIP numbers — the default for most developers — are line-type-checked and rejected before any SMS dispatches.
This is not a platform policy the agent can reason around. It is the output of Twilio Lookup V2 and Telesign PhoneID — commercial APIs that every major platform integrates. When those APIs return “Non-Fixed VoIP,” the OTP never sends. The agent loop stalls.
VirtualSMS solves the hardware layer so your agent does not have to. The REST API takes a service ID and country, returns a real SIM phone number, and lets you poll for the incoming code. Two API calls, a polling loop, and your agent has a verified account. The MCP server wraps this entirely for Claude — no code required at the agent layer.
Key Takeaways
- VoIP numbers fail AI agent verification because platforms run real-time line-type checks before dispatching OTPs
- VirtualSMS REST API: POST to get a number, GET to poll for the code — under 30 lines per framework
- LangChain, OpenAI Assistants, CrewAI, and Claude tool-use all support the same two-function pattern
- MCP server available for Claude agents — zero custom code needed
- Real carrier SIMs (Vodafone, O2, T-Mobile) across 145+ countries, 2500+ services, from $0.05
- Consistently high delivery rate; auto-refund after 20 minutes if no SMS arrives
Why Does AI Agent Phone Verification Fail on VoIP?
AI agents fail phone verification on VoIP numbers for the same reason human users do: platforms classify numbers via real-time line-type APIs before they ever dispatch an OTP. Twilio Lookup V2 classifies Google Voice, TextNow, Twilio virtual numbers, and similar services as “Non-Fixed VoIP” — and that classification triggers an automatic rejection at WhatsApp, Telegram, Google, Discord, Binance, and most platforms that take fraud prevention seriously.
The check fires before your agent sees any error. From the agent’s perspective, it submitted a number and got back “verification failed” or “invalid number” — no VoIP-specific signal, no path to distinguish the line-type rejection from a temporary network error. The agent retries. It fails again. The loop stalls.
| What the agent sees | What actually happened |
|---|---|
| ”Verification failed” | Platform queried Twilio Lookup V2, got “Non-Fixed VoIP,” blocked OTP |
| ”Invalid phone number” | HLR lookup returned unregistered or flagged result |
| Timeout with no SMS | OTP never dispatched — line-type check failed silently |
| ”Number already in use” | Different issue — see why SMS verification gets blocked |
The fix is not at the agent logic layer. It is at the phone number layer. Your agent needs to request a real carrier-issued SIM number for each verification — a number that returns “mobile” in every line-type API. That is what VirtualSMS provides.
For a full technical explanation of VoIP rejection mechanics: VoIP vs Physical SIM — Real-SIM Verification Guide
Which Agent Framework Are You Using?
The integration shape is identical across all frameworks: two API calls wrapped in two functions, registered as tools. The table below maps framework-specific patterns before the code sections.
| Framework | Tool registration | Polling pattern | MCP option? |
|---|---|---|---|
| LangChain | Tool objects in AgentExecutor | Loop inside tool function | No (use REST) |
| OpenAI Assistants | function in tools array | Assistant calls get_code after request_number | No (use REST) |
| CrewAI | @tool decorated functions on Agent | Loop inside @tool body | No (use REST) |
| Claude tool-use | tools array in API request | Tool result returned to Claude | Yes — use MCP server |
| Claude via MCP | None — tools auto-loaded | Handled by MCP server | Yes — native |
The REST API is the universal path. The MCP server is the zero-code path for Claude specifically.
The Integration Shape — Two Calls, One Pattern
Every framework implementation uses the same underlying API shape. Understand this once and all four code sections below become variations.
Step 1 — Request a number:
POST https://api.virtualsms.io/v1/orders
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
{
"service": "whatsapp",
"country": "gb"
}
Response:
{
"order_id": "ord_abc123",
"phone": "+447911123456",
"status": "waiting",
"expires_at": "2026-07-01T14:35:00Z"
}
Step 2 — Poll for the code:
GET https://api.virtualsms.io/v1/orders/ord_abc123
Authorization: Bearer YOUR_API_KEY
Response when code arrives:
{
"order_id": "ord_abc123",
"phone": "+447911123456",
"status": "received",
"code": "847291"
}
Poll every 5–10 seconds. The order auto-cancels with a full refund after 20 minutes if no code arrives. Your agent does not need to handle refund logic — the refund is automatic.
Supported services and countries: 2500+ services across 145+ countries. Full list at /verifications. Pricing starts at $0.05 per code.
LangChain Tool Wrapper (Python)
import time
import requests
from langchain.agents import AgentExecutor, create_openai_functions_agent
from langchain.tools import Tool
from langchain_openai import ChatOpenAI
API_KEY = "YOUR_VIRTUALSMS_API_KEY"
BASE_URL = "https://api.virtualsms.io/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
def request_phone_number(service_country: str) -> str:
"""Request a real SIM phone number. Input: 'service:country' e.g. 'whatsapp:gb'."""
service, country = service_country.split(":")
resp = requests.post(
f"{BASE_URL}/orders",
headers=HEADERS,
json={"service": service, "country": country},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
return f"Phone: {data['phone']} | Order ID: {data['order_id']}"
def get_verification_code(order_id: str) -> str:
"""Poll for SMS verification code. Input: order_id from request_phone_number."""
deadline = time.time() + 18 * 60 # 18-minute timeout, under the 20-min auto-refund
while time.time() < deadline:
resp = requests.get(f"{BASE_URL}/orders/{order_id}", headers=HEADERS, timeout=10)
resp.raise_for_status()
data = resp.json()
if data["status"] == "received":
return f"Code: {data['code']}"
if data["status"] in ("cancelled", "refunded"):
return "Order cancelled or refunded — no SMS received."
time.sleep(8)
return "Timeout — no SMS received within 18 minutes. Order will auto-refund."
tools = [
Tool(name="RequestPhoneNumber", func=request_phone_number,
description="Get a real SIM phone number for SMS verification. Input: 'service:country' e.g. 'whatsapp:gb' or 'telegram:de'."),
Tool(name="GetVerificationCode", func=get_verification_code,
description="Poll for an incoming SMS verification code. Input: the order_id returned by RequestPhoneNumber."),
]
llm = ChatOpenAI(model="gpt-4o", temperature=0)
agent = create_openai_functions_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
result = executor.invoke({"input": "Sign up for a WhatsApp account using a UK number."})
The agent calls RequestPhoneNumber first, enters the number into WhatsApp’s verification flow, then calls GetVerificationCode to retrieve the OTP. No agent-side timeout logic is needed for the refund — VirtualSMS handles that automatically.
For broader developer patterns using virtual numbers in QA and testing: Virtual Numbers for Developers — QA Testing Guide
OpenAI Assistants Function Definition (Python)
OpenAI Assistants use function-calling to invoke external APIs. Define two functions in the tools array and handle them in the requires_action loop.
import time
import json
import requests
from openai import OpenAI
client = OpenAI()
API_KEY = "YOUR_VIRTUALSMS_API_KEY"
BASE_URL = "https://api.virtualsms.io/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
TOOLS = [
{
"type": "function",
"function": {
"name": "request_phone_number",
"description": "Get a real carrier SIM phone number for SMS verification.",
"parameters": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "Service slug, e.g. 'whatsapp', 'telegram', 'google', 'discord'"},
"country": {"type": "string", "description": "ISO 3166-1 alpha-2 country code, e.g. 'gb', 'de', 'us'"},
},
"required": ["service", "country"],
},
},
},
{
"type": "function",
"function": {
"name": "get_verification_code",
"description": "Poll for the incoming SMS verification code.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order ID returned by request_phone_number"},
},
"required": ["order_id"],
},
},
},
]
def handle_tool_call(name: str, args: dict) -> str:
if name == "request_phone_number":
resp = requests.post(
f"{BASE_URL}/orders", headers=HEADERS,
json={"service": args["service"], "country": args["country"]}, timeout=10,
)
data = resp.json()
return json.dumps({"phone": data["phone"], "order_id": data["order_id"]})
if name == "get_verification_code":
deadline = time.time() + 18 * 60
while time.time() < deadline:
resp = requests.get(f"{BASE_URL}/orders/{args['order_id']}", headers=HEADERS, timeout=10)
data = resp.json()
if data["status"] == "received":
return json.dumps({"code": data["code"]})
if data["status"] in ("cancelled", "refunded"):
return json.dumps({"error": "No SMS received — order refunded."})
time.sleep(8)
return json.dumps({"error": "Timeout — order will auto-refund."})
# Assistant run loop (abbreviated)
assistant = client.beta.assistants.create(model="gpt-4o", tools=TOOLS,
instructions="You are an account setup agent. Use the provided tools to get a phone number and complete SMS verification.")
thread = client.beta.threads.create()
client.beta.threads.messages.create(thread.id, role="user",
content="Register a Telegram account using a German number.")
run = client.beta.threads.runs.create(thread_id=thread.id, assistant_id=assistant.id)
while run.status not in ("completed", "failed", "cancelled"):
run = client.beta.threads.runs.retrieve(thread_id=thread.id, run_id=run.id)
if run.status == "requires_action":
outputs = []
for call in run.required_action.submit_tool_outputs.tool_calls:
result = handle_tool_call(call.function.name, json.loads(call.function.arguments))
outputs.append({"tool_call_id": call.id, "output": result})
run = client.beta.threads.runs.submit_tool_outputs(thread_id=thread.id, run_id=run.id, tool_outputs=outputs)
time.sleep(2)
The Assistant calls request_phone_number, you feed the returned number into the registration flow externally (via Playwright or your browser automation), then the Assistant calls get_verification_code when the OTP prompt appears. The run loop handles both calls automatically.
CrewAI Tool Integration
CrewAI uses decorated functions as tools. The pattern is the same two-function shape, decorated with @tool.
import time
import requests
from crewai import Agent, Task, Crew
from crewai.tools import tool
API_KEY = "YOUR_VIRTUALSMS_API_KEY"
BASE_URL = "https://api.virtualsms.io/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
@tool("Request Phone Number")
def request_phone_number(service_country: str) -> str:
"""Request a real SIM phone number for SMS verification.
Input format: 'service:country' — e.g. 'discord:gb' or 'google:de'.
Returns the phone number and order ID."""
service, country = service_country.split(":")
resp = requests.post(
f"{BASE_URL}/orders", headers=HEADERS,
json={"service": service, "country": country}, timeout=10,
)
resp.raise_for_status()
data = resp.json()
return f"Phone: {data['phone']} | Order ID: {data['order_id']}"
@tool("Get Verification Code")
def get_verification_code(order_id: str) -> str:
"""Poll for the incoming SMS verification code.
Input: order_id from Request Phone Number tool.
Returns the OTP code when received."""
deadline = time.time() + 18 * 60
while time.time() < deadline:
resp = requests.get(f"{BASE_URL}/orders/{order_id}", headers=HEADERS, timeout=10)
data = resp.json()
if data["status"] == "received":
return f"Verification code: {data['code']}"
if data["status"] in ("cancelled", "refunded"):
return "No SMS received — order refunded automatically."
time.sleep(8)
return "18-minute timeout reached. Order will auto-refund. Request a new number to retry."
verification_agent = Agent(
role="Account Verification Specialist",
goal="Complete SMS verification for platform account registrations",
backstory="You handle phone verification steps in automated account setup workflows.",
tools=[request_phone_number, get_verification_code],
verbose=True,
)
task = Task(
description="Get a UK phone number for Discord verification and retrieve the OTP code.",
expected_output="The Discord verification code received on the UK number.",
agent=verification_agent,
)
crew = Crew(agents=[verification_agent], tasks=[task])
result = crew.kickoff()
CrewAI’s tool docstrings serve as the function descriptions the LLM uses for tool selection — write them precisely. The service:country input format keeps both values in a single string parameter, which CrewAI handles cleanly.
Anthropic Claude Tool-Use (Python SDK)
Claude’s tool-use API is the most explicit of the four frameworks — you define tools, Claude returns tool_use blocks, and you handle them in the agent loop.
import time
import json
import requests
import anthropic
client = anthropic.Anthropic()
API_KEY = "YOUR_VIRTUALSMS_API_KEY"
BASE_URL = "https://api.virtualsms.io/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
TOOLS = [
{
"name": "request_phone_number",
"description": "Get a real carrier SIM phone number for SMS verification. Returns phone number and order ID.",
"input_schema": {
"type": "object",
"properties": {
"service": {"type": "string", "description": "Service slug: 'whatsapp', 'telegram', 'google', 'discord', 'instagram', etc."},
"country": {"type": "string", "description": "ISO 3166-1 alpha-2 country code: 'gb', 'de', 'us', 'fr', etc."},
},
"required": ["service", "country"],
},
},
{
"name": "get_verification_code",
"description": "Poll VirtualSMS for the incoming OTP. Call this after the target platform has been given the phone number. Returns the code or a timeout/refund message.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order ID returned by request_phone_number"},
},
"required": ["order_id"],
},
},
]
def process_tool_call(name: str, inputs: dict) -> str:
if name == "request_phone_number":
resp = requests.post(
f"{BASE_URL}/orders", headers=HEADERS,
json={"service": inputs["service"], "country": inputs["country"]}, timeout=10,
)
data = resp.json()
return json.dumps({"phone": data["phone"], "order_id": data["order_id"]})
if name == "get_verification_code":
deadline = time.time() + 18 * 60
while time.time() < deadline:
resp = requests.get(f"{BASE_URL}/orders/{inputs['order_id']}", headers=HEADERS, timeout=10)
data = resp.json()
if data["status"] == "received":
return json.dumps({"code": data["code"]})
if data["status"] in ("cancelled", "refunded"):
return json.dumps({"status": "refunded", "message": "No SMS received — order auto-refunded."})
time.sleep(8)
return json.dumps({"status": "timeout", "message": "18-minute timeout. Order will auto-refund."})
messages = [{"role": "user", "content": "Verify a WhatsApp account. Use a UK number."}]
while True:
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=4096,
tools=TOOLS,
messages=messages,
)
if response.stop_reason == "end_turn":
print(response.content[-1].text)
break
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = process_tool_call(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
Claude handles the sequencing automatically: it calls request_phone_number, reasons about the returned number, and decides when to call get_verification_code based on the workflow context you describe in the user message.
Prefer the MCP path for Claude. If you are already using Claude, the VirtualSMS MCP server loads both tools as native Claude capabilities — no
TOOLSdefinition, noprocess_tool_callhandler, no agent loop boilerplate. See /mcp for setup.
VirtualSMS MCP Server — Zero-Code Path for Claude
The MCP server is the fastest integration for Claude-based agents. Install once, and Claude has native access to VirtualSMS without any code in your agent layer.
# Install the VirtualSMS MCP server (Claude Code / Claude Desktop)
claude mcp add --scope user virtualsms npx @virtualsms/mcp-server
# Set your API key
export VIRTUALSMS_API_KEY=your_key_here
Once connected, Claude can:
- Request a number for any of 2500+ services in 145+ countries
- Poll for incoming OTPs automatically
- Handle refund/retry logic without instructions from you
- Pick the optimal country for a given service based on delivery history
You describe the task in natural language. Claude handles the tool calls.
"Sign up for a Telegram account using a real SIM from Germany."
→ Claude calls virtualsms_request_number(service="telegram", country="de")
→ Claude waits, then calls virtualsms_get_code(order_id="ord_...")
→ Returns: "Code received: 847291"
Full setup guide: VirtualSMS MCP Server
Why VoIP Breaks Agent Verification Workflows
The core failure mode is predictable: VoIP numbers get line-type-checked before any OTP sends. The consequences for an agent loop are worse than for a human user.
A human fails verification, sees “invalid number,” and manually switches to a different number. An agent that retries a VoIP number gets the same result indefinitely. Without explicit error classification, the agent cannot distinguish “VoIP rejected” from “temporary network error” — and it retries until it hits a rate limit or a token budget.
The rejection sequence:
- Platform receives the phone number from your agent
- Platform queries Twilio Lookup V2 or Telesign PhoneID: “what line type is this?”
- VoIP number returns “Non-Fixed VoIP”
- Platform blocks OTP dispatch — no SMS sent
- Agent receives “verification failed” — indistinguishable from a transient error
- Agent retries, platform runs the same check, same result
- Platform rate-limits or flags the IP after repeated failed attempts
Real SIM numbers from Vodafone, O2, T-Mobile, Lebara, and other licensed carriers return “mobile” in the line-type check. The check passes, the OTP dispatches, and the agent loop completes.
The line-type API problem in agent context: an agent cannot know that “verification failed” means “VoIP rejected” without implementing specific API-error-code handling or pre-checking the number’s line type. The cleaner architectural decision is to source only real SIM numbers from the start, so the line-type layer never becomes an agent failure mode. See VoIP vs Physical SIM — full technical breakdown.
Rental Tiers for Agent Workflows That Need Persistent Numbers
Single activations (one OTP, one number, from $0.05) cover most agent use cases. But some workflows need a number that persists across sessions — an account that receives login codes over days, a QA environment that tests SMS flows repeatedly, or a development setup that needs a stable inbox.
VirtualSMS offers two rental tiers for these cases:
Platform Rental — Lock a real SIM to one specific service (WhatsApp, Telegram, Discord, etc.) for 1, 3, or 7 days. All SMS from that service routes to your inbox. Auto-refund if no SMS arrives within 20 minutes. Right for: agent workflows that manage an ongoing account, QA pipelines that test login flows repeatedly against the same number.
Full Access Rental — Exclusive access to an entire SIM for 1, 3, 7, 14, or 30 days. Every SMS from any service routes to your private inbox. No sharing. Right for: developers building SMS-integrated products who need a stable number across multiple services simultaneously, or teams running parallel verification tests.
Both tiers use real carrier SIMs. Both pass every line-type check. See rental options
Cost and Rate Limits at Agent Scale
| Factor | Value |
|---|---|
| Per-activation price | From $0.05 (varies by service and country) |
| API rate limit | 120 requests/minute |
| Auto-refund window | 20 minutes from order creation |
| Countries | 145+ |
| Services | 2500+ |
| Delivery rate (real SIM) | Consistently high |
| Minimum deposit | See /pricing |
At 120 requests/minute and $0.05 per code, an agent running 100 verifications costs roughly $5 and stays well within rate limits. With consistently high delivery, far fewer orders trigger a refund — compared to frequent failures on VoIP-based services where “cheaper per number” becomes significantly more expensive per successful verification.
For high-volume agent workflows, use the Full Access Rental tier to avoid per-activation overhead and hold a stable number pool. See virtual numbers for bulk workflows and developer QA for architecture patterns at scale.
Frequently Asked Questions
Can OpenAI Assistants make phone calls or receive SMS?
OpenAI Assistants cannot natively make calls or receive SMS — but they can call external functions via function-calling. You define a request_phone_verification function that calls the VirtualSMS API, and a get_verification_code function that polls for the incoming OTP. The Assistant calls these in sequence, exactly as it would call any other tool. No telephony access required on the OpenAI side — the API handles the SIM and SMS layer.
How do I add SMS verification to a LangChain agent?
Define two Tool objects: one that calls POST /v1/orders to get a number and order ID, and one that polls GET /v1/orders/{id} until status becomes received or a timeout fires. Register both with your AgentExecutor. The agent invokes them in sequence automatically when verification is needed. The full ~25-line implementation is in the LangChain section above.
Do AI agents need real phone numbers for verification?
Yes — any platform that runs a line-type check (WhatsApp, Telegram, Google, Discord, Binance, most banks) will reject VoIP numbers before the OTP ever sends. Platforms use Twilio Lookup V2 and Telesign PhoneID to classify numbers as “mobile” or “Non-Fixed VoIP” before dispatching any SMS. Only a real carrier-issued SIM returns “mobile.” VirtualSMS gives agents access to real SIM cards via a REST API with no telephony hardware required.
What is the cheapest phone verification API for AI agents?
VirtualSMS starts at $0.05 per SMS code on real SIM cards. Because real-SIM delivery is consistently high, the cost per successful verification is lower than cheaper VoIP alternatives that fail 40–50% of the time. Auto-refund fires after 20 minutes if no code arrives — your agent does not need retry logic for the refund case.
Can Claude tool-use call my existing Twilio integration for phone verification?
Claude’s tool-use API can call any function you define — including a Twilio wrapper. However, Twilio virtual numbers are classified as VoIP and will be rejected by WhatsApp, Telegram, and Google during verification. The correct pattern is to define a Claude tool that calls VirtualSMS’s API for a real-SIM number, then poll for the code. Claude handles the agent loop; VirtualSMS handles the real carrier SIM layer.
How do I handle SMS timeouts in an AI agent loop?
VirtualSMS orders expire after 20 minutes with an automatic refund. In your polling tool, set a loop timeout slightly under 20 minutes (18 minutes works well) and return a timeout signal to the agent. The agent can then request a new number and retry. For LangChain and CrewAI, raise a ToolException with a timeout message and let the agent decide whether to retry or escalate.
Does VirtualSMS have an MCP server for Claude agents?
Yes. VirtualSMS ships an MCP server that exposes SMS verification as native Claude tools — no custom function-calling code needed. Claude picks the right country and service, requests a number, waits for the code, and confirms receipt — all through the MCP protocol. Setup guide at /mcp.
The Bottom Line
AI agent phone verification stalls on VoIP for one reason: platforms run line-type checks before dispatching OTPs, and VoIP numbers fail that check every time. The agent-loop consequence is worse than the human experience — a human switches numbers manually; an agent retries until it hits rate limits or burns a token budget on a retry loop that cannot succeed.
The architectural fix is clean: source real carrier SIM numbers from VirtualSMS at the tool layer. Two API calls per verification — one to get the number, one to poll for the code. The rest of the agent logic is unaffected. LangChain, OpenAI Assistants, CrewAI, and Claude tool-use all support the same pattern in under 30 lines.
VirtualSMS covers 2500+ services across 145+ countries on real SIM cards from carriers like Vodafone, O2, and T-Mobile. Consistently high delivery rate. Auto-refund in 20 minutes. From $0.05 per code.
Start with the API docs or see pricing.