> ## Documentation Index
> Fetch the complete documentation index at: https://dev.writer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Export audit events

> Poll organization audit events with the Audit Events Export API, and resume the feed from a cursor.

This guide shows you how to poll organization audit events with the Audit Events Export API. After you connect a poller or an incremental connector, you can land those records in a data warehouse, SIEM, or compliance platform.

The feed combines Writer's two audit contracts (legacy administration events and newer platform audit events) into one stream, discriminated by a `source` field on every row. You do not need separate pollers for each contract.

## Prerequisites

Before you export audit events, make sure you have:

* A Writer API key. See instructions in the [API keys guide](https://dev.writer.com/api-reference/api-keys.md).

## Call the export endpoint

Request audit events for one organization.

**URL:** `GET https://api.writer.com/api/observability/v1/organization/{organizationId}/audit/events/export`

Call it with `order=tail` and no `cursor` or `start_date` to start at the current time. Only newly settled rows stream forward.

```bash cURL theme={null}
curl --get --location \
  "https://api.writer.com/api/observability/v1/organization/<ORGANIZATION_ID>/audit/events/export" \
  --header "Authorization: Bearer $WRITER_API_KEY" \
  --data-urlencode "order=tail" \
  --data-urlencode "limit=1000"
```

```python Python theme={null}
import os

import requests

organization_id = "<ORGANIZATION_ID>"
api_key = os.environ["WRITER_API_KEY"]

response = requests.get(
    "https://api.writer.com/api/observability/v1/organization/"
    f"{organization_id}/audit/events/export",
    headers={"Authorization": f"Bearer {api_key}"},
    params={"order": "tail", "limit": 1000},
    timeout=60,
)
response.raise_for_status()
print(response.json())
print(response.headers["x-next-cursor"])
```

```javascript JavaScript theme={null}
const organizationId = "<ORGANIZATION_ID>";
const apiKey = process.env.WRITER_API_KEY;

const params = new URLSearchParams({
  order: "tail",
  limit: "1000",
});

const response = await fetch(
  `https://api.writer.com/api/observability/v1/organization/${organizationId}/audit/events/export?${params}`,
  { headers: { Authorization: `Bearer ${apiKey}` } },
);

if (!response.ok) {
  throw new Error(`Export request failed with status ${response.status}`);
}

