invoance 0.2.0

Official Rust SDK for the Invoance compliance API
Documentation

Invoance Rust SDK

Official Rust SDK for the Invoance compliance API — cryptographic proof, document anchoring, and AI attestation.

Async, built on tokio and reqwest (rustls — no OpenSSL).

Install

[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:

export INVOANCE_API_KEY=inv_live_...
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:

Variable Required Default
INVOANCE_API_KEY Yes
INVOANCE_BASE_URL No https://api.invoance.com

Or configure explicitly with the builder:

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:

# 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:

# 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:

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.

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

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.

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.

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

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

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

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

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

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

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/ for a runnable example per resource area (events, documents, attestations, traces, audit, and quickstart).

License

MIT — see LICENSE.