SMS API · Rust

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).

reqwest 0.12 + serdeasync i blockingKlucz pk_test_… od razu po rejestracji

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 nowszyasync fn w traitach i axum 0.7 wymagają współczesnego kompilatora; rustup update stable wystarczy.
  • Klucz API pk_live_… lub testowy pk_test_… z panelu app.przypominamy.com. Konto testowe ma od razu 25 SMS-ów gratis na 2 własne numery.
  • Zmienna środowiskowa PRZYPOMINAMY_API_KEY czytana przez std::env::var — klucz nigdy nie trafia do repozytorium.
ZależnościCargo.toml
[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.

Typy i błędysrc/types.rs
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.

Klient asynchronicznysrc/client.rs
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.

Wysyłka SMSsrc/main.rs
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.

Klient blokującysrc/blocking.rs
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.

Wysyłka masowasrc/client.rs (ciąg dalszy)
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.

Ograniczona równoległośćsrc/personalized.rs
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.

HTTPerror.codeCo robić
400invalid_requestPopraw payload; pole w param. Nie ponawiaj.
401unauthorizedSprawdź klucz w zmiennej środowiskowej. Nie ponawiaj.
402insufficient_fundsDoładuj saldo (GET /v1/account). Nie ponawiaj automatycznie.
429rate_limitedOdczekaj Retry-After sekund i ponów.
502provider_errorRetry z rosnącym odstępem, ten sam Idempotency-Key.
Retry z Idempotency-Keysrc/retry.rs
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.

Handler webhookasrc/bin/webhook.rs
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::Client na proces. Wewnątrz trzyma pulę połączeń za Arc; klonuj go do tasków zamiast budować nowy. Zawsze ustawiaj timeout w builderze.
  • Idempotency-Key = identyfikator biznesowy (np. order-1234-confirm). Retry po timeoucie zwróci pierwotną odpowiedź zamiast wysłać drugi SMS.
  • reference do korelacji. Wraca w webhookach i w GET /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. thiserror daje warianty, na których wywołujący może dopasować wzorzec; anyhow zostaw dla binarki.
  • Nie loguj treści ani numerów (RODO). Loguj id, status, request_id. Struct Message implementuje Debug, więc nie wypisuj go w całości na produkcji.
  • Sprawdzaj saldo przez GET /v1/account w cronie i alarmuj poniżej progu — 402 insufficient_funds w ś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łóż konto

Inne języki: Go · Java · C# / .NET · Ruby · Kotlin · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.

Przypominamy.com – logo platformy SMS, MMS i IVR
Asystent Przypominamy ● Online. Odpowiada od razu
Cześć! 👋 Jestem asystentem platformy Przypominamy.com.

Pomogę Ci dobrać plan, wyjaśnię jak działa API lub odpowiem na pytania o SMS, MMS i IVR. Jak mogę pomóc?
teraz