const body = await response.json();
console.log(body);
console.log(response.headers.get("x-next-cursor"));
```

## Authenticate requests

Send the Writer API key on every request.

```http theme={null}
Authorization: Bearer <API_KEY>
```

Each key is bound to a single organization and can export audit events only for that organization. A request that uses a key against a different `organizationId` returns `403 Forbidden`. Missing or malformed credentials return `401 Unauthorized` at the public authentication boundary.

## Choose a start point

You choose where the stream begins. After the first response, page with the cursor alone. The cursor already encodes your position and, for a browse continuation, the window it was cut from.

### Start from the current time

Call the endpoint with `order=tail` and neither `cursor` nor `start_date`. The stream starts at the current time, with no backfill.

### Backfill from a date

Pass `start_date` with `order=tail` to request retained events from that ingestion time, then continue polling after the backlog drains. `start_date` accepts an ISO-8601 timestamp, epoch seconds, or epoch milliseconds. For example, `start_date=2026-07-01T00:00:00Z`.

### Browse a historical window

Pass both `start_date` and `end_date` with `order=browse` (the default) to walk a bounded `[start_date, end_date]` window newest-first. The response's `total_count` is the exact number of events in the window, independent of `limit` and `cursor`. Omit both dates to browse the default window (now minus 90 days through now).

### Stay inside the retention window

A `start_date` older than the retention window (90 days by default) returns `400 Validation Error`. Available history depends on completed backfills; this does not guarantee full historical coverage.

### Page with the cursor alone

After you store `x-next-cursor`, send it as the `cursor` query parameter and omit `start_date` and `end_date`. Sending either date together with `cursor` returns `400` because the cursor already encodes the window. The cursor is scoped to the order (`browse` or `tail`) that issued it; presenting a browse cursor with `order=tail` (or the reverse) returns `400`.

## Set query parameters

These parameters control paging and the browse window. The audit export has no filters, no `format` option, and no `include_bodies` option. It always returns native JSON.

| Parameter | Values | Default | Description |
| - | - | - | - |
| `order` | `browse`, `tail` | `browse` | `tail` selects the incremental stream. |
| `cursor` | Opaque string | None | Resume token from a prior `x-next-cursor` header. Scoped to the order that issued it. |
| `start_date` | ISO-8601, epoch seconds, or epoch milliseconds | Tail: now; browse: now minus 90 days if both dates are omitted | Tail backfill start, mutually exclusive with `cursor`. Browse window start. |
| `end_date` | ISO-8601, epoch seconds, or epoch milliseconds | Browse: now if both dates are omitted | Browse window end. Rejected when `order=tail`. |
| `limit` | Integer ≥ 1 | `100` | Page size. Clamped to 1000; a larger value is reduced rather than rejected. |

For `order=browse`, provide both `start_date` and `end_date`, or omit both. Providing only one date returns `400 Validation Error`.

## Read the response

Responses put the resume token and the caught-up signal in headers. The body mirrors them in `pagination` so a consumer can read either.

### Read response headers

Use these headers to resume and, in tail mode, decide when the settled backlog is drained.

| Header | Meaning |
| - | - |
| `x-next-cursor` | Opaque resume token. Set while another page is available, and on every tail response. A caught-up tail echoes the current position. Absent on the last browse page. |
| `x-has-more` | `true` when another page is immediately available. `false` when the stream is caught up (tail) or on the last window page (browse). |
| `x-total-count` | `order=browse` only: exact number of events in `[start_date, end_date]`, independent of `limit` and `cursor`. |
| `x-tail-watermark` | `order=tail` only: settled boundary in epoch milliseconds. The boundary is now minus a 5-minute settle lag. Rows with `inserted_at_ms` newer than this value are withheld from the page until they settle. |
| `retry-after` | Seconds to wait. Present only on `429`. |

### Read the body

The body is a JSON object. `result` holds the audit event rows. On a browse response, `total_count` repeats `x-total-count`. `pagination.next_cursor` mirrors `x-next-cursor` while another page is available, and is `null` on the last browse page; a tail response always sets it. For example:

```json theme={null}
{
  "result": [
    {
      "event_id": "01HR9F8PZ9S2E9S7D8EXAMPLE1",
      "organization_id": 644693,
      "event_time_ms": 1761148800000,
      "inserted_at_ms": 1761148812345,
      "source": "platform_audit_event",
      "event_category": "auth",
      "audit_event_type": "auth_login_success",
      "outcome_result": "SUCCESS",
      "outcome_reason": null,
      "outcome_reason_description": null,
      "actor_id": "9321",
      "actor_type": "USER",
      "actor_alternate_id": null,
      "actor_display_name": "jane.doe@example.com",
      "actor_impersonator_id": null,
      "client_ip_address": "203.0.113.42",
      "client_country": "US",
      "request_method": "POST",
      "request_path": "/api/auth/login",
      "response_status_code": 200,
      "response_time_ms": 42,
      "response_error_code": null,
      "parent_event_id": null,
      "action_id": null,
      "targets": [{ "type": "User", "id": "9321" }],
      "metadata": { "mfa": "totp" }
    }
  ],
  "total_count": 1,
  "pagination": {
    "limit": 1000,
    "has_more": false,
    "next_cursor": null
  }
}
```

## Read row fields

Every event row carries both ordering axes and the stored actor record. Use `event_id` as the upsert key.

| Field | Type | Notes |
| - | - | - |
| `event_id` | string | Stable event identity. Upsert key. A re-ingested revision reuses this ID with a newer `inserted_at_ms` (tail only). |
| `organization_id` | integer | The organization the event belongs to; matches the path. |
| `event_time_ms` | integer | Event time, epoch ms. Browse ordering axis. |
| `inserted_at_ms` | integer | Ingestion time, epoch ms. Tail ordering axis. |
| `source` | string | `reporting_admin_audit` for legacy administration events, `platform_audit_event` for newer platform audit events. Discriminates row shape in `targets`. |
| `event_category` | string | One of `admin`, `api`, `auth`, `authz`. Platform events may introduce new categories over time. |
| `audit_event_type` | string | Raw type as stored; legacy types verbatim, platform types as emitted. Not translated to a catalog label. |
| `outcome_result` | string | `SUCCESS`, `FAILURE`, `PARTIAL`, or `UNKNOWN`. |
| `outcome_reason` | string \| null | Short reason code when the outcome is not `SUCCESS`. |
| `outcome_reason_description` | string \| null | Human-readable reason, when available. |
| `actor_id` | string | Stable actor identifier. |
| `actor_type` | string | `USER`, `SERVICE`, `API_KEY`, `ANONYMOUS`, or another emitted type. |
| `actor_alternate_id` | string \| null | A secondary identifier for the actor (such as an external IdP subject), when available. |
| `actor_display_name` | string \| null | Display name as stored on the event. Not refreshed from the user directory. |
| `actor_impersonator_id` | string \| null | Impersonator's actor identifier, when the event was recorded under impersonation. |
| `client_ip_address` | string \| null | Caller IP as recorded by the service. |
| `client_country` | string \| null | ISO country code resolved from the client IP, when available. |
| `request_method` | string \| null | HTTP method on the originating request, when the event was recorded on an HTTP path. |
| `request_path` | string \| null | HTTP path on the originating request, when the event was recorded on an HTTP path. |
| `response_status_code` | integer \| null | HTTP status code on the originating response, when the event was recorded on an HTTP path. |
| `response_time_ms` | integer \| null | Response time of the originating request in milliseconds, when the event was recorded on an HTTP path. |
| `response_error_code` | string \| null | Service error code on failure, when available. |
| `parent_event_id` | string \| null | Parent event's `event_id` when the audit record belongs to a chain. |
| `action_id` | string \| null | Correlation identifier shared with Writer action logs, when available. |
| `targets` | array \| null | Parsed platform targets (resources the event acted on). Always `null` for `source=reporting_admin_audit`; emitted platform events populate it. |
| `metadata` | object | Parsed event payload: the legacy `data` object for `reporting_admin_audit`, or the whole platform `event` branch for `platform_audit_event`. Shape varies by type. |

> [!NOTE]
>
> The export row reflects the stored audit record. The actor's current directory
> name, email, and avatar are not joined in on the export, so historical events
> stay stable when users rename or leave the organization. Use `actor_id` to
> join against your own directory snapshot when you need the current identity.

## Store and resend the cursor

The cursor is a versioned, HMAC-signed token. Treat it as an opaque string. Store it verbatim and send it back unchanged on the `cursor` parameter.

> [!WARNING]
>
> Do not parse, truncate, or reconstruct the cursor. A tampered or
> version-incompatible token returns `400 Validation Error` with detail `Invalid
>     cursor`. A browse cursor also carries the `(start_date, end_date)` window it
> was cut from, so changing the window on a continuation is rejected with
> `cursor was issued for a different from_ms/to_ms window; restart paging without a cursor`.

## Apply delivery guarantees

Tail mode is an ordered revision feed. The settlement delay does not make an event immutable, and a cursor identifies a position rather than a fixed snapshot.

* **Browse ordering.** Browse pages return in `(event_time_ms DESC, event_id DESC)` order within the requested window. Browse collapses to the latest revision per `event_id`.
* **Tail ordering.** Tail pages return in `(inserted_at_ms ASC, event_id ASC)` order. Replaying a cursor returns no row that ordered before that cursor.
* **Settled window.** A tail page contains only rows whose `inserted_at_ms` is at or before `x-tail-watermark`. The watermark is now minus a 5-minute settle lag. An event with an older `event_time_ms` and a newer `inserted_at_ms` arrives when the watermark passes that `inserted_at_ms`. Settlement is not record finality.
* **Revision-aware upserts.** Later revisions can deliver the same `event_id` again on tail. Upsert on `event_id`, retaining the newest `inserted_at_ms`, so an older replay cannot overwrite a newer revision. Never permanently discard an ID because you have already seen it.
* **Replay.** Replaying a cursor can observe newer rows at or after that position. After the stream is caught up, polling again can return newly settled rows.

## Stay within limits

Each organization has a fixed window of 120 requests per 60 seconds by default. A request over that limit returns `429 Rate Limited` with a `retry-after` header. The value is the whole seconds until the window resets, with a minimum of one second. The limit is checked before authentication. Wait for `retry-after`, then retry.

`limit` is clamped to 1000. A larger value is reduced to 1000 rather than rejected.

## Handle errors

Service error bodies use the Problem+JSON shape, with `code` and `detail`. Gateway authentication errors can use a different envelope: an unauthenticated public request can return `401` with generic JSON rather than the service Problem+JSON shape. Handle the HTTP status even when the body does not contain a service error code.

| Status | Code | When |
| - | - | - |
| `400` | `VALIDATION_ERROR` | Tampered or version-incompatible cursor. `start_date` older than retention. `end_date` with tail. `start_date` with `cursor`. Only one browse date bound. Browse cursor presented with `order=tail` (or the reverse). Browse continuation whose cursor was issued for a different `(start_date, end_date)`. |
| `401` | `UNAUTHORIZED` or gateway-specific | Missing, malformed, or rejected credentials at the public authentication boundary. |
| `403` | `FORBIDDEN` or gateway-specific | The key targets a different organization, or the gateway or API-key verifier denies access. |
| `429` | `RATE_LIMITED` | The per-organization rate limit is exceeded. The response includes `retry-after`. |

## Poll for new audit events

Poll with the returned `x-next-cursor` to pick up newly settled events and revisions:

* **Drain and pause:** Drain settled pages until `x-has-more` is `false`, then pause before polling again.
* **Upsert revisions:** Upsert by `event_id`, retaining the newest `inserted_at_ms`. A primary key alone does not prevent older retries from overwriting newer revisions.
* **Persist cursor:** Save the cursor only after the complete page is successfully processed. Restart from that saved cursor. These examples keep the cursor in memory only.
* **Handle rate limits:** On `429`, wait for `retry-after` before retrying. The 30-second pause in these examples applies after the stream is caught up.

The Python and JavaScript examples emit each complete row revision as JSON to stdout. The cURL example prints each response body.

```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail

