Use cases

Phone verification without outbound SMS: how to stop SMS pumping

SMS pumping works because a phone verification endpoint texts any number an attacker types in, and you pay for each text. With ZeroSettle you flip phone verification around: the user sends a one-time code to your iMessage number, and your server never sends anything to an unverified number, so there is nothing to pump. The cost becomes a flat monthly line (Pro: $49 a month) instead of a per-verification fee. The flip only covers iMessage users, so Android users stay on your existing OTP provider, which still needs its own fraud controls.

Key takeaways

  • What it is: inbound phone verification. The user taps Send on a prefilled VERIFY code addressed to your ZeroSettle number, and your webhook records the handle Apple attached to that message, which is the user's phone number for almost everyone.
  • Why it stops pumping: your server sends nothing to a number until that number has texted you a valid code, so a scripted signup form triggers no billable texts.
  • Price: Pro is $49/month for one number with no per-message fees; Growth is from $599/mo. Inbound texts are unlimited on both.
  • Main limit: iMessage users only. There is no SMS fallback, so Android users need your existing OTP path.
  • Security: the flow verifies possession of an iMessage handle, the same guarantee as SMS OTP.

Who uses iMessage for phone verification?

Inbound iMessage verification is for growth and security engineers who pay per verification, or whose signup form has been scripted to send codes to numbers nobody uses. ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you, that your code texts customers from and hears back on through a bearer token and signed webhooks.

An SMS code is billed per message sent, whether or not anyone finishes verifying. Twilio's glossary describes the abuse: "SMS pumping fraud happens when fraudsters take advantage of a phone number input field" to have codes sent to a range of numbers controlled by one mobile network operator, and "the fraudsters get a share of the generated revenue." The sign Twilio tells you to look for is a spike of messages to a block of adjacent numbers with no completed verifications. The approach below fits apps whose signups mostly come from iPhone, iPad or Mac. For login on the same mechanism, see Sign in with iMessage; more workflows are in the use-case index.

How iMessage phone verification works, step by step

The user proves the number by sending a code to you, instead of you sending a code to the number, so the verify endpoint has no outbound step for an attacker to trigger.

  1. Your backend creates an attempt: a random 6-character code, a 10-minute expiry, tied to the signup session. Rate-limit this call per IP and per device. It sends nothing.
  2. A Verify with iMessage button opens sms:+18055550100&body=VERIFY%207KD2PA: Messages opens addressed to your number with the code filled in, and the user taps Send. Desktops without Messages get the link as a QR code.
  3. About 1–2 seconds after the line receives the text, ZeroSettle POSTs a signed message.received event to your webhook, carrying text and handle, the address Apple attached to the message.
  4. Your webhook verifies the signature, accepts the code once and only before it expires, and stores handle as the verified number. Every other inbound text is ignored and gets no reply.
  5. Optionally, POST /api/imessage/reactions puts a 👍 on the user's message: the only thing your number sends, and only to a handle that has just proved itself.
  6. The signup screen polls your status endpoint and continues. If the attempt expires, it offers your existing SMS code.
What 10,000 scripted verification requests trigger, a hypothetical attack (prices checked October 8, 2026)
EffectOutbound SMS codeInbound iMessage code
Texts you send10,000None
Billed to you$0.0083 per US attempt on Twilio Verify, verified or not: $83Nothing beyond the flat plan
Who picks recipientsThe attackerNobody: every text is addressed to you
Left afterwards10,000 codes sent to the attacker's numbersAttempts your rate limits let through, expiring in 10 minutes

Assumes no filter catches the attack. Twilio Verify includes Fraud Guard, which blocks SMS it judges likely fraudulent, so a real bill can be lower. Twilio prices SMS per destination country; this uses its US rate as one example.

Example conversation: phone verification

The whole verification thread is one text from the user and, optionally, one tapback from your number, shown here from the side of Acme Budget, a fictional budgeting app.

  1. Today 9:41 AM
  2. New user: VERIFY 7KD2PA👍
Illustrative example with a fictional app. The gray bubble is the prefilled text the user sent; the blue 👍 is Acme Budget's tapback, and signup continues with the number verified. An attacker scripting 10,000 fake numbers into the same form never appears here: those requests create attempts, not messages, and cost Acme Budget nothing per request.

Setup: inbound verification with rate limits

