> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Audit log export, chain verification, and refresh workflow

> Queue a tamper-evident export of your Devotel Orbit audit trail, poll the job to a signed download URL, and replay the hash chain and daily Merkle roots to prove the exported rows are complete and unaltered

# Audit log export, chain verification, and refresh workflow

This page covers the **audit-log export** flow: a queued job that bundles your
workspace's audit trail over a date range you choose, plus the **chain-verify**
endpoint that replays the tamper-evident hash chain over that same range and
returns the daily Merkle roots your auditor cross-checks against the exported
bundle.

Use this when a regulator, buyer, or internal reviewer needs a **downloadable,
provable slice** of the audit ledger — SOC 2 evidence requests, GDPR
supervisory replies, or quarterly internal reviews — rather than a continuous
stream.

<Warning>
  This page describes Devotel Orbit's platform controls. It is **not legal
  advice.** Your audit and retention obligations depend on your industry, your
  regulators, and your contracts. Confirm the specifics with qualified counsel.
</Warning>

***

## Export vs stream — pick the right surface

Two surfaces move your audit trail out of Orbit. Choose by lifecycle, not by
format:

* **Export (this page)** — a bounded date range, bundled into a file you hand
  to an auditor and keep as evidence. The export is day-aligned on UTC because
  the daily Merkle roots the chain-verifier returns anchor one digest per UTC
  day, and a partial day would anchor against an incomplete root.
* **Stream ([SIEM sinks](/compliance/siem-sinks))** — every audit event pushed
  continuously to Splunk, Datadog, an S3 bucket, or an HTTPS drain you own.
  Configure a sink when your SOC policy requires live delivery, and use
  exports for point-in-time evidence requests.

Both are tenant scope: export jobs and sinks only ever read your workspace's
own audit ledger.

***

## Queue an export

`GET /api/v1/compliance/audit-export` accepts three query parameters and
returns `202`:

| Parameter | Value                                                                                                                                                                                 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `from`    | Inclusive UTC day, `YYYY-MM-DD`. Required.                                                                                                                                            |
| `to`      | Inclusive UTC day, `YYYY-MM-DD`. Required; must be on or after `from`.                                                                                                                |
| `format`  | `json` (default) — one JSON object per audit event. `scim` — SCIM Event Token (RFC 8417) envelopes, the shape security-pipeline ingest layers that speak SCIM Events accept directly. |

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/compliance/audit-export?from=2026-01-01&to=2026-03-31&format=json" \
  -H "X-API-Key: dv_live_sk_..."
```

The response is the job handle. The heavy work (reading rows, encoding,
uploading the bundle) runs in the background, so a quarter covering hundreds of
thousands of events does not block your request:

```json theme={null}
{
  "data": {
    "id": "auditExportJob_...",
    "status": "pending",
    "format": "json",
    "from": "2026-01-01",
    "to": "2026-03-31",
    "message": "Audit log export queued. Poll /compliance/audit-export/:jobId for status."
  }
}
```

Bad parameters return `422 VALIDATION_ERROR` with the offending field; a
queue-write failure returns `500 AUDIT_EXPORT_QUEUE_FAILED`.

***

## Job lifecycle: pending → running → complete | failed

Poll `GET /api/v1/compliance/audit-export/:jobId` until `status` settles on
`complete` or `failed`:

| Field                                      | Meaning                                                                   |
| ------------------------------------------ | ------------------------------------------------------------------------- |
| `id`, `organization_id`                    | Job handle and owning workspace.                                          |
| `status`                                   | `pending` → `running` → `complete` or `failed`.                           |
| `format`, `from`, `to`                     | The request echoed back.                                                  |
| `total_rows`                               | Event count in the completed bundle (`0` while queued).                   |
| `download_url`                             | Signed URL to the bundle, present only while valid (`null` once expired). |
| `download_url_expired`                     | `true` when the signed URL has lapsed — see refresh below.                |
| `expires_at`                               | When `download_url` stops working.                                        |
| `error`                                    | Failure message on a `failed` job.                                        |
| `created_at`, `started_at`, `completed_at` | Lifecycle timestamps.                                                     |

The job row is scoped to your workspace — a job id from another organization
returns `404 NOT_FOUND`.

***

## Signed-URL expiry and refresh

The signed download URL has a **7-day TTL**. Poll the job inside that window
and mirror the bundle into your own evidence store instead of bookmarking the
link.

If you click a stale link, storage refuses it with a `403`. The status
response handles this explicitly: once the URL lapses, the job returns
`download_url: null` and `download_url_expired: true` rather than handing you
a link that will fail. The recovery path is to **queue a fresh export** over
the same range — the queue call is cheap, the export is deterministic against
the ledger, and two runs over the same UTC days produce the same rows.

***

## Chain-verify: replay the tamper-evident chain

`GET /api/v1/compliance/audit-export/:jobId/verify` replays the hash chain
over the job's exact range and returns the verdict plus every daily root that
anchors it. This is the endpoint auditors hit to prove the exported rows match
the tamper-evident chain Orbit persists.

Each audit event carries the hash of the event before it, so inserting,
removing, or reordering a row breaks the link at that position. The verifier
responds with:

| Field                           | Meaning                                                                                                                                     |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `organization_id`, `from`, `to` | The verified range, echoed.                                                                                                                 |
| `rows_checked`                  | Events replayed across the range.                                                                                                           |
| `chain_valid`                   | `true` when every link holds.                                                                                                               |
| `issues`                        | One entry per broken link (see reason codes below).                                                                                         |
| `first_hash`, `last_hash`       | The chain endpoints of the range — compare against your exported bundle.                                                                    |
| `daily_roots`                   | One Merkle root per UTC day in the range, with `merkle_root`, `row_count`, `min_id`/`max_id`, and `anchor_url` (the persisted root record). |

Issue `reason` codes flag exactly which link broke:

| `reason`                | Meaning                                                                                                |
| ----------------------- | ------------------------------------------------------------------------------------------------------ |
| `missing_current_hash`  | A row exists but carries no hash — it cannot be verified.                                              |
| `prev_hash_mismatch`    | The row's stored pointer to its predecessor doesn't match — a row was inserted, removed, or reordered. |
| `current_hash_mismatch` | The recomputed row hash differs from the stored one — the event content was altered after writing.     |

Each issue carries the affected row `id`, its `created_at`, and `expected` /
`actual` hashes, so a break narrows to one event rather than invalidating the
range.

### How an auditor cross-checks the bundle

1. Run `verify` against the job id. `chain_valid: true` with an empty `issues`
   list means every link in the range held at verification time.
2. Compare `rows_checked` against the `total_rows` on the export job — the
   counts must agree, proving the bundle contains every row the chain covers.
3. Compare `first_hash` / `last_hash` against the first and last rows of the
   exported bundle.
4. For each entry in `daily_roots`, the `merkle_root` commits to the set of
   rows Orbit recorded on that UTC day, and `anchor_url` points to the
   persisted root record the auditor can pin independently. Date-aligned
   exports make this comparison exact.

Treat `chain_valid: false` as a signal to hold the export internally and
contact [security@devotel.io](mailto:security@devotel.io) with the job id and the `issues` entries — do not
hand a broken-chain bundle to an external party without the caveat agreed
first. Orbit records every verification call in your audit log with the range,
row count, verdict, and issue count.

***

## Worked example — queue → poll → download → verify

```bash theme={null}
# 1. Queue the export
JOB=$(curl -s "https://api.orbit.devotel.io/api/v1/compliance/audit-export?from=2026-01-01&to=2026-03-31" \
  -H "X-API-Key: dv_live_sk_..." | jq -r '.data.id')

