Plugins
Analytics

Goals

Mark conversions from a data attribute, a block field, a form action, or server code, and let editors manage goals from the admin.

A goal is a named conversion. Declare the goals an install cares about, hook up whatever completes them, and conversions and revenue become readable metrics alongside pageviews.

Two pages sit beside this one. Attach goals to your own components is the hookup guide: the plugin ships no frontend components, only small pieces you spread into the markup you already have. Revenue covers the money: what a goal's own value is for, and why a sale sends its total with purchase instead.

Declare the goals

payload.config.ts
analytics({
  adapters: [native()],
  capture: { gate: 'all' },
  // Money is off unless set: without it, a goal's value reports nothing, and the boot says so.
  revenue: { currency: 'EUR' },
  goals: [
    { slug: 'book-demo', name: 'Book a demo', match: { kind: 'goal' }, value: { fixed: 500 }, currency: 'EUR' },
    { slug: 'signup', name: 'Signup', match: { kind: 'event', name: 'signup' } },
    { slug: 'thank-you', name: 'Thank-you page', match: { kind: 'path', pattern: '/thank-you' } },
    { slug: 'purchase', name: 'Purchase', match: { kind: 'goal' }, value: { prop: 'total' }, currency: 'EUR' },
  ],
})

goals also accepts the object form { defaults, collection, purchase }, where collection is the admin surface editors manage their own goals through. Slugs must be unique and kebab-case, so a slug is safe in a URL, a data attribute, and a key of the native daily totals; a duplicate or a non-kebab slug throws at config time, as does a negative or non-finite value.fixed. name is admin-facing and never reaches the browser: the tracker config carries a goal's slug, match, value and currency alone.

The built-in purchase goal

One goal exists without being declared: purchase, matching a goal call, carrying no fixed value.

It is there because purchase books against that slug unless told otherwise, and a goal event matching no goal completes nothing. Without it, an install that had not thought about goals yet would record its sales as events whose amounts never reach conversions or revenue, which looks exactly like a working integration until someone opens the goals table. So the resolver adds it whenever neither the config nor the goals collection defines that slug.

It is latent, which is the part worth knowing. A site that sells nothing should not open its dashboard to a "Purchase" row at zero, lose its "no goals yet" empty state, and find a goal it never declared in every picker. So the built-in goal counts everywhere and is listed almost nowhere:

WhereWhat it does
IngestAlways matches, so a sale always counts.
Read-side goal hintsAlways included, so its conversions and revenue reach the reports.
The goals panel, goals widget and goals breakdownA row only once it has conversions.
The goal picker, and the "has goals" decision behind the empty statesLeft out entirely.
The tracker configLeft out: it matches a goal call by slug, so the browser needs nothing for it.
GET /api/_x/goalsLeft out by default; ?include=builtin returns it tagged source: 'builtin'.

Declaring your own purchase goal, in config or in the collection, replaces it the same way a collection goal replaces a config goal, and yours is a goal like any other: listed, pickable, and counted towards the install having goals.

An install that never sells anything can remove it:

analytics({ adapters: [native()], capture: { gate: 'all' }, goals: { defaults: [], purchase: false } })

The example above declares its own purchase goal with a value property and a currency, which replaces the built-in one; drop that line and sales still count, against the built-in goal, with whatever amount purchase sends.

Match kinds

KindCompletes on
{ kind: 'goal' }An explicit trackGoal('<slug>'), which is what the attribute hookup emits.
{ kind: 'event', name }A track(name, props) custom event with that exact name.
{ kind: 'path', pattern }A pageview whose path matches the glob.

The kinds never cross: a custom event named signup does not complete a { kind: 'goal' } goal whose slug is signup, and a trackGoal('signup') does not complete a { kind: 'event', name: 'signup' } goal.

Path globs

Anchored and case-sensitive. * as a whole segment is exactly one non-empty segment, and inside a segment it is one or more characters of that segment. ** as a whole segment is one or more segments at any depth; inside a segment it is the same as *. A trailing slash is insignificant on both sides, a query string and a hash are cut off both the path and the pattern, and every other character is matched literally.

