Plugins
Form Builder

Actions and events

The post-submit pipeline (email, webhook, custom actions) and the lifecycle event sink.

When a submission is stored, the form's configured action blocks run in order. Actions never fail a submission: each is isolated, and a failure is captured as a failed result and logged, not re-thrown.

Built-in actions

Three ship. emailTeam emails one or more addresses via payload.sendEmail; the subject is a recall template (New submission from {{name}}) and the body is rich text (below). confirmation sends to the submitter: its Email field name config is a select over the form's email-type fields, and the server validates the choice against the form's current fields on save, so a renamed or deleted field surfaces as a save error instead of a silently skipped email. signedWebhook POSTs the submission as JSON with an HMAC signature header (below); its URL is validated on save as an absolute http(s) URL.

Editors add action blocks per form in the admin and fill in each block's config fields.

Rich text bodies

The email body of emailTeam and confirmation is a richText field that uses your project's configured editor; the plugin sets no editor of its own. Your config must therefore set one (Payload throws MissingEditorProp at startup when a richText field exists and neither the field nor the config provides an editor):

payload.config.ts
import { lexicalEditor } from '@payloadcms/richtext-lexical'

export default buildConfig({
  editor: lexicalEditor(),
  plugins: [formBuilder()],
})

When the project editor is feature-rich (blocks, embeds, admin-only conveniences) and you want email authors on a minimal toolbar instead, richText.bodyEditor overrides the editor on both action body fields and nothing else:

payload.config.ts
import { FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical'

formBuilder({
  richText: {
    bodyEditor: lexicalEditor({
      features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
    }),
  },
})

Its sibling richText.editor is the default for every plugin rich text field, action bodies included; bodyEditor falls back to it when unset. Reach for editor to move the whole plugin off the project editor and bodyEditor to single out email bodies. See rich text editors.

At send time the body is serialized to HTML. Author-typed text is HTML-escaped and supports recall tokens:

TokenRenders
{{ name }}The name field's answer, escaped. Empty string when absent.
{{ name|fallback }}The answer, or the literal fallback when the answer is empty.
{{*}}Every answer as Label: value lines separated by <br />.
{{*:table}}Every answer as a two-column HTML table.

The wildcard tokens render from the submission's descriptor snapshot, so internal values (the honeypot, hidden bookkeeping) never leak into an email. Link URLs are sanitized: only http(s), mailto:, tel:, and relative URLs survive; anything else becomes #. Recall tokens also work inside link URLs.

Bodies saved as plain strings before rich text existed keep working: a string body goes through recall interpolation exactly as before, no migration needed.

Custom nodes and non-HTML channels

The plugin-level richText option customizes serialization. converters spread over the default Lexical node converters (keyed by node.type), for editors with custom blocks:

payload.config.ts
formBuilder({
  richText: {
    converters: {
      callout: ({ children }) => `<div class="callout">${children}</div>`,
    },
  },
})

serialize replaces the whole pipeline, for actions that target chat or plain text instead of email HTML:

formBuilder({
  richText: {
    serialize: ({ body, values }) => toSlackMarkdown(body, values),
  },
})

Beyond body, values, and descriptors, serialize receives the submitted form (the whole document), the req, the submission's locale, and the actionType rendering the body (emailTeam, confirmation, or a custom action's type), so the replacement can branch on context: look up a per-tenant template, or hand the raw body off to a component renderer like react-email. To only wrap the email around the default output, reach for email.render instead:

formBuilder({
  richText: {
    serialize: async ({ body, values, form, req, locale, actionType }) => {
      const tenant = await tenantFromRequest(req)
      return render(
        <SubmissionEmail
          body={body}
          values={values}
          formTitle={form.title}
          branding={tenant.branding}
          locale={locale}
          audience={actionType === 'confirmation' ? 'visitor' : 'team'}
        />
      )
    },
  },
})

locale is the submission's own stored locale, the one body was loaded at, so a wrapper's own strings (a greeting, a footer) match the content inside it. Prefer it over req.locale: on the queued path req is the job runner's request, not the visitor's. A submission stores the locale of the request that created it, which <Form> sets from its submissionLocale prop. Content never authored in that locale falls back as your localization.fallback allows, or as the fallbackLocale option chooses.

