snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation

snippe

crates.io docs.rs

Async Rust client for Snippe — payments for Tanzania.

The SDK targets the 2026-01-25 API version and covers the full surface:

  • Collections — mobile money (M-Pesa, Airtel, Mixx, Halotel), card payments via the hosted Selcom checkout, dynamic QR.
  • Hosted sessions — pre-built mobile-optimised checkout pages and short payment links for SMS / WhatsApp distribution.
  • Disbursements — payouts to mobile wallets and 25+ Tanzanian banks (CRDB, NMB, NBC, ABSA, Equity, KCB, Stanbic, …).
  • Webhooks — HMAC-SHA256 signature verification with replay protection and typed event dispatch.

Install

[dependencies]
snippe = "0.1"
tokio = { version = "1", features = ["full"] }

Or with cargo add snippe.

The crate compiles with rustls-tls by default. Switch to OpenSSL / native-tls with:

snippe = { version = "0.1", default-features = false, features = ["native-tls"] }

Quick start

use snippe::models::common::Customer;
use snippe::models::payment::{CreatePaymentRequest, MobilePayment};
use snippe::{Client, IdempotencyKey};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new(std::env::var("SNIPPE_API_KEY")?)?;

    let request = CreatePaymentRequest::Mobile(
        MobilePayment::new(
            500,
            "255781000000",
            Customer::new("Jane", "Doe", "jane@example.com"),
        )
        .with_webhook_url("https://yoursite.com/webhooks/snippe"),
    );

    let key = IdempotencyKey::new("ord-12345-att-1")?;
    let payment = client.payments().create(&request, Some(&key)).await?;

    println!("created payment {} (status {:?})", payment.reference, payment.status);
    Ok(())
}

The customer's phone receives a USSD push. They enter their mobile-money PIN to authorise. Snippe then POSTs payment.completed (or payment.failed) to your webhook_url.

Hosted checkout sessions

For a pre-built checkout page (great for SMS / WhatsApp distribution):

use snippe::models::session::{AllowedMethod, CreateSessionRequest, SessionCustomer};
use snippe::Client;

# async fn run(client: Client) -> Result<(), snippe::Error> {
let request = CreateSessionRequest::fixed_amount(50_000)
    .with_allowed_methods([AllowedMethod::MobileMoney, AllowedMethod::Qr])
    .with_customer(SessionCustomer::new("John Doe").with_phone("+255712345678"))
    .with_redirect_url("https://yoursite.com/order/12345/success")
    .with_webhook_url("https://yoursite.com/webhooks/snippe")
    .with_description("Order #12345");

let session = client.sessions().create(&request, None).await?;

// Share `payment_link_url` over SMS / WhatsApp — it's the short vanity URL.
println!("{}", session.payment_link_url.unwrap_or(session.checkout_url));
# Ok(()) }

Disbursements

Payouts always need a balance preflight: calculate the fee, confirm available balance, then send.

use snippe::models::payout::{MobilePayout, SendPayoutRequest};
use snippe::{Client, IdempotencyKey};

# async fn run(client: Client) -> Result<(), Box<dyn std::error::Error>> {
let amount = 5_000;

// 1. Fee preflight — total = amount + fee
let fee = client.payouts().fee(amount).await?;

// 2. Balance check — `available` is the spendable amount
let balance = client.payments().balance().await?;
if balance.available.value < fee.total_amount {
    return Err("insufficient balance".into());
}

// 3. Send with an idempotency key (≤ 30 bytes — required for retry safety)
let request = SendPayoutRequest::Mobile(
    MobilePayout::new(amount, "255781000000", "Recipient Name")
        .with_narration("Salary January 2026"),
);
let key = IdempotencyKey::new("po-emp-001-jan26")?;
let payout = client.payouts().send(&request, Some(&key)).await?;
println!("queued payout {}", payout.reference);
# Ok(()) }

Bank transfers swap [MobilePayout] for BankPayout and add a BankCode:

use snippe::models::bank::BankCode;
use snippe::models::payout::{BankPayout, SendPayoutRequest};

let request = SendPayoutRequest::Bank(BankPayout::new(
    10_000,
    BankCode::Crdb,
    "0150000000",
    "Vendor LLC",
));

Webhook verification

Snippe signs webhooks with HMAC-SHA256. Always verify the signature against the raw request bytes — parsing-then-re-serialising the JSON breaks the HMAC. The verifier rejects timestamps older than 5 minutes by default to prevent replays.

use snippe::webhook::{EventData, Verifier};

