Audit trail with structured metadata
WHO did it, WHAT triggered it, WHY, and the dedup key.
By Solomon Ajayi · Free to read, no signup
Every journal entry in a real ledger carries metadata beyond the description: WHO posted it, WHAT external event caused it, WHY (a structured reason code), and a UNIQUE dedup key so a retried webhook does not double-book. When the auditor sits down, their first question is always 'why does this entry exist?' Without metadata, your answer is 'I don't know.' Apply the steps, then switch to the Journal view (top-left toggle) to see the metadata attached to each entry.
A balanced entry tells you what moved and how much, but not why it exists. Two ₦10,000 entries with mirror-image lines might be a legitimate refund and a fraud reversal, and the amounts alone cannot tell them apart. When an auditor points at any single line and asks why it is there, a description string is not an answer. The answer has to be structured data attached to the entry itself.
Four fields cover it. Actor records who or what posted the entry: a code version, a named human, a cron job, never a vague 'system' or 'admin.' Source event ID points back to the upstream trigger, the provider event, the support ticket, the specific cron run. Reason code is a structured why you can group and aggregate on. Idempotency key is the dedup boundary that stops a retry from posting the same entry twice.
The idempotency key deserves care because it carries double duty: provenance and protection. For a webhook it is the provider's event ID, so a retry collides and the second insert fails. For a cron job it must be deterministic, built from something like user ID plus month, so a re-run produces the same key and becomes a no-op rather than a double charge. A random key on a cron job defeats the entire purpose, because every run looks new.
Worked example, step by step
Deposit triggered by a provider webhook
₦10,000 deposit landed. The entry shown here looks normal, but click the Journal toggle on the left and you'll see the four metadata fields it carries.
| Account | Debit | Credit |
|---|---|---|
| Bank Account (1200) | ₦10,000.00 | |
| User Wallet (2000) | ₦10,000.00 |
Apply this and switch to Journal. You'll see: actor=payment-service-v3.1.2 (the code version that processed it), source=stripe_evt_abc123 (the provider's event ID), reason=card_deposit (a structured code for aggregation), idempotency_key=dep_abc123 (so if the webhook fires twice, the second insert fails at the DB layer). An auditor can trace this entry back to the exact webhook and code version.
Refund issued by a support agent
Customer complained. Support agent Alice issued a ₦10,000 refund through your internal tool. Notice how the metadata changes completely, different actor, different source, different reason, different dedup key.
| Account | Debit | Credit |
|---|---|---|
| User Wallet (2000) | ₦10,000.00 | |
| Bank Account (1200) | ₦10,000.00 |
Apply, then look at Journal. The actor is now alice@yourco.com (a real human, with their identity on the entry, not 'admin' or 'system'). Source is ticket_999 (the support ticket). Reason is customer_complaint_refund. Idempotency key is refund_alice_ticket_999 (so if Alice double-clicks Submit, you do not double-refund). Every entry tells a complete WHO/WHAT/WHY/dedup story.
Monthly account fee posted by a cron job
End of month. Your billing cron debits ₦100 from the user's wallet as a maintenance fee. Cron jobs need to be auditable too, with deterministic dedup keys so retries don't double-charge.
| Account | Debit | Credit |
|---|---|---|
| User Wallet (2000) | ₦100.00 | |
| Fee Revenue (4000) | ₦100.00 |
Switch to Journal to see the third entry. Actor=billing-cron-v2.0.1. Source is cron_run_2026-05-31T23:00 (the specific cron execution). Reason is monthly_account_fee. Idempotency key is fee_user123_2026-05, DETERMINISTIC (user_id + month) so if the cron retries the second run is a no-op at the DB layer. Random idempotency keys for cron jobs defeat the purpose.
Takeaway
Every journal entry needs four metadata fields: actor (who), source_event_id (what), reason_code (why), idempotency_key (dedup). Add these columns from day one with NOT NULL constraints. Without them, your audit trail is just timestamps and amounts, what regulators reject. With them, you can answer any 'why does this entry exist' question instantly, group by reason code for analytics, and prevent webhook double-fires at the database layer.
The code behind it
Reference schema
CREATE TABLE journal_entry (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
description text NOT NULL,
posted_at timestamptz NOT NULL DEFAULT now(),
idempotency_key text,
source_event_id text,
actor text,
reason_code text
);
CREATE TABLE entry_line (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entry_id uuid NOT NULL REFERENCES journal_entry (id) ON DELETE CASCADE,
account_code text NOT NULL,
debit bigint NOT NULL DEFAULT 0,
credit bigint NOT NULL DEFAULT 0,
CHECK (debit >= 0 AND credit >= 0),
CHECK (NOT (debit > 0 AND credit > 0))
);
-- Partial unique index: enforce one row per non-NULL idempotency_key,
-- still allow manually posted entries to have NULL keys.
CREATE UNIQUE INDEX journal_entry_idem_uniq
ON journal_entry (idempotency_key)
WHERE idempotency_key IS NOT NULL;Practice this on a real ledger
Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.