PatternMatchesDoes not match
/thank-you/thank-you, /thank-you/, /thank-you?ref=x/thank-you/deep
/docs/**/docs/intro, /docs/a/b/c/docs
/checkout/*/done/checkout/abc/done/checkout/done, /checkout/a/b/done
/blog-*/blog-launch/blog-, /blog-launch/comments

A pattern has at most 32 segments and uses ** at most 4 times. A config goal over either limit stops the boot with an error naming the goal, and the goals collection's own pattern field refuses to save one (a replacement of the match field that drops its validation saves it, and it then matches nothing). A path deeper than 128 segments matches no pattern. Matching compares each pattern segment with each path segment at most once, so neither a pattern nor a path can make it slow.

Value precedence

For an event server code records, its own value wins (an explicit 0 included), then the goal's value.fixed, then props[value.prop] when it is a number or a numeric string, else 0. A value that is not a finite number >= 0 falls through to the next source, so a negative amount can never subtract from a bucket's revenue.

For an event a browser sends, revenue.browserAmounts decides first. By default ('fixed') a goal's value.fixed wins, in the goal's currency, over whatever the page sent, and the page's amount counts only for a goal without one; 'none' takes no amount from the page at all; 'trust' follows the server order above.

One event can complete several goals, and each completion carries its own value, decided by the order above for that goal alone and converted from its own currency: a pageview matching two path goals worth 5 and 50 counts both.

A goal with browser: false is one only server code completes: native ingest ignores a browser's completion of it, so a request cannot forge one, whatever it sends. Use it for a goal without a fixed value that only your server records, such as a paid subscription (goals only the server completes).

A goal's value.fixed is what one completion is worth when that number is the same every time: the value of a lead, of a demo booking, of a signup. It is a business decision, not a transaction. A sale is the other case, and it sends the amount that actually moved with purchase instead. See Revenue before putting a fixed value on a goal that stands for a purchase.

The hookups

SurfaceReach for it when
Data attributeThe thing a visitor clicks or submits is markup you control.
goalField()Editors decide per document which goal a CTA completes.
Form-builder actionA @10x-media/form-builder submission is the conversion.
trackServerEvent()The conversion happens on the server: a webhook, a job, a server action.
purchase()The conversion is a sale, so the amount comes from the order rather than from the goal.
Goals collectionMarketers add and edit the goals themselves, without a deploy.

1. Tag the element

The simplest hookup is an attribute. The tracker's goalAttribute auto-capture (on by default) fires on a click on the element or anything inside it, and on a form, on its submit:

<button data-analytics-goal="book-demo">Book a demo</button>

<form data-analytics-goal="newsletter-signup">
  <input name="email" type="email" />
  <button type="submit">Subscribe</button>
</form>

A tagged form completes its goal when it is submitted, once per submission, however the visitor sent it: its button, the Enter key, or your own requestSubmit(). A click inside it completes nothing, focusing a field included. A form sent with form.submit() fires no submit event, so call trackGoal there yourself. A submit button tagged inside a tagged form completes its own goal on the click and the form's on the submit.

Add revenue on the element itself when the amount is per-instance:

<button
  data-analytics-goal="purchase"
  data-analytics-value="49.00"
  data-analytics-currency="EUR"
>
  Buy
</button>

The attribute is read from the nearest ancestor carrying it, so wrapping a whole card works. A blank or non-numeric data-analytics-value is treated as absent rather than zero, so an unfilled template slot never books a zero-revenue conversion. Native counts it only for a goal without a fixed value unless revenue.browserAmounts is 'trust', and not at all under 'none': anyone can edit a page's attributes.

Track it by hand

import { useAnalytics } from '@10x-media/analytics/react'

const { trackGoal } = useAnalytics()
trackGoal('book-demo')
trackGoal('purchase', { value: 49, currency: 'EUR' })

