How to Send OTP with FastAPI (2026 Guide)
Step-by-step FastAPI tutorial to send and verify SMS OTPs using StartMessaging. Includes Pydantic models, async httpx, error handling, dependency injection and a complete /auth router.
StartMessaging Team
Engineering
FastAPI’s combination of async-first design, Pydantic validation and dependency-injection container makes it a natural fit for HTTP-based OTP flows. This tutorial walks through a complete /auth router that sends and verifies OTPs via StartMessaging, with full error handling and a basic in-memory rate limiter.
Prerequisites
- Python 3.11+
- A StartMessaging account — sign up and copy your API key.
- Basic familiarity with FastAPI routers and Pydantic.
Project Setup
uv init otp-fastapi
cd otp-fastapi
uv add fastapi uvicorn[standard] httpx pydantic pydantic-settings python-multipartProject layout:
otp-fastapi/
app/
__init__.py
main.py
settings.py
sm_client.py
routers/
__init__.py
auth.py
.envConfiguration with Pydantic Settings
# app/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', env_prefix='SM_')
api_key: str
base_url: str = 'https://api.startmessaging.com'
otp_default_attempts: int = 3
settings = Settings() # type: ignoreAdd to .env:
SM_API_KEY=sm_live_xxxxxxxxxxxxxxxxA Reusable HTTP Client
# app/sm_client.py
from contextlib import asynccontextmanager
import httpx
from app.settings import settings
@asynccontextmanager
async def sm_client():
async with httpx.AsyncClient(
base_url=settings.base_url,
headers={'X-API-Key': settings.api_key, 'Content-Type': 'application/json'},
timeout=httpx.Timeout(10.0, connect=3.0),
) as client:
yield clientPOST /auth/send-otp
# app/routers/auth.py
from uuid import uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.sm_client import sm_client
router = APIRouter(prefix='/auth', tags=['auth'])
class SendOtpIn(BaseModel):
phone_number: str = Field(pattern=r'^\+91\d{10}$')
class SendOtpOut(BaseModel):
request_id: str
expires_at: str
attempts_left: int
@router.post('/send-otp', response_model=SendOtpOut)
async def send_otp(body: SendOtpIn):
payload = {
'phoneNumber': body.phone_number,
'idempotencyKey': str(uuid4()),
}
async with sm_client() as client:
res = await client.post('/otp/send', json=payload)
if res.status_code == 402:
raise HTTPException(503, 'Service temporarily unavailable. Try again shortly.')
if res.status_code >= 400:
detail = res.json().get('message', 'Failed to send OTP')
raise HTTPException(res.status_code, detail)
data = res.json()['data']
return SendOtpOut(
request_id=data['requestId'],
expires_at=data['expiresAt'],
attempts_left=data['attemptsLeft'],
)Notice we generate an idempotency key per request. If FastAPI’s outer client retries on transient failure, the same key is reused and no duplicate SMS is sent. See our idempotency guide.
POST /auth/verify-otp
class VerifyOtpIn(BaseModel):
request_id: str
otp_code: str = Field(pattern=r'^\d{4,8}$')
class VerifyOtpOut(BaseModel):
verified: bool
@router.post('/verify-otp', response_model=VerifyOtpOut)
async def verify_otp(body: VerifyOtpIn):
async with sm_client() as client:
res = await client.post(
'/otp/verify',
json={'requestId': body.request_id, 'otpCode': body.otp_code},
)
if res.status_code == 410:
raise HTTPException(410, 'OTP expired. Please request a new one.')
if res.status_code == 400:
raise HTTPException(400, res.json().get('message', 'Invalid OTP'))
if res.status_code >= 400:
raise HTTPException(res.status_code, 'Verification failed')
return VerifyOtpOut(verified=True)Error Handling
The status codes you will encounter mapped to FastAPI responses:
| StartMessaging | FastAPI response | Meaning |
|---|---|---|
| 400 | 400 Bad Request | Invalid phone or OTP format |
| 401 | 500 Internal | API key issue — surface as generic 500 to user |
| 402 | 503 Service Unavailable | Wallet exhausted; alert ops |
| 410 | 410 Gone | OTP expired |
| 429 | 429 Too Many Requests | Rate-limited |
Rate Limiting
Add a simple per-phone limit to protect your wallet from OTP traffic pumping:
from collections import defaultdict
from time import time
WINDOW = 3600 # 1h
LIMIT = 5
_buckets: dict[str, list[float]] = defaultdict(list)
def assert_under_limit(phone: str) -> None:
now = time()
bucket = [t for t in _buckets[phone] if now - t < WINDOW]
if len(bucket) >= LIMIT:
raise HTTPException(429, 'Too many OTP requests. Try again later.')
bucket.append(now)
_buckets[phone] = bucketFor production, swap the in-memory dict for Redis. Read our rate-limiting guide.
Testing the Endpoints
# tests/test_auth.py
import respx, httpx
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
@respx.mock
def test_send_otp():
respx.post('https://api.startmessaging.com/otp/send').mock(
return_value=httpx.Response(200, json={
'data': {
'requestId': 'req_123',
'expiresAt': '2026-04-26T12:00:00Z',
'attemptsLeft': 3,
}
})
)
r = client.post('/auth/send-otp', json={'phone_number': '+919876543210'})
assert r.status_code == 200
assert r.json()['request_id'] == 'req_123'FAQ
Looking for the same flow in another stack? Django, Flask, Node.js, PHP/Laravel, and many more in our tutorial library. Or jump into the API reference.
Related Articles
Python tutorial to send and verify OTP via SMS using the requests library and StartMessaging API. Includes Flask and Django integration examples.
Send and verify SMS OTPs from Django and Django REST Framework using the StartMessaging API. Includes a service module, DRF views, serializers, and rate limiting.
Send and verify SMS OTPs from a Flask application using the StartMessaging API. Includes app factory, blueprint routes, sessions, and error handling.
Ready to Send OTPs?
Integrate StartMessaging in 5 minutes. No DLT registration required.