# fn run(body: &[u8], ts: &str, sig: &str, secret: String) {
let verifier = Verifier::new(secret);
let event = verifier.verify_typed(body, ts, sig).expect("valid webhook");

// Deduplicate by event.id — Snippe may deliver the same event more than once.
match event.data {
    EventData::PaymentCompleted(p) => println!("paid: {}", p.reference),
    EventData::PaymentFailed(p)    => println!("failed: {:?}", p.failure_reason),
    EventData::PayoutCompleted(p)  => println!("payout out: {}", p.reference),
    _ => {}
}
# }

The verifier returns [EventData::Unknown] for event types this SDK version doesn't enumerate, so new server-side events don't break your handler.

For framework integration, read the request body once as raw bytes (axum::body::Bytes, actix_web::web::Bytes, hyper::body::to_bytes, etc.) and pass that slice straight to verify_typed — never round-trip through serde_json::Value.

Critical rules

  • Currency is TZS only. Amounts are integers in the smallest currency unit; 500 means 500 TZS, not 5.00.
  • Minimum amounts: 500 TZS for payments, 5,000 TZS for payouts.
  • Phone numbers must be 255XXXXXXXXX or +255XXXXXXXXX. Local formats like 0781000000 are rejected.
  • Idempotency keys must be ≤ 30 bytes. The SDK enforces this at construction time via [IdempotencyKey] — over-long keys can never reach the wire and trigger the cryptic PAY_001 error.
  • Webhook payloads have data.amount as {value, currency}, not a plain integer like request bodies. The Money type models this correctly.
  • Webhook URLs must be HTTPS and ≤ 500 characters.

Error handling

All API errors come through [Error::Api] carrying a structured [ApiError]:

use snippe::{Error, ErrorCode};

# async fn run(result: snippe::Result<snippe::models::payment::Payment>) {
match result {
    Ok(payment) => println!("ok: {}", payment.reference),
    Err(Error::Api(e)) => match e.error_code {
        ErrorCode::ValidationError => eprintln!("bad request: {}", e.message),
        ErrorCode::Unauthorized    => eprintln!("check API key"),
        ErrorCode::InsufficientScope => eprintln!("API key missing required scope"),
        ErrorCode::RateLimitExceeded => {
            // e.retry_after holds the X-Ratelimit-Reset value in seconds
            eprintln!("rate limited, retry in {:?}s", e.retry_after);
        }
        ErrorCode::Pay001 => {
            // First check idempotency key length; second cause is upstream
            // processor (Selcom) flakiness — retry with backoff.
            eprintln!("PAY_001 — check key length, then retry");
        }
        _ if e.is_retryable() => eprintln!("retry with backoff"),
        _ => eprintln!("permanent: {e}"),
    },
    Err(other) => eprintln!("transport / config error: {other}"),
}
# }

[ApiError::is_retryable] returns true for 5xx, 429, and PAY_001 — the cases where retrying with backoff (and the same idempotency key) is safe and likely to recover.

Examples

The examples/ directory has runnable end-to-end snippets:

  • create_mobile_payment.rs — collect a mobile-money payment.
  • create_session.rs — build a hosted checkout session and print the share link.
  • send_payout.rs — full payout preflight (fee → balance → send).
  • verify_webhook.rs — webhook signature verification outside an HTTP framework.
  • balance.rs — fetch the merchant balance.

Run any of them with SNIPPE_API_KEY=snp_xxx cargo run --example <name>.

Configuration

use std::time::Duration;
use snippe::{Client, Environment};

let client = Client::builder()
    .api_key("snp_test_xxx")
    .environment(Environment::Sandbox)            // or Environment::Production (default)
    .timeout(Duration::from_secs(60))             // default 30s
    .api_version("2026-01-25")                    // pinned via Snippe-Version header
    .user_agent_suffix("acme-checkout/2.3")       // for support correlation
    .build()
    .unwrap();

The base URL can be overridden via base_url(...) for staging environments or wiremock-based tests.

Testing

The crate's own tests use wiremock to stand up a local mock server. Build wiremock-based tests for your application by overriding the base URL:

use wiremock::MockServer;
use snippe::Client;

# async fn make() {
let server = MockServer::start().await;
let client = Client::builder()
    .api_key("snp_test")
    .base_url(server.uri())
    .build()
    .unwrap();
# }

Status

This SDK is at 0.1 — the public surface may evolve in minor releases until 1.0. The wire types use #[non_exhaustive] so new server-side fields and enum variants don't force a major bump.

License

MIT — see LICENSE.