Back to all guides
Audit Log

Audit Log API: Read and Export the Tamper-Proof Audit Trail

Page through your project's signed audit-event batches, the chain manifests that link them, and the signed checkpoints with the Audit Log REST API, fetch the public signing keys, and check an export independently of the platform.

What is the Audit Log API?

Every audit event the platform records for a project - Capture upserts and deletes, configuration changes, token introspections, AuthZEN decisions and searches, ContX IQ executions, CDC changes - is appended to that project's chain. Events are collected into batches; each batch is hashed and signed, a signed manifest links it to the batch before it, and a signed checkpoint periodically fixes the chain's head. The Audit Log API is the read side of that chain: it pages through the batches, the manifests and the checkpoints of one project, and publishes the keys that signed them, so an export can be kept and checked without an IndyKite account.

The events are the same indykite.audit.* events that Outbound Events can push to your own Kafka, Event Grid, Service Bus or Pub/Sub. Outbound Events deliver them as they happen; the Audit Log API keeps them, signed and linked. Who manages the signing key - the platform, or your own key store in GCP KMS, AWS KMS or Azure Key Vault - is declared by the Audit Signing configuration; the kid on every record names the key that signed it.

The chain: events are cut into batches, each batch is hashed and signed, a manifest per batch links to the previous one through prev_hash and head_hash, a checkpoint signs the head at a point in time, and every signature names its key by kid in the JWK Set

Terms used in this guide

Term Meaning
ChainOne project's audit trail: a sequence of batches numbered from 1, each linked to the previous one by a hash.
BatchA JSON array of consecutive audit events, hashed and signed as one unit. Returned by GET /audit/v1/logs.
ManifestThe linking record of one batch: its position, the previous head, the hash of its events, the new head, and a signature over that head. Returned by GET /audit/v1/manifests.
CheckpointA signed statement that, at a given time, the chain had reached a given sequence with a given head. Returned by GET /audit/v1/checkpoints.
SequenceThe position of a batch and its manifest in the chain, starting at 1, contiguous.
Key id (kid)The identifier of the key that made a signature; it matches a kid in the JWK Set at GET /audit/.well-known/jwks.json.

What do I need before calling it?

  • The project GID (gid:…). Every endpoint takes it as the project_id query parameter.
  • An Application Agent in that project with the Audit API permission, and one of its credentials. The credential goes into the X-IK-ClientKey header, as for every Application Agent API (see the Credentials guide). No user token, no Service Account token, no request body.
  • The regional base URL of the project: https://eu.api.indykite.com or https://us.api.indykite.com.

Audit is a separate permission and is not added to existing agents automatically. Grant it when creating the agent, or add it to an existing one; the Environment guide lists every permission.

POST /configs/v1/application-agents
{
  "application_id": "gid:YOUR_APPLICATION_GID",
  "name": "audit-exporter",
  "display_name": "Audit exporter",
  "api_permissions": [
    "Audit"
  ]
}

The grant is applied asynchronously, so an agent updated a moment ago can still answer 401 insufficient API access level for appAgent for a short while.

The credential is scoped to exactly one project, and the listing endpoints refuse any other project_id with 403. So project_id is always the project the credential belongs to; it is spelled out so the request is explicit.

Which endpoints are there?

Endpoint Returns Order Authentication
GET /audit/v1/logsThe chain batches, each with its audit events in dataSequence order, from the start of the chainX-IK-ClientKey, Audit permission
GET /audit/v1/manifestsThe chain manifests: linkage and signatures without the eventsSequence order, from the start of the chainX-IK-ClientKey, Audit permission
GET /audit/v1/checkpointsThe signed checkpointsNewest firstX-IK-ClientKey, Audit permission
GET /audit/.well-known/jwks.jsonThe JWK Set of the signing keys-None. The keys are public by design.

All four take project_id. The three listing endpoints also take cursor and pagesize, and answer with the same envelope. The OpenAPI document is published at openapi.indykite.com/api-documentation/audit.

Query parameters