organization_id="<ORGANIZATION_ID>"
poll_interval_seconds=30
cursor=""
base_url="https://api.writer.com/api/observability/v1/organization/${organization_id}/audit/events/export"

while true; do
  headers_file=$(mktemp)
  body_file=$(mktemp)

  args=(
    --silent
    --show-error
    --get
    --location
    --output "$body_file"
    --dump-header "$headers_file"
    --header "Authorization: Bearer ${WRITER_API_KEY}"
    --data-urlencode "order=tail"
    --data-urlencode "limit=1000"
  )
  if [[ -n "$cursor" ]]; then
    args+=(--data-urlencode "cursor=${cursor}")
  fi

  status=$(curl "${args[@]}" --write-out "%{http_code}" "$base_url")

  if [[ "$status" == "429" ]]; then
    retry_after=$(grep -i '^retry-after:' "$headers_file" | tail -n 1 | awk '{print $2}' | tr -d '\r')
    rm -f "$headers_file" "$body_file"
    sleep "${retry_after:-1}"
    continue
  fi

  if [[ "$status" != "200" ]]; then
    echo "Export request failed with status ${status}" >&2
    cat "$body_file" >&2
    rm -f "$headers_file" "$body_file"
    exit 1
  fi

  cat "$body_file"
  echo

  cursor=$(grep -i '^x-next-cursor:' "$headers_file" | tail -n 1 | cut -d' ' -f2- | tr -d '\r')
  has_more=$(grep -i '^x-has-more:' "$headers_file" | tail -n 1 | awk '{print $2}' | tr -d '\r')
  rm -f "$headers_file" "$body_file"

  if [[ "$has_more" != "true" ]]; then
    sleep "$poll_interval_seconds"
  fi
