10x-media 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 ({ id, title }) and the req, 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:

formBuilder({
  richText: {
    serialize: async ({ body, values, form, req }) => {
      const tenant = await tenantFromRequest(req)
      return render(
        <SubmissionEmail body={body} values={values} formTitle={form.title} branding={tenant.branding} />
      )
    },
  },
})

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

CC, BCC, and reply-to

emailTeam and confirmation both carry CC, BCC, and Reply-to fields alongside To/Subject. Each is localized like subject (a submission's own locale selects the stored value at send) 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.

Two costs come with the seam being server-resolved and failing closed:

  • The select is empty until the form's first save. It loads from a document-scoped endpoint, and an unsaved form has no id to resolve against, so the addresses appear once the form exists. Save the form, then pick the sender.
  • 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.

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/:id/departments endpoint, 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.

Either way, To is localized: each admin locale stores its own address (or department choice), and a submission routes to the address stored for its own locale, exactly like the subject and body already do. 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.

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, submission id, values, descriptors, 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.

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 five seconds 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.

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