Send OTP in Rust with Axum and Reqwest — 2026 Guide
Build a production OTP backend in Rust using Axum and reqwest, calling the StartMessaging API. Includes structs, handlers, error type, and tower-http rate limiting.
StartMessaging Team
Engineering
Rust is a great fit for OTP backends: tiny binary, predictable performance, and no GC pauses while a marketing burst slams your endpoints. This guide builds a complete OTP service on Axum that proxies the StartMessaging OTP API.
Why Rust for OTP Backends
OTP traffic is bursty and latency-sensitive. Rust’s async runtime gives you C-like throughput with memory safety, which means your OTP service can run on a small ARM VM and still handle spikes during sale events.
Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.5", features = ["limit"] }
uuid = { version = "1", features = ["v4"] }
anyhow = "1"StartMessaging Client
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct StartMessaging {
http: Client,
api_key: String,
}
#[derive(Serialize)]
struct SendBody<'a> { phoneNumber: &'a str, idempotencyKey: String }
#[derive(Deserialize)]
pub struct SendData { pub requestId: String, pub expiresAt: String, pub attemptsLeft: u32 }
#[derive(Deserialize)]
struct Envelope<T> { data: T }
impl StartMessaging {
pub fn new(api_key: String) -> Self {
Self { http: Client::new(), api_key }
}
pub async fn send_otp(&self, phone: &str) -> anyhow::Result<SendData> {
let body = SendBody { phoneNumber: phone, idempotencyKey: uuid::Uuid::new_v4().to_string() };
let env: Envelope<SendData> = self.http
.post("https://api.startmessaging.com/otp/send")
.header("X-API-Key", &self.api_key)
.json(&body)
.send().await?
.error_for_status()?
.json().await?;
Ok(env.data)
}
pub async fn verify_otp(&self, request_id: &str, code: &str) -> anyhow::Result<bool> {
#[derive(Serialize)] struct B<'a> { requestId: &'a str, otpCode: &'a str }
#[derive(Deserialize)] struct V { verified: bool }
let env: Envelope<V> = self.http
.post("https://api.startmessaging.com/otp/verify")
.header("X-API-Key", &self.api_key)
.json(&B { requestId: request_id, otpCode: code })
.send().await?
.error_for_status()?
.json().await?;
Ok(env.data.verified)
}
}Axum Handlers
use axum::{routing::post, Json, Router, extract::State};
#[derive(Deserialize)] struct SendReq { phone: String }
#[derive(Serialize)] struct SendRes { request_id: String, expires_at: String }
async fn send_handler(
State(sm): State<StartMessaging>,
Json(req): Json<SendReq>,
) -> Result<Json<SendRes>, String> {
let data = sm.send_otp(&req.phone).await.map_err(|e| e.to_string())?;
Ok(Json(SendRes { request_id: data.requestId, expires_at: data.expiresAt }))
}
#[tokio::main]
async fn main() {
let sm = StartMessaging::new(std::env::var("STARTMESSAGING_API_KEY").unwrap());
let app = Router::new()
.route("/auth/send-otp", post(send_handler))
.with_state(sm);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Error Type
For production, replace String error returns with a thiserror enum that maps each StartMessaging status code to a typed variant. This lets you log errors structurally and return clean JSON to the client.
Rate Limiting
Use tower-governor for IP-based limits and a Redis-backed sliding window keyed by phone for the send endpoint. See our OTP rate limiting guide for the algorithms.
Best Practices
- Reuse one
reqwest::Clientfor the lifetime of the process. - Use rustls-tls to avoid the OpenSSL dependency on minimal containers.
- Add a 10-second timeout on every reqwest call.
- Return verified=false rather than 401 if you want a generic UX.
FAQ
See pricing or compare with the Go version.
Related Articles
Step-by-step Go (Golang) tutorial to send and verify SMS OTPs using the StartMessaging API. Includes net/http examples, structs, error handling, and a complete Gin server.
Step-by-step Node.js tutorial to send and verify OTP via SMS using the StartMessaging API. Includes fetch examples, error handling, and verification flow.
Learn what idempotency keys are, why they matter for OTP APIs, and how to implement them correctly to prevent duplicate SMS charges and improve reliability.
Ready to Send OTPs?
Integrate StartMessaging in 5 minutes. No DLT registration required.