trackGoal is on the tracker itself too (@10x-media/analytics/tracker), for hosts that boot it without React. Passing no value falls back to the goal's own fixed value and currency, which is what lets a vendor sink book revenue; the tracker reads those from its config, a server-rendered snapshot of the request scope's goals with the collection already merged in. A slug that snapshot does not know is still sent, because the server may know goals it predates.

2. Pick a goal in a block

goalField() is a field factory for your own collections, globals, and blocks. It stores the goal slug as text and renders a picker listing the goals the requesting scope can see:

blocks/cta.ts
import { goalField } from '@10x-media/analytics'
import type { Block } from 'payload'

export const ctaBlock: Block = {
  slug: 'cta',
  fields: [
    { name: 'heading', type: 'text' },
    { name: 'label', type: 'text', required: true, defaultValue: 'Book a demo' },
    goalField({ name: 'goal', required: true }),
  ],
}

Read it back with goalSlug() and hand the slug to whichever hookup fires the completion, usually the data attribute:

components/cta.tsx
import { goalSlug } from '@10x-media/analytics'

export const Cta = ({ block }: { block: { label: string; goal?: string | null } }) => (
  <button type="button" data-analytics-goal={goalSlug(block.goal) ?? undefined}>
    {block.label}
  </button>
)

goalSlug accepts the stored string and returns null for an unset field, so a block whose goal was cleared renders no attribute at all rather than an empty one. It comes from the root entry, which pulls Payload in with it, so call it where the document is read and hand a client component the resolved slug as a prop.

OptionDefaultWhat it does
name'goal'Field name, and the property the slug is stored under.
labelthe translated "Goal"Any Payload label.
requiredfalseAn empty value then fails with Payload's own validation:required. A non-empty one always has to be a kebab-case slug.
adminMerged under the factory's own admin config. Every slot but Field passes through.
overrides({ field }) => TextField, applied last. Spread the field, never deep-merge it.

The picker is a server component wrapping a client select, registered at a component path of its own, so regenerate the import map (payload generate:importmap) after adding the field. It behaves like a native select: admin.condition, admin.readOnly, admin.description, admin.placeholder and admin.width all apply, and it resolves its own path through Payload's field context, so it works inside blocks and array rows whose index moves.

Options come from GET /api/_x/goals, an authenticated endpoint listing the config goals merged with the goals collection for the request's scope:

{
  "goals": [
    { "slug": "book-demo", "name": "Book a demo", "source": "config" },
    { "slug": "newsletter", "name": "Newsletter signup", "source": "collection" }
  ],
  "collection": { "slug": "analytics-goals" }  // null when the collection is off
}

The endpoint is registered whether or not the collection is enabled, so the picker works on a config-only install. It takes no scope parameter, so a tenant can only ever enumerate their own goals. It lists the collection's goals through the collection's own read access, asked with the reader's user, so narrowing who reads the goals collection narrows the picker too; config goals and the built-in one are not documents and always list. Matching is not narrowed: a goal a reader cannot see still counts when a visit completes it.