The building blocks are exported from the root for custom pipelines: serializeBody, defaultBodyConverters, sanitizeUrl, escapeHtml, renderAllValues, and renderAllValuesTable.

Email layout

email.render produces the final html of every emailTeam and confirmation email. It runs after the body is serialized, so it wraps the finished body (a header, a footer, branding) without re-rendering any rich text:

payload.config.ts
formBuilder({
  email: {
    render: ({ html, subject, locale, actionType }) =>
      renderLayout({
        content: html,
        title: subject,
        locale,
        variant: actionType === 'confirmation' ? 'visitor' : 'team',
      }),
  },
})

It receives the serialized html, the raw body it came from, the interpolated subject, the actionType (emailTeam or confirmation), and the submission context: locale, form, submissionId, values, descriptors, the verified form context, payload, and req. locale is the submission's own, so the layout's strings match the localized subject and body inside it.

The two hooks compose rather than compete: richText.serialize decides how an action body is rendered for any channel, custom actions included, and email.render then wraps what it produced for email only. A throwing render fails the action like a send failure, and absent, the serialized body is sent as is.

An email whose subject and body both come out empty is never sent: the action fails with empty subject and body, like a missing To, rather than mailing a blank message. The check runs before email.render, so a layout frame around an empty body does not count as content. It usually means the form was never filled in for the submission's locale or its fallback.

CC, BCC, and reply-to

emailTeam and confirmation both carry CC, BCC, and Reply-to fields alongside To/Subject. Like To, they are shared across locales by default (see Per-locale recipients) and interpolated with the same recall tokens as the body ({{field}}, {{field|fallback}}). Pass more than one address as a plain comma-separated string; it is forwarded to payload.sendEmail, and from there to your nodemailer transport, exactly as typed.

From addresses

By default emailTeam and confirmation send through your email adapter's default sender and neither action has a From field. Set email.fromAddresses to add one to both, backed by a resolver you supply:

payload.config.ts
formBuilder({
  email: {
    fromAddresses: async ({ req }) => {
      const tenant = await tenantFromRequest(req)
      return tenant.senders.map((sender) => ({ label: sender.label, value: sender.address }))
    },
  },
})

The resolver runs per request against an authenticated endpoint, so a multi-tenant host can scope the address list to the tenant derived from req instead of exposing every tenant's senders to every editor. Each option's value is the literal string handed to payload.sendEmail's from ('Name <addr@example.com>' or a bare address).

The chosen from is validated against the resolver on save (an address outside the resolved set is a save error), but not re-checked when the action sends: the config is admin-authored, not visitor-controlled, so the stored value is trusted at send time. Leaving From empty keeps the adapter default, so adding the option changes nothing for forms that ignore it.

The From select loads from GET /api/forms/from-addresses, an id-less route: the option set depends on the request (who is asking, which tenant), not on the form document, so the addresses are there while the form is still being created. The To department select works the same way at /api/forms/departments. The old /:id/-prefixed paths still answer for anything that hardcoded them.

One cost comes with the seam being server-resolved and failing closed:

  • A resolver that throws blocks saves. Payload runs the validate on every save, not only when from changed, so while the resolver is down every save of a form whose email action has a From set is rejected, including edits that never touch the address. It surfaces as a translated "unavailable" save error rather than a raw stack, and a form with no From set still saves. This is the deliberate trade: failing open would persist a sender the host can no longer vouch for. A resolver that reaches a flaky upstream should cache or fall back internally instead of throwing.

Senders resolved at send time

A literal from fromAddresses freezes when the form is saved. For a routing target that is the point; for a sender it can be wrong, because the sender is usually tenant identity rather than per-form configuration: a tenant that changes its from-address leaves every saved form sending the old one, with nothing telling the editor. email.fromSources covers that case. A source stores a stable, namespaced value on the action and resolves the actual address on every send:

payload.config.ts
formBuilder({
  email: {
    fromSources: {
      tenant: {
        value: 'tenant:default',
        label: 'Tenant default sender',
        resolve: async ({ form, payload }) => {
          const tenant = await payload.findByID({
            collection: 'tenants', id: form.tenant as string, depth: 0, overrideAccess: true,
          })
          return tenant?.fromAddress ?? null
        },
      },
    },
  },
})

