Overview

The audit log is the workspace’s memory. Every meaningful CREATE, UPDATE, or DELETE on a first-class entity — an order, a client, a vendor, a payment, an integration key, a team role — is recorded as an immutable row, hash-chained to the row before it. You can read it, filter it, export it, hand it to an auditor, and verify nothing has been tampered with.

It’s the surface that powers compliance reviews, dispute investigations, and internal “wait, who changed that?” questions. It’s also how we make decisions reversible: every entry includes a before/after diff so you can see exactly what changed.

What working correctly looks like

  • Creating, updating, or deleting an order writes a row in Settings → Audit log within a second.
  • The integrity check at Settings → Audit log → Verify chain returns Chain intact — every row’s previous-hash matches the prior row’s current-hash.
  • Filtering by actor, entity_type, action, or date range narrows the list immediately.
  • Daily archives appear under Settings → Audit log → Archives, one file per day.
  • Sensitive fields (emails, phone numbers, addresses) are masked unless you have admin permission to see them in full.
  • Sign-in and MFA events from auth-audit-hook show up alongside operational events — same chain, same view.

What gets logged

Anything that changes a first-class entity. The list below is illustrative, not exhaustive — new event types are added as the product grows.

Operational events

  • Ordersorder.created, order.updated, order.cancelled, order.refunded, order.shipped, order.delivered.
  • Clients & vendorsclient.created, client.merged, vendor.created, vendor.archived, etc.
  • Paymentspayment.created, payment.refunded, payment.disputed.
  • Communicationsemail.sent, email.bounced, email.complaint_received, sms.sent, sms.optout_received.
  • Settingssettings.email.changed, settings.sms.changed, settings.translation.cap_changed, etc.

Security events

  • Authentication (via auth-audit-hook) — auth.sign_in, auth.sign_out, auth.mfa_challenge, auth.mfa_success, auth.mfa_failed, auth.password_reset, auth.recovery_code_used.
  • Teamteam.invited, team.invite_accepted, team.role_changed, team.removed, team.store_access_changed.
  • Keys & webhooksintegration_key.created, integration_key.rotated, integration_key.revoked, webhook.endpoint_changed.

Billing events

  • billing.plan_changed, billing.payment_succeeded, billing.payment_failed, billing.subscription_cancelled, billing.module_flag_changed, billing.quota_warning_50 / _80 / _100.

Each row carries

  • id — monotonically-increasing chain index.
  • at — UTC timestamp.
  • org_id + store_id.
  • actoruser:<uuid>, integration_key:<uuid>, system (for cron/webhook actors), or auth_hook.
  • entity_type + entity_id.
  • action — the verb (created, updated…).
  • before + after — JSON diff of the affected fields.
  • request — IP, user-agent, request id.
  • prev_hash + hash — the chain links.

The hash chain

Each row’s hash is computed as sha256(prev_hash || canonical(this_row_minus_hash)). Tamper with any row in the middle and every subsequent prev_hash stops matching, which is exactly what an auditor wants.

row 0:  prev_hash = 0x000…   hash = H0
row 1:  prev_hash = H0       hash = H1
row 2:  prev_hash = H1       hash = H2
…
row N:  prev_hash = H(N-1)   hash = HN

To verify: walk forward, recompute each hash, ensure prev_hash on row k equals hash on row k-1.

Run the verifier at Settings → Audit log → Verify chain. Two outputs:

  • Chain intact — the chain through the latest row matches end-to-end.
  • Chain break at row # — we point you at the first row where the link fails. This is rare; if you see it, capture the row id and email {{CONTACT_EMAIL}} immediately.
Why “tamper-evident” not “tamper-proof”

The chain doesn’t prevent a database admin with raw UPDATE permission from rewriting rows. It guarantees that if they do, the chain breaks and we (or you) can detect it. Combined with the daily cold-storage archive, you have a comparison point that makes silent tampering impossible.

Viewing the log

Settings → Audit log is a virtualized list with the following filters in the top bar.

  • Actor — user, integration key, or system. Type-ahead.
  • Entity type — pick from a dropdown of all known types in your workspace.
  • Action — the verb.
  • Date range — presets (24h, 7d, 30d, 90d) or custom.
  • Free-text — matches against entity ids, key handles, IPs.
  • Store — defaults to the active store. All stores for cross-store views.