A scope with no goals at all still renders the select, with a notice under the input (and a link to the collection when the install has one). A slug stored before the goals changed renders as its own option and stays editable, clearable on an optional field; a required: true field keeps the stored value until a goal exists, because the picker itself makes a required goal field non-clearable (Payload's native select stays clearable regardless of required, and admin.isClearable is not read here).

The endpoint refuses an anonymous request with 401 unauthorized, a denied one with 403 forbidden, and a tenant the user does not belong to with 400 untrusted_scope, all in the plugin-wide error envelope (Goals endpoint). A resolution that throws answers 200 with the config goals on an unscoped install and an empty list on a scoped one, rather than a 5xx the picker would have to render. Every answer carries Cache-Control: private, no-store, successful listings included, because which goals a caller sees depends on that caller's own scope.

Anything outside the admin reads the field by hand. The value is a plain slug string, and goalSlug() is the only normalization it needs.

3. Track a form submission

trackGoalAction() is a post-submit action for @10x-media/form-builder. Analytics never imports form-builder, and form-builder never imports analytics: the action is registered by the host.

payload.config.ts
import { GOAL_ACTION_TYPE, trackGoalAction } from '@10x-media/analytics'
import { formBuilder } from '@10x-media/form-builder'

formBuilder({
  actions: { [GOAL_ACTION_TYPE]: trackGoalAction({ hostname: 'example.com' }) },
})

Each form then gets a Track analytics goal block under its Actions tab:

FieldWhat it does
GoalThe goal this submission completes, through the same picker as goalField().
AmountWhat one completion is worth, overriding the goal's own amount, with its currency picked in the same row. Stored as value and currency.
Amount from a fieldA submission field whose numeric answer becomes the amount, used when Amount is empty. Stored as valueFrom.

The Amount is the same control the goals collection uses, and a currency is required once an amount is set. Pass defaultCurrency, normally your own revenue.currency, and the control fills it in as soon as an amount is typed: the action's fields are out of this plugin's reach, so it cannot read that setting itself. Without it the editor picks the currency.

Both are money inputs, so an install that measures none is offered only the goal. Form-builder often registers its action blocks after this plugin has run, which puts them out of reach of the pass that hides them, so pass revenue: false yourself there to be certain.

The completion is recorded against /forms/<form id> and carries formId, formTitle and submissionId as event props. Pass path to attribute it to the page the form lives on instead. Both hostname and path also take a synchronous function of the run arguments, for one Payload serving several sites: hostname: ({ req }) => req?.headers.get('x-site') ?? 'example.com'. defaultCurrency must be a three-letter code, or the action throws when it is built.

The browser's consent gate does not reach a form action, so identity decides whose completion it is, as it does for trackServerEvent. 'request' makes it the submitter's, with their visitor id, location, device and language, as a beacon would; 'none' records it as no visitor's. Unset, it is 'none' while capture.gate holds a slot the native beacon could fill, and 'request' otherwise. A function of the run arguments decides per submission from what your own code knows of the visitor's answer: identity: async ({ req }) => (req && (await measuredVisitor(req)) ? 'request' : 'none'), where measuredVisitor stands for your own server-side record of that answer. A queued run carries the runner's request, where no visitor's answer can be read.

Where the action's hostname comes from

A form submission is a public request whose Host the visitor chose, so the action does not store it as given. In order:

  1. The hostname option, normalized to what counts as a hostname. An option that is not one is skipped rather than stored.
  2. The submitting request's host, judged by the native adapter's own hostname policy: 'request' stores it, a list stores it only when the list holds it, and a resolver decides. A forged host that the endpoint would have refused is refused here too, so a conversion cannot mint a hostname a beacon could not.
  3. The host of the install's serverURL.

The scoped-install drop rule does not apply: a submission's scope comes from the form document, not from its host. There is no fourth rung: an install with no serverURL whose submitting request carries no usable host has nothing to attribute the completion to, so the action records nothing, writes one warn line for the life of the process, and returns. The submission itself is unaffected either way, since the action is not essential.

Set hostname on any install with a jobs runner. Form-builder queues every non-essential action, and a queued run carries the runner's request rather than the submitter's, so the fall-through lands on serverURL and every site's conversions pile up under the CMS host.

On a scoped install, pass scope for the same reason: the submitter's tenant cannot be recovered from the runner's request, so an unset scope resolves install-wide. It takes a literal, null for install-wide, or a function of the run arguments.

The action is deliberately not essential: a goal is a measurement, so a failed write is logged by form-builder and the submission still succeeds. It records through trackServerEvent, so it needs the native adapter.

4. Track from server code

trackServerEvent() records an event from anywhere on the server, through the native engine's own ingest pipeline: same sanitizers, same goal matching, same write path, no HTTP round trip.

app/api/checkout/route.ts
import { trackServerEvent } from '@10x-media/analytics'
import config from '@payload-config'
import { getPayload } from 'payload'

/** Your own signature check: the order the webhook names, once its signature holds. */
declare function verifiedWebhookBody(request: Request): Promise<{ total: number; plan: string }>

export const POST = async (request: Request) => {
  const order = await verifiedWebhookBody(request)
  const payload = await getPayload({ config })

  await trackServerEvent(payload, {
    type: 'goal',
    name: 'purchase',
    path: '/orders',
    hostname: 'shop.example.com',
    value: order.total,
    currency: 'EUR',
    props: { plan: order.plan },
  })

  return new Response(null, { status: 204 })
}

type, path and hostname are required, and so is name on everything but a pageview. An event that fails one of them rejects with an AnalyticsTrackError naming the field, and nothing is written. hostname is required here and optional over the ingest endpoint, which stores the host the request carried instead: this is trusted server code, and there is no request to attribute a server event to, so the caller owns the value and it is stored as given, up to 253 characters. A missing type, path or name answers 400 over that endpoint the same way it throws here. Every optional field is sanitized exactly as it is on the wire.

OptionWhat it does
opts.reqThe request behind the event. Attribution, scope and reporting timezone all follow it. Your scope, timezone and currency resolvers receive it as the capture call site's request: without its user, cookies or authorization header.
opts.identity'request' attributes the event to the visitor behind opts.req, or event.ip and event.userAgent. 'none' records it as no visitor's: no visitor id, and no address, agent, language or location read from anything, while the request still supplies scope, timezone and goals. Unset, it is 'none' while capture.gate holds a slot the native beacon could fill (the server cannot know the visitor's answer), and 'request' otherwise; pass 'request' where your code knows the visitor consented (what the gate does not cover). Any other value throws an AnalyticsTrackError.
opts.flushAwaits the write on an install with a write buffer. Without it the call resolves as soon as the event is queued, and a failed flush only reaches the logger. On SQLite, a call made inside a transaction opts.req holds open resolves at once either way, and the event is stored once that transaction ends.
event.ip, event.userAgentOverlaid on the request's own headers, for a proxy that knows better than the request it holds. Ignored under opts.identity: 'none'; when that is the default rather than your call's, the log says so once.
event.scopeBoundary to stamp, null for install-wide. See below.
event.timezoneThe zone whose local day picks the salt the visitor id is computed with, for a call with no request to resolve the scope's own zone from. The day the event is counted on is always its scope's.
event.nowThe event time; now when unset. A day already counted is counted again to include it; a date on or before the oldest day retention.rawDays surely keeps, or more than five minutes ahead of the server's clock, is refused with an AnalyticsTrackError (see revenue).