Sources appear in the same From select, listed ahead of the fromAddresses literals, and either option can be set without the other. What the editor picks decides the behaviour: a source stores its value (tenant:default above, audit-stable in the saved form), a literal stores and sends the address as before and never touches a source.

resolve receives the same run-time arguments as a recipient source: the form, the submission's values and descriptors, the verified context, payload, and the request. Derive the tenant from the form document rather than the request where you can; on the queued path the request is the job runner's, while the form is the same document the action was saved on. The result is reduced to a single sender: anything past a line break is cut, a whole line that already reads as one sender passes through (Name <addr@example.com> form included, quoted display names with commas too), and a comma-joined list is clamped to its first entry. Returning null or an empty string sends with the adapter's default sender, and a throw fails the action rather than sending as an identity the host no longer vouches for; on the queued path that failure retries.

The namespaced value is the whole trick: it cannot collide with a literal address, so the send path can tell "re-resolve this" from "send this" by a map lookup, with no flags stored on the form. Pick values like tenant:default and keep them stable. Renaming or removing a source orphans every form that stored its value: the stored string then reads as a literal, reaches the adapter as an invalid sender, and fails the send loudly, which is the intended failure mode, visible rather than silently sending as the wrong identity.

Department routing

By default emailTeam's To is a plain address your editors type in themselves. Set email.departments to turn it into a select over addresses you manage instead, so an editor routes a form to "Sales" or "Support" without ever typing (or mistyping) an address:

payload.config.ts
import { departmentsField, formBuilder, resolveDepartmentOptions } from '@10x-media/form-builder'

formBuilder({
  email: {
    departments: async ({ req }) => {
      const settings = await req.payload.findGlobal({ slug: 'settings', depth: 0, locale: 'all', req })
      return resolveDepartmentOptions({ payload: req.payload, req, doc: settings })
    },
  },
})

