Plugins
Audit Logs

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

OptionTypeRequiredDescription
collectionstringyesWhich collection the event is about.
documentIdstring | numbernoThe specific document. Omit for a collection-level event.
eventTypestringyesYour identifier, for example score_adjustment. This is what you filter on.
metadataRecord<string, unknown>noAnything you want to keep. The shape is yours.
groupstringnoOverrides the value read from req.context.
tenantstring | numbernoThe 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 }noWho 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.

collections/Athletes.ts
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:

payload.config.ts
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:

components/RefundEvent.tsx
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.

On this page