Event schema
Every audit event uses the invoance.audit/1 envelope. This page lists its fields, the bytes that get signed, and how seq works.
You send an action, when it happened, who did it and what it was done to. The envelope is strict: an unknown key, a missing required field or a wrong type gets 400 and nothing is stored.
The backend adds id, org_id, seq, ingested_at and schema_id, serializes the result canonically, hashes it with SHA-256 and signs the bytes with your Ed25519 key. Every read returns the event with payload_hash, signature and signing_public_key.
Free-form data goes in metadata: flat keys with string, boolean or integer values. The envelope itself is the same for every customer.
| Schema id | invoance.audit/1 |
|---|---|
| Send to | POST /v1/audit/events |
| Body | JSON up to 64 KB; the Idempotency-Key header is required |
| Unknown keys | 400 invalid_envelope, at the top level and inside actor, targets[] and context |
| Duplicate keys | 400 duplicate_json_key, anywhere in the body |
| version | Reserved; must be 1 when present and is not signed |
{
"organization_id": "org_8472",
"action": "user.signed_in",
"occurred_at": "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
}
}
What you send. Each limit is the one the backend enforces; the error it returns is listed on the ingest endpoint.
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.
What every read returns. Get, list, the portal, stream deliveries and exports all use this shape. Your organization_id is not on it; org_id is the aorg_ id.
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.
Signed object
Ten fields plus the constant schema_id. Nothing else is covered.
- org_id, event_id, seq, ingested_at
- action, occurred_at, actor, targets
- context and metadata, when present and not null
- schema_id, always invoance.audit/1
Not signed: organization_id as you sent it, version, and the payload_hash, signature and signing_public_key columns themselves.
Verification
GET /v1/audit/events/{id}/verify rebuilds the bytes from the stored row. It checks the hash, then schema_id, then the signature against your registered key, never the key on the row.
reason names the first failed check: canonicalization_failed, payload_hash_mismatch, wrong_domain, signature_invalid.
POST /v1/proof/audit/{event_id}/verify does the same for a copy you hold, without an API key. The SDK verifiers rebuild the same bytes offline; see verifying an export.
Assemble
Take the ten signed fields from the stored event and add schema_id. Nothing else goes in.
Normalize timestamps
occurred_at and ingested_at become UTC with exactly three fractional digits, truncated, and a Z suffix.
Drop nulls
A member whose value is null is removed at every depth. Absent and null give the same bytes.
Sort keys
Object keys are sorted recursively. Arrays keep their order.
Serialize
Compact UTF-8 JSON with no whitespace. Only quotes, backslashes and control characters are escaped. A float anywhere is refused.
Hash and sign
payload_hash is the SHA-256 of the bytes as lowercase hex. signature is Ed25519 over the same bytes with your registered key.
Assignment
The signer locks the org's counter, adds one and writes the event in the same transaction.
A redelivered duplicate rolls the transaction back, so no number is burned.
seq starts at 1, follows commit order and has no gaps.
Gap detection
GET /v1/audit/orgs/{id}/integrity scans the hot log from evacuated_through_seq + 1 to last_seq.
It lists every missing range. A hole below last_seq means a row was removed.
It cannot see the newest rows removed together with a rolled-back counter; the response says so in its note field.