Setup is a small service that creates rate-limited attempts and accepts each code once, registered with PUT /api/imessage/webhook (store the returned secret, shown once, as ZEROSETTLE_WEBHOOK_SECRET). It adapts the backend in the docs' Sign in with iMessage section, with the signature check copied from Webhooks, step by step.

python
# verify_phone.py: run with  uvicorn verify_phone:app --port 8100
import hashlib, hmac, json, os, re, secrets, time
from collections import defaultdict, deque
import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response

app = FastAPI()
BASE = os.environ.get("ZEROSETTLE_BASE_URL", "https://portal.zerosettle.io")
TOKEN, SECRET = os.environ["ZEROSETTLE_TOKEN"], os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"     # no 0/O/1/I
attempts, by_code = {}, {}                         # use Redis in production
hits = defaultdict(deque)

def allow(key, limit=5, window=600):
    q, now = hits[key], time.time()
    while q and q[0] < now - window:
        q.popleft()
    q.append(now)
    return len(q) <= limit

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t, v1 = int(parts["t"]), parts["v1"]
    except Exception:
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

@app.post("/verify/start")
def start(req: Request):
    device = req.headers.get("X-Device-Id")        # your app's install id
    ip = req.client.host                           # behind a proxy, run uvicorn with --forwarded-allow-ips
    if not allow("ip:" + ip) or (device and not allow("dev:" + device)):
        raise HTTPException(429)
    aid, code = secrets.token_urlsafe(16), "".join(secrets.choice(ALPHABET) for _ in range(6))
    account = current_signup(req)                  # your signup session
    attempts[aid] = {"exp": time.time() + 600, "state": "pending", "account": account}
    by_code[code] = aid
    return {"id": aid, "code": code}

@app.post("/webhook")
async def webhook(req: Request, tasks: BackgroundTasks):
    raw = await req.body()
    if not verify(SECRET, req.headers.get("X-ZeroSettle-Signature", ""), raw):
        return Response(status_code=401)
    ev = json.loads(raw)
    if ev["event"] != "message.received":
        return {"ok": True}
    m = ev["message"]
    match = re.search(r"\bVERIFY\s+([A-Z2-9]{6})\b", (m.get("text") or "").upper())
    a = attempts.get(by_code.pop(match.group(1), None)) if match else None
    if not a or a["state"] != "pending" or a["exp"] < time.time():
        return {"ok": True}                        # junk, expired or reused: no reply
    if not m["handle"].startswith("+"):
        a["state"] = "no_phone_number"             # email handle: offer your SMS code
        return {"ok": True}
    a.update(state="verified", handle=m["handle"])
    mark_phone_verified(a["account"], m["handle"])  # your database
    tasks.add_task(like, m["id"])                  # optional 👍, after the 2xx
    return {"ok": True}

async def like(message_id):
    async with httpx.AsyncClient() as c:
        await c.post(f"{BASE}/api/imessage/reactions", headers={"Authorization": f"Bearer {TOKEN}"},
                     json={"message_id": message_id, "reaction": "like"}, timeout=90)

@app.get("/verify/status")
def status(id: str):
    a = attempts.get(id) or {"state": "unknown"}
    if a["state"] == "pending" and a["exp"] < time.time():
        a["state"] = "expired"
    return {"state": a["state"], "phone": a.get("handle")}

Three details are specific to verification. by_code.pop consumes each code, so a reused code or a webhook retry is ignored. The 👍 runs after the webhook answers, because ZeroSettle expects a 2xx within 10 seconds. And the line must stay Managed by API: on a line the hosted AI agent manages, inbound texts are not POSTed to your webhook, and the agent would answer the code itself. More on webhook signing and retries and ZeroSettle's API security.

Limits and consent for phone verification

Phone verification is inbound only, so it carries no new-contact cost and no per-message fee on the flat plan. ZeroSettle's new-contact limit (5 a day per number on Pro, 50 on Growth) counts only handles the line messages first, and anyone who texted the line first is never limited.