done
```

```python Python theme={null}
import json
import os
import time

import requests

organization_id = "<ORGANIZATION_ID>"
api_key = os.environ["WRITER_API_KEY"]
poll_interval_seconds = 30
cursor = None

url = (
    "https://api.writer.com/api/observability/v1/organization/"
    f"{organization_id}/audit/events/export"
)

while True:
    params = {"order": "tail", "limit": 1000}
    if cursor:
        params["cursor"] = cursor

    response = requests.get(
        url,
        headers={"Authorization": f"Bearer {api_key}"},
        params=params,
        timeout=60,
    )

    if response.status_code == 429:
        retry_after = int(response.headers.get("retry-after", "1"))
        time.sleep(max(retry_after, 1))
        continue

    response.raise_for_status()

    for row in response.json()["result"]:
        print(json.dumps(row), flush=True)

    cursor = response.headers["x-next-cursor"]
    if response.headers.get("x-has-more") != "true":
        time.sleep(poll_interval_seconds)
```

```javascript JavaScript theme={null}
const organizationId = "<ORGANIZATION_ID>";
const apiKey = process.env.WRITER_API_KEY;
const pollIntervalMs = 30000;
let cursor = null;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function pollAuditEvents() {
  const url = `https://api.writer.com/api/observability/v1/organization/${organizationId}/audit/events/export`;

  while (true) {
    const params = new URLSearchParams({
      order: "tail",
      limit: "1000",
    });
    if (cursor) {
      params.set("cursor", cursor);
    }

    const response = await fetch(`${url}?${params}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await sleep(Math.max(retryAfter, 1) * 1000);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Export request failed with status ${response.status}`);
    }

    const body = await response.json();
    for (const row of body.result) {
      console.log(JSON.stringify(row));
    }

    cursor = response.headers.get("x-next-cursor");
    if (response.headers.get("x-has-more") !== "true") {
      await sleep(pollIntervalMs);
    }
  }
}

