SMS API w Rust: reqwest, serde i axum
Wysyłka SMS z Rusta to jedno żądanie POST /v1/messages. Poniżej kompletny klient na reqwest w wersji asynchronicznej i blokującej, struct Message z serde, enum błędów zbudowany na thiserror, ponawianie 429/5xx z nagłówkiem Idempotency-Key oraz handler webhooka w axum z weryfikacją HMAC w stałym czasie (hmac + sha2).
Instalacja i wymagania
Potrzebujesz stabilnego toolchaina Rusta, kilku popularnych crate’ów i klucza API. Cały kod z tej strony to jeden projekt Cargo.
- Rust 1.75 lub nowszy —
async fnw traitach iaxum 0.7wymagają współczesnego kompilatora;rustup update stablewystarczy. - Klucz API
pk_live_…lub testowypk_test_…z panelu app.przypominamy.com. Konto testowe ma od razu 25 SMS-ów gratis na 2 własne numery. - Zmienna środowiskowa
PRZYPOMINAMY_API_KEYczytana przezstd::env::var— klucz nigdy nie trafia do repozytorium.
[package]
name = "sms"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.12", features = ["json", "blocking"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
# tylko dla webhooka
axum = "0.7"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
API to zwykły REST z JSON-em: nagłówek Authorization: Bearer, odpowiedź 201 z obiektem wiadomości, błędy w polu error z kodem, komunikatem i nazwą parametru. Feature json w reqwest daje .json(&body) i .json::<T>(), feature blocking wersję synchroniczną dla skryptów i narzędzi CLI.
Pierwszy SMS: struct Message i klient
Najpierw typy: Message odwzorowuje odpowiedź API, SendRequest ciało żądania, a PrzypominamyError zbiera błędy HTTP, JSON i API w jeden enum, na którym można dopasowywać wzorce.
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Obiekt wiadomości zwracany przez API (HTTP 201) i w webhookach.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: String,
pub status: String,
pub to: String,
pub from: String,
pub text: String,
pub parts: u32,
pub cost_grosze: u32,
pub reference: Option<String>,
pub send_at: Option<String>,
pub delivered_at: Option<String>,
pub error: Option<String>,
pub created_at: String,
pub updated_at: String,
}
/// Pole `to`: jeden numer albo tablica do 500 numerów.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum Recipients {
One(String),
Many(Vec<String>),
}
/// Ciało POST /v1/messages.
#[derive(Debug, Clone, Serialize)]
pub struct SendRequest {
pub to: Recipients,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
}
/// Kształt błędu API: { "error": { code, message, param }, "request_id" }.
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorEnvelope {
pub error: ErrorBody,
pub request_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ErrorBody {
pub code: String,
pub message: String,
pub param: Option<String>,
}
#[derive(Debug, Error)]
pub enum PrzypominamyError {
#[error("API {code} ({status}): {message}")]
Api {
status: u16,
code: String,
message: String,
param: Option<String>,
request_id: Option<String>,
/// sekundy z nagłówka Retry-After (tylko przy 429)
retry_after: Option<u64>,
},
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
Klient asynchroniczny trzyma reqwest::Client (wewnątrz jest Arc, więc klonowanie jest tanie) i klucz. Metoda send przyjmuje opcjonalny Idempotency-Key i zwraca Result<Message, PrzypominamyError>. Odpowiedzi z kodem 4xx/5xx są parsowane do wariantu Api razem z Retry-After.
use std::time::Duration;
use reqwest::header::RETRY_AFTER;
use serde::de::DeserializeOwned;
use crate::types::{ErrorEnvelope, Message, PrzypominamyError, Recipients, SendRequest};
pub const BASE_URL: &str = "https://api.przypominamy.com/v1";
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
api_key: String,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("reqwest client");
Self { http, api_key: api_key.into() }
}
/// Wysyła jeden SMS. `idempotency_key` może być None.
pub async fn send(
&self,
req: &SendRequest,
idempotency_key: Option<&str>,
) -> Result<Message, PrzypominamyError> {
self.post("/messages", req, idempotency_key).await
}
async fn post<B: serde::Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
idempotency_key: Option<&str>,
) -> Result<T, PrzypominamyError> {
let mut builder = self
.http
.post(format!("{BASE_URL}{path}"))
.bearer_auth(&self.api_key)
.header("Accept", "application/json")
.json(body);
if let Some(key) = idempotency_key {
builder = builder.header("Idempotency-Key", key);
}
let resp = builder.send().await?;
let status = resp.status();
if status.is_success() {
return Ok(resp.json::<T>().await?);
}
let retry_after = resp
.headers()
.get(RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let text = resp.text().await?;
let (code, message, param, request_id) = match serde_json::from_str::<ErrorEnvelope>(&text) {
Ok(env) => (env.error.code, env.error.message, env.error.param, env.request_id),
Err(_) => (format!("http_{}", status.as_u16()), text, None, None),
};
Err(PrzypominamyError::Api {
status: status.as_u16(),
code,
message,
param,
request_id,
retry_after,
})
}
}
impl SendRequest {
pub fn single(to: &str, text: &str) -> Self {
Self {
to: Recipients::One(to.to_string()),
text: text.to_string(),
from: None,
send_at: None,
reference: None,
}
}
}
Program główny: jeden SMS z przypomnieniem, identyfikator wizyty jako reference i klucz idempotencji zbudowany z tego samego identyfikatora.
mod client;
mod types;
use client::Client;
use types::SendRequest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("PRZYPOMINAMY_API_KEY")?;
let client = Client::new(api_key);
let mut req = SendRequest::single("+48600123456", "Przypominamy o wizycie jutro o 14:00.");
req.reference = Some("wizyta-4521".into());
let msg = client.send(&req, Some("wizyta-4521-przypomnienie")).await?;
println!("{} {} {} {}", msg.id, msg.status, msg.parts, msg.cost_grosze); // msg_… queued 1 15
Ok(())
}
Odpowiedź 201 zawiera parts i cost_grosze: SMS bez polskich znaków to 160 znaków na część (153 przy sklejaniu, GSM-7), z polskimi znakami 70 (67) w UCS-2. Pole send_at w formacie ISO 8601 planuje wysyłkę, from ustawia nadpis z listy zatwierdzonych w panelu.
Wersja blokująca (skrypty, CLI, cron)
Gdy nie chcesz wciągać tokio, ta sama logika działa na reqwest::blocking::Client. Uwaga: klienta blokującego nie wolno tworzyć wewnątrz runtime’u tokio — w serwerze async używaj wersji powyżej.
use std::time::Duration;
use crate::types::{ErrorEnvelope, Message, PrzypominamyError, SendRequest};
pub struct BlockingClient {
http: reqwest::blocking::Client,
api_key: String,
}
impl BlockingClient {
pub fn new(api_key: impl Into<String>) -> Self {
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("reqwest client");
Self { http, api_key: api_key.into() }
}
pub fn send_sms(&self, req: &SendRequest, idempotency_key: Option<&str>) -> Result<Message, PrzypominamyError> {
let mut builder = self
.http
.post(format!("{}/messages", crate::client::BASE_URL))
.bearer_auth(&self.api_key)
.json(req);
if let Some(key) = idempotency_key {
builder = builder.header("Idempotency-Key", key);
}
let resp = builder.send()?;
let status = resp.status();
if status.is_success() {
return Ok(resp.json::<Message>()?);
}
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let text = resp.text()?;
let env: Option<ErrorEnvelope> = serde_json::from_str(&text).ok();
Err(PrzypominamyError::Api {
status: status.as_u16(),
code: env.as_ref().map(|e| e.error.code.clone()).unwrap_or_else(|| format!("http_{}", status.as_u16())),
message: env.as_ref().map(|e| e.error.message.clone()).unwrap_or(text),
param: env.as_ref().and_then(|e| e.error.param.clone()),
request_id: env.and_then(|e| e.request_id),
retry_after,
})
}
}
Wielu odbiorców
Ta sama treść do wielu numerów to jedno żądanie: wariant Recipients::Many przyjmuje do 500 numerów. Liczy się jako jedno żądanie w limicie 120/min, a w odpowiedzi dostajesz Vec<Message>, po jednym obiekcie na numer.
impl Client {
/// Jedna treść do wielu numerów (do 500 w żądaniu).
pub async fn send_many(
&self,
to: Vec<String>,
text: &str,
from: Option<&str>,
idempotency_key: Option<&str>,
) -> Result<Vec<Message>, PrzypominamyError> {
assert!(to.len() <= 500, "max 500 odbiorców w jednym żądaniu");
let req = SendRequest {
to: Recipients::Many(to),
text: text.to_string(),
from: from.map(str::to_string),
send_at: None,
reference: None,
};
self.post("/messages", &req, idempotency_key).await
}
}
// Użycie:
// let msgs = client.send_many(numery, "Promocja -20% do niedzieli. Kod: SMS20", Some("SKLEP"), Some("promo-2026-09")).await?;
// for m in &msgs { println!("{} {}", m.to, m.status); }
Spersonalizowane treści (imię, godzina) idą osobnymi żądaniami. Przy większej liczbie ogranicz równoległość przez tokio::sync::Semaphore i JoinSet, tak by nie przekroczyć 120 żądań na minutę, i nadawaj Idempotency-Key per wiadomość — wtedy ponowienie po błędzie sieci nie zdubluje SMS-a.
use std::sync::Arc;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use crate::client::Client;
use crate::types::SendRequest;
pub struct Wizyta {
pub id: u64,
pub telefon: String,
pub imie: String,
pub godzina: String,
}
pub async fn send_reminders(client: Client, wizyty: Vec<Wizyta>) {
let sem = Arc::new(Semaphore::new(8)); // max 8 równoległych żądań
let mut set = JoinSet::new();
for w in wizyty {
let client = client.clone();
let sem = Arc::clone(&sem);
set.spawn(async move {
let _permit = sem.acquire_owned().await.expect("semaphore");
let mut req = SendRequest::single(
&w.telefon,
&format!("Cześć {}, wizyta jutro o {}.", w.imie, w.godzina),
);
req.reference = Some(format!("wizyta-{}", w.id));
let key = format!("wizyta-{}-przypomnienie", w.id);
(w.id, client.send(&req, Some(&key)).await)
});
}
while let Some(joined) = set.join_next().await {
match joined {
Ok((id, Ok(msg))) => println!("wizyta {id}: {} {}", msg.id, msg.status),
Ok((id, Err(e))) => eprintln!("wizyta {id}: {e}"),
Err(e) => eprintln!("task panicked: {e}"),
}
}
}
Obsługa błędów i retry
Każdy błąd API to wariant PrzypominamyError::Api; decyzję podejmuj po polu code, nie po tekście komunikatu. Ponawiaj tylko to, co ma sens ponawiać — z tym samym Idempotency-Key.
| HTTP | error.code | Co robić |
|---|---|---|
| 400 | invalid_request | Popraw payload; pole w param. Nie ponawiaj. |
| 401 | unauthorized | Sprawdź klucz w zmiennej środowiskowej. Nie ponawiaj. |
| 402 | insufficient_funds | Doładuj saldo (GET /v1/account). Nie ponawiaj automatycznie. |
| 429 | rate_limited | Odczekaj Retry-After sekund i ponów. |
| 502 | provider_error | Retry z rosnącym odstępem, ten sam Idempotency-Key. |
use std::time::Duration;
use tokio::time::sleep;
use crate::client::Client;
use crate::types::{Message, PrzypominamyError, SendRequest};
/// Ponawia 429, 5xx i błędy sieci (max `attempts` prób), zawsze z tym samym
/// kluczem idempotencji, więc po timeoucie SMS nie zostanie wysłany dwa razy.
pub async fn send_with_retry(
client: &Client,
req: &SendRequest,
idempotency_key: &str,
attempts: u32,
) -> Result<Message, PrzypominamyError> {
let mut attempt = 0;
loop {
match client.send(req, Some(idempotency_key)).await {
Ok(msg) => return Ok(msg),
Err(err) => {
attempt += 1;
let wait = match &err {
PrzypominamyError::Api { code, retry_after, .. } if code == "rate_limited" => {
Duration::from_secs(retry_after.unwrap_or(backoff_secs(attempt)))
}
PrzypominamyError::Api { status, .. } if *status >= 500 => {
Duration::from_secs(backoff_secs(attempt))
}
PrzypominamyError::Http(e) if e.is_timeout() || e.is_connect() => {
Duration::from_secs(backoff_secs(attempt))
}
// invalid_request, unauthorized, insufficient_funds, błędy JSON: nie ponawiaj
_ => return Err(err),
};
if attempt >= attempts {
return Err(err);
}
sleep(wait).await;
}
}
}
}
fn backoff_secs(attempt: u32) -> u64 {
1u64 << (attempt - 1) // 1s, 2s, 4s…
}
Po stronie wywołującego dopasuj wzorzec: Err(PrzypominamyError::Api { code, request_id, .. }) i match code.as_str(). Loguj request_id — to identyfikator, po którym support znajdzie żądanie. Dzięki #[error(transparent)] i #[from] operator ? sam opakowuje błędy reqwest i serde_json.
Webhook: weryfikacja podpisu
Adres ustawiasz przez PUT /v1/account/webhook, w odpowiedzi dostajesz webhook_secret. Każde zdarzenie message.sent, message.delivered, message.undelivered, message.failed i message.expired przychodzi jako POST z JSON-em { id, type, created_at, data: { message } } i nagłówkiem X-Przypominamy-Signature: t=<unix>,v1=<hex>, gdzie v1 = HMAC-SHA256(secret, "<t>.<body>"). Odrzucaj, gdy podpis się nie zgadza lub t różni się od zegara o więcej niż 300 s.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::Router;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Deserialize)]
struct Event {
id: String,
#[serde(rename = "type")]
kind: String,
created_at: String,
data: EventData,
}
#[derive(Debug, Deserialize)]
struct EventData {
message: WebhookMessage,
}
#[derive(Debug, Deserialize)]
struct WebhookMessage {
id: String,
status: String,
to: String,
reference: Option<String>,
error: Option<String>,
}
/// Sprawdza nagłówek t=<unix>,v1=<hex> względem surowego body.
fn verify_signature(header: &str, body: &[u8], secret: &str, tolerance_secs: u64) -> bool {
let mut t: Option<&str> = None;
let mut sig: Option<&str> = None;
for part in header.split(',') {
match part.trim().split_once('=') {
Some(("t", v)) => t = Some(v),
Some(("v1", v)) => sig = Some(v),
_ => {}
}
}
let (Some(t), Some(sig)) = (t, sig) else { return false };
let Ok(ts) = t.parse::<u64>() else { return false };
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
if now.abs_diff(ts) > tolerance_secs {
return false;
}
let Ok(expected) = hex::decode(sig) else { return false };
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC przyjmuje klucz dowolnej długości");
mac.update(t.as_bytes());
mac.update(b".");
mac.update(body);
mac.verify_slice(&expected).is_ok() // porównanie w stałym czasie
}
async fn handler(State(secret): State<Arc<String>>, headers: HeaderMap, body: Bytes) -> StatusCode {
let header = headers
.get("x-przypominamy-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !verify_signature(header, &body, &secret, 300) {
return StatusCode::FORBIDDEN;
}
let event: Event = match serde_json::from_slice(&body) {
Ok(e) => e,
Err(_) => return StatusCode::BAD_REQUEST,
};
// Odpowiedz 200 od razu, ciężką pracę zrób w tle.
tokio::spawn(async move {
let m = &event.data.message;
println!("{} {} -> {} (ref={:?})", event.kind, m.id, m.status, m.reference);
// db.update_status(&m.id, &m.status).await;
});
StatusCode::OK
}
#[tokio::main]
async fn main() {
let secret = Arc::new(std::env::var("PRZYPOMINAMY_WEBHOOK_SECRET").expect("PRZYPOMINAMY_WEBHOOK_SECRET"));
let app = Router::new()
.route("/webhooks/sms", post(handler))
.with_state(secret);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Trzy rzeczy, które najczęściej psują weryfikację: liczenie HMAC na sparsowanym i ponownie zserializowanym JSON-ie (zawsze używaj surowych bajtów, stąd ekstraktor Bytes, nie Json<T>), porównanie == na wektorach zamiast verify_slice oraz brak tolerancji czasu. Webhooki mogą przyjść ponownie, jeśli endpoint nie odpowie 2xx, więc obsłuż duplikaty po id zdarzenia. Kolejność ekstraktorów ma znaczenie: Bytes konsumuje body, więc musi być ostatni.
Najlepsze praktyki
- Jeden
reqwest::Clientna proces. Wewnątrz trzyma pulę połączeń zaArc; klonuj go do tasków zamiast budować nowy. Zawsze ustawiajtimeoutw builderze. Idempotency-Key= identyfikator biznesowy (np.order-1234-confirm). Retry po timeoucie zwróci pierwotną odpowiedź zamiast wysłać drugi SMS.referencedo korelacji. Wraca w webhookach i wGET /v1/messages, więc łączysz status z rekordem w swojej bazie bez osobnej tabeli mapującej.- Typowane błędy zamiast
Box<dyn Error>w bibliotece.thiserrordaje warianty, na których wywołujący może dopasować wzorzec;anyhowzostaw dla binarki. - Nie loguj treści ani numerów (RODO). Loguj
id,status,request_id. StructMessageimplementujeDebug, więc nie wypisuj go w całości na produkcji. - Sprawdzaj saldo przez
GET /v1/accountw cronie i alarmuj poniżej progu — 402insufficient_fundsw środku kampanii to najgorszy moment na doładowanie. - Testuj na koncie testowym. Klucz
pk_test_…wysyła prawdziwe SMS-y na 2 zweryfikowane numery, więc przetestujesz kodowanie polskich znaków i webhooki bez kosztów.
Częste pytania: SMS API i Rust
Czy do wysyłki SMS w Rust potrzebuję dedykowanego SDK?
Nie. reqwest z feature json i serde wystarczą: jeden POST na /v1/messages z nagłówkiem Authorization: Bearer. Klient z tej strony to trzy niewielkie pliki bez makr proceduralnych poza derive z serde i thiserror.
Jak obsłużyć limit 120 żądań na minutę w Rust?
Przy HTTP 429 API zwraca error.code rate_limited i nagłówek Retry-After w sekundach. Odczekaj tyle przez tokio::time::sleep i ponów z tym samym Idempotency-Key. Przy wysyłce tej samej treści do wielu numerów użyj Recipients::Many (do 500 numerów) — to jedno żądanie. Spersonalizowane wiadomości wysyłaj z Semaphore ograniczającym równoległość.
Jak zweryfikować podpis webhooka w axum?
Użyj ekstraktora Bytes zamiast Json, żeby dostać surowe body. Wyciągnij t i v1 z nagłówka X-Przypominamy-Signature, policz HMAC-SHA256 z kluczem webhook_secret nad ciągiem "<t>.<body>" przez crate hmac i sha2, a porównaj metodą verify_slice, która działa w stałym czasie. Odrzuć, gdy t różni się od czasu serwera o więcej niż 300 sekund.
Czy mogę użyć klienta blokującego reqwest w serwerze async?
Nie wewnątrz runtime’u tokio: reqwest::blocking panikuje, gdy jest tworzony w kontekście asynchronicznym. Klient blokujący jest dla skryptów, narzędzi CLI i zadań cron; w axum, actix czy innym serwerze async używaj reqwest::Client i async fn send.
Ile kosztuje wysyłka SMS przez API z Rusta?
Od 0,10 zł za część SMS (stawkę ustala kwota doładowania: 0,15 zł przy 50 zł, 0,10 zł od 500 zł, i zostaje na stałe), bez abonamentu i opłat za API. Wiadomość bez polskich znaków mieści 160 znaków w jednej części (153 przy wieloczęściowej), z polskimi znakami 70 (67). Liczbę części i koszt zwraca odpowiedź w polach parts i cost_grosze. Konto testowe ma 25 SMS-ów gratis. Szczegóły w cenniku.
Pierwszy SMS z Rusta w kwadrans
Rejestracja daje od razu klucz testowy i 25 darmowych SMS-ów na dwa własne numery. Bez karty, bez abonamentu: płacisz od 0,10 zł za część SMS, doładowanie od 50 zł.
Załóż kontoInne języki: Go · Java · C# / .NET · Ruby · Kotlin · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.