Audit records, streaming and export
Read, stream, retain and export the audit record of who did what in your workspace, from approval votes to key changes, in one versioned shape for your SIEM, bucket or an auditor.
Anlyon keeps an audit record of who did what in a workspace: approvals and every vote, governed invocations and their outcomes, policies, action definitions, API keys, members, roles, invitations, single sign-on and SCIM changes, environment halts and resumes, connected accounts, limits, workspace defaults, and the audit settings themselves.
Every record has one shape, anlyon.audit.v1. The same record is what a sink receives, what the API lists and what an export contains.
Why keep an audit record here
When an agent acts in production, someone eventually asks who allowed it: a customer, a security review, an auditor. Anlyon makes the call, so it can record the request, the policy version that routed it, every vote, the decision and the outcome, written in the same transaction as the change itself. You do not stitch together logs from your agent, your approval tool and your provider, and you do not build an export for each review.
Check what your plan includes
Audit records, reading them and exporting them are on every plan. Records are kept for 6 months on Starter and Pro, 12 months on Team, and as long as the contract says on Enterprise; see Set how long records are kept. Streaming to a sink and changing the retention window are Team features: they are on the Team and Enterprise plans, and on every plan while Early Beta Access is active. A sink or retention setting made earlier keeps working on another plan, and you can always delete a sink. Adding or changing one on another plan answers 403 PLAN_FEATURE_REQUIRED. See Billing and pricing.
Read the record shape
{
"schema": "anlyon.audit.v1",
"id": "aud_3f9c2a7d41b0e8c5",
"sequence": 48211,
"type": "approval.vote",
"category": "approval",
"occurredAt": "2026-10-22T09:14:03.512Z",
"workspaceId": "2f8a1f72-7cf9-4fef-95e8-df9de5f2f5d0",
"environmentId": "9d1c6a0e-4b7f-4e3a-8f51-0c2d7e6b1a94",
"actor": { "type": "user", "id": "usr_8hX2kq" },
"resource": { "type": "approval", "id": "apr_7Qm4nT" },
"outcome": "approve",
"data": {
"approvalId": "apr_7Qm4nT",
"decision": "approve",
"note": null,
"selfApproved": false,
"status": "pending",
"approvedCount": 1,
"requiredApprovals": 2
},
"context": { "requestId": "req_5b2e", "ip": "203.0.113.7", "userAgent": "Mozilla/5.0" }
}
| Field | Meaning |
|---|---|
schema |
Always anlyon.audit.v1. A field is added to v1 only if it is optional. Anything else is a new schema value, so a parser can branch on this one field |
id |
The deduplication key, starting aud_. See below |
sequence |
Orders records within one deployment. It is not a deduplication key |
type, category |
What happened, and its group: approval, invocation, policy, action, access, environment, connection, limit or audit |
occurredAt |
When it happened, in UTC |
environmentId |
null for a workspace-level record, such as a member or API key change |
endUserId |
Present when the act was for one of your end users: your own id for them, or erased~<hash> once they are erased |
actor |
Who did it: user, api_key, oauth_client, assistant, policy or system, with its id. For an OAuth app, onBehalfOf names the member it acted for |
resource |
What it was done to, or null |
outcome |
For example approve, approved, denied, expired or pending, or null |
data |
Details of that type |
context |
The request id, the client address Anlyon’s own proxy recorded, and the user agent, or null |
test |
Present and true only on a test record you sent to a sink |
backfilled |
Present and true only on a record built from history that predates audit records, with its original time |
GET /api/v2/audit/schema returns the JSON Schema of one record and every record type by category.
Deduplicate records on id
Delivery is at least once, so a sink can receive a record more than once. Every delivery of a record, to every sink, carries the same id: a retry, a redelivered dead letter and an export all agree. For the records below that are written with the change, the id is derived from the fact itself, so recording the same fact twice keeps one record.
Know which records are written with the change
These records are written in the same database transaction as the change they describe: approval requests, every vote, decisions (by an approver, a policy, Vigil or expiry), invocation outcomes, manual resolutions, policy versions, halts and resumes, API key, member, role, invitation, single sign-on and SCIM changes, and changes to sinks and audit settings. They commit together or not at all. If the record cannot be written, the change fails.
Connection events, budget alerts, and edits to impact limits and action definitions are buffered for a moment and written after the change. A process that stops in that moment loses them.
A record is a row in Anlyon’s database, not a cryptographic proof. The webhook delivery below is signed, so you can check it came from Anlyon. The record itself carries no signature.
Stream records to your own systems
A workspace admin adds up to 10 sinks per workspace, in the console under Settings → Audit streaming, or through /api/v2/workspaces/{workspaceId}/audit/sinks with a signed-in console session. An API key cannot add or change a sink: a key belongs to one environment, and a sink reaches every environment’s records. Every change to a sink is itself a record, so a sink quietly added or turned off shows up in the stream the others still receive.
| Sink | config |
credentials |
What arrives |
|---|---|---|---|
webhook |
url |
None: Anlyon generates the signing secret | One signed POST per batch of up to 100 records |
s3 |
bucket, region, optional endpoint for an S3-compatible store, prefix, forcePathStyle |
accessKeyId, secretAccessKey, optional sessionToken |
One gzip-compressed NDJSON object per batch of up to 1,000 records |
datadog |
site, optional service and tags |
apiKey |
Datadog Logs, batches of up to 500, with ddsource: anlyon |
splunk |
url (the HEC base URL), optional index, sourcetype, source |
token |
Splunk HTTP Event Collector, batches of up to 200, sourcetype anlyon:audit unless you set one |
Every URL must be HTTPS and reachable at a public address. Link-local and cloud metadata addresses are always refused. Credentials are encrypted and never returned: the API shows a hint only.
curl -X POST https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/audit/sinks \
-H "Cookie: better-auth.session_token=YOUR_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "SIEM intake",
"type": "webhook",
"config": { "url": "https://siem.example.com/anlyon" },
"eventTypes": ["approval.*", "policy.*", "member.*"],
"environmentIds": ["PRODUCTION_ENVIRONMENT_ID"]
}'
eventTypestakes types, prefixes such asapproval.*, or*(the default).environmentIdslimits the sink to some environments. Leave it out for all of them, and for workspace-level records.startFromisnow(the default) orbeginning.includeHistory(off by default) also sends records markedbackfilled.POST /api/v2/workspaces/{workspaceId}/audit/sinks/{sinkId}/testsends one record markedtest: true.
Verify the signed webhook
The response to creating a webhook sink carries signingSecret, once. Store it. PATCH /api/v2/workspaces/{workspaceId}/audit/sinks/{sinkId} with { "rotateSigningSecret": true } replaces it and returns the new one, once.
Each batch is a JSON body, { "schema": "anlyon.audit.batch.v1", "batchId": "<id>", "events": [<records>] }, sent with:
Anlyon-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">Anlyon-TimestampandAnlyon-Batch-Id
That is the signature format of Anlyon’s other signed deliveries, so the SDKs’ Receiver verifies it unchanged. Audit batches belong to the workspace, not to one environment, so do not set an expected environment on the receiver.
import { Receiver } from '@anlyonhq/sdk';
const receiver = new Receiver({ signingSecret: process.env.ANLYON_AUDIT_SECRET! });
export async function POST(req: Request) {
const body = await req.text();
if (!receiver.verify({ signature: req.headers.get('anlyon-signature'), body })) {
return new Response('Invalid signature', { status: 401 });
}
const batch = JSON.parse(body);
for (const record of batch.events) {
// Store keyed by record.id: a retry sends the same id again.
console.log(record.id, record.type, record.occurredAt);
}
return new Response('ok');
}import json
import os
from anlyon import Receiver
receiver = Receiver(os.environ["ANLYON_AUDIT_SECRET"])
def handle(signature: str | None, body: str) -> int:
if not receiver.verify(signature=signature, body=body):
return 401
batch = json.loads(body)
for record in batch["events"]:
# Store keyed by record["id"]: a retry sends the same id again.
print(record["id"], record["type"], record["occurredAt"])
return 200Answer with a 2xx status once the batch is stored. Anything else is a failed delivery.
Write to a bucket
An object’s key is <prefix>/yyyy/mm/dd/HHMMSSZ-<batch>.ndjson.gz, dated by its first record. The key is derived from the batch, so a retry overwrites the same object rather than adding one. A quiet workspace writes at most about one object a minute. Leave endpoint out for AWS S3, or set it for an S3-compatible store.
Understand delivery, retries and dead letters
- Each sink reads the stored records in order from its own position, sends a batch, and moves its position only after the destination accepted it. A failure between sending and moving resends the batch.
- A failed batch is retried with backoff: from 30 seconds, doubling, up to an hour, with jitter.
- After 12 failed attempts (about five hours) the batch becomes a dead letter, and the sink moves past it, so one batch a destination will never accept cannot stop the stream. The records stay stored. Dead letters are listed under
GET /api/v2/workspaces/{workspaceId}/audit/sinks/{sinkId}/dead-lettersand in the console, andPOST /api/v2/workspaces/{workspaceId}/audit/sinks/{sinkId}/dead-letters/{deadLetterId}/redeliversends them again with their original ids.
Set how long records are kept
Your plan decides how long records are kept:
| Plan | Audit records are kept for |
|---|---|
| Early Beta Access, Starter and Pro | 6 months |
| Team | 12 months |
| Enterprise | As set in the contract |
Records from the last 90 days are listed in the console and by GET /api/v2/audit/events. Older records move to the audit archive, where each day’s records are stored compressed with a checksum. They are not listed, but every export includes them, in the same order, and each one is checked against its checksum as it is read. When a record reaches the end of its plan’s retention it is deleted. Moving and deleting records are themselves recorded, as audit.records.archived and audit.records.expired.
A change of plan never deletes records at once. When retention gets shorter (a downgrade, or a contract that shortens it), the longer retention still applies for 30 days, recorded as audit.retention.changed with the date the new one takes effect. When retention gets longer (an upgrade), it applies straight away to every record still kept. GET /api/v2/workspaces/{workspaceId}/audit/settings returns the plan’s retention as planRetentionMonths, and retentionGraceUntil while a shorter retention is waiting.
An admin can also keep records for less time than the plan, with a window from 30 to 3,650 days:
curl -X PUT https://api.anlyon.com/api/v2/workspaces/$WORKSPACE_ID/audit/settings \
-H "Cookie: better-auth.session_token=YOUR_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "retentionDays": 365 }'
null goes back to the plan’s retention. A window longer than the plan’s retention is held to the plan’s. Older records are deleted hourly. Retention applies to the audit records only: approvals, invocations and policy history keep their own lifecycles. A dead letter whose records have aged out can no longer be redelivered.
Read and export records through the API
Three routes read the records:
| Route | What it returns |
|---|---|
GET /api/v2/audit/events |
Records from the last 90 days, newest first, up to 200 a page. Pass pagination.nextBefore as before for the next, older page |
GET /api/v2/audit/export |
Every record still kept from from to to, archived ones included, at most 366 days, oldest first, as JSON (anlyon.audit.export.v1, with events and count) or CSV (format=csv) |
GET /api/v2/audit/schema |
The JSON Schema of one record, and the record types by category |
Both lists filter by from, to, type (comma-separated types or prefixes), category, action (a governed action’s name or id) and endUserId (the records of acts for one of your end users). GET /api/v2/audit/events returns 50 records by default. In a CSV export, a cell a spreadsheet would run as a formula starts with an apostrophe. Taking an export is itself recorded, before any data is sent.
They need an API key with audit:read, a privileged scope no key receives by default. A key reads its own environment’s records only. A console admin reads the whole workspace’s records at /api/v2/workspaces/{workspaceId}/audit/events and /api/v2/workspaces/{workspaceId}/audit/export, or in the console.
import { Client } from '@anlyonhq/sdk';
const audit = new Client({ apiKey: process.env.ANLYON_AUDIT_KEY! }); // holds audit:read
const page = await audit.audit.listEvents({ type: ['approval.*'], limit: 100 });
const older = await audit.audit.listEvents({ before: page.pagination.nextBefore ?? undefined });
const csv = await audit.audit.export({ from: '2026-10-01', to: '2026-11-01', format: 'csv' });
const { data: schema } = await audit.audit.schema();import os
from anlyon import Client
audit = Client(api_key=os.environ["ANLYON_AUDIT_KEY"]) # holds audit:read
page = audit.audit.list_events(type=["approval.*"], limit=100)
csv = audit.audit.export(start="2026-10-01", end="2026-11-01", format="csv")
schema = audit.audit.schema()curl "https://api.anlyon.com/api/v2/audit/events?type=approval.*&limit=100" -H "Authorization: Bearer $ANLYON_AUDIT_KEY"
curl "https://api.anlyon.com/api/v2/audit/export?from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z&format=csv" -H "Authorization: Bearer $ANLYON_AUDIT_KEY" -o audit-2026-10.csv
In Python, the range is start and end. They are sent as from and to. See List audit records, Export audit records and Get the audit schema.
Know what erasing an end user changes
When you erase an end user, their records stay, so the history of what happened is kept, and the id is replaced. The record’s endUserId, and the resource.id of their end_user.* records, become the same erased~<hash> value, in exports of archived records too. Any other change to a stored record is refused by the database, and so is removing one other than by the archive and retention described above.
Fix the errors you will meet
| Status and code | When | What to do |
|---|---|---|
403 AUTHORIZATION_ERROR |
The key lacks audit:read, or a console member without the admin role opens the workspace routes. |
Use a key with audit:read, or an admin session. |
403 PLAN_FEATURE_REQUIRED |
Adding or changing a sink or the retention window on a plan without Team features. | Move to the Team plan; existing settings keep working. |
400 VALIDATION_ERROR |
An export range longer than 366 days, a sink URL that is not HTTPS, or one at a private, link-local or metadata address. | Split the range, or use a public HTTPS endpoint. |
409 CONFLICT |
Adding an eleventh sink. | Delete a sink first. |
503 AUDIT_ARCHIVE_UNAVAILABLE |
The export range includes archived records and the archive cannot be reached right now. Nothing was exported. | Retry later, or export a range within the last 90 days. |
AUDIT_ARCHIVE_INTEGRITY in the export stream |
An archived record failed its checksum, so the export stopped rather than leave it out. | Contact support with the request id. |
Related
- Approvals & policies: what each approval record describes.
- Slack approvals: decisions from Slack, recorded like any other.
- Single sign-on and SCIM: the access records they write.
- Authentication & API keys: the
audit:readscope. - EU AI Act mapping: how records relate to the Article 12 logging obligation.

