Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

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"]
  }'
  • eventTypes takes types, prefixes such as approval.*, or * (the default).
  • environmentIds limits the sink to some environments. Leave it out for all of them, and for workspace-level records.
  • startFrom is now (the default) or beginning. includeHistory (off by default) also sends records marked backfilled.
  • POST /api/v2/workspaces/{workspaceId}/audit/sinks/{sinkId}/test sends one record marked test: 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-Timestamp and Anlyon-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 200

Answer 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-letters and in the console, and POST /api/v2/workspaces/{workspaceId}/audit/sinks/{sinkId}/dead-letters/{deadLetterId}/redeliver sends 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.

Was this page helpful?