# snippe
[](https://crates.io/crates/snippe)
[](https://docs.rs/snippe)
Async Rust client for [Snippe](https://snippe.sh) — 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
```toml
[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:
```toml
snippe = { version = "0.1", default-features = false, features = ["native-tls"] }
```
## Quick start
```rust
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):
```rust
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.
```rust
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`](https://docs.rs/snippe/latest/snippe/models/bank/enum.BankCode.html):
```rust
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.
```rust
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`]:
```rust
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/`](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
```rust
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`](https://docs.rs/wiremock) to stand up a local mock server. Build wiremock-based tests for your application by overriding the base URL:
```rust
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](LICENSE).