Pass opts.req whenever there is a request. It supplies the scope, timezone and goals under either identity. Under 'request' the event also inherits that request's whole header set, so the platform geo headers reach the geo resolver, the device classifies from the real user agent, and the visitor hashes to the same person the browser events did. Without a request and without an explicit ip or userAgent, or with opts.identity: 'none', the event belongs to no visitor and to no visit, and reports no device at all rather than padding the desktop row.

The event is stored outside any transaction opts.req holds. It is written on the native engine's own connection, so it stays when the host's own work around it rolls back; see server events and host transactions.

On a scoped install the scope comes from opts.req unless event.scope says otherwise, resolved at the capture call site, so a sale a signed-in editor makes is filed under the site it happened on rather than the tenant selected in the admin. Goals resolve under the scope the event is stamped with, so a tenant's server event can only complete that tenant's goals. An explicit scope is never validated against the scopes that exist, and an unscoped install ignores it outright, exactly as the ingest endpoint ignores a scope on the wire.

trackServerEvent needs the native adapter. Provider slots have no server-side ingestion seam yet, so an install without native() throws rather than dropping the event.

5. Let editors manage goals

goals.collection adds an analytics-goals collection to the admin, so goals can be added and edited without a deploy:

payload.config.ts
analytics({
  adapters: [native()],
  capture: { gate: 'all' },
  goals: {
    defaults: [{ slug: 'book-demo', name: 'Book a demo', match: { kind: 'goal' } }],
    collection: true,
  },
})