departmentsField() is the array you place on any collection or global you own (a settings global, a tenant document) to hold the addresses: each row is a label and an email, both localized by default, rendered as condensed flat rows with no per-row collapsible, added and removed with their own buttons. resolveDepartmentOptions reads that array back (fetch it at locale: 'all' so every locale's addresses are available in one call) and resolves each row for the request's locale with a current-locale -> default-locale -> next-available-locale fallback, so the locale logic never has to be reimplemented per host. Multi-tenancy needs no plugin support: place the field on a tenant-scoped document and scope the resolver's read by the tenant derived from req, exactly like email.fromAddresses.

With email.departments set, To becomes a select backed by an authenticated GET /api/forms/departments endpoint (request-scoped and id-less, so the departments are pickable while the form is still being created), validated on save against the resolver's current options (fails closed, the same trade-off as fromAddresses: a resolver outage blocks saving a form whose email action has a To set, rather than persisting a routing target the host can no longer vouch for). Leave email.departments unset and To stays the plain text field it always was.

Per-locale recipients

Either way, To, CC, BCC, and Reply-to are shared across locales by default: routing is usually the same whatever language the visitor wrote in, and a per-locale list the editor never filled in would leave that locale with no recipient at all. When the routing does differ per locale, opt in:

payload.config.ts
formBuilder({ email: { localizeRecipients: true } })

Each admin locale then stores its own addresses (or department choice), and a submission routes to the list stored for its own locale, exactly like the subject and body. This holds on the queued (job-runner) dispatch path too: the form is loaded at the submission's own stored locale, not the job runner's request locale, so a German visitor's submission still reaches the German department when the job that sends it runs with no locale of its own. The option has no effect with localizeContent: false. Toggling it changes how existing forms store these fields, like toggling localized on any Payload field.

A per-locale list is empty in every locale the editor did not fill it in for, and it falls back only as your localization.fallback allows. With fallback off, a submission in such a locale has no To, so emailTeam fails there with missing "to" address on every submission. Either fill the recipients in for each locale, turn fallback on, or force a fallback for these reads with the fallbackLocale option, for example to each tenant's default locale.

Recipient sources

Beyond typed addresses, department picks, and {{field}} tokens, a recipient field can offer sources: recipients the plugin resolves on the server at send time, so the address never has to reach the browser. The motivating case is a single contact form, reused across every profile page, that emails the person the page is about, while the same form on an ordinary page still goes to its configured recipients.

Register sources under email.recipientSources. Each has a namespaced value (stored in the recipient list, so it cannot collide with an address), a label (a string or a per-locale record, shown in the recipient field), and a resolve returning zero or more addresses:

payload.config.ts
formBuilder({
  email: {
    recipientSources: {
      pageContact: {
        value: 'context:pageContact',
        label: { en: 'The person this page is about', de: 'Die Person, um die es auf dieser Seite geht' },
        resolve: async ({ context, payload }) => {
          if (!context) return []
          const doc = await payload.findByID({
            collection: context.relationTo, id: context.value, depth: 0, overrideAccess: true,
          })
          return typeof doc?.email === 'string' && doc.email ? [doc.email] : []
        },
      },
    },
  },
})

A registered source appears in every recipient field (To, Cc, Bcc, Reply-To) as its own option group, reading as a recipient rather than as configuration. It resolves alongside statically configured recipients rather than replacing them; a source resolving to [] is dropped and the others kept; and every resolved address passes through the same single-address sanitizer as a {{field}} token, so a source cannot inject headers or extra recipients. If every recipient resolves empty, the send is skipped rather than sent with an empty To; a resolver that throws fails that action loudly.

Form context

resolve receives a form context: a verified { relationTo, value } reference to the document a form was rendered for, or null when it had none. It is how a source knows which profile to look up without the address ever entering the page.

The rendering surface mints a signed reference and hands it to <Form>:

app/[person]/page.tsx (server component)
import { signFormContext } from '@10x-media/form-builder/rsc'

const context = signFormContext({ payload, relationTo: 'people', value: person.id })
// ...pass through to the client form:
<FormRenderer form={form} context={context} />

The token is signed, not encrypted (with payload.secret by default, and a one-day expiry you can override with expiresIn): the reference is not a secret, since the id is already in the URL, and the address never enters the token. Signing only stops the browser from rewriting the recipient. On submit the plugin verifies the signature and expiry, stores the verified { relationTo, value } on the submission (a legible audit record of which document it came through), and hands it to every source's resolve. A missing context is normal; an invalid one is rejected, so a tampered reference cannot quietly degrade into a send to the static recipients. The context is never an answer: it does not appear in values, the answers view, or {{*}} body output.

The form document

Every hook a send runs through, a recipient source, a from source, richText.serialize, email.render, and a custom action's run, receives form as the whole form document the plugin loaded for the run, not just its id and title. It is read once per run, at depth 0 and in the submission's locale: localized fields (a subject, an option label) hold that locale's value, and relationships hold ids, so a multi-tenant host reads form.tenant straight off it instead of loading the form again. Cast for your own fields (form.tenant as string), and populate a related document yourself when you need more than its id. Every hook of the run shares the one object, so treat it as read-only. The type is exported as SubmissionForm.

Custom actions

payload.config.ts
import { defineAction } from '@10x-media/form-builder'
import type { TextField } from 'payload'

const slackNotify = defineAction({
  type: 'slackNotify',
  label: 'Notify Slack channel',
  config: [
    { name: 'webhookUrl', type: 'text', required: true } as TextField,
    { name: 'message', type: 'text' } as TextField,
  ],
  run: async ({ values, config }) => {
    const body = config.message ?? values.map((v) => `${v.label}: ${v.value}`).join('\n')
    await fetch(config.webhookUrl as string, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: body }),
    })
  },
})

formBuilder({ actions: { slackNotify, confirmation: false } })

config becomes the block's authoring fields in the form editor. run receives the form document, submission id, values, descriptors, the verified form context (or null), the stored config, payload, req, locale, a translate helper, and renderBody, an async function that serializes a rich text (or legacy string) body config into channel-ready HTML through the same pipeline the built-in emails use, honoring the plugin's richText option. Give a custom action a richText config field and call renderBody(config.body) to get the built-in templating for free. The actions option follows the opt-in convention.

