invoist 1.0.1

Official Rust client for the Invoist API: extract invoices, read a household's bills, verify webhooks.
Documentation

Invoist for Rust

crates.io docs.rs

The official Rust client for the Invoist API. Send an invoice, receipt or payment confirmation and get clean, structured data back; read a household's bills; check that a webhook really came from Invoist.

  • Async on reqwest and Tokio, with an optional blocking client.
  • Types for every request and response, generated from the API's OpenAPI document. Money is always an i64 in cents, never a float.
  • One error type, safe retries, and webhook verification built in.

Requires Rust 1.88 or newer.

Install

cargo add invoist

TLS uses rustls by default, so there is no OpenSSL to install. See Features for the alternatives.

A business workspace

Upload a document and wait for what Invoist reads from it:

use invoist::{Client, UploadFile, WaitOptions};

#[tokio::main]
async fn main() -> Result<(), invoist::Error> {
    let client = Client::new(std::env::var("INVOIST_API_KEY").unwrap_or_default())?;
    let file = UploadFile::from_path("invoice.pdf").expect("readable file");
    let done = client.extract_and_wait(file, WaitOptions::default()).await?;
    println!("{:?} for {:?} cents", done.extraction.vendor_name, done.extraction.total_cents);
    Ok(())
}

Reading happens in the background. extract returns at once with the extraction pending; wait_for_extraction polls every 2 seconds for up to 2 minutes, and returns the extraction whether it is done or failed (a failed reading is an answer, with error_message saying why). Uploading the same bytes twice returns the first document with duplicate: true and isn't billed again.

The other business calls: extract_batch, list_documents, get_document, get_document_file, list_review_queue, resolve_review_item, list_categories, get_usage, get_stats, list_webhook_endpoints, create_webhook_endpoint and delete_webhook_endpoint.

A household

A household's key (Invoist Personal) reads its bills:

use invoist::{Client, ListBillsParams};

#[tokio::main]
async fn main() -> Result<(), invoist::Error> {
    let client = Client::from_env()?; // INVOIST_API_KEY, and INVOIST_BASE_URL if set
    let unpaid = client
        .bills()
        .list(ListBillsParams { paid: Some(false), ..Default::default() })
        .await?;
    for bill in &unpaid {
        println!("{:?}: {:?} cents, due {:?}", bill.vendor_name, bill.total_cents, bill.due_date);
    }
    Ok(())
}

Also on client.bills(): get, get_file, summary, stats, mark_paid and mark_unpaid; and client.held_emails().list() for emails waiting for a person to approve their sender. Months are invoist::Month (Month::new(2026, 10)? or "2026-10".parse()?).

Configuration

use std::time::Duration;

let client = invoist::Client::builder()
    .api_key("inv_sk_...")
    .base_url("https://api.invoi.st") // the default
    .timeout(Duration::from_secs(60)) // per attempt; the default
    .max_retries(2)                   // the default
    .build()?;
# Ok::<(), invoist::Error>(())

Bring your own reqwest::Client (for a proxy, custom certificates, or tests) with .http_client(...); invoist::reqwest re-exports the matching version. A Client is cheap to clone and safe to share: build one and reuse it.

Errors

Every failure is an invoist::Error:

# async fn run(client: invoist::Client) -> Result<(), invoist::Error> {
match client.bills().mark_paid("01a19812-0000-7000-8000-000000000001").await {
    Ok(record) => println!("paid at {:?}", record.paid_at),
    Err(e) if e.is_permission_error() => println!("this key may not mark bills paid"),
    Err(e) if e.is_not_found() => println!("no such bill"),
    Err(e) => eprintln!("{} (code {}, request {:?})", e.message(), e.code(), e.request_id()),
}
# Ok(())
# }
  • status(): the HTTP status, or 0 when no response arrived.
  • code(): a stable machine code such as invalid_api_key, insufficient_scope, not_found, rate_limited, network_error, timeout or unexpected_response. Branch on this, never on the message.
  • message(), request_id() (quote it to support) and body() (the raw response).
  • is_auth_error() (401), is_permission_error() (403), is_not_found() (404), is_rate_limited() (429), is_timeout(), is_retryable().

Retries

A request that got no response, a 429, or a 5xx is retried up to twice. The client waits what Retry-After asks for, otherwise 500ms, then 1s (each ±20%, never more than 8s). A Retry-After above 60 seconds isn't waited for: the call fails at once with the 429 so your program can decide. Any other 4xx is never retried. Uploads retry safely: the API recognises the same bytes, and ExtractOptions::idempotency_key covers a re-encoded file.

To cancel a call, drop its future, for instance with tokio::time::timeout. A cancelled call is never retried.

Webhooks

Verify every delivery before trusting it. Pass the raw request body: parsing the JSON and serialising it again changes the bytes, and the signature no longer matches. With axum, take the body as Bytes:

use axum::{Router, body::Bytes, http::{HeaderMap, StatusCode}, routing::post};
use invoist::webhooks::{self, events};

async fn invoist_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let secret = std::env::var("INVOIST_WEBHOOK_SECRET").unwrap_or_default();
    let signature = headers
        .get(webhooks::SIGNATURE_HEADER)
        .and_then(|v| v.to_str().ok())
        .unwrap_or_default();
    let event = match webhooks::verify(&body, signature, &secret) {
        Ok(event) => event,
        Err(_) => return StatusCode::BAD_REQUEST,
    };
    if event.event_type == events::EXTRACTION_COMPLETED {
        println!("document {} is read", event.data["document_id"]);
    }
    StatusCode::NO_CONTENT
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let app = Router::new().route("/hooks/invoist", post(invoist_webhook));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await
}

verify allows 5 minutes of clock difference either way; webhooks::verify_with takes VerifyOptions to change that or to fix the clock in tests, and webhooks::sign builds a valid header for your own handler's tests. A failed check is an Error with status 400 and code invalid_signature, signature_too_old or invalid_payload. The X-Invoist-Delivery header (webhooks::DELIVERY_HEADER) stays the same when a delivery is retried, so use it to skip repeats. The event types are constants in webhooks::events.

Blocking

With the blocking feature, invoist::blocking::Client has the same methods without async, for programs with no runtime:

let client = invoist::blocking::Client::new("inv_sk_...")?;
let summary = client.bills().summary(Default::default())?;

Don't use it from inside an async runtime; use the async client there.

Features

Feature Default What it does
rustls-tls yes TLS with rustls and the platform's certificate verifier. No OpenSSL.
native-tls no TLS with the platform's library (OpenSSL on Linux) instead.
http2 no HTTP/2 when the server offers it.
blocking no invoist::blocking::Client.

Links

License

MIT. See LICENSE.