Audit Trail
Slonge Billing records every write operation — whether from the dashboard, the CLI, or an MCP client — into two tables:
ApiTokenUsage: Request envelope (endpoint, HTTP method, status code, response time, IP address, user agent) for every API token call.AuditLog: Who (userIdand/ortokenId), What (action + entity + entity ID), before/after diff for updates, plus context metadata.
Exactly one AuditLog row is written per successfully changed business entity. PATCH calls that do not modify any tracked fields produce no entry — keeping the log free of meaningless no-op records.
Two-layer model
Every CLI/MCP call
│
├─► ApiTokenUsage (always: envelope metadata, regardless of success)
│
└─► AuditLog (only on success + an actual change)
Dashboard actions do not write an ApiTokenUsage row (no API token involved), but they do write an AuditLog entry with the userId of the logged-in user.
Financial lifecycle: compliance ledger
For lifecycle actions on all financially-relevant entities (invoices · journal entries · credit notes · vendor bills · expenses · quotes), a hash-chained event is additionally appended to the ComplianceEvent ledger. This applies equally to dashboard and CLI/MCP-initiated actions. Sent PDF documents are also registered as DocumentArtifact rows with SHA-256 checksums. All compliance data is included in the tenant backup export — see Settings → Data & Export → Compliance dossier.
What is logged?
| Action | Entity | Action code | Example metadata |
|---|---|---|---|
| Create client | Client | create | { name, email } |
| Update client | Client | update | Diff of changed fields |
| Delete client | Client | delete | { name } |
| Create project | Project | create | { name, clientId } |
| Create invoice | Invoice | create | { invoiceNumber, clientId, totalAmount } |
| Send invoice | Invoice | send | { invoiceNumber, recipientEmail } |
| Mark invoice paid | Invoice | status_change | { invoiceNumber, status: "paid" } |
| Create quote | Quote | create | { quoteNumber, amount } |
| Send quote | Quote | send | { quoteNumber, recipientEmail } |
| Convert quote → invoice | Invoice + Quote | create + status_change | { quoteNumber, invoiceNumber } |
| Create time entry | TimeEntry | create | { projectId, hours, date } |
| Create expense | Expense | create | { amount, vendor, date } |
| Upload receipt (OCR) | Expense | view | { fileSize, mimeType, parsedAmount, parsedVendor, parsedDate } |
| Update settings | Settings | update | Diff of changed fields |
| Create API token | ApiToken | create | { tokenName, tokenPrefix, expiresAt } |
| Revoke API token | ApiToken | revoke | { tokenName, tokenPrefix } |
| Dunning run (cron or CLI) | Invoice | status_change | { invoiceNumber, source: "cron"/"cli" } |
| Create journal entry | JournalEntry | journal.created | Full snapshot incl. lines |
| Update journal entry | JournalEntry | journal.updated | Before + after |
| Delete journal entry | JournalEntry | journal.deleted | Full snapshot |
| Create credit note | CreditNote | creditNote.created | Full snapshot |
| Credit note from invoice | CreditNote | creditNote.created_from_invoice | sourceInvoiceId + snapshot |
| Update credit note | CreditNote | creditNote.updated | Before + after |
| Delete credit note | CreditNote | creditNote.deleted | Full snapshot |
| Create vendor bill | VendorBill | vendorBill.created | Full snapshot |
| Update vendor bill | VendorBill | vendorBill.updated | Before + after |
| Delete vendor bill | VendorBill | vendorBill.deleted | Full snapshot |
| Vendor bill paid | VendorBill | vendorBill.paid | paidAt + paymentReference |
| Create expense | Expense | expense.created | Full snapshot |
| Update expense | Expense | expense.updated | Before + after |
| Delete expense | Expense | expense.deleted | Full snapshot |
| Upload receipt (OCR) | Expense | expense.uploaded | fileSize, mimeType, parsedAmount, parsedVendor, parsedDate |
| Create quote | Quote | quote.created | Full snapshot |
| Update quote | Quote | quote.updated | Before + after |
| Delete quote | Quote | quote.deleted | Full snapshot |
| Send quote | Quote | quote.sent | recipientEmail + snapshot |
| Accept quote | Quote | quote.accepted | Full snapshot |
| Convert quote to invoice | Quote | quote.converted | createdInvoiceId + snapshot |
Security note: The full token value is never logged — only the name and prefix (e.g.,
slk_live_abc…).
Origin attribution
Each AuditLog entry carries either a userId or a tokenId (or both):
| Surface | userId | tokenId |
|---|---|---|
| Dashboard | ✓ (logged-in user) | — |
| CLI / MCP / Streamable HTTP | — | ✓ (API token used) |
The token name you assigned at creation time (e.g., "Claude Desktop — Ben's laptop") appears in the audit log and makes attribution easy. This is why we recommend creating one token per device and application.
Viewing the audit log
The audit log is not currently available as a direct UI view in the dashboard. However, the raw data is included in the compliance dossier export:
- Settings → Data & Export → Export compliance dossier
- The ZIP contains
compliance/events.json(hash-chained lifecycle log) andcompliance/document-artifacts.json(PDF checksums).
If you need a direct UI for the audit log, contact us at [email protected].
Relationship to other security features
| Feature | Purpose |
|---|---|
| Audit trail (this page) | Who changed what — for internal control and debugging |
| Compliance ledger | Hash-chained invoice events — for external auditors |
| ApiTokenUsage | Per-token request statistics — for rate-limiting and anomaly detection |
| SHA-256 document artifacts | Integrity proof for sent PDFs |
All four layers complement each other: the audit trail explains what changed, the compliance ledger proves that an invoice existed and was sent, and the document artifacts verify that the stored PDF version is the one that was delivered.