Click any row to open the detail panel: full before/after diff, the request fingerprint, the chain neighbors, and links to the affected entity (which opens with the timestamp pre-filtered).

Export

Two formats from Settings → Audit log → Export:

  • CSV — flat rows, one per event. Useful for spreadsheets and accountants.
  • JSON — the full structured form including before/after diffs and chain hashes. The right format for compliance reviews.

Exports respect the active filters — if you’re looking at the last 30 days for one user, that’s what gets exported. Files larger than ~50 MB are streamed; you receive an email with a signed download link when the file is ready.

Retention & archives

  • Hot retention — the live Settings → Audit log view holds 365 days of rows for fast filtering.
  • Cold retention — everything is archived to encrypted cold storage by audit-archive-cron, which runs daily at 02:00 UTC. One file per day, named audit/{org}/{YYYY-MM-DD}.jsonl.gz.
  • Forever — archived files are retained for the lifetime of the workspace plus 7 years on Scale tier (configurable per-contract on Custom). Smaller tiers retain hot for 365 days and cold for 24 months.

You can pull any archive file from Settings → Audit log → Archives; the file is signed and chain-linked at its boundaries, so verifying a single archive day verifies the chain for that day.

Redaction

Some fields that get logged are sensitive. The audit log shows them masked by default and only unmasks for users with the right permission.

  • Always-masked even for admins — full credit-card numbers, full bank account numbers, MFA secrets, recovery codes, raw API key plaintext. These never enter the log; only their last-4 / handle / hash do.
  • Masked for staff / read_only — client emails, phone numbers, full mailing addresses. Shown to admins in full.
  • Always shown — entity ids, action verbs, timestamps, IP cities (not full IPs).

The “Show full PII” toggle is admin-only and the act of toggling is itself audited (audit.pii_unmasked). Every unmask event is recoverable in the log.

Don’t paste secrets into descriptions

The redaction layer covers known sensitive columns. If you paste an API token into a free-form note or message, it’s logged as plaintext — we don’t scan free text. Use the integration-keys flow for tokens.

Meta-audit

Reading the audit log, exporting it, unmasking PII, and verifying the chain are themselves audited. So is anything that adjusts retention or archive behavior. The relevant rows:

  • audit.viewed — a user opened the log (rate-limited to one per session per hour to keep the chain readable).
  • audit.exported — CSV or JSON export, with the row count and filter snapshot.
  • audit.pii_unmasked — admin toggled “Show full PII”.
  • audit.chain_verified — chain verification was run, with the result.
  • audit.archive_written — the daily cron wrote the day’s archive successfully.

The chain self-protects against deletion: archives include their own start- and end-hash so the daily file is verifiable in isolation.

Settings & permissions

  • Settings → Audit log — visible to owner / admin in full; staff can see events for entities they have access to (their own actions and the entities they’ve worked on); read_only sees the same scope as staff but with PII masked.
  • Settings → Audit log → Export — owner / admin only.
  • Settings → Audit log → Verify chain — owner / admin only.
  • Settings → Audit log → Archives — owner / admin only.

API + automation

Read-only programmatic access via the public API.

GET /v1/audit-events?since=2026-05-01T00:00:00Z&entity_type=order&limit=200
Authorization: Bearer pck_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The endpoint is keyset-paginated by (at, id), returns the same diff/hash structure shown in the UI, and respects redaction (the key’s scopes decide whether PII is unmasked). For the full request shape and rate limits, see Public API.

There is no “append to audit log” endpoint by design — the chain is platform-managed.

Troubleshooting

SymptomLikely causeFix
An action you took isn’t in the logAction didn’t commit (validation failed silently)Repeat the action, watch for a toast; if it commits, the row appears within a second.
Filter returns no rows for a clearly-real eventDate range too narrow / wrong store filterSwitch to All stores; widen the date range.
“Chain break at row #”Database tampered or row physically corruptedCapture the row id and email {{CONTACT_EMAIL}} immediately.
Daily archive missing for a dateCron didn’t fire (rare; infrastructure incident)Email {{CONTACT_EMAIL}} with the date; we re-run the archive from the hot table.
Export job email never arrivesEmail not delivered (see Email pipeline) or filter matched 0 rowsRun the export with broader filters; check Settings → Email log.
PII still masked after toggling unmaskToggle is admin-only; you’re a non-adminAsk an admin to share the row, or have them export.