Audit Logs
Append signed activity records for each of your end-customer orgs, then read, stream, export and verify them.
An audit event says who did what to which targets, and when. You register an audit org for each end customer (your own organization_id, mapped to an aorg_ id), then POST events for it with an action such as user.signed_in, an actor, up to 20 targets, optional context and flat metadata, and an Idempotency-Key. The backend checks the envelope, counts the event against the monthly audit quota, mints an aevt_ id, and replies 201 as soon as the event is on a durable queue.
A worker then assigns the org's next seq (a per-org counter with no gaps), builds the signed object (org_id, event_id, seq, ingested_at, action, occurred_at, actor, targets, context, metadata and the constant schema_id invoance.audit/1), serializes it canonically (nulls removed, keys sorted, compact), hashes it with SHA-256 into payload_hash and signs the bytes with the tenant's Ed25519 key. The signature and public key are stored with the row and returned by every read.
Verification recomputes the canonical bytes from the row (or from a copy you hold) and checks the hash and the signature against the key registered for the tenant, not the key on the row. Rows are never edited; after the plan's hot window they move to cold storage and after retention_days they are purged, with evacuated_through_seq recording the floor so the integrity scan can tell purged from missing.
/v1/audit/eventsIngest an audit eventGET/v1/audit/eventsList audit eventsGET/v1/audit/events/{id}Get an audit eventGET/v1/audit/events/{id}/verifyVerify an audit event/v1/audit/orgsCreate an audit orgGET/v1/audit/orgsList audit orgsPATCH/v1/audit/orgs/{id}Rename an audit orgDELETE/v1/audit/orgs/{id}Delete an audit orgPOST/v1/audit/orgs/{id}/archiveArchive an audit orgPOST/v1/audit/orgs/{id}/unarchiveUnarchive an audit orgGET/v1/audit/orgs/{id}/integrityCheck an org's sequence integrityPUT/v1/audit/orgs/{id}/retentionSet an org's retention/v1/audit/orgs/{id}/streamsCreate a webhook streamGET/v1/audit/orgs/{id}/streamsList an org's streamsDELETE/v1/audit/orgs/{id}/streams/{stream_id}Delete a streamPOST/v1/audit/orgs/{id}/streams/{stream_id}/testSend a test delivery/v1/audit/portal/exchangeExchange a portal link tokenGET/v1/audit/portal/eventsList events through the portalGET/v1/audit/portal/events/{id}Get an event through the portalGET/v1/audit/portal/events/{id}/verifyVerify an event through the portalGET/v1/audit/portal/orgGet the portal's org and issuerGET/v1/audit/portal/streamsList streams through the portalPOST/v1/audit/portal/streamsCreate a stream through the portalDELETE/v1/audit/portal/streams/{id}Delete a stream through the portalPOST/v1/audit/portal/streams/{id}/testTest a stream through the portal| Ingest body | up to 64 KB (65,536 bytes) of JSON; duplicate keys anywhere are rejected |
|---|---|
| Idempotency-Key header | required on ingest; 1 to 255 characters after trimming |
| action | 1 to 128 characters matching ^[a-z0-9_]+(\.[a-z0-9_]+){1,2}$ (two or three dotted lower-snake segments) |
occurred_at | RFC 3339, between now minus 5 years and now plus 24 hours |
| targets | up to 20 entries, each with a non-empty type and id |
| metadata (event, actor and each target) | up to 50 keys; keys up to 40 characters; values string, boolean or 64-bit integer up to 500 characters serialized; floats, arrays and nested objects rejected |
| organization_id and name on org create | each up to 255 characters |
| Event list page size | limit clamped to 1 to 100; default 50 |
| Org list and stream list | up to 100 rows, newest first, no pagination |
| Integrity gaps | up to 1,000 gap ranges per response, gaps_truncated set when the cap is hit |
| Audit events per month | set by plan; seeded as developer 10,000, builder 1,000,000, growth 5,000,000, compliance 25,000,000, enterprise unlimited, plus purchased top-ups |
| Audit orgs per tenant | set by plan; seeded as developer 2, builder 1,000, growth 3,000, compliance 10,000, enterprise unlimited |
| Streams per org | set by plan; seeded as developer 1, builder 2, growth 5, compliance 20, enterprise unlimited |
| Retention cap | the plan's retention_days: developer 30, builder 365, growth 2,555, compliance 2,555, enterprise 36,500; a new org starts at 90 days or the cap, whichever is lower |
| Hot window | days an event stays in Postgres before moving to cold storage: developer 30, builder 90, growth 90, compliance 180, enterprise 365 |
| Webhook delivery | up to 100 events per POST in seq order, 15 second timeout, no redirects, retry backoff up to 300 seconds |
| Portal link | open window 60 to 3,600 seconds (default 300); viewer session 60 to 86,400 seconds (default 7,200) |
| Portal rate limit | per IP: 10 requests per second on exchange, 60 per second on reads |
| Export download URL | presigned for 24 hours from each GET |
| Requests per second | set by plan, default 10; the per-minute budget is 60 times that, shared by all keys of a tenant |
/v1/audit/eventsIngest an audit event
Accepts one audit event for an existing org, queues it for sequencing and signing, and returns the new event_id with its ingested_at timestamp.
Content-TypeMust be application/json.
Idempotency-KeyClient-chosen key that makes retries safe; the same key with the same body replays the stored 201 for 24 hours, and the key is scoped to the org, the API key and this endpoint.
organization_idYour id for the end customer as registered with create org, or the aorg_ id; org is accepted as a legacy alias.
actionWhat happened, as two or three dotted lower-snake segments, for example user.signed_in or billing.plan.changed.
occurred_atWhen the event happened in your system, RFC 3339 with any offset; normalized to UTC with three fractional digits (truncated) before signing.
actorWho performed the action.
actor.typeKind of principal, for example user, api_key or system; must not be empty.
actor.idYour identifier for the principal; must not be empty.
actor.nameDisplay name for the principal.
actor.metadataFlat key-value details about the actor, under the same rules as the top-level metadata.
targetsWhat the action was applied to; send an empty array when there is no target.
targets[].typeKind of target; must not be empty.
targets[].idYour identifier for the target; must not be empty.
targets[].nameDisplay name for the target.
targets[].metadataFlat key-value details about the target, under the same rules as the top-level metadata.
contextWhere the action came from; only the two keys below are accepted.
context.locationThe actor's IP address or location string.
context.user_agentThe actor's user agent string.
metadataFlat key-value details about the event; null values are dropped from the signed bytes.
versionReserved envelope version; must equal 1 when present and is not part of the signed bytes. One of 1.
- The 201 means the event is on a durable queue; a worker assigns seq, signs and writes the row moments later, so a get right after ingest can return 404 not_found until the row lands.
- Checks run in this order: scope, Idempotency-Key, body size, JSON parse, envelope, field validation, occurred_at window, org lookup, monthly quota, idempotency lookup, publish; a request that fails before the org lookup consumes no quota.
- The quota is counted before the idempotency lookup, so a replay of an already stored request still counts one event for the period.
- The idempotency request hash covers the resolved org id, action, the normalized occurred_at and the raw body bytes, so a retry with the same key but different whitespace or key order gets 409 idempotency_key_reuse_mismatch.
- A retry with the same Idempotency-Key while the first request is still in flight gets 202 with body {"status": "processing"}; once the first request completes, the stored 201 body is replayed for 24 hours.
- The signed object is org_id, event_id, seq, ingested_at, action, occurred_at, actor, targets, context and metadata plus schema_id invoance.audit/1; organization_id as you sent it and version are not signed, and null metadata values are dropped before signing.
- When you omit idempotencyKey, every SDK derives one as idem_ plus the SHA-256 of the sorted, compact body, so two calls with byte-identical content (including occurred_at) map to one stored event; pass your own key when identical events must be stored twice.
- The Go SDK defaults occurred_at to whole seconds and the PHP SDK to a Z timestamp without fractions; the backend accepts both and normalizes to three fractional digits.
- The SDKs default targets to an empty array when omitted; the backend itself requires the key to be present.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const result = await client.audit.events.ingest({
organizationId: "org_8472",
action: "user.signed_in",
occurredAt: "2026-09-22T08:14:07Z",
actor: { type: "user", id: "u_4821", name: "Ada Lovelace" },
targets: [{ type: "workspace", id: "ws_17" }],
context: { location: "203.0.113.10", user_agent: "Mozilla/5.0" },
metadata: { method: "sso", mfa: true },
idempotencyKey: "signin-u_4821-2026-09-22T08:14:07Z",
});
console.log(result.event_id, result.ingested_at);
{
"event_id": "aevt_01J0Y1Z2A3B4C5D6E7F8G9H0JK",
"ingested_at": "2026-09-22T08:14:07.312Z"
}
event_idId assigned to the event, aevt_ followed by a ULID; use it with get, verify and the public proof page.
ingested_atServer time at which the event was accepted, UTC with three fractional digits; this is the timestamp that gets signed.
missing_idempotency_keyThe Idempotency-Key header is absent or empty after trimming.
invalid_idempotency_keyThe Idempotency-Key is longer than 255 characters.
payload_too_largeThe request body is larger than 65,536 bytes.
invalid_jsonThe body is not valid UTF-8 or not valid JSON.
duplicate_json_keyThe same key appears twice in one JSON object anywhere in the body.
invalid_envelopeA required field is missing, a field has the wrong type, or an unknown key appears at the top level, in actor, in a target or in context.
invalid_orgorganization_id is empty after trimming.
invalid_actionaction is empty, longer than 128 characters, or does not match the two-to-three-segment grammar.
invalid_actoractor.type or actor.id is empty after trimming.
too_many_targetstargets has more than 20 entries.
invalid_targetA target has an empty type or id.
invalid_versionversion is present and not 1.
invalid_metadatametadata (on the event, the actor or a target) is not an object, has more than 50 keys, a key over 40 characters, a value over 500 characters, a float, an array or a nested object.
invalid_occurred_atoccurred_at is not a valid RFC 3339 timestamp.
occurred_at_out_of_windowoccurred_at is more than 5 years in the past or more than 24 hours in the future.
invalid_api_contextThe request was authenticated without an API key id; not reachable with a normal key.
payment_requiredThe tenant is on a paid plan whose subscription is past due.
insufficient_scopeThe key does not have audit:write; audit:read alone is not enough.
feature_not_availableThe plan's audit_events_per_month limit is 0.
org_not_foundNo org with that organization_id or aorg_ id belongs to the tenant; orgs are never created by ingest.
org_archivedThe org is archived; unarchive it first.
idempotency_key_reuse_mismatchThe Idempotency-Key was already used for this org by this API key with a different action, occurred_at or body bytes.
quota_exceededThe tenant has used its monthly audit events quota for the current billing period.
rate_limitedThe tenant used up its per-second or per-minute request budget; the Retry-After header says when to retry.
db_errorThe org lookup failed.
quota_check_failedThe quota counter could not be read; the request is rejected rather than allowed through.
encode_failedThe queue envelope could not be serialized; the idempotency reservation is released.
ingest_failedThe event could not be published to the queue; the idempotency reservation is released so a retry can succeed.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeAn Authorization header was sent without the Bearer scheme.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyThe key does not match any API key.
api_key_revokedThe key has been revoked.
ip_not_allowedThe key has an IP allowlist and the caller's address is not on it.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/audit/eventsList audit events
Returns one org's events newest first with optional filters, plus a cursor for the next page.
organization_idYour organization_id or the aorg_ id of the org to read; org_id is accepted as a legacy alias.
actionsComma-separated list of action strings; only events whose action is in the list are returned.
actor_idOnly events whose actor.id equals this value.
target_idOnly events with a target whose id equals this value.
range_startInclusive lower bound on occurred_at, RFC 3339; occurred_after is accepted as an alias.
range_endInclusive upper bound on occurred_at, RFC 3339; occurred_before is accepted as an alias.
limitPage size.
cursorOpaque next_cursor from the previous page.
- Pagination is keyset on (occurred_at, id); the cursor is the hex encoding of "<occurred_at>|<id>" of the last row, and new rows always land on the first page, so paging never skips or repeats a row.
- The page holds at most limit rows; next_cursor is null when no more rows match.
- organization_id is required by the backend; the SDKs type it as optional, and a call without it is rejected by the framework with a plain-text 400 before the handler runs.
- actor_id compares against the stored actor.id; target_id matches any entry of targets by id.
- Archived orgs stay readable here; only writes are blocked while an org is archived.
- Events past the plan's hot window are no longer in this list; use an export to read cold storage.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const page = await client.audit.events.list({
organizationId: "org_8472",
actions: "user.signed_in,user.signed_out",
rangeStart: "2026-09-01T00:00:00Z",
limit: 50,
});
for (const event of page.events) {
console.log(event.seq, event.action, event.actor?.id);
}
console.log(page.next_cursor);
{
"events": [
{
"id": "aevt_01J0Y1Z2A3B4C5D6E7F8G9H0JK",
"org_id": "aorg_01J0XW9K3RQ5T7V8Y2C4E6G8HM",
"seq": 42,
"schema_id": "invoance.audit/1",
"occurred_at": "2026-09-22T08:14:07.000Z",
"ingested_at": "2026-09-22T08:14:07.312Z",
"action": "user.signed_in",
"actor": {
"type": "user",
"id": "u_4821",
"name": "Ada Lovelace"
},
"targets": [
{
"type": "workspace",
"id": "ws_17"
}
],
"context": {
"location": "203.0.113.10",
"user_agent": "Mozilla/5.0"
},
"metadata": {
"method": "sso",
"mfa": true
},
"payload_hash": "df929158c7ce2107eff769fbcd58376c1d84dee0b5212b314f6f423dd20534d3",
"signature": "c6a2bf3bc3895915ead5f0b99eaf84534d4e8b9aa68bef8b0508cfd473f06e694d76d2bc330b83cfa613e49e7d2490d0413285017acfb78746bcc1524d05160d",
"signing_public_key": "bee215a9d2a0170176a88733056d8a0ae5372b0da8238796bef4a0b75d4c0974"
}
],
"next_cursor": "323032362d30392d32325430383a31343a30372e3030305a7c616576745f30314a3059315a32413342344335443645374638473948304a4b"
}
eventsThe page of events, ordered by occurred_at descending then id descending.
events[].idEvent id, aevt_ followed by a ULID; minted at ingest before the event is queued.
events[].org_idThe aorg_ id of the audit org the event belongs to (not your organization_id).
events[].seqPosition in the org's log, assigned by the signer in commit order; per org it starts at 1 and has no gaps.
events[].schema_idAlways invoance.audit/1; it is inside the signed bytes as a domain tag. One of invoance.audit/1.
events[].occurred_atWhen the event happened in your system, normalized to UTC with exactly three fractional digits and a Z suffix.
events[].ingested_atServer time when the event was accepted, in the same canonical form; this value is signed.
events[].actionThe action string as sent, for example user.signed_in.
events[].actorThe actor object as sent: type and id, plus name and metadata when given.
events[].targetsThe targets array as sent; empty when the event had none.
events[].contextThe context object as sent (location, user_agent), or null when omitted.
events[].metadataThe flat metadata object as sent, or null when omitted.
events[].payload_hashSHA-256 of the canonical signed bytes, 64 hex characters.
events[].signatureEd25519 signature over the canonical bytes, 128 hex characters.
events[].signing_public_keyThe tenant's Ed25519 public key as recorded with the row, 64 hex characters; shown for display, verification uses the key registered in tenant_keys.
next_cursorOpaque cursor to pass as ?cursor= for the next page, or null on the last page.
invalid_organization_idorganization_id is empty after trimming.
unknown_orgNo org with that organization_id or aorg_ id belongs to the tenant.
invalid_timestamprange_start or range_end is not RFC 3339.
invalid_cursorcursor is not a cursor this endpoint issued.
insufficient_scopeThe key has neither audit:read nor audit:write; a ledger key with only read or write is rejected on audit routes.
rate_limitedThe tenant used up its per-second or per-minute request budget; the Retry-After header says when to retry.
db_errorA database query failed.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeAn Authorization header was sent without the Bearer scheme.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyThe key does not match any API key.
api_key_revokedThe key has been revoked.
ip_not_allowedThe key has an IP allowlist and the caller's address is not on it.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/audit/events/{id}Get an audit event
Returns one event with its hash, signature and signing public key.
idThe aevt_ id returned by ingest.
- An id that exists under another tenant returns the same 404 as an unknown id.
- Every SDK ships an offline verifier (verifyAuditEvent / verify_audit_event) that rebuilds the canonical bytes from this JSON and checks the signature; pass the key from GET /keys/{domain} as publicKey to pin it, otherwise the row's own signing_public_key is used.
- Events past the plan's hot window return 404 here even though they are still in cold storage; exports read cold storage.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const event = await client.audit.events.get("aevt_01J0Y1Z2A3B4C5D6E7F8G9H0JK");
console.log(event.seq, event.action, event.payload_hash);
{
"id": "aevt_01J0Y1Z2A3B4C5D6E7F8G9H0JK",
"org_id": "aorg_01J0XW9K3RQ5T7V8Y2C4E6G8HM",
"seq": 42,
"schema_id": "invoance.audit/1",
"occurred_at": "2026-09-22T08:14:07.000Z",
"ingested_at": "2026-09-22T08:14:07.312Z",
"action": "user.signed_in",
"actor": {
"type": "user",
"id": "u_4821",
"name": "Ada Lovelace"
},
"targets": [
{
"type": "workspace",
"id": "ws_17"
}
],
"context": {
"location": "203.0.113.10",
"user_agent": "Mozilla/5.0"
},
"metadata": {
"method": "sso",
"mfa": true
},
"payload_hash": "df929158c7ce2107eff769fbcd58376c1d84dee0b5212b314f6f423dd20534d3",
"signature": "c6a2bf3bc3895915ead5f0b99eaf84534d4e8b9aa68bef8b0508cfd473f06e694d76d2bc330b83cfa613e49e7d2490d0413285017acfb78746bcc1524d05160d",
"signing_public_key": "bee215a9d2a0170176a88733056d8a0ae5372b0da8238796bef4a0b75d4c0974"
}
idEvent id, aevt_ followed by a ULID; minted at ingest before the event is queued.
org_idThe aorg_ id of the audit org the event belongs to (not your organization_id).
seqPosition in the org's log, assigned by the signer in commit order; per org it starts at 1 and has no gaps.
schema_idAlways invoance.audit/1; it is inside the signed bytes as a domain tag. One of invoance.audit/1.
occurred_atWhen the event happened in your system, normalized to UTC with exactly three fractional digits and a Z suffix.
ingested_atServer time when the event was accepted, in the same canonical form; this value is signed.
actionThe action string as sent, for example user.signed_in.
actorThe actor object as sent: type and id, plus name and metadata when given.
targetsThe targets array as sent; empty when the event had none.
contextThe context object as sent (location, user_agent), or null when omitted.
metadataThe flat metadata object as sent, or null when omitted.
payload_hashSHA-256 of the canonical signed bytes, 64 hex characters.
signatureEd25519 signature over the canonical bytes, 128 hex characters.
signing_public_keyThe tenant's Ed25519 public key as recorded with the row, 64 hex characters; shown for display, verification uses the key registered in tenant_keys.
not_foundNo event with that id belongs to the tenant, or the worker has not written it yet.
insufficient_scopeThe key has neither audit:read nor audit:write; a ledger key with only read or write is rejected on audit routes.
rate_limitedThe tenant used up its per-second or per-minute request budget; the Retry-After header says when to retry.
db_errorA database query failed.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeAn Authorization header was sent without the Bearer scheme.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyThe key does not match any API key.
api_key_revokedThe key has been revoked.
ip_not_allowedThe key has an IP allowlist and the caller's address is not on it.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/audit/events/{id}/verifyVerify an audit event
Recomputes the canonical bytes from the stored row and checks the hash and the Ed25519 signature against the tenant's registered key.
idThe aevt_ id of the event to verify.
- This is a GET with no body, unlike POST /v1/events/{event_id}/verify; there is nothing to submit because the row already holds everything that was signed.
- A row whose signature was replaced with one made by a different key fails with signature_invalid even when the row's own signing_public_key matches, because the check pins the key in tenant_keys.
- The order of checks is hash, then domain tag, then signature; reason names the first check that failed.
- Calls to this endpoint are not counted against the api_verifications quota.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const result = await client.audit.events.verify("aevt_01J0Y1Z2A3B4C5D6E7F8G9H0JK");
console.log(result.valid, result.reason, result.payload_hash);
{
"event_id": "aevt_01J0Y1Z2A3B4C5D6E7F8G9H0JK",
"valid": true,
"reason": null,
"schema": "invoance.audit/1",
"payload_hash": "df929158c7ce2107eff769fbcd58376c1d84dee0b5212b314f6f423dd20534d3",
"signed_data": {
"action": "user.signed_in",
"actor": {
"id": "u_4821",
"name": "Ada Lovelace",
"type": "user"
},
"context": {
"location": "203.0.113.10",
"user_agent": "Mozilla/5.0"
},
"event_id": "aevt_01J0Y1Z2A3B4C5D6E7F8G9H0JK",
"ingested_at": "2026-09-22T08:14:07.312Z",
"metadata": {
"method": "sso",
"mfa": true
},
"occurred_at": "2026-09-22T08:14:07.000Z",
"org_id": "aorg_01J0XW9K3RQ5T7V8Y2C4E6G8HM",
"schema_id": "invoance.audit/1",
"seq": 42,
"targets": [
{
"id": "ws_17",
"type": "workspace"
}
]
},
"key_source": "tenant_keys"
}
event_idThe id from the path.
validTrue when the recomputed hash matches the stored payload_hash, the bytes carry the audit schema_id, and the signature verifies under the tenant's registered key.
reasonWhy the check failed, or null when valid. One of canonicalization_failed, payload_hash_mismatch, wrong_domain, signature_invalid.
schemaAlways invoance.audit/1. One of invoance.audit/1.
payload_hashSHA-256 of the recomputed canonical bytes; empty when canonicalization failed.
signed_dataThe canonical signed object parsed back to JSON (keys sorted, nulls removed, schema_id included); null when canonicalization failed.
key_sourceAlways tenant_keys: the key used is the tenant's registered key, never the one stored on the row. One of tenant_keys.
not_foundNo event with that id belongs to the tenant.
insufficient_scopeThe key has neither audit:read nor audit:write; a ledger key with only read or write is rejected on audit routes.
rate_limitedThe tenant used up its per-second or per-minute request budget; the Retry-After header says when to retry.
db_errorA database query failed, or the tenant has no registered key in tenant_keys.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeAn Authorization header was sent without the Bearer scheme.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyThe key does not match any API key.
api_key_revokedThe key has been revoked.
ip_not_allowedThe key has an IP allowlist and the caller's address is not on it.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/audit/exportsCreate an export
Queues a job that writes the org's events matching the filters, cold storage and hot log in seq order, to one CSV or NDJSON file, and returns the job id to poll.
Content-TypeMust be application/json.
organization_idYour organization_id or the aorg_ id of the org to export; org_id is accepted as a legacy alias.
formatOutput format. One of csv, ndjson.
filtersOptional filters; only the keys below are accepted.
filters.actionsComma-separated action strings; only matching events are exported.
filters.actor_idOnly events whose actor.id equals this value.
filters.target_idOnly events with a target whose id equals this value.
filters.occurred_afterInclusive lower bound on occurred_at, RFC 3339; range_start is accepted as an alias.
filters.occurred_beforeInclusive upper bound on occurred_at, RFC 3339; range_end is accepted as an alias.
- This endpoint needs only audit:read, unlike the other POST routes of the resource.
- A worker polls for pending jobs about every 10 seconds, moves the job to running, writes the file and marks it ready or failed; poll GET /v1/audit/exports/{id} until status is ready or failed.
- Unknown keys inside filters are rejected by the framework with a plain-text 4xx before the handler runs; a malformed timestamp in filters is accepted here and fails the job later with status failed.
- NDJSON lines are the same event JSON that get returns, one per line; CSV columns are id, seq, occurred_at, ingested_at, action, actor_type, actor_id, actor_name, targets, context_location, payload_hash, signature, with cells that start with =, +, -, @, tab or carriage return prefixed to block spreadsheet formula injection.
- Exports include events that left the hot log for cold storage, which list and get no longer return.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const job = await client.audit.exports.create({
organizationId: "org_8472",
format: "ndjson",
filters: {
actions: "user.signed_in,user.signed_out",
occurred_after: "2026-09-01T00:00:00Z",
},
});
console.log(job.id, job.status);
{
"id": "aexp_01J0Y4Q6S8V0X2Z4B6D8F0H2JK",
"status": "pending",
"format": "ndjson"
}
idExport id, aexp_ followed by a ULID; poll it with get export.
statusAlways pending on creation. One of pending.
formatThe format you requested. One of csv, ndjson.
invalid_formatformat is neither csv nor ndjson.
insufficient_scopeThe key has neither audit:read nor audit:write; a ledger key with only read or write is rejected on audit routes.
not_foundNo org with that organization_id or aorg_ id belongs to the tenant.
org_archivedThe org is archived; unarchive it first.
rate_limitedThe tenant used up its per-second or per-minute request budget; the Retry-After header says when to retry.
db_errorA database query failed.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeAn Authorization header was sent without the Bearer scheme.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyThe key does not match any API key.
api_key_revokedThe key has been revoked.
ip_not_allowedThe key has an IP allowlist and the caller's address is not on it.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/audit/exports/{id}Get an export
Returns the job's status and, once it is ready, a presigned download URL valid for 24 hours.
idThe aexp_ id returned by create export.
- Each GET presigns a fresh URL, so a client that lost the link can call again.
- The file is served with Content-Type text/csv or application/x-ndjson.
- Exports are deleted with their org; nothing else removes them.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
let job = await client.audit.exports.get("aexp_01J0Y4Q6S8V0X2Z4B6D8F0H2JK");
while (job.status === "pending" || job.status === "running") {
await new Promise((r) => setTimeout(r, 5000));
job = await client.audit.exports.get("aexp_01J0Y4Q6S8V0X2Z4B6D8F0H2JK");
}
console.log(job.status, job.row_count, job.download_url ?? job.error);
{
"id": "aexp_01J0Y4Q6S8V0X2Z4B6D8F0H2JK",
"status": "ready",
"format": "ndjson",
"row_count": 1284,
"error": null,
"created_at": "2026-09-22T08:20:14.201457+00:00",
"completed_at": "2026-09-22T08:20:31.884016+00:00",
"download_url": "https://storage.example.com/tenants/3b9d6f10-52c4-4a7e-9e1b-8d0c2f4a6e71/audit/exports/aexp_01J0Y4Q6S8V0X2Z4B6D8F0H2JK.ndjson?X-Amz-Expires=86400&X-Amz-Signature=8f2b1c"
}
idThe export id.
statuspending until a worker claims the job, running while the file is written, then ready or failed. One of pending, running, ready, failed.
formatThe requested format. One of csv, ndjson.
row_countEvents written to the file; null until the job is ready.
errorFailure message (up to 500 characters) when status is failed, otherwise null.
created_atWhen the job was queued.
completed_atWhen the job finished, ready or failed; null before that.
download_urlPresigned GET URL for the file, valid 24 hours from this response; null unless status is ready, or when presigning failed.
insufficient_scopeThe key has neither audit:read nor audit:write; a ledger key with only read or write is rejected on audit routes.
not_foundNo export with that id belongs to the tenant.
rate_limitedThe tenant used up its per-second or per-minute request budget; the Retry-After header says when to retry.
db_errorA database query failed.
missing_api_keyNeither an Authorization header nor an X-API-Key header was sent.
invalid_authorization_schemeAn Authorization header was sent without the Bearer scheme.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyThe key does not match any API key.
api_key_revokedThe key has been revoked.
ip_not_allowedThe key has an IP allowlist and the caller's address is not on it.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/audit/orgsCreate an audit orgGET/v1/audit/orgsList audit orgsPATCH/v1/audit/orgs/{id}Rename an audit orgDELETE/v1/audit/orgs/{id}Delete an audit orgPOST/v1/audit/orgs/{id}/archiveArchive an audit orgPOST/v1/audit/orgs/{id}/unarchiveUnarchive an audit orgGET/v1/audit/orgs/{id}/integrityCheck an org's sequence integrityPUT/v1/audit/orgs/{id}/retentionSet an org's retention/v1/audit/orgs/{id}/streamsCreate a webhook streamGET/v1/audit/orgs/{id}/streamsList an org's streamsDELETE/v1/audit/orgs/{id}/streams/{stream_id}Delete a streamPOST/v1/audit/orgs/{id}/streams/{stream_id}/testSend a test delivery/v1/audit/portal/exchangeExchange a portal link tokenGET/v1/audit/portal/eventsList events through the portalGET/v1/audit/portal/events/{id}Get an event through the portalGET/v1/audit/portal/events/{id}/verifyVerify an event through the portalGET/v1/audit/portal/orgGet the portal's org and issuerGET/v1/audit/portal/streamsList streams through the portalPOST/v1/audit/portal/streamsCreate a stream through the portalDELETE/v1/audit/portal/streams/{id}Delete a stream through the portalPOST/v1/audit/portal/streams/{id}/testTest a stream through the portal| Audit org | One end customer of yours, registered with POST /v1/audit/orgs; every event, stream, portal session and export belongs to exactly one org. |
|---|---|
organization_id | Your own id for an audit org; every endpoint also accepts the aorg_ id in its place. |
| aorg_ id | The internal id Invoance assigns to an audit org, returned as id and org_id. |
| aevt_ id | An audit event id: aevt_ followed by a ULID, minted at ingest. |
| seq | The event's position in its org's log, assigned by the signer in commit order, starting at 1 with no gaps. |
| Canonical bytes | The signed object (signed fields plus schema_id) with null members removed, keys sorted recursively and compact serialization; what payload_hash and signature are computed over. |
payload_hash | SHA-256 of the canonical bytes, stored with the row and returned as 64 hex characters. |
| Pinned key | The tenant's Ed25519 public key in tenant_keys; every verification uses it rather than the key stored on the row. |
| Hot, cold, purged | An event is hot in Postgres for the plan's hot window, then archived to cold storage until retention_days, then deleted. |
evacuated_through_seq | The highest seq that retention has moved out of the hot log; the integrity scan starts above it. |
| Stream | A webhook destination that receives an org's new events in seq batches signed with an HMAC secret. |
| Cursor | A stream's cursor_seq, the last seq delivered; it starts at the org's seq when the stream is created. |
| Portal link | A one-time token with a short open window, minted with POST /v1/audit/portal_sessions and spent by POST /v1/audit/portal/exchange. |
| Portal JWT | The org-scoped, read-only bearer token returned by the exchange, with audience audit_portal and the session's intent. |
| Intent | What a portal link may do: audit_logs reads events, log_streams manages streams; neither can do the other. |
| Export | An asynchronous job that writes an org's events, cold and hot, to one CSV or NDJSON file behind a 24 hour presigned URL. |