API Reference
Audit Log

Audit Log

The audit log records who did what, and when, across the org - offboarding actions, integration changes, exports, and administrative changes. Entries are hash-chained (each entry's hash covers the previous entry's hash), so tampering with or deleting a row breaks the chain and can be detected.

These endpoints sit under /api/audit-log and use your regular dashboard session, not an API key. Viewing needs the audit_view permission (by default superadmin, admin and hr); exporting needs audit_export (by default superadmin and admin). Custom roles can grant or withhold either.


List audit log entries

GET /api/audit-log

Query parameters

ParamTypeDescription
limitintegerResults per page (default 50, max 100)
offsetintegerRow offset for pagination (default 0)
actionstringFilter by exact action name
actorIdUUIDFilter by the user who performed the action
fromdateOnly entries created on or after this date
todateOnly entries created on or before this date
offboardingIdUUIDOnly entries about this offboarding, whatever their target says
orgEmployeeIdUUIDOnly entries about this employee directory record
searchstringMatches against target, details, or actor name, and finds entries about the offboardings and directory records whose name or email matches. An erased person has no name left to match.

Response 200 OK

{
  "rows": [
    {
      "id": "9e1c2f3a-...",
      "action": "offboarding_stage_changed",
      "target": "Jordan Lee",
      "details": "Stage changed from notice_period to it_revocation",
      "created_at": "2024-06-02T14:30:00.000Z",
      "is_signed": true,
      "actor": { "id": "usr_abc123", "full_name": "Jane Smith" }
    }
  ],
  "total": 1480,
  "limit": 50,
  "offset": 0
}

is_signed just reflects whether the row has a hash recorded - it doesn't tell you whether the chain around it is intact. Use /verify for that.

Personal values. Personal details of departing employees and the other people an erasure covers (names, email addresses, phone numbers, IP addresses, browser details, notes and reasons) are stored encrypted with a key for that person's records, and returned readable. In their place you may see:

PlaceholderMeaning
[erased]The person was erased (or their record anonymized or deleted by retention), and the key destroyed
[withheld]A value that could not be tied to a person's record, so it was never stored
[unreadable]A value that could not be decrypted

See Data Retention: the audit log.


Export the audit log

GET /api/audit-log/export

Exports every matching entry, oldest first (the order the hash chain replays in), up to 50,000 entries. A request that matches more is refused with 413 and a message to narrow the date range; an export is never cut short. Rate-limited to 10 exports per 15 minutes per user, and 50 per 15 minutes per org.

Query parameters - same filters as the list endpoint, plus:

ParamTypeDescription
formatstringcsv (default), json or pdf. Any other value falls back to csv
bundle1For csv and json: return a ZIP with the export, its detached signature (<file>.sig, raw bytes), verification.json and the public key (signing-key.pem). This is what the dashboard downloads. The integrity headers below are sent either way

Response - a file download. CSV columns are ID, Timestamp, Actor, Action, Target, Details, Entry Hash, Prev Hash, with personal values readable. The JSON format returns { "exported_at": "...", "row_count": 0, "rows": [...] } with each row's hash chain fields included. In JSON, target and details are exactly as stored and hashed, so personal values in them are encrypted tokens ([[aps:v1:...]]); target_text and details_text hold the same text readable, and actor_name is readable. The PDF is a readable copy of the JSON export (see below). An erased person's values read [erased] in every format.

Integrity headers

HeaderDescription
X-Content-SHA256SHA-256 (hex) of the exact bytes of the file
X-Signature-Ed25519Base64 Ed25519 signature over those bytes, when the deployment has a signing key
X-Signing-Key-IdWhich key signed it (see GET /signing-key)
X-Export-Row-CountNumber of entries in the file
X-Content-Digesthmac-sha256=<digest> keyed with the server's own secret. Only PurpletGo can check it; use the headers above to verify a file yourself.

The export is logged as an audit_log_exported entry whose details include the file's SHA-256 and signing key. That entry is part of the hash chain, so any copy of an export can later be matched to the entry that produced it. If the entry can't be written, the export is refused with 503 rather than sent unrecorded.

