# Invoance Rust SDK
Official Rust SDK for the [Invoance](https://invoance.com) compliance API — cryptographic proof, document anchoring, and AI attestation.
Async, built on [`tokio`](https://tokio.rs) and [`reqwest`](https://docs.rs/reqwest) (rustls — no OpenSSL).
## Install
```toml
[dependencies]
invoance = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
MSRV: 1.86 (driven by transitive dependencies of `reqwest`).
## Quick start
Set your API key:
```bash
export INVOANCE_API_KEY=inv_live_...
```
```rust
use invoance::InvoanceClient;
use invoance::models::IngestEventParams;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Reads INVOANCE_API_KEY (and optional INVOANCE_BASE_URL) from the env.
let client = InvoanceClient::new()?;
// Ingest a compliance event
let event = client
.events()
.ingest(IngestEventParams {
event_type: "policy.approval".into(),
payload: json!({ "policy_id": "pol_001", "decision": "approved" })
.as_object()
.unwrap()
.clone(),
..Default::default()
})
.await?;
println!("{}", event.event_id);
// Anchor a document by file (hashes + uploads in one call)
use invoance::models::{AnchorFileParams, FileSource};
let anchored = client
.documents()
.anchor_file(AnchorFileParams {
file: FileSource::Path("./invoice.pdf".into()),
document_ref: Some("Invoice #1042".into()),
event_type: None,
metadata: None,
idempotency_key: None,
skip_original: false,
trace_id: None,
})
.await?;
println!("{}", anchored.event_id);
// Ingest an AI attestation
use invoance::models::IngestAttestationParams;
let att = client
.attestations()
.ingest(IngestAttestationParams {
r#type: "output".into(),
input: "Summarize this contract".into(),
output: "The contract states...".into(),
model_provider: "openai".into(),
model_name: "gpt-4o".into(),
model_version: "2025-01-01".into(),
subject: json!({ "user_id": "u_42", "session_id": "sess_4f9a" })
.as_object()
.cloned(),
..Default::default()
})
.await?;
println!("{}", att.attestation_id);
Ok(())
}
```
## Configuration
The client reads from environment variables automatically:
| `INVOANCE_API_KEY` | Yes | — |
| `INVOANCE_BASE_URL` | No | `https://api.invoance.com` |
Or configure explicitly with the builder:
```rust
use std::time::Duration;
use invoance::InvoanceClient;
let client = InvoanceClient::builder()
.api_key("inv_live_...")
.timeout(Duration::from_secs(60))
.build()?;
# Ok::<(), invoance::Error>(())
```
## Quick validation
Sanity-check that your API key works before wiring the SDK into a larger app:
```rust
# async fn run(client: invoance::InvoanceClient) {
let result = client.validate().await;
if !result.valid {
panic!("Invoance: {:?} (base: {})", result.reason, result.base_url);
}
# }
```
`validate()` probes `GET /v1/me` (which requires no scopes, so scope-limited keys validate too), never returns `Err` for the probe itself, and yields `{ valid, reason, base_url }` — use it in health checks, startup scripts, or CI guards.
Need the details behind the key? `client.me()` returns the raw `GET /v1/me` introspection document (`serde_json::Value`): organization, tenant, API-key metadata (scopes, prefix, last4), and rate limits.
## Error handling
Every fallible call returns `Result<T, invoance::Error>`. Classify with the boolean predicates or by matching the variants:
```rust
# async fn run(client: invoance::InvoanceClient, params: invoance::models::IngestEventParams) {
match client.events().ingest(params).await {
Ok(event) => println!("{}", event.event_id),
Err(e) if e.is_authentication() => eprintln!("bad API key"),
Err(e) if e.is_quota_exceeded() => {
eprintln!("rate limited, retry in {:?}s", e.retry_after_seconds());
}
Err(e) if e.is_timeout() => eprintln!("timed out"),
Err(e) => eprintln!("invoance error: {e}"),
}
# }
```
Accessors: `status_code()`, `error_code()`, `retry_after_seconds()`, `body()`, `request()`, `kind()`. Predicates: `is_authentication()`, `is_forbidden()`, `is_not_found()`, `is_validation()`, `is_conflict()`, `is_quota_exceeded()`, `is_server()`, `is_network()`, `is_timeout()`.
## Offline verification
The SDK reproduces the server's cryptography so you can verify proofs without trusting the API:
```rust
use invoance::verify_audit_event;
# fn run(event_json: serde_json::Value) {
// `event` is the JSON object returned by `client.audit().events.get(..)`.
let result = verify_audit_event(&event_json, None);
println!("valid = {} ({:?})", result.valid, result.reason);
# }
```
- `verify_audit_event` — Ed25519 signature check over the `invoance.audit/1` canonical bytes.
- `canonical_audit_bytes`, `payload_hash_hex`, `normalize_ts`, `AUDIT_SCHEMA_ID` — the canonicalizer primitives.
- `content_idempotency_key(body)` — derive a content-stable `Idempotency-Key`.
- `client.attestations().verify_signature(id)` — verify an attestation's Ed25519 signature fully client-side.
## Resources
Every resource is reached through an accessor on the client: `client.events()`,
`client.documents()`, `client.attestations()`, `client.traces()`,
`client.audit()`. Each method takes a typed param struct (most derive
`Default`, so pass only the fields you need and finish with
`..Default::default()`) and returns `Result<T, invoance::Error>`.
Import param structs from `invoance::models`. Payloads and metadata are
`serde_json::Map<String, Value>` — build them with `serde_json::json!` and
`.as_object().unwrap().clone()`.
### Events
`client.events()` — the compliance event ledger.
```rust
use invoance::models::{IngestEventParams, ListEventsParams, VerifyEventParams};
use serde_json::json;
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
// ingest — POST /events
let payload = json!({ "user_id": "u_42", "ip": "203.0.113.4" });
let event = client
.events()
.ingest(IngestEventParams {
event_type: "user.login".into(),
payload: payload.as_object().unwrap().clone(),
event_time: None, // RFC3339; defaults to ingest time
trace_id: None, // attach to a trace bundle
idempotency_key: None, // safe-retry key
..Default::default()
})
.await?;
println!("{} at {}", event.event_id, event.ingested_at);
// list — GET /events (paginated)
let listed = client
.events()
.list(ListEventsParams {
limit: Some(50),
event_type: Some("user.login".into()),
// page, date_from, date_to also supported
..Default::default()
})
.await?;
println!("{} of {} (has_more={})", listed.events.len(), listed.total, listed.has_more);
// get — GET /events/{event_id}
let fetched = client.events().get(&event.event_id).await?;
println!("payload_hash = {}", fetched.payload_hash);
// verify — POST /events/{event_id}/verify
// Provide EXACTLY ONE of payload_hash (hex SHA-256) or payload (raw JSON,
// which the server canonicalizes and hashes for you). Neither → validation error.
let verify = client
.events()
.verify(
&event.event_id,
VerifyEventParams {
payload: payload.as_object().cloned(),
..Default::default()
},
)
.await?;
println!("match_result = {}", verify.match_result);
# Ok(()) }
```
### Documents
`client.documents()` — anchor document hashes (with optional original bytes).
```rust
use invoance::models::{
AnchorDocumentParams, AnchorFileParams, FileSource, VerifyDocumentParams,
};
use sha2::{Digest, Sha256};
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
// anchor — POST /document/anchor (you supply the 64-char lowercase hex hash)
let bytes = b"...your document bytes...";
let document_hash = hex::encode(Sha256::digest(bytes));
let doc = client
.documents()
.anchor(AnchorDocumentParams {
document_hash: document_hash.clone(),
document_ref: Some("Invoice #1042".into()),
event_type: Some("invoice".into()),
original_bytes_b64: None, // base64 of the original, to store it server-side
metadata: None,
trace_id: None,
idempotency_key: None,
..Default::default()
})
.await?;
println!("anchored {} ({})", doc.event_id, doc.status);
// anchor_file — convenience: hashes, base64-encodes, and anchors in one call.
// NOTE: AnchorFileParams does not derive Default — list every field.
let anchored = client
.documents()
.anchor_file(AnchorFileParams {
file: FileSource::Path("./invoice.pdf".into()), // or FileSource::Bytes(..)
document_ref: None, // defaults to the file's basename for a path
event_type: Some("invoice".into()),
metadata: None,
idempotency_key: None,
skip_original: false, // true → hash only, don't upload the bytes
trace_id: None,
})
.await?;
println!("anchored via file helper {}", anchored.event_id);
// list — GET /document (paginated)
let listed = client
.documents()
.list(invoance::models::ListDocumentsParams {
limit: Some(50),
document_ref: Some("Invoice".into()),
..Default::default()
})
.await?;
println!("{} documents", listed.total);
// get — GET /document/{event_id}
let detail = client.documents().get(&doc.event_id).await?;
println!("has_original = {}", detail.has_original);
// get_original — GET /document/{event_id}/original → raw bytes
let original: Vec<u8> = client.documents().get_original(&doc.event_id).await?;
std::fs::write("recovered.pdf", &original).ok();
// verify — POST /document/{event_id}/verify
let verify = client
.documents()
.verify(&doc.event_id, VerifyDocumentParams { document_hash })
.await?;
println!("match_result = {}", verify.match_result);
# Ok(()) }
```
### AI Attestations
`client.attestations()` — anchor AI inputs/outputs with model context.
```rust
use invoance::models::{
IngestAttestationParams, ListAttestationsParams, VerifyAttestationParams,
};
use serde_json::json;
# async fn run(client: invoance::InvoanceClient) -> Result<(), Box<dyn std::error::Error>> {
// ingest — POST /ai/attestations. `input`/`output` nest under `payload`;
// the model fields nest under `context` (field order matters for hashing).
let att = client
.attestations()
.ingest(IngestAttestationParams {
r#type: "output".into(),
input: "Summarize this contract".into(),
output: "The contract states...".into(),
model_provider: "openai".into(),
model_name: "gpt-4o".into(),
model_version: "2025-01-01".into(),
subject: json!({ "user_id": "u_42", "session_id": "sess_4f9a" })
.as_object()
.cloned(), // optional; extra keys pass through
trace_id: None,
idempotency_key: None,
..Default::default()
})
.await?;
println!("{} ({})", att.attestation_id, att.status);
// list — GET /ai/attestations (paginated)
let listed = client
.attestations()
.list(ListAttestationsParams {
limit: Some(50),
model_provider: Some("openai".into()),
attestation_type: Some("output".into()),
..Default::default()
})
.await?;
println!("{} attestations", listed.total);
// get — GET /ai/attestations/{id}
let one = client.attestations().get(&att.attestation_id).await?;
println!("signature_alg = {}", one.signature_alg);
// verify — POST /ai/attestations/{id}/verify (server-side, by content hash)
let verify = client
.attestations()
.verify(
&att.attestation_id,
VerifyAttestationParams { content_hash: "<64-hex sha256>".into() },
)
.await?;
println!("match_result = {}", verify.match_result);
// get_raw — GET /ai/attestations/{id}/raw → the canonical JSON record
let raw = client.attestations().get_raw(&att.attestation_id).await?;
// verify_payload — hashes client-side, then calls verify. Pass the raw JSON
// STRING (not a parsed Value) to preserve key order — the server hashes with
// struct field order, not alphabetical.
let raw_string = serde_json::to_string(&raw)?;
let vp = client
.attestations()
.verify_payload(&att.attestation_id, raw_string.as_str())
.await?;
println!("payload match_result = {}", vp.match_result);
// verify_signature — fully client-side Ed25519 check (no server trust needed)
let sig = client.attestations().verify_signature(&att.attestation_id).await?;
println!("signature valid = {} ({:?})", sig.valid, sig.reason);
# Ok(()) }
```
### Traces
`client.traces()` — group items into sealed bundles with a composite hash.
```rust
use invoance::models::{CreateTraceParams, GetTraceParams, ListTracesParams};
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
// create — POST /traces
let trace = client
.traces()
.create(CreateTraceParams {
label: "Onboarding run 2026-07".into(),
metadata: None,
})
.await?;
println!("trace_id = {} ({})", trace.trace_id, trace.status);
// Attach items by passing `trace_id: Some(trace.trace_id.clone())` when you
// ingest events, anchor documents, or ingest attestations.
// list — GET /traces (paginated; filter by status "open" or "sealed")
let listed = client
.traces()
.list(ListTracesParams { status: Some("open".into()), ..Default::default() })
.await?;
println!("{} traces", listed.total);
// get — GET /traces/{trace_id} with paginated events
let detail = client
.traces()
.get(&trace.trace_id, GetTraceParams { event_page: Some(1), event_limit: Some(50) })
.await?;
println!("status={} events={}", detail.status, detail.events.len());
// seal — POST /traces/{trace_id}/seal (async; returns 202)
let sealed = client.traces().seal(&trace.trace_id).await?;
println!("seal: {}", sealed.message);
// proof — GET /traces/{trace_id}/proof → structured proof bundle (JSON)
let bundle = client.traces().proof(&trace.trace_id).await?;
println!(
"composite_hash_valid = {}",
bundle.verification.composite_hash_valid
);
// proof_pdf — GET /traces/{trace_id}/proof/pdf → raw PDF bytes
let pdf: Vec<u8> = client.traces().proof_pdf(&trace.trace_id).await?;
std::fs::write("proof.pdf", &pdf).ok();
// delete — DELETE /traces/{trace_id} (only an empty, open trace)
let deleted = client.traces().delete(&trace.trace_id).await?;
println!("deleted = {}", deleted.deleted);
# Ok(()) }
```
### Audit logs
`client.audit()` is a namespace of five sub-resources — access them as fields:
`.events`, `.orgs`, `.streams`, `.portal_sessions`, `.exports`. Most methods
return the server's raw `serde_json::Value`.
#### `client.audit().events` — the signed event ledger
```rust
use invoance::models::{IngestAuditEventParams, ListAuditEventsParams};
use serde_json::json;
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
let audit = client.audit();
// ingest — POST /audit/events. The ledger requires an Idempotency-Key; when
// you omit one, a content-stable key is derived automatically.
let appended = audit
.events
.ingest(IngestAuditEventParams {
organization_id: "org_customer_1".into(),
action: "user.role.granted".into(),
actor: json!({ "type": "user", "id": "admin_1" }).as_object().unwrap().clone(),
targets: Some(vec![json!({ "type": "user", "id": "u_9" })]),
occurred_at: None, // defaults to now (RFC3339 UTC)
context: None,
metadata: None,
idempotency_key: None,
..Default::default()
})
.await?;
// list — GET /audit/events (keyset-paginated via `cursor`)
let listed = audit
.events
.list(ListAuditEventsParams {
organization_id: Some("org_customer_1".into()),
limit: Some(100),
// actions, actor_id, target_id, range_start, range_end, cursor also supported
..Default::default()
})
.await?;
println!("next_cursor = {:?}", listed.next_cursor);
// get — GET /audit/events/{id}
let one = audit.events.get("evt_123").await?;
// verify — GET /audit/events/{id}/verify (server-side, pinned key)
let checked = audit.events.verify("evt_123").await?;
# let _ = (appended, one, checked);
# Ok(()) }
```
#### `client.audit().orgs` — end-customer organizations
```rust
use invoance::models::{CreateAuditOrgParams, ListAuditOrgsParams, UpdateAuditOrgParams};
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
let audit = client.audit();
// create — POST /audit/orgs
audit
.orgs
.create(CreateAuditOrgParams {
organization_id: "org_customer_1".into(),
name: Some("Acme Corp".into()),
})
.await?;
// list — GET /audit/orgs (archived orgs are excluded by default)
audit.orgs.list(Default::default()).await?;
audit
.orgs
.list(ListAuditOrgsParams {
include_archived: Some(true),
})
.await?;
// update — PATCH /audit/orgs/{id} (rename; `name: None` clears it)
audit
.orgs
.update(
"org_customer_1",
UpdateAuditOrgParams {
name: Some("Acme Corp Ltd".into()),
},
)
.await?;
// archive / unarchive — POST .../archive, POST .../unarchive (idempotent).
// Archiving freezes new activity (ingest/streams/portal/exports return 409
// org_archived); history stays verifiable.
audit.orgs.archive("org_customer_1").await?;
audit.orgs.unarchive("org_customer_1").await?;
// delete — DELETE /audit/orgs/{id}. Only when nothing signed would be
// destroyed (never-ingested, or archived + retention fully purged);
// 409 org_not_deletable otherwise.
audit.orgs.delete("org_customer_1").await?;
audit.orgs.integrity("org_customer_1").await?; // GET .../integrity
audit.orgs.set_retention("org_customer_1", 365).await?; // PUT .../retention
# Ok(()) }
```
#### `client.audit().streams` — SIEM / webhook delivery
```rust
use invoance::models::CreateAuditStreamParams;
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
let audit = client.audit();
// create — POST /audit/orgs/{org}/streams. The signing secret is returned ONCE.
let stream = audit
.streams
.create(
"org_customer_1",
CreateAuditStreamParams {
url: "https://siem.example/hook".into(),
r#type: None, // defaults to "webhook" (the only v1 type)
},
)
.await?;
audit.streams.list("org_customer_1").await?; // list
audit.streams.test("org_customer_1", "stream_1").await?; // send a test delivery
audit.streams.delete("org_customer_1", "stream_1").await?; // delete
# let _ = stream;
# Ok(()) }
```
#### `client.audit().portal_sessions` — hosted-viewer links
```rust
use invoance::models::CreatePortalSessionParams;
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
// create — POST /audit/portal_sessions (mints a one-time hosted-viewer link)
let session = client
.audit()
.portal_sessions
.create(CreatePortalSessionParams {
organization_id: "org_customer_1".into(),
intent: "audit_logs".into(), // or "log_streams"
session_duration_seconds: None, // default 7200 (clamped 60..86400)
link_duration_seconds: None, // default 300 (clamped 60..3600)
})
.await?;
# let _ = session;
# Ok(()) }
```
#### `client.audit().exports` — async CSV / NDJSON jobs
```rust
use invoance::models::CreateAuditExportParams;
# async fn run(client: invoance::InvoanceClient) -> Result<(), invoance::Error> {
let audit = client.audit();
// create — POST /audit/exports (queues an async job)
let job = audit
.exports
.create(CreateAuditExportParams {
organization_id: "org_customer_1".into(),
format: "csv".into(), // or "ndjson"
filters: None,
})
.await?;
// get — GET /audit/exports/{id}. Poll until status == "ready", then read
// `download_url` from the returned JSON.
let status = audit.exports.get("export_1").await?;
# let _ = (job, status);
# Ok(()) }
```
Offline-verify an audit event returned by the API (no server trust):
```rust
use invoance::verify_audit_event;
# async fn run(client: invoance::InvoanceClient) -> Result<(), Box<dyn std::error::Error>> {
let event = client.audit().events.get("evt_123").await?;
let event_json = serde_json::to_value(&event)?;
// Pass None to trust the key embedded in the event, or Some(hex_key) to pin
// against the tenant's registered signing key for a real tamper guarantee.
let result = verify_audit_event(&event_json, None);
println!("valid={} reason={:?} key_source={:?}",
result.valid, result.reason, result.key_source);
# Ok(()) }
```
See [`examples/`](./examples) for a runnable example per resource area
(`events`, `documents`, `attestations`, `traces`, `audit`, and `quickstart`).
## License
MIT — see [LICENSE](./LICENSE).