# 2. Poll until complete
until curl -s "https://api.orbit.devotel.io/api/v1/compliance/audit-export/$JOB" \
  -H "X-API-Key: dv_live_sk_..." | jq -e '.data.status == "complete"' > /dev/null; do
  sleep 5
done

# 3. Download the bundle and mirror it into your evidence store
URL=$(curl -s "https://api.orbit.devotel.io/api/v1/compliance/audit-export/$JOB" \
  -H "X-API-Key: dv_live_sk_..." | jq -r '.data.download_url')
curl -o audit-export-2026-q1.json.gz "$URL"

# 4. Verify the chain over the same range
curl "https://api.orbit.devotel.io/api/v1/compliance/audit-export/$JOB/verify" \
  -H "X-API-Key: dv_live_sk_..." | jq '.data | {rows_checked, chain_valid, first_hash, last_hash, roots: (.daily_roots | length)}'
```

## Worked example — SOC 2 evidence request

An auditor asks for your access-control audit trail for Q1 with proof the
records are complete and untampered:

1. Queue the export with `from=2026-01-01&to=2026-03-31`. Choose `format=json`
   for the raw ledger, or `format=scim` when the recipient's evidence tooling
   ingests SCIM Event Token (RFC 8417) envelopes.
2. Poll to `complete`, download inside the 7-day window, and mirror the file
   into your own store so the evidence survives the URL TTL.
3. Run `verify` and capture `rows_checked`, `chain_valid`, `first_hash`,
   `last_hash`, and the `daily_roots` list. Attach that JSON alongside the
   bundle.
4. Hand the auditor both files plus the cross-check steps above. They confirm
   the row counts agree and the chain endpoints match before reading a single
   event.
5. If the signed URL lapses before the auditor fetches from your mirror,
   queue the same range again — do not forward the expired link.

For a broader framework-mapped pack that assembles these rows into SOC 2,
ISO 27001, GDPR, or HIPAA chapters, use the [evidence
binder](/compliance/evidence-binder); the export flow here is for handing over
the raw ledger itself.

***

## Rate limits and roles

* All three endpoints are gated to the workspace **owner** or **admin** role
  (API keys follow the same gate), because the payload exposes every audit
  event on the workspace — user ids, IP addresses, and event details.
* The queue endpoint (`GET /audit-export`) is write-weighted: **5 per minute**
  per workspace, so scripted evidence pulls should queue a handful of ranges,
  not hundreds.
* Status-poll and chain-verify are read-weighted: **120 per minute** per
  workspace, so a 1–2 second poll loop from a dashboard or script stays well
  under the ceiling.
* Queueing writes a `compliance.audit_export_requested` audit entry; each
  verification call writes a `compliance.audit_export_verified` entry with the
  verdict and issue count.

## Tenant-owned posture

Audit export and chain verification are **tenant-owned controls**: Orbit
produces the bundle and anchors the chain and Merkle roots; you retain the
evidence. Mirror downloads into your own store within the 7-day window, keep
the verify response alongside the bundle, and treat the export's integrity as
yours to check — the endpoint gives you the verdict, the retention decision is
yours.

## Related

* [Stream audit logs to your SIEM](/compliance/siem-sinks) — continuous
  delivery instead of bounded exports.
* [Compliance evidence binder](/compliance/evidence-binder) — framework-mapped
  packs with the chain pre-checked for SOC 2, ISO 27001, GDPR, and HIPAA.
* [The export families model](/concepts/export-families-model) — where this
  export sits among Orbit's four outbound-data families.
* [Audit log](/guides/audit-log) — query the ledger directly and subscribe to
  the signed `audit.log.created` webhook.
* [Immutable archival export](/compliance/archival-export) — WORM bundles of
  messages and recordings, the corresponding control for payload evidence.