ZeroSettle limits applied to inbound verification (API docs, checked October 8, 2026)
ItemWhat applies
Verification texts receivedUnlimited (counted), no per-message fee
New-contact limitDoes not apply: the user texts first
WebhookAbout 1–2 s after the line receives the text; 10 s timeout; retries after 1, 5 and 20 s
Code (docs' reference implementation, which you run)6 characters from a 32-character alphabet, single-use, 10 minutes
Sends during verificationThe optional 👍 only; one send at a time per line, 429 line_busy after about 12 s queued

Verification sends nothing to anyone who has not just texted you, so there is no outreach to collect consent for. A verified number is generally not consent for anything else: the docs' compliance rules say a person must give you their number for the purpose you text them about, so plan on a separate opt-in before marketing to it. Rules vary by country and use; this isn't legal advice, so check with counsel. A user who once texted STOP can still verify, but the 👍 to them is refused with 403 recipient_opted_out, so the code treats it as optional.

When ZeroSettle is not a fit for phone verification

ZeroSettle is the wrong verification channel in these cases:

  • Most users are on Android. There is no SMS fallback, so they stay on outbound OTP and the pumping exposure stays with them. See iMessage vs SMS vs RCS vs WhatsApp.
  • You need verification by voice call. ZeroSettle carries iMessages, not calls.
  • A regulator or partner requires a specific OTP vendor. The contract decides, not the cost.
  • You can't change signup to a "tap Send" step. The user has to leave your form for Messages and come back.
  • Every account must have a phone number. From an iMessage-only device, such as an iPad with no number, the handle is an email address.
  • Signup needs a contractual SLA. ZeroSettle shows "99.9% uptime" on its homepage but publishes no SLA, so keep the expiry fallback.

How much does iMessage phone verification cost?

Inbound phone verification costs the ZeroSettle plan price and nothing per message: messages are unlimited (counted) on both plans.

ZeroSettle plans (zerosettle.io and API docs, checked October 8, 2026)
PlanPriceWhat you get
Pro$49/monthOne number, unlimited messages with people who message you first, first messages to 5 new contacts a day, no setup fee, 30-day money-back guarantee, self-serve
GrowthFrom $599/moSeveral numbers working as one, dedicated support, after a demo

Compare against your provider's published per-verification price. As of October 2026, per its public pricing page, Twilio Verify lists "$0.05 per successful verification + $0.0083 per SMS (US)" and charges the SMS fee on every attempt, so $49 a month equals about 840 completed US verifications, and each pumped attempt that Fraud Guard misses still costs its SMS fee. Per Twilio's US SMS pricing page, sending your own codes through its standard US SMS costs $0.0083 per 160-character segment plus a carrier fee ($0.005 to Verizon on a long code) and $0.001 per failed message. Your Android fallback keeps part of that bill. See ZeroSettle vs Twilio and ZeroSettle's pricing, then get a number or read the API docs.

Frequently asked questions

Does inbound verification stop all SMS fraud?

No. It removes outbound sends to unverified iMessage users, but the Android fallback can still be pumped. Twilio's fraud guide recommends enabling Verify SMS Fraud Guard, disabling countries you do not send to, and rate limits by user, IP or device.

Is a texted-in code as secure as an SMS code?

It proves possession of the iMessage handle, the same guarantee SMS OTP gives, and the handle comes from Apple rather than from what the user typed. Return the result only to the caller holding the attempt id, so a stranger who sees the code gets nothing.

Can attackers flood my number with codes?

They can send texts, but each code is single-use and expires, junk gets no reply, and inbound carries no per-message fee. Rate-limit attempt creation so attempts can't pile up in your store.

Does the user still type a phone number?

No. The verified number is the handle on the incoming message, so show it back to the user. If your form already collected a number, flag a mismatch instead of replacing it silently.

Sources

  1. ZeroSettle iMessage API docs: "Sign in with iMessage" (including its security checklist), "AI agent", "Webhooks, step by step" and "Errors, limits, and compliance". Checked October 8, 2026.
  2. What is SMS Pumping Fraud?, Twilio Docs glossary. Checked October 8, 2026.
  3. Preventing Fraud in Verify, Twilio Docs. Checked October 8, 2026.
  4. Verify Pricing, Twilio. Checked October 8, 2026.
  5. SMS Pricing in United States for Text Messaging, Twilio. Checked October 8, 2026.

Written by the ZeroSettle team. ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you, that your code texts customers from and hears back on through a bearer token and signed webhooks.

Product facts on this page come from the ZeroSettle iMessage API docs as of October 8, 2026; plan limits were updated October 12, 2026. Nothing here is legal advice. Twilio figures come from Twilio's own pricing and documentation pages, checked the same day.

Text your customers from your own iMessage number

ZeroSettle gives your business its own phone number on real iMessage, running on Apple hardware we operate for you in a server facility. Your code sends with one API call and hears every reply on a signed webhook.

Pro is $49 a month with a 30-day money-back guarantee. Growth, for several numbers working as one, is from $599/mo.