When a failure has more context than a message, throw an ActionError (or any error with a detail property): throw new ActionError('provider rejected', { status: 502, body }). The detail lands on the failed ActionResult and in the plugin's failure log as a structured field, so the thrown message can stay free of whatever the provider quoted back.

Validating action config at save time

Per-field validate covers a single config field, but some checks span the whole block: every {{token}} in a body template has a mapping, a provider choice matches the credentials configured for it. Worse, an optional array field's validate never runs when the array is empty, and empty is usually the case the check cares about. validateConfig on the definition is the save-time seam for exactly this:

const subscribe = defineAction({
  type: 'subscribe',
  label: 'Subscribe',
  config: [provider, mappings],
  validateConfig: (config) => {
    const missing = unmappedTokens(config)
    return missing.length === 0 ? true : `unmapped tokens: ${missing.join(', ')}`
  },
  run: async ({ config, values }) => { /* ... */ },
})

It runs on every form save, once per stored instance of the action, alongside field validation. data is the whole form under validation (merged over the stored document on partial updates), so a check can reach across to the form's fields; req enables lookups. Return true to accept or a message to refuse; the message is attached to the action block itself (actions.<index>), not to whichever config field happened to be required. Stored actions are validated even when a save does not touch them, so deleting a form field that an action's template references is caught on that save, not at send time.

A custom action's type is registered as a Payload block whose slug is that type. Because Payload resolves blocks globally by slug, a type that matches one of your own content-block slugs would merge the two definitions and pollute the stored action. The plugin throws a clear error at boot if it detects such a collision, so pick an action type that no content block shares.

Track a submission as an analytics goal

@10x-media/analytics ships a ready-made action, trackGoalAction, that records a submission as a conversion. Neither plugin imports the other: the host registers it, like any custom action.

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, with a goal picker and an optional amount, its currency picked in the same row. The action is not essential, so a failed write is logged and the submission still succeeds. See Goals for what it records and where its hostname comes from, and Attach goals to your own components for the other hookups.

Run model

Actions run as a Payload job (task form-builder-actions) when a runner is present: config.jobs.autoRun set, or the Jobs plugin installed (see Jobs family interop). Without a runner, an inline fallback runs them after the submission row is stored, so a project with zero queue infrastructure still gets its emails sent. The fallback is bounded: the submit response waits at most the dispatch deadline for action work, then returns while the remainder finishes (or fails, logged) in the background, so a slow webhook receiver cannot hold a visitor's submit hostage. Either way an action error never fails the submission, unless the action declared otherwise, below.

The deadline defaults to five seconds and is a plugin option, so an action making outbound calls never has to guess at it:

formBuilder({ dispatch: { deadlineMs: 8_000 } })

The default is also exported as INLINE_DISPATCH_DEADLINE_MS, so an action can derive its own HTTP timeout from the bound it runs under (size it below the deadline, or the dispatch cutoff fires before your timeout does and the failure gets vaguer).

Essential actions

Fire-and-forget is right for a notification: a failed email should not reject a submission that is already stored. It is wrong when the action is the point of the submission, a newsletter signup whose provider is the system of record. essential: true on defineAction declares that this action's failure is the submission's failure:

const subscribe = defineAction({
  type: 'subscribe',
  label: 'Subscribe',
  essential: true,
  run: async ({ values }) => {
    await provider.subscribe(addressOf(values)) // a throw fails the submission
  },
})

An essential action runs inline before the response, never queued, bounded by the dispatch deadline so a hanging provider cannot pin the request. On a definite failure (a throw), the visitor's submit returns a 502 with a translated plugin message, no per-form copy to author, and the remaining actions are skipped. The other actions keep today's fire-and-forget behaviour, running after the essential pass succeeds.

