Custom events
Record something that matters to the business but is not a field change.
Automatic logging covers writes. Some things worth auditing are not writes: an approval, a manual override, a compliance review, a status change decided by business logic somewhere other than the document form. createAuditEvent puts those in the same log.
import { createAuditEvent } from '@10x-media/audit-logs'
await createAuditEvent(req, {
collection: 'athletes',
documentId: athlete.id,
eventType: 'score_adjustment',
metadata: {
reason: 'doping_disqualification',
adjustedScore: -5,
effectiveDate: '2026-02-15',
notes: 'Test result received 2026-03-20',
},
})That writes an entry with operation: 'custom'. The user comes from req.user, and the group from req.context[contextKey], the key you configured, when grouping is enabled.
Options
| Option | Type | Required | Description |
|---|---|---|---|
collection | string | yes | Which collection the event is about. |
documentId | string | number | no | The specific document. Omit for a collection-level event. |
eventType | string | yes | Your identifier, for example score_adjustment. This is what you filter on. |
metadata | Record<string, unknown> | no | Anything you want to keep. The shape is yours. |
group | string | no | Overrides the value read from req.context. |
tenant | string | number | no | The tenant the event belongs to, with multiTenancy on. Without it only the all-tenants view lists the entry. Ignored, with a warning, when multiTenancy is off. |
user | { collection: string; id: string | number } | no | Who the event is attributed to, in place of req.user. impersonator is still read from req. |
Two timestamps
createdAt is when the function ran. That is not always when the thing happened: a disqualification recorded on 20 March may take effect from 15 February.
The log deliberately does not let you forge createdAt, because an audit log whose timestamps can be set by the caller is not evidence of anything. Put the domain date in metadata and you keep both facts: when it happened, and when someone wrote it down.
Where to call it
Anywhere you hold a req: a collection hook, a custom endpoint, a server action, a job.
hooks: {
afterChange: [
async ({ req, doc, previousDoc, operation }) => {
if (operation !== 'update') return
if (doc.status === previousDoc?.status) return
if (doc.status !== 'disqualified') return
await createAuditEvent(req, {
collection: 'athletes',
documentId: doc.id,
eventType: 'disqualified',
metadata: { previousStatus: previousDoc?.status },
})
},
],
}From a job
A queued job runs on a request without a user, and a multi-tenant host's job has no tenant on req either, even when a person started it for one tenant. Pass both from the job's input:
await createAuditEvent(req, {
collection: 'orders',
documentId: input.orderId,
eventType: 'export_finished',
tenant: input.tenantId,
user: { collection: 'users', id: input.requestedBy },
})user changes the entry only. Setting req.user on the job instead would change how every hook and validation of the job's own writes behaves.
Passing req matters for more than the user: Payload's local API carries req.context down, which is what keeps the custom event in the same group as the write that triggered it.
A custom event called from an afterChange hook on an audited collection lands next to the automatic entry for the same save, not instead of it. Reach for this when the automatic diff cannot express the event, not to annotate one.
Rendering them in the view
Expanded, a custom event shows its type, collection and document, then metadata as JSON. That is enough to debug and not much to read. Give an event type its own renderer and it replaces that block:
auditLogs({
logs: {
view: {
components: {
customEvents: {
order_refunded: '/components/RefundEvent#RefundEvent',
'*': '/components/AnyEvent#AnyEvent',
export_downloaded: false,
},
},
},
},
})Keys are eventType values. '*' covers every type without its own entry, and false keeps the default block, wildcard included. Paths are ordinary Payload component paths; the plugin registers them in the import map, so generate:importmap picks them up.
The component receives CustomEventComponentProps from @10x-media/audit-logs/types: eventType, collection, documentId, metadata, createdAt, and the whole entry. A server component also gets payload and req, so it can read what the event points at instead of copying it into metadata:
import type { CustomEventComponentProps } from '@10x-media/audit-logs/types'
import type { Payload } from 'payload'
export async function RefundEvent({
documentId,
metadata,
payload,
}: CustomEventComponentProps & { payload: Payload }) {
const order = documentId
? await payload.findByID({ collection: 'orders', id: documentId, depth: 0 })
: null
return (
<p>
Refunded {String(metadata?.amount)} on {order?.reference ?? documentId}
</p>
)
}Renderers run on the server for the entries on the current page only. The rest of the row, its header and metadata line, stays the plugin's.
Querying
Custom events live in the same collection, so the same queries work. Narrow by eventType:
const adjustments = await payload.find({
collection: 'audit-logs',
where: {
and: [
{ operation: { equals: 'custom' } },
{ eventType: { equals: 'score_adjustment' } },
{ relationTo: { equals: 'athletes' } },
],
},
overrideAccess: true,
})eventType is indexed. metadata is not, so filtering on what is inside it means reading the rows out and filtering in application code. If you expect to query by something, promote it to a field with logs.override rather than burying it in metadata.