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.
[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"] }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.
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(()) }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",
));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.
- Currency is TZS only. Amounts are integers in the smallest currency unit;
500means 500 TZS, not 5.00. - Minimum amounts: 500 TZS for payments, 5,000 TZS for payouts.
- Phone numbers must be
255XXXXXXXXXor+255XXXXXXXXX. Local formats like0781000000are 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 crypticPAY_001error. - Webhook payloads have
data.amountas{value, currency}, not a plain integer like request bodies. TheMoneytype models this correctly. - Webhook URLs must be HTTPS and ≤ 500 characters.
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.
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>.
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.
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();
# }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.
MIT — see LICENSE.