pollAuditEvents();
```

## Connect Airbyte, Fivetran, or Singer

The cursor and caught-up signal are header strings, so an incremental connector can store them as a bookmark. Read `x-next-cursor` from the header, emit every revision, and configure the destination to upsert on `event_id`, retaining the newest `inserted_at_ms`. Save the bookmark only after the destination processes the page.

The same mapping that the [Logs Export](https://dev.writer.com/home/logs-export.md#connect-airbyte-fivetran-or-singer) uses applies here, with `event_id` as the record primary key instead of `log_id`. On `429`, honor `retry-after` before retrying.

## Review default limits

These operational defaults apply to the audit events export. To request different values, contact your Customer Success team.

| Setting | Default | Effect |
| - | - | - |
| Settle lag | 5 minutes | Withholds rows newer than now minus the lag; later revisions can still arrive. |
| Export retention | 90 days | Oldest `start_date` a tail backfill can request. |
| Rate limit (requests) | 120 | Requests per window, per organization. |
| Rate limit (window) | 60 seconds | Length of the rate-limit window. |
| Page size | 1000 | Maximum `limit`; a larger value is clamped rather than rejected. |

## Next steps

Pull related telemetry alongside your audit events.

* [Export logs](https://dev.writer.com/home/logs-export.md): Poll captured LLM calls, agent-run records, and agent feedback into a warehouse or SIEM
* [Event logs](https://dev.writer.com/home/event-logs.md): View, filter, and search request logs in Control Center
