Docs · Audit log & export
On this page

Audit log & export

Every change in a workspace and who made it, numbered and hash-chained. An owner can export it as a file that anyone can check without trusting the owner, and can choose how long it is kept.

The log#

Audit log in the sidebar lists, for owners, every publish, share, invitation, key, setting and sign-in in the workspace, and every time a reader was refused or asked for access, newest first. Nothing in it can be edited. Entries leave it in one way only: the workspace’s retention setting.

Each entry has a number in the workspace’s own chain, 1, 2, 3 and on, with no gaps, and a hash: SHA-256 over the entry and the hash of the entry before it. Change or remove an entry and its hash, and every hash after it, no longer match. What the platform’s operators did to the workspace (a suspension, a takedown) is kept in their own record and is not part of the workspace’s chain; you see its effect, not their notes.

Export#

On Audit log → Export, pick the first and last day (or neither, for everything kept) and a format, JSON Lines or CSV, and the file downloads. Exporting needs the owner role and the Pro plan, and every export is itself recorded in the log, as audit.export. The same file comes from the API and the CLI (0.16.0 or newer):

GET /api/v1/audit/export

scope audit · ?format=jsonl|csv · ?from= · ?to=

shell
colophon audit export --from 2026-01-01 --to 2026-04-01 --out q1.jsonl

from and to are ISO 8601 moments, or dates meaning midnight UTC; to is not included, so the line above is the first quarter. The manifest gives them, and the moment of the export, to the microsecond as entries are dated, so the to of one export passed as the from of the next starts it where the first one ended. On the API a key needs the audit scope, which a key gets only when an owner ticks it on API keys (or passes --scopes audit to colophon create-token); an owner’s own colophon login has it. A pull for a SIEM is a key with only audit and the command above on a schedule.

An export is a run of the chain with no gaps: from the first entry dated on or after from to the last dated before to. An entry is dated when the change it records began, so at either end an entry from a second outside the range can be included, never one left out. Each line is one entry:

FieldWhat it is
seqits number in the workspace’s chain
idits id in the service’s log
occurred_atUTC, to the microsecond: 2026-10-16T09:12:33.123456Z
tenant_idthe workspace
actor_user_id, actor_key_idthe person or the API key who acted; both empty for the platform itself (a scheduled job)
actionsite.publish, permission.grant, gate.deny and the rest
target_type, target_idwhat it was done to
detailthe specifics, as the JSON text stored: parse it to read it, and hash it as it is
actor_email, key_namewho that person or key is today: looked up as the file is made, so not part of the hash
prev_hash, hashthe chain, in hex

The last line is the manifest, signed by Colophon: the run’s first and last numbers, its count, the hash before it and its last hash, the range asked for and when the file was made. The signature covers both ends of the run and the hashes cover everything between, so neither whoever exported the file nor anyone after them can change, add, drop or reorder an entry without it showing. IP addresses and browsers are never exported.

A CSV export has the same columns and the manifest as its last row, with export.manifest in action and the manifest as JSON in detail. An empty cell is no value, and "" is an empty one. A cell a spreadsheet would run as a formula (one starting =, +, -, @, a tab or a return), or one already starting with an apostrophe, has an apostrophe put in front; the verifier takes it off again.

Check an export#

shell
npx @strangenoob/colophon audit verify q1.jsonl
Output
verified: entries 1204–1873 (670) of workspace 0b6c…, 2026-01-01T00:00:00.000000Z to 2026-04-01T00:00:00.000000Z, exported 2026-10-16T09:20:11.204391Z, signed by key 3f9a0c51d2e87b46

It needs no account, so you can hand the file to an auditor and they can run this themselves. It fetches Colophon’s public key from https://app.colophon.fyi/.well-known/colophon-audit-keys (a JWK Set; --keys takes another address or a saved copy), then checks, in order: the manifest’s signature; that the entries are numbered from the manifest’s first to its last with none missing or added; that each entry’s prev_hash is the hash before it; that each hash is the SHA-256 of the entry’s own fields; and that the last is the manifest’s. If anything fails it names the entry and exits 1.

By hand, an entry’s hash is SHA-256 over ten lines, each a field encoded as JSON (null for none), in this order: prev_hash, seq, occurred_at, tenant_id, actor_user_id, actor_key_id, action, target_type, target_id, detail. The signature is Ed25519 over the manifest’s fields put together the same way, in the order SIGNED lists them below. The whole check in Python, for a JSON Lines export:

python
import base64, hashlib, json, sys, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

KEYS = "https://app.colophon.fyi/.well-known/colophon-audit-keys"
SIGNED = ["format", "workspace", "from", "to", "first_seq", "last_seq", "count",
          "first_prev_hash", "last_hash", "pruned_through_seq", "exported_at"]
HASHED = ["prev_hash", "seq", "occurred_at", "tenant_id", "actor_user_id",
          "actor_key_id", "action", "target_type", "target_id", "detail"]
text = lambda values: "\n".join(json.dumps(v, ensure_ascii=False) for v in values).encode()
b64 = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

records = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
signed, entries = records[-1], records[:-1]
m = signed["manifest"]

# 1. The manifest is Colophon's: its signature verifies under the published key.
key = next(k for k in json.load(urllib.request.urlopen(KEYS))["keys"] if k["kid"] == signed["key_id"])
Ed25519PublicKey.from_public_bytes(b64(key["x"])).verify(b64(signed["signature"]), text([m[f] for f in SIGNED]))

# 2-4. Every entry is there, in order, linked to the one before, and hashes to what it says.
assert len(entries) == m["count"], "entries missing or added"
prev = m["first_prev_hash"]
for i, e in enumerate(entries):
    assert e["seq"] == m["first_seq"] + i and e["tenant_id"] == m["workspace"], f"entry {e['seq']} out of place"
    assert e["prev_hash"] == prev, f"chain broken at entry {e['seq']}"
    prev = hashlib.sha256(text([e[f] for f in HASHED])).hexdigest()
    assert prev == e["hash"], f"entry {e['seq']} was altered"
assert prev == m["last_hash"], "the file does not end where the manifest does"
print(f"verified: {m['count']} entries of workspace {m['workspace']}")
NoteThe key is Colophon’s, not the workspace’s, and the export says which one signed it (key_id, a fingerprint of the key). When the key is replaced, the old one stays in the key document, so what it signed keeps verifying. Keep a copy of the document with exports you archive all the same: a key replaced because it leaked is taken out of it.

How long it is kept#

Forever, unless an owner chooses otherwise under Members & settings → Audit log retention: 90 days, 1 year, 2 years or 7 years, on the Pro plan. Each hour, entries older than that are removed, oldest first, and the change of setting is itself an entry (audit.retention). Going back to forever is always allowed. Export what you want to keep before shortening it.

Removing old entries does not break the chain. Before any are removed, a checkpoint records the last one going: its number and its hash. The first entry kept already names that hash as its prev_hash, so the rest verifies from there, and the same transaction adds an audit.prune entry stating the checkpoint, so it is in the chain too. An export that starts at the first entry kept signs that hash as the hash before it, and its manifest’s pruned_through_seq says up to which number entries were removed.

The cut is by number, not by date alone: it stops before the first entry newer than the setting, so an entry is never removed ahead of one numbered before it. Entries of the platform’s own record are not affected by the setting. A site’s Shared with list shows the name of someone who once asked for access because the log remembers the request; once it is removed, they show by address unless they have opened the site or are a member.

Audit log & export — Colophon docs