Parameter Required Meaning
project_idyesThe project GID. Must be a well-formed project identifier and, on the listing endpoints, the project of the credential.
cursornoOpaque page cursor, copied verbatim from the next_cursor of the previous response. Omit it for the first page. Listing endpoints only.
pagesizenoItems per page. Default 50, maximum 50; a larger value is capped to 50 without an error. 0, a negative number or a non-number is a 400. Listing endpoints only.

Requests carry no body. The request must accept JSON: send Accept: application/json or */* (curl does so by default). Any other value, or no Accept header at all, is refused with 406.

Response envelope

{
  "next_cursor": "NTA",
  "has_more": true,
  "items": [ … ]
}
  • has_more is true while another page exists. Loop until it is false.
  • next_cursor is the cursor of the next page and "" on the last one. Pass it back as it is; never build one yourself.
  • items is the page, [] on an empty chain. An empty chain is a 200, not an error: no audit event has been recorded for the project yet.

/logs and /manifests page in lockstep: a page of each started from the same cursor covers the same sequences, so a manifest cursor can be used on /logs and the other way round. Checkpoint cursors are their own family and are not interchangeable with those two.

How do I export a project's trail?

The examples below use three shell variables. The order is the one an export is usually taken in: keys first, then the small manifests, then the events, then the checkpoints.

API_URL=https://eu.api.indykite.com      # or https://us.api.indykite.com, matching the project's region
PROJECT_ID="gid:YOUR_PROJECT_GID"
CLIENT_KEY="<application agent credential>"

Step 1: Fetch the signing keys

curl -s "$API_URL/audit/.well-known/jwks.json?project_id=$PROJECT_ID" > jwks.json

No credential is sent. The response is a standard JWK Set (RFC 7517) with one or more EC keys:

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "…",
      "y": "…",
      "use": "sig",
      "kid": "…"
    }
  ]
}

The keys carry no alg member on purpose: the algorithm label travels with each signed item (see How do I verify an export?). The response is cacheable for 5 minutes (Cache-Control: public, max-age=300). Keep the file next to the trail you export: it names the keys the export was signed with. When a kid in the trail is no longer in the set, the key has been rotated out, so keep the JWKS that was current when each export was taken.

Step 2: Page through the manifests

curl -s "$API_URL/audit/v1/manifests?project_id=$PROJECT_ID&pagesize=50" \
  -H "X-IK-ClientKey: $CLIENT_KEY"
{
  "next_cursor": "NTA",
  "has_more": true,
  "items": [
    {
      "manifest_id": "0d1a7a6e-6c6c-4a53-9f1c-0e0f4b4a1c2d",
      "batch_id": "7b3f2b9a-5d1e-4c1b-8a4e-2f6c9d8e7a10",
      "batch_uri": "…",
      "project_id": "gid:YOUR_PROJECT_GID",
      "sequence": 1,
      "prev_hash": "",
      "data_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "head_hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
      "kid": "…",
      "alg": "ES256-DER",
      "signature": "MEUCIQ…",
      "created_at": "2026-09-30T14:02:11.482913604Z"
    }
  ]
}
Field Meaning
manifest_idIdentifier of this manifest. The batch at the same sequence names it in its manifest_id.
batch_idIdentifier of the batch this manifest describes.
batch_uriAn opaque reference to the batch, for correlation only; it is not fetchable by the caller. Use GET /audit/v1/logs for the events.
project_idThe project the chain belongs to.
sequencePosition in the chain, starting at 1, contiguous.
prev_hashThe head_hash of the previous manifest. Empty for sequence 1.
data_hashHex SHA-256 of the batch's data; equal to the batch's hash, so the content is described without fetching it.
head_hashThe chain head after this batch. The next manifest carries it as prev_hash, and the batch carries it as chain_hash.
kid, algThe key that signed this manifest and the algorithm label, always ES256-DER.
signatureBase64 signature over head_hash.
created_atWhen the manifest was written, RFC 3339 with nanoseconds, UTC.

To fetch every manifest, follow next_cursor until has_more is false. An HTTP error must stop the loop rather than end it, otherwise a 401 or 500 body would be taken for the last page and the export would be silently truncated; curl -f turns such a status into a failure. The same loop writes logs.jsonl or checkpoints.jsonl when the path and the file name are changed. For the logs, keep the response pages as well (curl -o), since the batch hash covers the data array as served and jq -c re-encodes it; the export and verification scripts do both.

cursor=""
: > manifests.jsonl
while :; do
  page=$(curl -fsS "$API_URL/audit/v1/manifests?project_id=$PROJECT_ID&pagesize=50&cursor=$cursor" \
    -H "X-IK-ClientKey: $CLIENT_KEY") || { echo "export failed; manifests.jsonl is incomplete" >&2; exit 1; }
  echo "$page" | jq -c '.items[]' >> manifests.jsonl
  [ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break
  cursor=$(echo "$page" | jq -r '.next_cursor')
done
jq -s 'map(.sequence) | max' manifests.jsonl     # the newest sequence recorded

Step 3: Read the audit events

curl -s "$API_URL/audit/v1/logs?project_id=$PROJECT_ID&pagesize=10" \
  -H "X-IK-ClientKey: $CLIENT_KEY"

Each item is one signed batch. data is the JSON array of the audit events in that batch, in the order the platform received them; the other fields describe and sign it. A batch with one Capture upsert event:

{
  "next_cursor": "MQ",
  "has_more": true,
  "items": [
    {
      "batch_id": "7b3f2b9a-5d1e-4c1b-8a4e-2f6c9d8e7a10",
      "project_id": "gid:YOUR_PROJECT_GID",
      "sequence": 1,
      "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "signature": "MEQCIB…",
      "kid": "…",
      "alg": "ES256-DER",
      "chain_hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
      "manifest_id": "0d1a7a6e-6c6c-4a53-9f1c-0e0f4b4a1c2d",
      "data": [
        {
          "appSpaceId": "gid:YOUR_PROJECT_GID",
          "customerId": "gid:YOUR_CUSTOMER_GID",
          "data": {
            "@type": "type.googleapis.com/indykite.auditsink.v1beta1.UpsertData",
            "node": {
              "id": "…",
              "externalId": "millicent",
              "type": "Person",
              "isIdentity": true,
              "properties": [
                {
                  "type": "email",
                  "value": { "stringValue": "millicent@email.com" },
                  "externalValue": ""
                }
              ]
            }
          },
          "eventSource": "…",
          "eventType": "indykite.audit.capture.upsert.node",
          "initiator": {
            "appAgentCredentialsId": "gid:YOUR_APP_AGENT_CREDENTIALS_GID",
            "appAgentId": "gid:YOUR_APP_AGENT_GID",
            "appSpaceId": "gid:YOUR_PROJECT_GID",
            "applicationId": "gid:YOUR_APPLICATION_GID",
            "customerId": "gid:YOUR_CUSTOMER_GID"
          },
          "requestId": "…",
          "time": "2026-09-30T14:01:58.113904211Z"
        }
      ]
    }
  ]
}
Batch field Meaning
batch_id, project_id, sequenceAs on the manifest at the same sequence.
dataThe audit events: a JSON array, one object per event, in the order the platform received them, which is not always the order in which they happened. A batch holds events of one project that arrived together; it can hold one event or many, since batches are cut by size or by time.
hashHex SHA-256 of data, the value signature covers. Equal to the manifest's data_hash.
signature, kid, algBase64 signature over hash, the key that made it, and the algorithm label ES256-DER.
chain_hashThis batch's position in the chain; equal to the manifest's head_hash.
manifest_idThe manifest describing this batch.

Every event in data has the same top-level shape, whatever its type:

Event field Meaning
eventTypeThe event type, one of the indykite.audit.* names listed in the Outbound Events guide: indykite.audit.capture.upsert.node, indykite.audit.config.update, indykite.audit.authorization.evaluation, indykite.audit.ciq.execute, indykite.audit.cdc.node.update and so on.
timeWhen the event happened, RFC 3339 with nanoseconds, UTC.
customerId, appSpaceIdThe customer and the project the event happened in. appSpaceId is the project_id of the request.
initiatorWho initiated the request, as a flat object of string identifiers whose keys tell the kind of caller apart: an Application Agent carries applicationId, appAgentId and appAgentCredentialsId; a Service Account carries serviceAccountId and serviceAccountCredentialsId; an end user carries digitalTwinId, viaApplicationId and sessionId. Each form also repeats customerId and appSpaceId. Keys with no value are left out, and the whole object is absent when the caller is not known.
requestIdThe identifier of the request that caused the event, the same value the API returned to the caller. Always present, empty when there was none.
dataThe event payload, typed by its @type member, for example type.googleapis.com/indykite.auditsink.v1beta1.UpsertData for a Capture upsert, …DeleteData for a Capture delete, …TokenIntrospected, …ExecuteKnowledgeQuery, …CDCEvent. Fields are in camelCase; a Capture property value is a typed object such as {"stringValue": "…"}, {"integerValue": "…"} or {"boolValue": true}. Fields without a value are present and empty.
contextString key-value pairs attached to some events, for example soft_delete on a configuration delete. Present only when there is at least one pair.
eventSourceWhich part of the platform recorded the event. Informational.

The top-level keys of an event object are in alphabetical order, as are the keys inside initiator and context; inside data the fields follow the payload's own order, with @type first. The array is served as compact JSON. None of this has to be reproduced by a verifier, since the batch hash covers the bytes exactly as served.

Two notes on reading events. Delivery into the chain is at least once, so an event can appear twice. A redelivered event is byte-for-byte identical, including its time and requestId; two separate actions always differ in at least time, and in requestId when they came from separate requests. So deduplicate on the whole event object, never on its content with the timestamp or request identifier stripped, and never on adjacency alone. And the content of an event is whatever the caller sent - property values, query parameters, identifiers - so it is evidence to store, index and report, never something to execute.

With the batches exported to logs.jsonl by the loop above (one batch per line), flattening every event out of the export and counting them per batch:

jq -c '.data[]' logs.jsonl > events.jsonl                      # one event per line
jq -c '{sequence, events: (.data | length)}' logs.jsonl
jq -c 'select(.eventType | startswith("indykite.audit.authorization."))' events.jsonl
sort -u events.jsonl | wc -l                                   # distinct events, duplicates removed

Step 4: Fetch the checkpoints

curl -s "$API_URL/audit/v1/checkpoints?project_id=$PROJECT_ID" \
  -H "X-IK-ClientKey: $CLIENT_KEY"
{
  "next_cursor": "",
  "has_more": false,
  "items": [
    {
      "checkpoint_id": "4e2d9c5a-1b7f-4e0a-9c3d-6a8b2f1e0d47",
      "project_id": "gid:YOUR_PROJECT_GID",
      "sequence": 128,
      "head_hash": "b3a8e0e1e4c3…",
      "created_at": "2026-10-01T13:47:09.219540877Z",
      "signature": "MEUCIG…",
      "kid": "…",
      "alg": "ES256-DER"
    }
  ]
}

A checkpoint says: at created_at this chain had reached sequence with head head_hash, and the platform signed that statement. Checkpoints are written periodically, only for chains that have grown since the last one, so a new project has none for a while and the newest checkpoint is normally behind the newest manifest. They page newest first; the first item is the latest.

How do I verify an export?

The platform signs digests, never the JSON documents themselves, so a verifier recomputes each digest from the fields below and checks the signature with the key the kid names. These are the exact rules:

Three digests are recomputed: SHA-256 of the data array as served, SHA-256 of the five pipe-joined manifest fields, and SHA-256 of the prefixed checkpoint string; each is compared with the stored value and then checked against the Base64 DER ECDSA P-256 signature with the JWK the kid names
Value How it is computed
Batch hash, manifest data_hashSHA-256 of the bytes of the data array exactly as served (compact JSON, no whitespace), as lower-case hex.
Manifest head_hash, batch chain_hashSHA-256, as lower-case hex, of the string <project_id>|<sequence>|<prev_hash>|<data_hash>|<batch_id>: the five fields joined with |, sequence in decimal, prev_hash empty for sequence 1.
Batch signatureOver the 32-byte digest that hash encodes.
Manifest signatureOver the 32-byte digest that head_hash encodes.
Checkpoint signatureOver the SHA-256 of the string project-checkpoint|<project_id>|<sequence>|<head_hash>|<created_at>, with created_at exactly as returned.
Signature encoding (alg ES256-DER)ECDSA with the P-256 curve over a SHA-256 digest, the signature ASN.1 DER-encoded and then Base64-encoded with the standard alphabet and padding. This is the encoding a KMS produces, not the fixed-size R||S form that JWS ES256 (RFC 7518) defines; a JWT or JWS library will reject it unless the signature is converted first.
KeyThe JWK in the set whose kid equals the item's kid: kty EC, crv P-256.

Re-serializing data can change its bytes (key order, escaping, whitespace), so hash the array as it appears in the response rather than a re-encoded copy. Checking one manifest signature with OpenSSL. The JWK is first converted to a PEM public key; the lines below do it with the same cryptography package the verifier uses, so no JWT library is needed:

KID=$(head -n 1 manifests.jsonl | jq -r '.kid')          # the key the first exported manifest names
python3 - "$KID" <<'EOF' > key.pem
import json, base64, sys, glob
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
keys = {}
for path in sorted(glob.glob("jwks*.json")):          # the current set and every archived snapshot
    with open(path, encoding="utf-8") as f:
        doc = json.load(f)
        for key in doc["keys"]:
            keys[key["kid"]] = key
k = keys[sys.argv[1]]
n = lambda s: int.from_bytes(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)), "big")
pub = ec.EllipticCurvePublicNumbers(n(k["x"]), n(k["y"]), ec.SECP256R1()).public_key()
print(pub.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode(), end="")
EOF
HEAD_HASH=$(head -n 1 manifests.jsonl | jq -r '.head_hash')   # the first exported manifest
SIGNATURE=$(head -n 1 manifests.jsonl | jq -r '.signature')
echo -n "$HEAD_HASH" | xxd -r -p > digest.bin        # the 32 bytes the signature covers
echo "$SIGNATURE" | base64 -d > signature.der
openssl pkeyutl -verify -pubin -inkey key.pem -in digest.bin -sigfile signature.der
# Signature Verified Successfully

The chain is intact when every manifest's prev_hash equals the previous manifest's head_hash, every head_hash recomputes from its five fields, every batch's hash recomputes from its data and equals the manifest's data_hash, every signature verifies under its kid, and a checkpoint's head_hash equals the manifest's at the same sequence. An export that holds the manifests, the checkpoints and the logs for one project_id, each fetched to has_more: false, together with JWKS snapshots that between them contain every kid those records reference, is self-contained for that check. The current jwks.json alone is enough only while no key referenced in the trail has been rotated out, which is why each export should keep the JWKS that was current when it was taken.

What errors should I expect?

Errors are a JSON body with a message, except the 406, whose body has an error member.

Status Message Cause and fix
200"items": []Empty chain: nothing recorded for the project yet. Not an error. A new project also has no checkpoints until its chain has grown.
400project_id is requiredAdd ?project_id=<gid>. Applies to the JWKS endpoint too.
400project_id is not a valid project identifierThe value is not a project GID.
400invalid cursor: …, invalid cursorThe cursor was edited, truncated, taken from the other endpoint family, or from another project's checkpoints. Copy next_cursor verbatim.
400pagesize must be a positive integerpagesize is 0, negative or not a number.
401Missing or malformed AppAgent credential token in X-IK-ClientKey headerThe header is absent or not an Application Agent credential. The JWKS endpoint takes none.
401insufficient API access level for appAgentThe agent lacks the Audit permission. Add it to api_permissions; the grant is applied asynchronously.
403the authenticated credential does not have access to this projectproject_id is not the credential's project, even if it is another project of the same customer. Use that project's own agent.
406Accept must be application/json or */*The Accept header is absent or allows neither. Set Accept: application/json.
500Internal Server ErrorA platform-side failure. Retry; if it persists, report it with the time and the project.

Next Steps