# Invoist for Rust
[](https://crates.io/crates/invoist)
[](https://docs.rs/invoist)
The official Rust client for the [Invoist API](https://invoi.st/docs). 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](https://crates.io/crates/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
```sh
cargo add invoist
```
TLS uses rustls by default, so there is no OpenSSL to install. See
[Features](#features) for the alternatives.
## A business workspace
Upload a document and wait for what Invoist reads from it:
```rust,no_run
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:
```rust,no_run
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
```rust,no_run
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`:
```rust,no_run
# 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](https://crates.io/crates/axum), take
the body as `Bytes`:
```rust,no_run
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:
```rust,ignore
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
| `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
- API documentation: <https://invoi.st/docs>
- Crate documentation: <https://docs.rs/invoist>
- Changes: [CHANGELOG.md](CHANGELOG.md)
- Security reports: [SECURITY.md](SECURITY.md)
## License
MIT. See [LICENSE](LICENSE).