Audit Logs
A signed, independently verifiable activity log for your product. Send events over the API and each one is signed with your tenant's Ed25519 key and given a gap-free sequence number, so tampering or deletion shows up as a broken signature or a missing sequence. Hand your end customers a hosted viewer, stream events to a SIEM, export a range, and verify any event without trusting our answer.
Before you begin
The audit API uses its own scopes, distinct from the ledger scopes: a key needsaudit:write to send events and audit:readto read, verify, and export. Grant them on an API key in Settings. Every example below is a complete request, swap invoance_live_xxx for your key.
https://api.invoance.com/v1Quick start
Each of your end customers is an org, addressed by your ownexternal_id. Create it once; events reference it by that id.
curl -X POST https://api.invoance.com/v1/audit/orgs \
-H "Authorization: Bearer invoance_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "external_id": "acme-prod", "name": "Acme Production" }'Post an event with an action, an actor, and optional targets. An Idempotency-Key is required; the SDKs generate one for you. The response returns the minted event id.
curl -X POST https://api.invoance.com/v1/audit/events \
-H "Authorization: Bearer invoance_live_xxx" \
-H "Idempotency-Key: 7f4c1c9d-5b8a-4d42-9e0b-1f2a3b4c5d6e" \
-H "Content-Type: application/json" \
-d '{
"org": "acme-prod",
"action": "user.signed_in",
"occurred_at": "2026-06-24T12:00:00.000Z",
"actor": { "type": "user", "id": "user_123", "name": "Ada Lovelace" },
"targets": [{ "type": "team", "id": "t_eng" }]
}'Mint a one-time link to a read-only, org-scoped viewer your customer can open with no Invoance account. The link is single-use and the session is short-lived; setintent toaudit_logs for the event viewer or log_streams for the stream-config screen.
curl -X POST https://api.invoance.com/v1/audit/portal_sessions \
-H "Authorization: Bearer invoance_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "org_id": "aorg_01J…", "intent": "audit_logs" }'Ask the API to re-check an event's signature against your tenant's pinned key, or verify it yourself offline with the SDK (below). Both reconstruct the exact signed bytes and check the Ed25519 signature.
curl https://api.invoance.com/v1/audit/events/aevt_01J…/verify \
-H "Authorization: Bearer invoance_live_xxx"Event schema (invoance.audit/1)
Every event uses the frozen invoance.audit/1 envelope: a fixed set of fields, fixed types, within the limits below. The envelope is strict and identical for every customer, so an unknown top-level field, a missing required field, or a wrong type is rejected 400 and never stored or signed. What is not constrained is the contents of metadata: you send whatever key/values you want within the size limits, with no pre-declaration.
A complete event
Exactly what you send to POST /v1/audit/events. The server adds id, org_id, seq, ingested_at and schema_id, then signs it.
{
"action": "user.signed_in",
"occurred_at": "2026-06-24T12:00:00.000Z",
"actor": {
"type": "user",
"id": "user_42",
"name": "Ada Lovelace"
},
"targets": [
{ "type": "team", "id": "team_eng" }
],
"context": {
"location": "203.0.113.10",
"user_agent": "Chrome/124.0.0.0"
},
"metadata": {
"plan": "growth"
}
}invoance.audit/1·version 1 (reserved; if you send a version it must equal 1)| Field | Type | Required | Notes |
|---|---|---|---|
| action | string | Yes | Dotted resource.verb, e.g. user.signed_in. |
| occurred_at | string (RFC3339 UTC) | Yes | When it happened, on your clock. Bounded to now − 5y … now + 24h. SDKs default this to now. |
| actor | object | Yes | Who did it: type (user | api_key | system) and id required; name and metadata optional. |
| targets | array of object | Yes | What it was done to. May be empty ([]) but the field must be present. Each: type, id, optional name and metadata. |
| context | object | No | location = actor IP, user_agent = client string. |
| metadata | object | No | Flat key to scalar map (see limits). Free-form contents. |
| version | integer | No | Reserved. Must equal 1 if present. |
Absent optional fields, or fields sent as null, are omitted from the signed bytes entirely, so the signature covers exactly the fields present. The server assigns id (aevt_<ULID>), org_id, ingested_at, seq and schema_id; client-sent values for those are ignored.
Limits and rules
- metadata (and actor / target metadata): ≤ 50 keys, key ≤ 40 chars, value ≤ 500 chars.
- metadata values are scalars only: string, boolean, or int64. Floats and nested objects/arrays are rejected 400 (send decimals as strings).
- Idempotency-Key header is required on ingest; the SDKs derive one from the event content.
- Ids: orgs are
aorg_<ULID>, events areaevt_<ULID>.
What gets signed: the canonical bytes of the present fields are signed with your tenant's Ed25519 key. The /verify endpoint and the SDK offline verifier rebuild those exact bytes and check the signature against your pinned key. The signature attests ingested_at (our server clock), not the client-supplied occurred_at.
Using the SDKs
The Python and Node SDKs expose the same surface under client.audit. They defaultoccurred_at to now, generate the idempotency key, and ship an offline verifier that needs no network call.
from invoance import InvoanceClient, verify_audit_event
async with InvoanceClient() as client:
ev = await client.audit.events.ingest(
org="acme-prod",
action="user.signed_in",
actor={"type": "user", "id": "user_123", "name": "Ada"},
)
stored = await client.audit.events.get(ev["event_id"])
print(verify_audit_event(stored).valid) # True — verified offlineimport { InvoanceClient, verifyAuditEvent } from "invoance";
const client = new InvoanceClient();
const ev = await client.audit.events.ingest({
org: "acme-prod",
action: "user.signed_in",
actor: { type: "user", id: "user_123", name: "Ada" },
});
const stored = await client.audit.events.get(ev.event_id as string);
console.log(verifyAuditEvent(stored).valid); // true — verified offlineExporting events
Exports are asynchronous. Create a job with the same filters you would pass to the list endpoint, poll until its status is ready, then download from the short-lived presigned URL. Choose csv for spreadsheets or ndjson for line-delimited JSON. A single export spans both hot and cold-storage rows, so an old range comes back whole.
curl -X POST https://api.invoance.com/v1/audit/exports \
-H "Authorization: Bearer invoance_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"org_id": "aorg_01J…",
"format": "csv",
"filters": { "actions": "user.signed_in", "occurred_after": "2026-06-01T00:00:00Z" }
}'curl https://api.invoance.com/v1/audit/exports/aexp_01J… \
-H "Authorization: Bearer invoance_live_xxx"curl -L "https://…r2.cloudflarestorage.com/…" -o audit-export.csvimport asyncio
from invoance import InvoanceClient
async with InvoanceClient() as client:
job = await client.audit.exports.create(
org_id="aorg_01J…",
format="csv",
filters={"actions": "user.signed_in"},
)
while True:
status = await client.audit.exports.get(job["id"])
if status["status"] in ("ready", "failed"):
break
await asyncio.sleep(2)
print(status["status"], status.get("download_url"))Beyond the basics
Hosted viewer
A read-only, org-scoped event viewer and stream-config screen your customers open from a one-time link, with no account.
SIEM streaming
Register a webhook destination per org and Invoance delivers each new event, HMAC-signed, in order, with retries and backoff.
Exports
Queue an async CSV or NDJSON export of any filtered range; the worker streams it to storage and returns a short-lived download URL.