verification.json (in a bundle) says exactly which bytes were signed (the export file, byte for byte), and carries the export's SHA-256, signature and key id, its last entry's id and hash, the org's signed chain anchors with the exact text each signature covers, the public key, and the steps to check them. server/scripts/verifyAuditExport.js (Node.js only, no dependencies) runs every check on a bundle: see Audit Log: checking an export yourself.

Verifying an export

  1. Get the public key: GET /api/audit-log/signing-key returns { "algorithm": "Ed25519", "keyId": "...", "publicKeyPem": "..." } (404 when signing isn't configured). Save publicKeyPem as key.pem.
  2. Save the signature as raw bytes (the dashboard downloads it as <file>.sig next to the export; from the API, base64-decode X-Signature-Ed25519).
  3. Check it with OpenSSL 3.0 or later (the LibreSSL that ships with macOS can't verify Ed25519; brew install openssl@3):
openssl pkeyutl -verify -pubin -inkey key.pem -rawin -in audit-log.json -sigfile audit-log.json.sig
sha256sum audit-log.json   # matches the sha256 in the audit_log_exported entry

Verify the hash chain from the JSON export, using target and details (not target_text and details_text). In the CSV, a cell starting with =, +, - or @ gets a leading tab so spreadsheets don't run it as a formula, which means those cells no longer match the hashed values. The PDF (format=pdf, also what the dashboard downloads) is a readable copy of a JSON export and prints that file's SHA-256; the JSON file is the verifiable record.


Verify chain integrity

GET /api/audit-log/verify

Requires the tamper-evident audit log feature (Team and Enterprise). Rate-limited to 2 requests per minute per org.

Walks the org's whole chain, oldest entry first, in one consistent snapshot. The oldest surviving entry is the anchor, since entries past the retention period are purged. An erasure changes no stored byte (it destroys a key), so entries about erased people still verify.

Response 200 OK

{
  "ok": true,
  "checked": 1480,
  "complete": true,
  "broken": [],
  "brokenCount": 0,
  "anchoredThrough": "2024-06-01T00:00:00.000Z"
}

If any link in the chain has been tampered with, ok is false, brokenCount is the number of broken links and broken lists up to 1,000 of them. complete is false only for a chain longer than one run checks (2,000,000 entries); ok then covers only the entries checked. Every verification run is itself written back to the audit log as audit_chain_verified.

anchoredThrough is the created_at of the newest chain anchor the run checked against, or null when there is none. PurpletGo records the chain's head as an anchor on a schedule and with every archive; the anchored entry must still be in the chain with the same hash, so entries deleted from the end of the chain are detected once an anchor covers them (broken then has an anchor_missing or anchor_mismatch reason). Deletions after the newest anchor can't be detected until the next one is recorded.


Signed chain anchors

GET /api/audit-log/anchors

Needs audit_view. The org's chain anchors, newest first: the chain's head (entry id and hash) recorded every hour when it has moved, every night, and with every archive. Each is signed with the export key, so you can check that an export ends where PurpletGo attested the chain to be, without PurpletGo's HMAC secret.

ParamTypeDescription
limitintegerDefault 500, max 1000
sincedateOnly anchors recorded on or after this time

Response 200 OK

{
  "format": "purpletgo-audit-anchor/v1",
  "org_id": "9f2b3c1a-...",
  "signed_message": "purpletgo-audit-anchor/v1\norg_id=<org_id>\nentry_id=<entry_id>\nentry_hash=<entry_hash>\nanchored_at=<anchored_at, ISO 8601 UTC with milliseconds>\n",
  "key": { "algorithm": "Ed25519", "key_id": "3fa9...", "public_key_pem": "-----BEGIN PUBLIC KEY-----..." },
  "anchors": [
    {
      "id": "c1d2...",
      "org_id": "9f2b3c1a-...",
      "entry_id": "5e6f...",
      "entry_hash": "8a1b...",
      "entry_created_at": "2026-10-14T09:59:58.120Z",
      "anchored_at": "2026-10-14T10:00:00.004Z",
      "source": "hourly",
      "signature": "base64...",
      "key_id": "3fa9...",
      "signed_at": "recorded"
    }
  ]
}

Each signature is Ed25519 over the signed_message text filled in with the anchor's own fields, in UTF-8, each line ending in a newline. signed_at is recorded when the anchor was signed as it was recorded, or served for an anchor recorded before anchors were signed, which is signed when served. source is hourly, nightly, archive or migration.


Retention settings

GET /api/audit-log/retention · PUT /api/audit-log/retention

Superadmin only. Controls how many months of audit history the org retains.

{ "audit_retention_months": 24 }

PUT accepts a value between 3 and 120 and requires the tamper-evident audit log feature.

GET /api/audit-log/archive-retention · PUT /api/audit-log/archive-retention

Superadmin only; PUT requires the tamper-evident audit log feature (Team and Enterprise). How long each archive copy is kept from the day it is written, after which it is deleted (unless a case in the org is under legal hold). A change applies to archives written from then on, and is recorded in the audit log as audit_archive_retention_updated.

{ "audit_archive_retention_days": 2555, "min_days": 365, "default_days": 2555 }

PUT takes { "audit_archive_retention_days": <whole number of days, 365 to 36500> }.


Archives

GET /api/audit-log/archives · POST /api/audit-log/archive

Both endpoints require the tamper-evident audit log feature (Team and Enterprise), like /verify. Orgs on those plans have their audit log archived to Google Cloud Storage - every night, or on demand via POST /archive. Each archive holds the entries written since the previous one (the first holds everything), is named after its range (audit-logs/<org>/<first entry time>_<last entry time>_<count>.json), and is deleted when the org's archive retention period has passed (see above) (needs audit_export, by default superadmin only; rate-limited to 4 per hour per org). Archiving has to be set up on the server: the deployment needs AUDIT_ARCHIVE_BUCKET (or, failing that, GCS_BUCKET) configured, and with neither set POST /archive returns 503 with a message to contact support. Each archive file carries x-content-sha256 and, when signing is configured, x-signature-ed25519 and x-signing-key-id in its object metadata. Archives hold entries exactly as stored, with personal values encrypted, so an erasure makes them unreadable in every archive, earlier ones included, and the files still verify.

GET /archives (needs audit_view) lists the 50 most recent archive runs, newest first:

{
  "archives": [
    {
      "id": "b2c3d4e5-...",
      "gcs_path": "gs://purpletgo-audit-archives/audit-logs/org_id/2024-06-01T00-00-00-000Z.json",
      "file_digest": "3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855a",
      "row_count": 48213,
      "from_ts": "2023-01-01T00:00:00.000Z",
      "to_ts": "2024-06-01T00:00:00.000Z",
      "is_auto": true,
      "created_at": "2024-06-01T00:05:00.000Z",
      "object_name": "audit-logs/org_id/2024-05-31T03-00-00-000Z_2024-06-01T00-04-59-120Z_48213.json",
      "sha256": "9c1d...",
      "signing_key_id": "3fa9...",
      "retention_days": 2555,
      "expires_at": "2031-05-31T00:05:00.000Z",
      "exported_by_name": null
    }
  ]
}

file_digest is the bare hex of an HMAC-SHA256 over the file, keyed with the server's own secret (the same value the object's x-content-digest metadata carries with an hmac-sha256= prefix). Only PurpletGo can check it; verify a file yourself with the x-content-sha256 and signature metadata.

expires_at is when the copy is deleted; it is null for archives written before archive retention existed. POST /archive returns the newly created archive record as a bare object, straight from the table: the same fields as above except that it has org_id and exported_by (the user id) and no exported_by_name. When nothing was written since the last archive it returns { "skipped": true, "reason": "nothing_new", ... } and writes no file.


Related

  • Webhooks - get offboarding, revocation, asset, checklist and document events as they happen
  • SIEM Integration - stream security events, and replay the audit log to your SIEM with a batch export
  • Reports - the compliance summary and SLA export endpoints