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](https://img.shields.io/crates/v/snippe.svg)](https://crates.io/crates/snippe)
[![docs.rs](https://docs.rs/snippe/badge.svg)](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).