Audit Events
10 operations under /v1/audit-events (GET POST PATCH): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Base URL: https://api.codespar.dev
Every operation below requires a Bearer token. See Authentication.
GET /v1/audit-events
https://api.codespar.dev/v1/audit-eventsBrowse the audit chain
A read-only page of the caller's own chain, newest first. This is the browse endpoint, not the export one: it emits no manifest, no signature and no audit_export chain entry, so paging through it leaves no trace and proves nothing offline.
Scoped to the credential's org with no way to widen it. event_type is an exact match unless it ends with a dot, in which case it is a prefix match, so tool_call. returns the succeeded and the failed variants in one pass. The default window is the last seven days.
actor_kind=person keeps the entries that name a member of the organization as their actor, and project_id the entries whose payload names that project; actor_person_id is that member on every entry, null when it names none.
An API key or an OAuth token reads its own project only: the entries whose payload names the credential's project, plus the organization-level entries, which name no project. A policy_evaluation recorded without a project is the exception and is not returned to a key, the same line GET /v1/policy-evaluations draws. Naming another project in project_id is refused with 403 audit_project_out_of_scope. The dashboard (service auth) reads the whole organization, and the x-codespar-project header does not narrow it. A key's page is therefore NOT contiguous in the chain: sequence_number skips the entries of other projects, and prev_hash of one entry is not the entry_hash of the one listed before it.
Pagination walks backwards: pass the previous response's next_before_sequence as before_sequence. It is null on the last page, and a full page whose sequence_number values happen to end at the chain floor still returns a non-null cursor, so the last call is the one that returns fewer rows than limit.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
actor_kind | "person" | no | person: only entries that name a member of the organization as their actor (see actor_person_id). Any other value is refused with invalid_actor_kind. |
before_sequence | integer | no | Returns entries strictly below this sequence. Use the previous page's next_before_sequence. |
event_type | string | no | Exact match, or a prefix match when it ends with a dot. |
from | string (date-time) | no | ISO 8601. Defaults to seven days ago. |
limit | integer | no | Clamped to 1..200. Defaults to 50, and falls back to 50 when the value is 0 or is not a number; a negative value becomes 1. |
project_id | string | no | Only entries whose payload names this project (project_id, else projectId). The chain has no project column, so an organization-level entry, which names no project, is not returned under this filter. A malformed id is refused with invalid_project_id. An API key or an OAuth token may name its own project only: any other is refused with 403 audit_project_out_of_scope. |
to | string (date-time) | no | ISO 8601. Defaults to now. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Bad Request. invalid_before_sequence when the cursor is not a number, invalid_iso_8601 when from or to is not a date this server can parse, from_after_to when the window is inverted, invalid_actor_kind and invalid_project_id when those filters are malformed. An out-of-range limit is NOT an error here: it is clamped. |
403 | object | Forbidden, in a bare body. audit_project_out_of_scope when an API key or an OAuth token names in project_id a project that is not its own. Service auth is never refused here. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
events | array of object | yes | — |
next_before_sequence | integer,null | yes | The lowest sequence on this page when it was full; null when it was not. |
curl -X GET https://api.codespar.dev/v1/audit-events \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/audit-events HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/audit-events",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const r = await cs.api.response("get", "/v1/audit-events");
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"events": [
{
"sequence_number": 0,
"event_type": "string",
"happened_at": "2026-01-15T12:00:00.000Z",
"payload": {},
"prev_hash": "string",
"entry_hash": "string",
"actor_person_id": "actorperson_0000000000000000"
}
],
"next_before_sequence": 0
}GET /v1/audit-events/anchors
https://api.codespar.dev/v1/audit-events/anchorsList audit-chain anchors
This organization's audit-chain anchors, newest first.
status filters among pending, sent, local_only and failed. local_only is a genuinely terminal state, not a late sent: the anchor is durable in this database and witnessed NOWHERE outside it. It used to be reported as sent, and the question that this made impossible to answer was exactly the one an auditor asks.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | no | 1 to 200; defaults to 50. |
status | "pending" | "sent" | "local_only" | "failed" | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | The query did not match the schema. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
anchors | array of object | yes | — |
curl -X GET https://api.codespar.dev/v1/audit-events/anchors \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/audit-events/anchors HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/audit-events/anchors",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/anchors", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/audit-events/anchors");{
"anchors": [
{
"id": "obj_0000000000000000",
"head_sequence": 0,
"head_hash": "string",
"signature": "string",
"signature_alg": "string",
"target": "string",
"target_status": "string",
"target_response": "string",
"delivery_attempts": 0,
"anchored_at": "2026-01-15T12:00:00.000Z",
"delivered_at": "2026-01-15T12:00:00.000Z"
}
]
}POST /v1/audit-events/anchors/verify
https://api.codespar.dev/v1/audit-events/anchors/verifyVerify an anchor document against our record
Takes an anchor document you kept and answers two SEPARATE questions: does the signature check out, and does our copy match?
They are not the same question, and that is why the verdict has four values instead of a boolean. A good signature with our row different (local_record_diverges) is the case an auditor needs to see; a good signature with no copy of ours (signature_ok_but_no_local_record) is a different problem and has a different name.
Verifying an anchor of ANOTHER organization is refused: the signature is derived from the owning organization's secret.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
anchor_id | string | yes | — |
anchored_at | string (date-time) | yes | — |
head_hash | string | yes | — |
head_sequence | integer | yes | — |
org_id | string | yes | — |
signature | string | yes | — |
signature_alg | "HMAC-SHA256" | yes | — |
version | 1 | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | The document did not match the schema. |
403 | object | The document's org_id is not the credential's. |
503 | object | The anchor secret is not configured in this deployment, so nothing can be verified here. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
anchor_id | string | yes | — |
head_sequence | integer | yes | — |
org_id | string | yes | — |
persisted_locally | boolean | yes | A row of ours exists with that anchor_id. |
persisted_matches | boolean | yes | And it matches the presented document field by field. |
signature_valid | boolean | yes | The signature checks out against this organization's secret. |
verdict | "verified" | "signature_ok_but_no_local_record" | "local_record_diverges" | "signature_invalid" | yes | The first three are NOT the same thing, and the distinction is the product: verified is a good signature with our record identical; signature_ok_but_no_local_record is a document we signed and of which we no longer hold a copy; local_record_diverges is a good signature with our row DIFFERENT, the case an auditor cares about. |
curl -X POST https://api.codespar.dev/v1/audit-events/anchors/verify \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"version": 1,
"anchor_id": "anchor_0000000000000000",
"org_id": "org_0000000000000000",
"head_sequence": 0,
"head_hash": "string",
"anchored_at": "2026-01-15T12:00:00.000Z",
"signature_alg": "HMAC-SHA256",
"signature": "string"
}'POST /v1/audit-events/anchors/verify HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"version": 1,
"anchor_id": "anchor_0000000000000000",
"org_id": "org_0000000000000000",
"head_sequence": 0,
"head_hash": "string",
"anchored_at": "2026-01-15T12:00:00.000Z",
"signature_alg": "HMAC-SHA256",
"signature": "string"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/audit-events/anchors/verify",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"version": 1,
"anchor_id": "anchor_0000000000000000",
"org_id": "org_0000000000000000",
"head_sequence": 0,
"head_hash": "string",
"anchored_at": "2026-01-15T12:00:00.000Z",
"signature_alg": "HMAC-SHA256",
"signature": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/anchors/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"version": 1,
"anchor_id": "anchor_0000000000000000",
"org_id": "org_0000000000000000",
"head_sequence": 0,
"head_hash": "string",
"anchored_at": "2026-01-15T12:00:00.000Z",
"signature_alg": "HMAC-SHA256",
"signature": "string"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/audit-events/anchors/verify", {
body: {
version: 1,
anchor_id: "anchor_0000000000000000",
org_id: "org_0000000000000000",
head_sequence: 0,
head_hash: "string",
anchored_at: "2026-01-15T12:00:00.000Z",
signature_alg: "HMAC-SHA256",
signature: "string"
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"anchor_id": "anchor_0000000000000000",
"org_id": "org_0000000000000000",
"head_sequence": 0,
"signature_valid": true,
"persisted_locally": true,
"persisted_matches": true,
"verdict": "verified"
}GET /v1/audit-events/config
https://api.codespar.dev/v1/audit-events/configRead the chain verification thresholds
The per-org settings the verifier and the health endpoint read: how far back the chain is walked, and how stale each age bucket is allowed to get before the health report says the verifier is behind.
Never 404. An org with no stored row reads the seeded defaults, with created_at and updated_at set to the time of the read rather than to a stored timestamp. Those defaults are computed in the handler, not written back, so a repeated read of an org with no row returns a moving updated_at — and GET /v1/audit-events/health reports that same org as verifying, because the absent row is what initial_walk_completed_at is read from.
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
cold_max_staleness_seconds | integer | yes | — |
created_at | string (date-time) | yes | — |
hot_max_staleness_seconds | integer | yes | — |
hot_window_days | integer | yes | — |
initial_walk_completed_at | string,null (date-time) | yes | Null until a forward walk reaches chain tip. |
org_id | string | yes | — |
updated_at | string (date-time) | yes | — |
verification_window_days | integer | yes | — |
warm_max_staleness_seconds | integer | yes | — |
warm_window_days | integer | yes | — |
curl -X GET https://api.codespar.dev/v1/audit-events/config \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/audit-events/config HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/audit-events/config",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/config", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/audit-events/config");{
"org_id": "org_0000000000000000",
"verification_window_days": 0,
"hot_window_days": 0,
"hot_max_staleness_seconds": 1,
"warm_window_days": 0,
"warm_max_staleness_seconds": 1,
"cold_max_staleness_seconds": 1,
"initial_walk_completed_at": "2026-01-15T12:00:00.000Z",
"created_at": "2026-01-15T12:00:00.000Z",
"updated_at": "2026-01-15T12:00:00.000Z"
}PATCH /v1/audit-events/config
https://api.codespar.dev/v1/audit-events/configChange the chain verification thresholds
A sparse patch: an omitted field keeps its current value, and the whole merged result is validated, not just the fields you sent. So a patch touching only hot_window_days can still be refused for ordering against the warm and verification windows it did not touch.
A field carrying anything other than a JSON number is IGNORED rather than refused, and the response then shows the unchanged value. A caller who sent "3600" as a string gets 200 and no change, so compare the response against what you sent.
Two invariants are enforced. hot_max_staleness_seconds may not exceed 259200 seconds, 72 hours, which is a regulatory floor on how long a chain may go unchecked and not a tunable. And the windows must be ordered hot_window_days <= warm_window_days <= verification_window_days. Neither warm_max_staleness_seconds nor cold_max_staleness_seconds has a ceiling of its own.
Admin or owner only, and only on service auth (the dashboard): an API key or an OAuth access token is refused with 403 human_session_required before the body is read, whatever x-codespar-user says. These thresholds decide how loudly a tampered chain reports itself. The x-codespar-user header is what the change is authorized against, but unlike an acknowledgement it is not stored on the row and not written to the chain, so the config row does not say who last changed it.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
cold_max_staleness_seconds | integer | no | — |
hot_max_staleness_seconds | integer | no | Capped at 259200 (72 hours). Above that the whole patch is refused. |
hot_window_days | integer | no | — |
verification_window_days | integer | no | — |
warm_max_staleness_seconds | integer | no | — |
warm_window_days | integer | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | object | Bad Request. regulatory_minimum_violation when the merged hot_max_staleness_seconds exceeds the ceiling, with limit carrying it as a number. window_ordering_invalid when the merged windows are out of order, with limit carrying the three values as an OBJECT so the caller can see which pair collided — the two bodies give limit different types, which is why they are a union here and not one schema. Nothing is written in either case. |
401 | object | Unauthorized. The x-codespar-user header is missing or blank. The credential itself was accepted; what is missing is who is acting. |
403 | object | Forbidden. human_session_required when the credential is anything but service auth (an API key or an OAuth access token): this operation is attributed to a person, and only the dashboard's service credential carries one, so a machine credential cannot perform it whatever x-codespar-user says. insufficient_role when the x-codespar-user member is not an admin or owner of the org. The credential check runs FIRST, before the body is read, so a key or token with no x-codespar-user header gets this 403 rather than the 401 below, and one with an invalid body gets it rather than a 400. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
cold_max_staleness_seconds | integer | yes | — |
created_at | string (date-time) | yes | — |
hot_max_staleness_seconds | integer | yes | — |
hot_window_days | integer | yes | — |
initial_walk_completed_at | string,null (date-time) | yes | Null until a forward walk reaches chain tip. |
org_id | string | yes | — |
updated_at | string (date-time) | yes | — |
verification_window_days | integer | yes | — |
warm_max_staleness_seconds | integer | yes | — |
warm_window_days | integer | yes | — |
curl -X PATCH https://api.codespar.dev/v1/audit-events/config \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"verification_window_days": 0,
"hot_window_days": 0,
"hot_max_staleness_seconds": 1,
"warm_window_days": 0,
"warm_max_staleness_seconds": 1,
"cold_max_staleness_seconds": 1
}'PATCH /v1/audit-events/config HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"verification_window_days": 0,
"hot_window_days": 0,
"hot_max_staleness_seconds": 1,
"warm_window_days": 0,
"warm_max_staleness_seconds": 1,
"cold_max_staleness_seconds": 1
}import os
import requests
res = requests.patch(
"https://api.codespar.dev/v1/audit-events/config",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"verification_window_days": 0,
"hot_window_days": 0,
"hot_max_staleness_seconds": 1,
"warm_window_days": 0,
"warm_max_staleness_seconds": 1,
"cold_max_staleness_seconds": 1
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/config", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"verification_window_days": 0,
"hot_window_days": 0,
"hot_max_staleness_seconds": 1,
"warm_window_days": 0,
"warm_max_staleness_seconds": 1,
"cold_max_staleness_seconds": 1
}),
});
const data = await res.json();const r = await cs.api.response("patch", "/v1/audit-events/config", {
header: {
"x-codespar-user": "string"
},
body: {
verification_window_days: 0,
hot_window_days: 0,
hot_max_staleness_seconds: 1,
warm_window_days: 0,
warm_max_staleness_seconds: 1,
cold_max_staleness_seconds: 1
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"org_id": "org_0000000000000000",
"verification_window_days": 0,
"hot_window_days": 0,
"hot_max_staleness_seconds": 1,
"warm_window_days": 0,
"warm_max_staleness_seconds": 1,
"cold_max_staleness_seconds": 1,
"initial_walk_completed_at": "2026-01-15T12:00:00.000Z",
"created_at": "2026-01-15T12:00:00.000Z",
"updated_at": "2026-01-15T12:00:00.000Z"
}GET /v1/audit-events/export
https://api.codespar.dev/v1/audit-events/exportExport audit events, with a signed manifest
Exports the audit events in the requested window, as JSON, with a SIGNED manifest at the end.
The response is streamed, not assembled. The body opens with {"export_id", "org_id", "rows": [ and then writes row by row straight from the cursor, so a consumer that expects a complete document in memory works against the design. The x-export-row-count header arrives before the first row.
The manifest signs the SCOPE, not just the content. Besides final_hash and row_count, it carries the filters applied: without this, an export narrowed to one agent was presented, honestly signed and intact, as if it were the whole day. The rows_digest covers the numbering, so a renumbering does not get past a final_hash that would still match.
An API key or an OAuth token cannot export, for now. The export covers every project of the organization, and a key reads its own project only (see GET /v1/audit-events). Narrowing the export to one project has to be stated in the signed scope, which is a change to the manifest; until it ships, a key or a token is refused with 403 audit_export_key_unavailable and the export answers service auth (the dashboard) only.
A window that is too large is refused BEFORE the stream opens, with the count and the ceiling in the body.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
agent_id | string | no | Narrows to one agent, and the manifest SAYS so. |
from | string | yes | Start of the window, ISO 8601. Required. |
to | string | yes | End of the window, ISO 8601. Required. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Query outside the schema, a date that is not ISO 8601, or from after to. Bare body, not the { error: { code } } envelope. |
403 | object | forbidden: the credential cannot export audit data for this organization. audit_export_key_unavailable: the caller is an API key or an OAuth token, and the export answers service auth only until it can be narrowed to one project. Bare body. |
413 | object | The requested window exceeds the row ceiling. row_count says how many there would be and limit says the ceiling: narrow the window. Bare body. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
export_id | string | yes | — |
export_signature | object | yes | — |
key_id | string | yes | — |
manifest | object | yes | — |
org_id | string | yes | — |
rows | array of object | yes | — |
curl -X GET https://api.codespar.dev/v1/audit-events/export \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/audit-events/export HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/audit-events/export",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/export", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const r = await cs.api.response("get", "/v1/audit-events/export", {
query: {
from: "string",
to: "string"
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"export_id": "export_0000000000000000",
"org_id": "org_0000000000000000",
"rows": [
{}
],
"manifest": {
"export_id": "export_0000000000000000",
"org_id": "org_0000000000000000",
"requested_by": "string",
"requested_at": "2026-01-15T12:00:00.000Z",
"date_range": {},
"scope_version": 0,
"filters": {},
"final_hash": "string",
"row_count": 1,
"rows_digest": "string"
},
"export_signature": {},
"key_id": "key_0000000000000000"
}GET /v1/audit-events/health
https://api.codespar.dev/v1/audit-events/healthWhat the verifier says about this chain
The chain's integrity report, and the endpoint to poll if you monitor one thing on this resource. It combines the background verifier's persisted watermark with a live re-check of the five newest rows performed during this request, so it keeps answering truthfully while the verifier worker is down.
Read actionable_status, not status. status is the older two-valued field kept for compatibility, and it folds FOUR distinct situations into degraded — a detected break, an open incident, an uncertified stretch of chain, and a chain whose verification has not reached its tip while rows sit above the watermark. It also reads healthy over catching_up, which is a real state with a real backlog.
This read has side effects, on purpose. A writer-tip failure opens an incident and an unverifiable segment is recorded, both keyed so that repeat polling refreshes one row rather than accumulating: evidence found while answering an HTTP request has to outlive the response, which the response alone does not.
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
actionable_status | "verifying" | "healthy" | "catching_up" | "link_unverifiable" | "degraded" | "broken" | yes | broken means a row is not what the chain says it is. degraded means an incident is open. link_unverifiable is the absence of proof rather than evidence of tampering: a stretch of chain has no verified link, either because the rows that would close it are gone or because a known break pins the verifier below it. catching_up is transient and self-healing. verifying means no walk has reached chain tip yet — which is not the same as no walk having finished. It is returned when there is no watermark row at all, and equally when initial_walk_completed_at is unset on the org's config row, which covers a walk that ran to completion without reaching the tip AND an org that has no config row at all. A chain can therefore sit at verifying indefinitely; use detail and verification.watermark_sequence to tell which case you are in. |
detail | string | yes | One human sentence explaining the status. Under verifying it is also the only field that separates the two ways to get there: it reads no verification run has ever completed when no watermark row exists, and initial verification walk in progress when one does. |
incidents | object | yes | — |
initial_walk | object | yes | — |
last_checked_at | string (date-time) | yes | — |
last_sequence_number | integer | yes | Chain tip. 0 for an empty chain. |
status | "healthy" | "degraded" | yes | The two-valued summary, kept for compatibility. degraded covers exactly four situations: a detected break (actionable_status broken), an open incident (degraded), an uncertified stretch of chain (link_unverifiable), and a chain still under verifying whose last_sequence_number is above verification.watermark_sequence. Read actionable_status for which one. Note what it does NOT cover: catching_up reads healthy here, on purpose. That state means the first walk DID complete and the verifier is merely behind on newer events, which is transient and self-healing. So healthy in this field is not the same claim as actionable_status: "healthy". |
verification | object | yes | — |
curl -X GET https://api.codespar.dev/v1/audit-events/health \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/audit-events/health HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/audit-events/health",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/health", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/audit-events/health");{
"status": "healthy",
"last_sequence_number": 0,
"last_checked_at": "2026-01-15T12:00:00.000Z",
"detail": "string",
"actionable_status": "verifying",
"verification": {
"window_days": 0,
"watermark_sequence": 0,
"watermark_at": "2026-01-15T12:00:00.000Z",
"watermark_entry_hash": "string",
"coverage_ratio": 0,
"hot_bucket_oldest_verified_at": "2026-01-15T12:00:00.000Z",
"hot_bucket_max_staleness_seconds": 1,
"writer_tip_check_passed": true,
"writer_tip_check_at": "2026-01-15T12:00:00.000Z",
"chain_link_check": {
"unverifiable_segments": 0,
"oldest_unverifiable_segment": {
"from_sequence": 0,
"to_sequence": 0,
"reason": "sequence_gap",
"first_detected_at": "2026-01-15T12:00:00.000Z"
},
"writer_tip_link_unverifiable": true,
"watermark_pinned_by_break_at": 0
}
},
"incidents": {
"open_count": 1,
"acknowledged_count_30d": 1,
"open_truncated": true,
"open": [
{
"id": "obj_0000000000000000",
"detected_at": "2026-01-15T12:00:00.000Z",
"detected_at_sequence": 0
}
]
},
"initial_walk": {
"in_progress": true,
"started_at": "2026-01-15T12:00:00.000Z",
"events_processed": 0
}
}GET /v1/audit-events/incidents
https://api.codespar.dev/v1/audit-events/incidentsList chain-integrity incidents
Every incident is a detected break in the org's own chain, opened by the verifier or by a health read. Newest first, capped at 100 rows, and NOT paginated: an org with more than 100 incidents cannot reach the older ones from here.
status defaults to open. It is a comma-separated list; open, acknowledged and all are recognized and any other entry is silently dropped, so open,bogus behaves as open. all anywhere in the list returns every incident. Only a list where nothing is recognized is refused — including an empty status=, which is a present-but-unrecognized value rather than an absent one and so does not fall back to the default.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
status | string | no | Comma-separated: open, acknowledged, all. Defaults to open. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Bad Request. invalid_status when none of the comma-separated values is one of open, acknowledged or all. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
incidents | array of object | yes | — |
curl -X GET https://api.codespar.dev/v1/audit-events/incidents \
-H "Authorization: Bearer $CODESPAR_API_KEY"GET /v1/audit-events/incidents HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEYimport os
import requests
res = requests.get(
"https://api.codespar.dev/v1/audit-events/incidents",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/incidents", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
},
});
const data = await res.json();const result = await cs.api.get("/v1/audit-events/incidents");{
"incidents": [
{
"id": "obj_0000000000000000",
"org_id": "org_0000000000000000",
"detected_at": "2026-01-15T12:00:00.000Z",
"detected_at_sequence": 0,
"discovered_by_run_id": "discoveredbyrun_0000000000000000",
"status": "open",
"acknowledged_at": "2026-01-15T12:00:00.000Z",
"acknowledged_by": "string",
"incident_reference": "string",
"resolution_summary": "string",
"anpd_notification_status": "not_required",
"personal_data_affected": true,
"affected_from_sequence": 0,
"affected_to_sequence": 0,
"ack_event_entry_hash": "string",
"created_at": "2026-01-15T12:00:00.000Z"
}
]
}POST /v1/audit-events/incidents/{id}/acknowledge
https://api.codespar.dev/v1/audit-events/incidents/{id}/acknowledgeAcknowledge a chain-integrity incident
Closes an open incident with a written resolution, and appends that statement to the audit chain as an entry of its own, so the acknowledgement is auditable by the same walk as the break it answers. The new entry's hash comes back on the incident as ack_event_entry_hash, and its sequence as chain_sequence_number.
A chain break has no chain-level repair. Acknowledging is a statement about your process, not a fix: the verifier stays pinned below the break and GET /v1/audit-events/health keeps reporting the stretch above it as uncertified afterwards.
This is a person's act, not a key's: only service auth (the dashboard) may call it. An API key or an OAuth access token is refused with 403 human_session_required before the body is read, whatever x-codespar-user says, and on the service call that header must name an admin or owner. That value is stored as the incident's acknowledged_by and carried into the chain entry, which is what makes the acknowledgement attributable. id is the incident UUID the list returns; an incident belonging to another org is 404, the same body as one that does not exist.
Not idempotent, and does not pretend to be. A second acknowledgement of the same incident is 409 with the incident as it stands, which is also what a concurrent acknowledgement that lost the race receives: the update only moves open to acknowledged, and the losing transaction rolls its chain entry back rather than writing a second one.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | yes | The incident UUID. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
affected_from_sequence | number,null | no | Send with affected_to_sequence or with neither. One without the other is refused, and from above to is refused. |
affected_to_sequence | number,null | no | — |
anpd_notification_status | "not_required" | "notified" | yes | Terminal only. pending_assessment is refused with its own code: an incident cannot be closed while the notification question is still open. |
incident_reference | string,null | no | Your ticket or case number. Control characters are stripped; null is the same as omitting it. |
personal_data_affected | boolean | yes | Required. There is no default. |
resolution_summary | string | yes | Required. C0 control characters and DEL are stripped before both the emptiness check and the length check, so the 8000 limit is measured on the stripped text and a summary that is only whitespace counts as empty. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | object | Bad Request. pending_assessment_not_terminal when anpd_notification_status is pending_assessment, which is a working state and never a closing one; it is checked before everything else. Otherwise field_invalid, naming the field and why: empty and too_long for resolution_summary (with limit: 8000), invalid_type and too_long for incident_reference (with limit: 256), invalid_enum for anpd_notification_status, invalid_type for personal_data_affected, and paired_required, invalid_type or range_inconsistent for the affected-sequence pair. One field is reported per response, the first that fails. Note that a resolution_summary that is not a string reports empty, not invalid_type. |
401 | object | Unauthorized. The x-codespar-user header is missing or blank. The credential itself was accepted; what is missing is who is acting. |
403 | object | Forbidden. human_session_required when the credential is anything but service auth (an API key or an OAuth access token): this operation is attributed to a person, and only the dashboard's service credential carries one, so a machine credential cannot perform it whatever x-codespar-user says. insufficient_role when the x-codespar-user member is not an admin or owner of the org. The credential check runs FIRST, before the body is read, so a key or token with no x-codespar-user header gets this 403 rather than the 401 below, and one with an invalid body gets it rather than a 400. |
404 | object | Not Found. No open or acknowledged incident with that id belongs to the caller's org. An incident that exists under another tenant is indistinguishable from one that does not exist. |
409 | object | Conflict. The incident is already acknowledged, either from an earlier call or from one that won a concurrent race. incident carries the current row, and is null only if the row disappeared between losing the race and being re-read. |
503 | object | Service Unavailable. lock_timeout: the per-org chain lock was not granted within the timeout, so nothing was written. Safe to retry. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
chain_sequence_number | integer | yes | Sequence of the chain entry this acknowledgement wrote. |
incident | object | yes | — |
curl -X POST https://api.codespar.dev/v1/audit-events/incidents/{id}/acknowledge \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"resolution_summary": "string",
"anpd_notification_status": "not_required",
"personal_data_affected": true,
"incident_reference": "string",
"affected_from_sequence": 0,
"affected_to_sequence": 0
}'POST /v1/audit-events/incidents/{id}/acknowledge HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"resolution_summary": "string",
"anpd_notification_status": "not_required",
"personal_data_affected": true,
"incident_reference": "string",
"affected_from_sequence": 0,
"affected_to_sequence": 0
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/audit-events/incidents/{id}/acknowledge",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"resolution_summary": "string",
"anpd_notification_status": "not_required",
"personal_data_affected": True,
"incident_reference": "string",
"affected_from_sequence": 0,
"affected_to_sequence": 0
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/incidents/{id}/acknowledge", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"resolution_summary": "string",
"anpd_notification_status": "not_required",
"personal_data_affected": true,
"incident_reference": "string",
"affected_from_sequence": 0,
"affected_to_sequence": 0
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/audit-events/incidents/{id}/acknowledge", {
path: {
id: "incident_0000000000000000"
},
header: {
"x-codespar-user": "string"
},
body: {
resolution_summary: "string",
anpd_notification_status: "not_required",
personal_data_affected: true,
incident_reference: "string",
affected_from_sequence: 0,
affected_to_sequence: 0
}
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
console.log(r.data);
}{
"incident": {
"id": "obj_0000000000000000",
"org_id": "org_0000000000000000",
"detected_at": "2026-01-15T12:00:00.000Z",
"detected_at_sequence": 0,
"discovered_by_run_id": "discoveredbyrun_0000000000000000",
"status": "open",
"acknowledged_at": "2026-01-15T12:00:00.000Z",
"acknowledged_by": "string",
"incident_reference": "string",
"resolution_summary": "string",
"anpd_notification_status": "not_required",
"personal_data_affected": true,
"affected_from_sequence": 0,
"affected_to_sequence": 0,
"ack_event_entry_hash": "string",
"created_at": "2026-01-15T12:00:00.000Z"
},
"chain_sequence_number": 0
}POST /v1/audit-events/verify
https://api.codespar.dev/v1/audit-events/verifyVerify the chain over an interval
Walks your chain across the interval and answers with a verdict: verified, broken with the first violated sequence, or unverified when a stretch of it could not be checked at all.
Before this existed a client had to COMPOSE that answer — list the interval, read watermark_sequence off /v1/audit-events/health, and call the interval verified when every sequence sat at or below it. The deduction is sound and conservative, and it is still the client asserting something this server never said. This operation says it. It runs the same walk as the background verifier: the same link check, the same hash recompute, the same tombstone-witness rule, over the same rows.
unverified is NOT a weaker verified and NOT a broken. It means part of the interval has no verifiable link — rows are missing, or the first row has no predecessor to measure against — so nothing here certifies that stretch either way. unverified_segments names them, capped at 20 entries while unverified_segment_count stays the true total.
THE INTERVAL IS STATED IN TIME AND THE CHAIN IS ORDERED BY SEQUENCE. from/to select rows by happened_at; the walk then covers the contiguous sequence span those rows occupy, reported as sequence_range, because a link can only be checked against the row immediately before it. That span can hold more rows than the window does, which is why events_walked is reported next to events_in_interval.
BOUNDED BY ROWS, not by days: at most 50,000 rows in the span, or the request is refused with 413 interval_too_large and the count, so you can split it. A day count would bound nothing — happened_at is written by whoever appended the event, so an hour-wide window can hold an entire chain.
Unlike GET /v1/audit-events/health, this read has NO side effects. It opens no incident, records no unverifiable segment and moves no watermark; poll it as often as you like.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Start of the interval, inclusive. ISO 8601, read against happened_at. |
to | string | yes | End of the interval, inclusive. ISO 8601, read against happened_at. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | Bad Request. invalid_body when from or to is missing or not a string, invalid_iso_8601 when either is not a date this server can parse, from_after_to when the interval is inverted. |
413 | object | The sequence span covered by the interval holds more rows than one request will walk. Split the interval; row_count and limit say by how much. |
422 | object | The interval holds no audit events, so there is no verdict to give. Deliberately NOT a verified 200: a walk over nothing trivially finds nothing wrong, and a clean verdict for a period the chain says nothing about is the one claim a proof bundle must not carry — a mistyped date and an intact month would read identically. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
checked_at | string (date-time) | yes | When this walk ran. The verdict is about the rows as they stood then. |
events_in_interval | integer | yes | Rows whose happened_at falls inside the interval. |
events_verified | integer | yes | Rows that passed both checks. Below events_walked when the walk stopped at a break. |
events_walked | integer | yes | Rows inside sequence_range. At least events_in_interval, and larger when rows written with a happened_at outside the window sit between them. |
first_violation | object,null | yes | The FIRST violated sequence. The walk stops there; later rows are not reported. |
interval | object | yes | — |
sequence_range | object | yes | The contiguous sequence span the interval's rows occupy, and what was walked. |
status | "verified" | "unverified" | "broken" | yes | verified — every row in the span linked to the one before it and hashed to its stored entry_hash. broken — a row was caught lying, named by first_violation. unverified — nothing was caught, and part of the span has no verifiable link, so it is certified neither way. |
unverified_segment_count | integer | yes | — |
unverified_segments | array of object | yes | A sample, capped at 20. unverified_segment_count is the total. |
verified_through_sequence | integer,null | yes | The last sequence that verified. null when the very first row of the span did not, which is also the shape of a break at sequence_range.from. |
curl -X POST https://api.codespar.dev/v1/audit-events/verify \
-H "Authorization: Bearer $CODESPAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "string",
"to": "string"
}'POST /v1/audit-events/verify HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json
{
"from": "string",
"to": "string"
}import os
import requests
res = requests.post(
"https://api.codespar.dev/v1/audit-events/verify",
headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
json={
"from": "string",
"to": "string"
},
)
res.raise_for_status()
data = res.json()const res = await fetch("https://api.codespar.dev/v1/audit-events/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"from": "string",
"to": "string"
}),
});
const data = await res.json();const r = await cs.api.response("post", "/v1/audit-events/verify", {
body: {
from: "string",
to: "string"
}
});
// r.status is one of the documented statuses (200, 422),
// each with its own body shape in r.data; nothing here throws on 422.
if (r.ok) {
console.log(r.data);
}{
"status": "verified",
"interval": {
"from": "2026-01-15T12:00:00.000Z",
"to": "2026-01-15T12:00:00.000Z"
},
"sequence_range": {
"from": 0,
"to": 0
},
"events_in_interval": 0,
"events_walked": 0,
"events_verified": 0,
"verified_through_sequence": 0,
"first_violation": {
"sequence_number": 0,
"check": "chain_link_break_detected",
"detail": "string"
},
"unverified_segments": [
{
"from_sequence": 0,
"to_sequence": 0,
"reason": "sequence_gap",
"detail": "string"
}
],
"unverified_segment_count": 1,
"checked_at": "2026-01-15T12:00:00.000Z"
}Audit
3 operations under /v1/audit (GET POST): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Consent Records
2 operations under /v1/consent-records (GET): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.