Events
Append signed, hashed records of things that happened in your system, then verify them later.
An event is a JSON payload with an event_type, sent once and kept as it was. On ingest the backend canonicalizes the payload (object keys sorted recursively, compact serialization), hashes it with SHA-256, assigns a UUID and an ingested_at timestamp, and replies 201 as soon as the event is accepted onto a durable queue.
A worker then signs the event with the tenant's Ed25519 key and writes it to the append-only ledger together with three hashes: payload_hash (SHA-256 of the canonical payload), event_hash (SHA-256 of the payload re-serialized compactly in the key order it was sent, without sorting) and request_hash (SHA-256 of the ingest envelope: event_id, tenant, key, ingested_at, event_type, event_time, payload, idempotency key and payload_hash). The signature, the signing public key and the exact bytes that were signed are stored with the row and exposed on the public proof endpoint GET /v1/proof/event/{event_id}.
Later, anyone holding the original payload can submit it (or its hash) to the verify endpoint and get back whether it matches what was anchored. Ledger rows are never deleted; 30 days after the plan's retention window ends a row is sealed, which hides it from get and list but leaves it verifiable.
event_type | 1 to 128 bytes of UTF-8 after trimming surrounding whitespace |
|---|---|
| payload | up to 64 KB (65,536 bytes) once serialized as canonical JSON |
| Idempotency-Key header | 1 to 128 bytes after trimming |
| Request body | 2 MB, the framework default; the API routes set no override |
| List page size | limit is clamped to 1 to 500; default 50 |
| Events per month | set by plan; 429 quota_exceeded once the monthly count is reached |
| API verifications per month | set by plan; every call to the verify endpoint that passes authentication counts one, including calls rejected for a malformed body |
| Requests per second | set by plan, default 10; the per-minute budget is 60 times that, shared by all keys of a tenant |
| List cache | list responses are cached for 30 seconds per tenant and query |
/v1/eventsIngest an event
Accepts one event, hashes its payload, queues it for signing and storage, 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.
event_typeFree-form classifier for the event, for example policy.approval; surrounding whitespace is trimmed before validation.
payloadThe JSON to anchor; stored as sent and hashed in canonical form (keys sorted, compact), so key order does not affect payload_hash.
event_timeWhen the event happened in your system; stored alongside ingested_at and included in the idempotency request hash when present.
trace_idId of an open trace owned by the same tenant; the event is attached to that trace and the trace must not be sealed or sealing.
- The 201 means the event is on a durable queue; a worker signs it and writes the ledger row moments later, so a get immediately after ingest can return 404 event_not_found until the row lands.
- The monthly events quota is checked and counted before event_type, payload size and trace_id are validated, so a request rejected with one of those errors still counts one event for the period.
- The idempotency request hash covers event_type, event_time (when sent) and the canonical payload; it is scoped to the tenant, the API key and this endpoint.
- 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.
- Any JSON value is accepted as payload by the backend; the SDKs type it as an object, which is what the ledger is designed for.
- A body that is not valid JSON, that lacks event_type or payload, or that is sent without Content-Type: application/json is rejected by the framework before the handler runs with a plain-text 400, 415 or 422 rather than a JSON error body.
- retention_policy and expires_at are not request fields; they are derived from the plan's retention_days when the row is written (short up to 90 days, standard up to 365, extended up to 1,825, regulatory up to 2,555, otherwise indefinite with expires_at null).
- trace_id is accepted here but not returned by get or list; read the trace endpoints to see which events a trace holds.
- ingested_at is serialized as RFC 3339 in UTC with a fractional second and a Z suffix, for example 2026-09-22T08:14:07.512345678Z.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const result = await client.events.ingest({
eventType: "policy.approval",
eventTime: "2026-09-22T08:14:07Z",
payload: {
policy_id: "pol_8472",
approved_by: "risk_committee",
decision: "approved",
},
idempotencyKey: "policy-approval-pol_8472",
});
console.log(result.event_id, result.ingested_at);
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"ingested_at": "2026-09-22T08:14:07Z"
}
event_idId assigned to the event; use it with get, verify and the public proof page.
ingested_atServer time at which the event was accepted; this is the timestamp that gets signed.
invalid_event_typeevent_type is empty after trimming or longer than 128 bytes.
payload_too_largeThe canonical JSON form of payload is larger than 65,536 bytes.
invalid_payloadpayload could not be serialized as JSON.
invalid_idempotency_keyIdempotency-Key is present but empty after trimming, longer than 128 bytes or not valid UTF-8; this check runs before the API key is checked.
invalid_api_contextThe authenticated context has no API key id; not expected for key-authenticated calls.
payment_requiredThe tenant is on a paid plan whose subscription is past due.
insufficient_scopeThe key does not have the write scope.
feature_not_availableThe plan's events_per_month limit is 0.
trace_not_foundtrace_id was given but no trace with that id belongs to the tenant.
idempotency_key_reuse_mismatchThe Idempotency-Key was already used by this key on this endpoint with a different event_type, event_time or payload.
trace_sealedtrace_id points at a trace that has already been sealed.
trace_sealingtrace_id points at a trace that is being sealed right now.
trace_invalid_statustrace_id points at a trace whose status is neither open, sealing nor sealed.
quota_exceededThe tenant has used its monthly events quota for the current billing period.
ingest_failedThe event could not be published to the queue; the idempotency reservation is released so a retry can succeed.
encode_failedThe envelope or the response could not be serialized; the idempotency reservation is released.
quota_check_failedThe plan limits or the quota counter could not be read; the request is rejected rather than allowed through.
missing_api_keyNo Authorization header and no X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo key with that value exists.
api_key_revokedThe key was revoked in the dashboard.
ip_not_allowedThe key has an IP allowlist and the caller's IP is not on it.
rate_limitedThe tenant went over its per-second or per-minute request budget; Retry-After says how long to wait.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/eventsList events
Returns a page of the tenant's active events, newest first, with the hashes of each and the total count for the filter.
page1-based page number; values below 1 are treated as 1.
limitEvents per page; values outside the range are clamped, not rejected.
date_fromOnly events with ingested_at at or after this time (inclusive).
date_toOnly events with ingested_at before this time (exclusive).
event_typeOnly events whose event_type equals this value exactly.
- Ordering is by ingested_at descending and cannot be changed.
- Only rows with access_tier active are listed and counted; sealed rows (past retention) are left out.
- date_from and date_to filter on ingested_at, not on event_time.
- Each distinct combination of tenant, page, limit, date_from, date_to and event_type is cached for 30 seconds, so a freshly written event can take up to 30 seconds to appear on a page that was just requested.
- The full payload is not included; use get for one event's payload and request_hash.
- A page or limit value that is not a whole number is rejected by the framework with a plain-text 400 before the handler runs.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const page = await client.events.list({
page: 1,
limit: 50,
eventType: "policy.approval",
});
console.log(page.total, page.has_more);
for (const event of page.events) {
console.log(event.event_id, event.ingested_at, event.payload_hash);
}
{
"events": [
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"event_type": "policy.approval",
"payload_hash": "f3c57489bbda1ac2876ce8cef9f7778e07ce2eecd5021907ad2ca2cc3895011d",
"event_hash": "9831ee77aaae90c0b9cbf5d731c3285967ed73847d9ad7e0d8ec0a39de3c09c7",
"retention_policy": "standard",
"ingested_at": "2026-09-22T08:14:07Z",
"event_time": "2026-09-22T08:14:07Z",
"idempotency_key": "policy-approval-pol_8472"
},
{
"event_id": "b04d7e2a-6c13-4f58-8a9e-51d2c7f0e3ab",
"event_type": "policy.approval",
"payload_hash": "d9298a10d1b0735837dc4bd85dac641b0f3cef27a47e5d53a54f2f3f5b2fcffa",
"event_hash": "85531eb3c2dec71c359eff539f98c696c07a89340eeaef6411502d1c36f9044b",
"retention_policy": "standard",
"ingested_at": "2026-09-21T17:02:44Z"
}
],
"page": 1,
"limit": 50,
"total": 2,
"has_more": false
}
eventsThe events on this page, ordered by ingested_at descending.
events[].event_idId of the event.
events[].event_typeClassifier given at ingest.
events[].payload_hashSHA-256 of the canonical payload, 64 hex characters.
events[].event_hashSHA-256 of the payload re-serialized as compact JSON in the key order it was sent, without sorting, 64 hex characters.
events[].retention_policyRetention class derived from the plan when the row was written. One of short, standard, extended, regulatory, indefinite.
events[].ingested_atWhen the backend accepted the event.
events[].event_timeThe event_time given at ingest; omitted when none was sent.
events[].idempotency_keyThe Idempotency-Key sent at ingest; omitted when none was sent.
pageThe page that was returned.
limitThe page size that was applied after clamping.
totalNumber of active events matching the filter across all pages.
has_moreTrue when page times limit is still below total.
insufficient_scopeThe key has neither the read nor the write scope.
db_errorThe count or data query failed.
missing_api_keyNo Authorization header and no X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo key with that value exists.
api_key_revokedThe key was revoked in the dashboard.
ip_not_allowedThe key has an IP allowlist and the caller's IP is not on it.
rate_limitedThe tenant went over its per-second or per-minute request budget; Retry-After says how long to wait.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/events/{event_id}Get an event
Returns one event with its stored payload, its three ledger hashes, its retention details and the issuing organization.
event_idThe event_id returned by ingest.
- The ledger row also stores the Ed25519 signature, the signing public key and the signed bytes (canonical JSON of v, event_id, tenant_id, event_type, payload_hash, request_hash and ingested_at), but this endpoint does not return them; they are exposed hex-encoded as signature, public_key and signed_payload on the public proof endpoint GET /v1/proof/event/{event_id}, which needs no API key.
- user_id is omitted from the body when null; event_time, expires_at, api_key_id and idempotency_key are present with a null value when unset.
- Sealed rows return 410 rather than 404 so a client can tell an expired record from one that never existed; sealing happens 30 days after expires_at, and during those 30 days the row is still returned.
- trace_id, source and access_tier are stored on the row but not returned by this endpoint.
- ingested_at, event_time and expires_at are serialized as RFC 3339 in UTC with a fractional second and a Z suffix, for example 2026-09-22T08:14:07.512345678Z.
- Every SDK models this response as ComplianceEvent with optional access_tier and organization fields; the backend always sends organization and never sends access_tier.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const event = await client.events.get("7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4");
console.log(event.event_type, event.ingested_at);
console.log(event.payload_hash, event.event_hash, event.request_hash);
console.log(event.payload);
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"tenant_id": "0a4f6d18-2b9c-4e7a-8d31-6c5e9f2a7b40",
"event_type": "policy.approval",
"payload": {
"policy_id": "pol_8472",
"approved_by": "risk_committee",
"decision": "approved"
},
"event_time": "2026-09-22T08:14:07Z",
"retention_policy": "standard",
"expires_at": "2027-09-22T08:14:07Z",
"api_key_id": "5e2c8b7a-1d4f-4c93-a6e0-9b3f7d2c1e58",
"ingested_at": "2026-09-22T08:14:07Z",
"payload_hash": "f3c57489bbda1ac2876ce8cef9f7778e07ce2eecd5021907ad2ca2cc3895011d",
"request_hash": "15142ed07f2ed8fc3f7a50ba6d980d2455ad35ba95d8744759b7afe53f092402",
"event_hash": "9831ee77aaae90c0b9cbf5d731c3285967ed73847d9ad7e0d8ec0a39de3c09c7",
"idempotency_key": "policy-approval-pol_8472",
"organization": {
"name": "Northwind Compliance",
"issuer_name": "Northwind Compliance Ltd",
"primary_domain": "northwind.example",
"domain_verified": true,
"domain_verified_at": "2026-08-30T11:20:15Z",
"logo_url": "https://cdn.invoance.com/logos/northwind.png"
}
}
event_idId of the event.
tenant_idTenant that owns the event.
event_typeClassifier given at ingest, after trimming.
payloadThe payload as it was sent, read back from a JSONB column, so object key order can differ from the order sent.
event_timeThe event_time given at ingest; null when none was sent.
retention_policyRetention class derived from the plan's retention_days when the row was written. One of short, standard, extended, regulatory, indefinite.
expires_atingested_at plus the plan's retention_days; null when the policy is indefinite.
api_key_idId of the API key that ingested the event; null for events created from the dashboard.
user_idId of the dashboard user who created the event; omitted for events ingested with an API key.
ingested_atWhen the backend accepted the event; this timestamp is part of the signed bytes.
payload_hashSHA-256 of the canonical payload (keys sorted recursively, compact), 64 hex characters; this is the hash the verify endpoint reports as anchored_hash.
request_hashSHA-256 of the ingest envelope (v, event_id, tenant_id, api_key_id, user_id, ingested_at, event_type, event_time, payload, idempotency_key, payload_hash), 64 hex characters; it is unique per ingest even for identical payloads.
event_hashSHA-256 of the payload re-serialized as compact JSON in the key order it was sent, without key sorting, 64 hex characters.
idempotency_keyThe Idempotency-Key sent at ingest; null when none was sent.
organizationIssuer identity of the organization that owns the event, taken from its organization profile.
organization.nameOrganization name.
organization.issuer_nameName shown as the issuer on proof pages.
organization.primary_domainPrimary domain registered for the organization.
organization.domain_verifiedTrue when the primary domain passed DNS verification.
organization.domain_verified_atWhen the domain was verified; omitted when the domain is not verified.
organization.logo_urlLogo URL; omitted when the organization has not uploaded one.
invalid_path_parameterevent_id is not a UUID.
insufficient_scopeThe key has neither the read nor the write scope.
event_not_foundNo event with this id belongs to the tenant, or the worker has not written the row yet.
retention_expiredThe event exists but the retention worker sealed it 30 days after expires_at; it is still verifiable and can be unsealed by a plan upgrade.
db_errorThe lookup query failed.
missing_api_keyNo Authorization header and no X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo key with that value exists.
api_key_revokedThe key was revoked in the dashboard.
ip_not_allowedThe key has an IP allowlist and the caller's IP is not on it.
rate_limitedThe tenant went over its per-second or per-minute request budget; Retry-After says how long to wait.
api_key_lookup_failedThe key could not be looked up in the database.
/v1/events/{event_id}/verifyVerify an event
Compares a payload or a hash you hold against the hashes anchored for the event and returns whether they match, which stored hash matched, and when the event was anchored.
Content-TypeMust be application/json.
event_idThe event to verify against.
payload_hashA SHA-256 digest you computed yourself; send this or payload, never both.
payloadThe original JSON; the backend canonicalizes it (keys sorted, compact) and hashes it with SHA-256 for you.
- The submitted hash is compared against payload_hash first, then request_hash, then event_hash; matched_field names the first one that was equal.
- This is a hash comparison only; the response has no signature field. To check the Ed25519 signature yourself, fetch signature, public_key and signed_payload from GET /v1/proof/event/{event_id} and verify them with any Ed25519 library.
- Sealed events (past retention) can still be verified here; only get and list hide them.
- The quota is checked and counted before the body is validated, so every authenticated call counts one against the plan's monthly API verifications quota, including one rejected with 400 bad_request; a call that reaches the comparison is also recorded as a verification event visible in the dashboard, whether or not it matched.
- When you send payload_hash, compute it over the canonical form of the payload (object keys sorted recursively at every level, no whitespace, UTF-8) or it will only match event_hash when your compact serialization in the original key order equals what the backend computed.
- A verify right after ingest can return 404 event_not_found until the worker writes the row; once found, the event's hashes and organization are cached for 60 seconds per event_id.
- All eight SDKs refuse the call client-side when neither payload nor payload_hash is given and check that payload_hash is 64 hex characters before sending.
- A body that is not valid JSON, or that is sent without Content-Type: application/json, is rejected by the framework with a plain-text 400, 415 or 422 rather than the JSON error shape.
import { InvoanceClient } from "invoance";
// Reads INVOANCE_API_KEY from the environment.
const client = new InvoanceClient();
const result = await client.events.verify("7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4", {
payload: {
policy_id: "pol_8472",
approved_by: "risk_committee",
decision: "approved",
},
});
console.log(result.match_result, result.matched_field);
console.log(result.anchored_hash, result.submitted_hash, result.anchored_at);
{
"event_id": "7c1e4b52-9a0f-4d2e-b6f3-2f8a61c0d9e4",
"match_result": true,
"matched_field": "payload_hash",
"anchored_hash": "f3c57489bbda1ac2876ce8cef9f7778e07ce2eecd5021907ad2ca2cc3895011d",
"submitted_hash": "f3c57489bbda1ac2876ce8cef9f7778e07ce2eecd5021907ad2ca2cc3895011d",
"anchored_at": "2026-09-22T08:14:07Z",
"method": "payload",
"organization": {
"name": "Northwind Compliance",
"issuer_name": "Northwind Compliance Ltd",
"primary_domain": "northwind.example",
"domain_verified": true,
"domain_verified_at": "2026-08-30T11:20:15Z",
"logo_url": "https://cdn.invoance.com/logos/northwind.png"
}
}
event_idThe event that was checked.
match_resultTrue when the submitted hash equals the stored payload_hash, request_hash or event_hash.
matched_fieldWhich stored hash matched; null when match_result is false. One of payload_hash, request_hash, event_hash.
anchored_hashThe stored payload_hash of the event, 64 hex characters, returned whether or not it matched.
submitted_hashThe hash that was compared: payload_hash as sent, or the SHA-256 of the canonical form of payload.
anchored_atThe event's ingested_at.
methodWhich body field was used. One of hash, payload.
organizationIssuer identity of the organization that owns the event, taken from its organization profile.
organization.nameOrganization name.
organization.issuer_nameName shown as the issuer on proof pages.
organization.primary_domainPrimary domain registered for the organization.
organization.domain_verifiedTrue when the primary domain passed DNS verification.
organization.domain_verified_atWhen the domain was verified; omitted when the domain is not verified.
organization.logo_urlLogo URL; omitted when the organization has not uploaded one.
bad_requestBoth payload_hash and payload were sent, neither was sent, payload_hash is not 64 hex characters, or payload could not be serialized.
invalid_path_parameterevent_id is not a UUID.
payment_requiredThe tenant is on a paid plan whose subscription is past due.
insufficient_scopeThe key has neither the read nor the write scope.
feature_not_availableThe plan's api_verifications_per_month limit is 0.
event_not_foundNo event with this id belongs to the tenant.
quota_exceededThe tenant has used its monthly API verifications quota for the current billing period.
db_errorThe lookup query failed.
quota_check_failedThe quota counter could not be read; the request is rejected rather than allowed through.
missing_api_keyNo Authorization header and no X-API-Key header was sent.
invalid_authorization_schemeThe Authorization header is present but does not start with Bearer.
invalid_api_key_formatThe key does not start with invoance_live_.
invalid_api_keyNo key with that value exists.
api_key_revokedThe key was revoked in the dashboard.
ip_not_allowedThe key has an IP allowlist and the caller's IP is not on it.
rate_limitedThe tenant went over its per-second or per-minute request budget; Retry-After says how long to wait.
api_key_lookup_failedThe key could not be looked up in the database.
| canonical JSON | The payload with object keys sorted by code point at every level and serialized without whitespace; this is what payload_hash is computed over. |
|---|---|
payload_hash | SHA-256 of the canonical payload, 64 hex characters; the hash the verify endpoint reports as anchored_hash. |
event_hash | SHA-256 of the payload re-serialized as compact JSON in the key order it was sent, without key sorting. |
request_hash | SHA-256 of the whole ingest envelope, including event_id and ingested_at, so two identical payloads still get different values. |
| signed payload | Canonical JSON of v, event_id, tenant_id, event_type, payload_hash, request_hash and ingested_at, signed with the tenant's Ed25519 key and stored with the row. |
retention_policy | One of short, standard, extended, regulatory or indefinite, derived from the plan's retention_days when the row was written. |
| sealed | A row the retention worker retired 30 days after expires_at; it stays in the ledger and stays verifiable, but get returns 410 and list leaves it out. |
| Idempotency-Key | A client-chosen header value that lets a retry of the same ingest return the original 201 instead of writing a second event. |