Outliving the deadline is treated differently, because it is a different fact: "we could not complete this" and "we are not sure this completed" prompt different visitor behaviour (retry versus wait). A breach returns a 504 with its own message saying the outcome is unknown, and the work is not cancelled; the plugin keeps watching it. When it eventually settles, the real outcome is recorded, in the same order an on-time success would have used: a late success first runs the skipped actions and the pruning completion, and only then clears the uncertain stamp and emits submission.created, so the stamp stays truthful even if reconciliation is interrupted partway. The end state converges on an on-time success apart from the response the visitor already saw. A late definite failure converges on the failure state below. Because the visitor may have retried in the meantime, make the provider call idempotent; the provider is the one place a duplicate can be collapsed.

One deliberate interaction with pruning: when an essential action fails on a persistSubmissions: false form, the submission is kept, not pruned. The reason not to persist is that the provider holds the record; if the provider never received it, the stored row is the only copy of what the visitor sent, and an operator can recover it. This gives a non-persisting form an unusual retention shape, successes leave nothing while failures accumulate, so every kept row is stamped (indexed, read-only, hidden in the admin until set): actionFailed: true for a definite failure, actionUncertain: true while a breached pass is still unsettled. Filter on actionFailed to find the rows once the provider is fixed, replay the addresses, and delete them; an actionUncertain row is still in flight and clears itself (late success) or upgrades to actionFailed (late failure), so replay only what has settled. The plugin does not expire kept rows for you, since only the host knows when the replay happened. Essentialness is declared on the action type, not per form, on purpose: whether a failed handoff may be silently swallowed is a property of the integration, and a per-form toggle would let an author accidentally demote it. The error surfaces through the REST submit path (what <Form> uses); a server-side payload.create gets the submission plus a logged failure, and the same context flags to inspect when a req is threaded. A failed-essential submission also emits no submission.created event, so a sink-driven automation never counts it as a completed signup.

Pruning submissions

Every form stores each submission by default. Uncheck Store submissions on a form (the persistSubmissions flag) and the plugin deletes the submission once its actions have run, for a pure signup form whose only job is to POST to a provider and leave no stored row. Deletion happens once, after the whole action pass, regardless of whether an individual fire-and-forget action failed (every action still received the values first), so a failing notification never strands the row. A failed essential action is the exception: it keeps the row, because the provider never took the handoff. A pruned submission never appears in the submissions list or in poll and analytics counts, by intent. Pruning does not cascade to files: a non-persist form with a file field leaves its uploaded file in the uploads collection (newsletter signups have none). If the job runner is down, the submission is retained until the job eventually runs, never dropped unprocessed.

Signed webhook verification

The signedWebhook action attaches:

X-Form-Signature: v1=<hex>

HMAC-SHA256 over the raw JSON body alone (no timestamp prefix; intentionally distinct from the Webhooks plugin's scheme so receivers cannot confuse the two). Verify with a constant-time compare:

receiver.ts
import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyFormSignature(rawBody: string, secret: string, header: string): boolean {
  const expected = `v1=${createHmac('sha256', secret).update(rawBody).digest('hex')}`
  const a = Buffer.from(header)
  const b = Buffer.from(expected)
  return a.length === b.length && timingSafeEqual(a, b)
}

signPayload and SIGNATURE_HEADER are exported from the root for manual signing and verification.

signedWebhook is a lightweight per-form push: one POST per submission, configured by the editor on the form itself. When you need delivery guarantees (automatic retries, manual redelivery, delivery logs, HMAC subscriptions managed at scale), pair the plugin with @10x-media/webhooks subscribed to the form-submissions collection. The two are complementary, not coupled: use signedWebhook for a quick per-form integration, the Webhooks plugin for operational delivery infrastructure.

Lifecycle events

The plugin emits typed events through a pluggable sink, a no-op by default. Pass one to the plugin (server events) and to <Form> (client events):

import type { FormEvent, FormEventSink } from '@10x-media/form-builder/types'

const sink: FormEventSink = { emit: (event) => analytics.track(event.type, event) }

formBuilder({ events: sink })          // server side
<Form form={form} events={sink} />     {/* client side */}
EventEmitterWhen
form.viewedclientThe form mounts.
form.startedclientFirst field interaction.
step.viewed / step.completedclientMulti-step: a step activates / passes validation.
field.erroredclientA field receives a validation error.
form.abandonedclientUnmount without a successful submission.
submission.createdserverSubmission stored and actions dispatched.

On this page