true takes the defaults, the object form reshapes them, and false leaves the collection off:

OptionDefaultWhat it does
slug'analytics-goals'Collection slug.
scopeField'scope'Field matched against the resolved scope. Setting it fails the boot on an unscoped install. Left at the default, the plugin registers its own hidden text field, on every install; name another (a tenant plugin's, say) and it registers none, and the boot fails with a named error when nothing else registered that field. 'tenant' with the collection registered in @payloadcms/plugin-multi-tenant makes goals tenant-owned: see Register goals with the tenant plugin.
fieldsPer-field overrides, ({ field }) => Field each. See Replacing a field.
accessscope-aware defaultsOverrides individual operations.
overrides(collection) => CollectionConfig, applied last. It may not rename the collection (slug does) nor turn off its timestamps, which every instance notices an edit by: either fails the boot. See Renaming the plugin's collections.

Built-in goals and your goals

There are two kinds of goal. The built-in goals are the ones the plugin's options declare plus the built-in purchase goal; your goals are the ones added in the admin. The two are merged into one list, and a goal added in the admin with the same slug replaces the built-in one. The Goals collection holds your goals and nothing else: the built-in ones are not documents, so its list view shows only what an editor can open and change.

The goal picker groups its options the same way, under Built-in goals and Your goals, so a per-document choice shows which kind it is. The built-in purchase goal is not among the options: it is latent, and offering a goal nobody declared would put it in documents that never meant to book against it.

A document whose slug matches a config goal replaces it in place, keeping config's order so reports built on it do not reorder; the collection's own extras follow. That is what makes the collection an editor's handle on what config declared, rather than a second, competing list.

The fields

Each document carries the same shape config goals do:

FieldWhat it is
NameThe name shown in the reports. The browser never sees it.
Short codeKebab-case identifier trackGoal() and the reports key on. Unique per scope. Stored as slug.
EnabledIn the sidebar. Turning a goal off stops it matching new events; what it already recorded stays. This is the soft delete.
Counted from the browserIn the sidebar, on by default. Off, only server code completes the goal and a browser's completion of it is ignored: browser: false on a config goal. Stored as browser.
Trigger > TypeOn the page: the page calls trackGoal() with this slug, or an element carries the goal attribute. A specific action: a track() event with the exact name below. Page address: any pageview whose path matches the pattern below. Stored as match.kind, and the column the goals list shows.
Action name / Address patternShown for the type that uses it.
AmountWhat one completion is worth, for example the value of a lead, with its currency picked in the same row. Leave it empty for goals that carry no money; for sales, send the order total with purchase instead. Stored as value.fixed and currency.
Amount inside the action (optional)Shown for A specific action only. The name of the action detail (a track() prop) that carries the amount, for a site that sends it there. Stored as value.prop.

An amount server code sends with a completion wins. Without one, the Amount counts, then the amount inside the action, and with neither the completion carries no money: the same precedence config goals follow, including what revenue.browserAmounts does with an amount a browser sends.

The currency picker lists about thirty common currencies by name in the editor's language, for example Euro (EUR), and a goal saved with a code the list lacks keeps it. A currency is required once a goal has an amount. While none is stored the picker shows the install's reporting currency, when revenue.currency is one fixed code, and stores it as soon as an amount is set, so a save keeps what the picker shows. When revenue.currency is a resolver, one currency per tenant, the form has no answer to show, so the editor picks.

Both fields are offered only on an install that measures money; one that does not is never asked what a signup is worth. They are hidden rather than removed, so a goal that already carries an amount keeps it and turning money back on shows it again.

Replacing a field

goals.collection.fields replaces one of the collection's own fields at a time. Each entry is ({ field }) => Field: it receives the plugin's default, so it can spread it, and it may return any field type, a row or a collapsible included. They run before overrides, which still gets the last word on the whole config.

KeyReplaces
name, slug, enabled, browserThat one field.
matchThe whole match group.
valueThe whole value group: the Amount control (fixed) and the amount inside the action (prop).
currencyThe currency the Amount control writes. It is a hidden field, so a replacement that shows it puts a second currency input on the page.

While money is off, whatever value and currency return is hidden too.

The common case is a project that has its own slug component. Hand back the row it expects, wherever the field named slug ends up inside it:

payload.config.ts
import type { Field } from 'payload'

/** The project's own slug field factory, as `@/payload/fields/slug` exports it. */
declare function slug(fieldToUse: string): Field

analytics({
  adapters: [native()],
  capture: { gate: 'all' },
  goals: {
    defaults: [{ slug: 'book-demo', name: 'Book a demo', match: { kind: 'goal' } }],
    collection: {
      fields: {
        // Returns a sidebar `row` holding a hidden `generateSlug` checkbox and the slug text
        // field with the project's own Field component.
        slug: () => slug('name'),
      },
    },
  },
})

Whatever the replacement looks like, these rules hold:

  • Kebab-case and uniqueness live at collection level, in a beforeValidate hook, not on the field. A replacement with no validate of its own still cannot save Book Demo, and still cannot take a slug another goal in the same scope owns.
  • A field named slug must exist somewhere in what the replacement returns, at any depth. If it does not, the boot fails with a named error, because every report keys on that value.

Name and slug share the first row while both are still plain named fields. A replacement that is a layout field, or one that asks for the sidebar, cannot live inside a row, so it is placed at top level and keeps the position it declared.

Each instance keeps a scope's resolved goals until one of them changes, so native ingest, which resolves goals on every single event, does not read the collection per request. What says they changed is a small read of the scope's goal documents (their ids, save times, enabled and scope, nothing else), which an instance repeats at most once every ten seconds per scope. A save is therefore picked up by every instance within ten seconds, and by the instance that handled it as soon as it commits. The documents are read outside any transaction the request holds, so a goal saved in a transaction that later rolls back is never used. One read serves at most 500 enabled goals per scope, which is a ceiling on how many an install can have in play rather than a page size: a scope holding more gets an arbitrary 500 of them. A read that fails falls back to the config goals rather than taking ingest down with it.

Access is scope-aware out of the box, the same rules the provider collection follows: only users of the admin collection (admin.user) read or write goals at all, a tenant reads, updates and deletes only the goals of a scope they belong to (scope.member), every create is stamped with the request's resolved scope server-side (a forged one is refused, not silently rewritten), and access.crossScope.named lifts both scope restrictions for platform admins. See Multi-tenancy, which also shows how to register goals per tenant with @payloadcms/plugin-multi-tenant.

Collection goals reach the browser too. The tracker config carries the merged set for the request's own scope, trimmed to what a browser may see (slug, match, value, currency, never the admin-facing name), so trackGoal('purchase') attaches an editor-set amount and its currency to the event a vendor sink receives, exactly as it does for a config goal.

An edit therefore reaches browsers on a delay: up to ten seconds for an instance other than the one that saved it to notice, plus the tracker endpoint's own Cache-Control: private, max-age=60. Native matches at ingest, so it waits for the first part only.

On Postgres the collection is a new table, so an existing install needs a migration before the option ships: payload migrate:create in the app generates the DDL from the collection config, the same way it does for the native engine's own collections.

How native reports them

Goals are matched at ingest, against the same sanitized fields that get stored, and the completions are stamped onto the hit. Editing a goal later never rewrites history.

The native engine then serves three metrics and one dimension:

KeyWhat it is
conversionsCompletions. Every row of the daily totals carries them, so a breakdown by country or device can report conversions too, credited to the visit they happened in.
revenueSummed completion values. See the currency note below.
scrollDepthAverage deepest scroll, over the pageviews that actually reported one; null, shown as a dash, when none did.
goal (dimension)Groups by goal slug, and filters by it.

All three metrics appear in the widget metric, trend, and breakdown pickers, and the built-in analytics-breakdown-goals widget lists completions per goal (defaulting its metric to conversions). For the fuller table, the goals widget reports conversions, revenue and a conversion rate per goal, under each goal's own name rather than its slug. See Dashboard widgets.

A goal event counts under the events metric, not pageviews, and is deliberately kept out of the event dimension breakdown: its name is a goal slug and belongs to the goal dimension instead.

revenue is declared only on an install that measures money. Set revenue.currency and revenue renders as money everywhere, and an event arriving in another currency is reconciled at ingest through the optional convert hook. Without one, revenue is stored and summed as a plain number in whatever currency each event sent, so a total spanning currencies sums figures that are not comparable; native names them in meta.mixedCurrencies and the view says so. Keep one currency per install, set the hook, or read the currency breakdown.

How providers report them

A goal travels to a vendor as an ordinary event named by its slug, so it shows up wherever that vendor lists event names. The plugin does not create the vendor's own goal or conversion definitions for you.

AdapterCallRevenue
PostHogposthog.capture(slug, { ...props, value, currency, items })As the value and currency properties, which PostHog's revenue analytics reads. Properties are arbitrary JSON, so the lines of a sale travel whole.
Plausibleplausible(slug, { props, revenue: { amount, currency } })Plausible's documented ecommerce shape. Props are scalars, so no items.
Umamiumami.track(slug, { ...props, value, currency })Umami has no revenue field; both ride along as event data. Event data is scalars, so no items.
GA4gtag('event', name, { ...props, value, currency, transaction_id, items })As the value and currency params, plus GA4's own ecommerce parameters for a sale.

The name GA4 receives is the slug normalized to an event name GA4 accepts, since it takes no hyphens: see reading them back.

Reading them back on a provider

Every provider serves conversions and the goal dimension. GA4 serves revenue too, and Plausible does where revenueCurrency names the currency of a revenue goal:

AdapterGoal rows fromConversionsRevenue
Plausibleevent:goalthe events metric on goal rows (the dashboard's Total Conversions)total_revenue, only with revenueCurrency set
GA4eventNamekeyEventstotalRevenue
PostHogeventcount() over the goal eventsnone
Umami/metrics?type=eventevent occurrencesnone

A provider does not know which of its events are this install's goals, so a read that asks about goals carries the scope's goal slugs with it, set by the query endpoint, the widgets and the display fields, and partitioning the cache. The slug has to be the provider's own name for the goal: the goal's display name in Plausible's own goal list, the captured event's name on PostHog and Umami, and on GA4 the slug normalized to a valid GA4 event name (hyphens become underscores, anything outside letters, digits and underscores is dropped, a name not starting with a letter gains an e_ prefix, 40 characters at most), which is exactly what the GA4 sink sends.

conversions on a provider means this install's goals. It counts completions of the goals declared here, as it does on native, rather than whatever the vendor counts install-wide. An install with no goals configured therefore reads no conversions from a provider at all: the read answers an empty goal table rather than meta.goalsUnresolved, which is reserved for goals that could not be resolved. GA4's keyEvents narrows twice: it counts only the goals here and only the events the property itself marks as key events.

More worth knowing before you build on it:

  • A rejected goal request costs the goals, not the read. When a provider refuses the goal half, the site numbers are served without conversions (and without Plausible's revenue) and the answer carries meta.goalsUnresolved. Emptying a pageviews card because goals could not resolve would be worse than the missing column.
  • Conversions can cost a second request. A read that asks for site metrics and conversions together cannot goal-filter the whole request without zeroing the site numbers, so Plausible and GA4 issue a second, goal-filtered request and merge it row by row. It runs after the first, inside the same rate-limit permit.
  • Umami serves conversions as goal rows and a range total only. There is no per-day source for them, so a conversions trend on Umami charts nothing rather than a flat zero line.

revenue is still native, GA4 and Plausible only. That is what the capability gate enforces, so a widget whose active source cannot answer degrades rather than reporting a wrong number.

On this page