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

| 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

- 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).