Submit & Actions

The submission pipeline, callbacks, HTTP/webhook/email/custom actions, triggers, field mapping, and programmatic execution.

View as Markdown

Submit & Actions

@saastro/forms gives you three ways to handle a submission:

  1. CallbacksonSuccess/onError for simple handling
  2. Submit Actions — declarative system for HTTP, webhooks, emails, custom handlers, and field mapping
  3. Hosted backend<HubForm> submits to the hosted endpoint at submit.saastro.io with zero configuration

The Submission Pipeline

When the user clicks the submit button on the last step (and validation passes), the form runs this pipeline:

form values (react-hook-form)
  → applyFieldTransforms()           field-level transforms (trim, lowercase, ...)
  → pluginManager.transformValues()  plugins — captcha tokens are appended here
  → onBeforeSubmit plugin hook       validation only; throwing aborts the submit
  → dispatch (first match wins):
      1. submitActions with an onSubmit trigger → executeSubmitActions()
      2. else config.submit { type: 'custom' }  → your onSubmit(values)
      3. else config.submit (legacy default)    → defaultSubmit()
      4. else                                   → nothing is sent
  → onAfterSubmit plugin hook
  → success UI · onSuccess(values) · optional redirect

Key behaviors to know:

  • Submit actions replace the legacy submit config. If at least one enabled action has an onSubmit trigger, config.submit is never used.
  • A failed action fails the whole submission — unless it opts out. If executeSubmitActions reports a failure, the first failed action without continueOnError: true throws and the form shows its error state. Failures of actions marked continueOnError: true are recorded in the result but don’t abort the submit. stopOnFirstError additionally controls whether remaining sequential actions still run after a failure.
  • No submit config at all is a silent success. With no submitActions and no submit, nothing is sent — the form flips straight to the success state and calls onSuccess(values). Useful if you handle the data yourself in onSuccess, but remember nothing has left the browser.
  • Redirectconfig.redirect (a URL string or (values) => string) navigates via window.location.href after onSuccess; the success message never gets a chance to show.
  • Error path — errors are normalized to Error, passed to the plugin onError hook and config.onError(error, values), shown as a toast, and rendered in the form’s error panel.

Default Messages

The built-in defaults are English: '✅ Thank you!' for success and '❌ Something went wrong while submitting the form.' for error (the loading label is 'Submitting...'). Override them with successMessage / errorMessage (below), per-locale via the i18n guide, or globally with setDefaultMessages (Spanish apps: setDefaultMessages(es)).


Simple Callbacks

The simplest way to react to a submission using FormBuilder:

import { Form, FormBuilder } from '@saastro/forms';

const config = FormBuilder.create('contact')
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addField('message', (f) => f.type('textarea').label('Message').required())
  .addStep('main', ['email', 'message'])
  .onSuccess(async (values) => {
    console.log('Form values:', values);
    // Send to your API
    await fetch('/api/contact', {
      method: 'POST',
      body: JSON.stringify(values),
    });
  })
  .onError((error, values) => {
    console.error('Error:', error, values);
  })
  .build();

function ContactForm() {
  return <Form config={config} />;
}

Note: onSuccess runs after the form has already switched to its success state. If the request you make inside onSuccess fails, the form still shows success. To surface failures in the form UI, send the data with a submit action (or a legacy submit config with type: 'custom') instead, and keep onSuccess for side effects like analytics.

Success/Error Messages

const config = FormBuilder.create('contact')
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['email'])
  .successMessage("Thanks! We'll be in touch.")
  .errorMessage('Something went wrong. Please try again.')
  .onSuccess((values) => {
    console.log('Success:', values);
  })
  .onError((error, values) => {
    console.error('Error:', error, values);
  })
  .build();

Dynamic Messages

.successMessage((values) =>
  `Thanks ${values.name}! We'll email you at ${values.email}.`
)
.errorMessage((error, values) =>
  `Failed to submit for ${values.email}: ${error.message}`
)

Submit Actions System

For more complex scenarios, use the declarative Submit Actions system by configuring submitActions directly in the config (or via FormBuilder.submitAction()):

Action Types

Type Use Case
http REST API calls with full configuration
webhook Send data to external services (Zapier, Make, etc.)
email Send notification emails via provider APIs
custom Run custom async functions
integration Declarative reference executed server-side by a hosted backend — see below

HTTP Action

Send form data to a REST API:

import type { FormConfig, HttpSubmitAction } from '@saastro/forms';

const httpAction: HttpSubmitAction = {
  type: 'http',
  name: 'submit-to-api',
  endpoint: {
    url: 'https://api.example.com/contacts',
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
  },
  body: {
    format: 'json',
    includeFields: ['name', 'email', 'message'],
  },
  auth: {
    type: 'bearer',
    token: 'your-api-token',
  },
  retry: {
    enabled: true,
    maxAttempts: 3,
    delayMs: 1000,
  },
  timeout: 30000, // ms — this is also the default
};

// Use in FormConfig
const config: FormConfig = {
  formId: 'contact',
  fields: {
    /* ... */
  },
  steps: {
    /* ... */
  },
  submitActions: {
    'api-submit': {
      id: 'api-submit',
      action: httpAction,
      trigger: { type: 'onSubmit' },
    },
  },
  submitExecution: {
    mode: 'sequential',
    stopOnFirstError: true,
  },
};

HTTP Body Formats

// JSON (default)
body: {
  format: 'json',
}

// Form Data (multipart)
body: {
  format: 'form-data',
}

// URL Encoded
body: {
  format: 'url-encoded',
}

// Custom template with placeholders
body: {
  format: 'json',
  template: JSON.stringify({
    contact: {
      fullName: '{{name}}',
      emailAddress: '{{email}}',
      inquiry: '{{message}}'
    }
  }),
}

Placeholder rule (JSON templates, since 0.21). A visitor’s value can never change the structure of the payload:

  • Inside a string literal — "name": "{{name}}", or mixed with text, "msg": "Hi {{name}}" — the value is inserted as the content of that string, JSON-escaped ("\", \\\, newlines → \n) and without outer quotes. null/undefined become empty; an object becomes its JSON, escaped.
  • Outside a string — "age": {{age}} — the value is inserted as a JSON literal (JSON.stringify): numbers stay numbers, objects stay objects, null/undefined become null, and a string is quoted — so a visitor typing {"a":"x","admin":true} gets a string, not two new keys.

The same rule applies to webhook.payloadTemplate.

HTTP Authentication

// Bearer Token
auth: {
  type: 'bearer',
  token: 'your-jwt-token',
}

// Basic Auth
auth: {
  type: 'basic',
  username: 'user',
  password: 'pass',
}

// API Key
auth: {
  type: 'api-key',
  token: 'your-api-key',
  headerName: 'X-API-Key', // default
}

Webhook Action

Send data to webhook endpoints (Zapier, Make, n8n, etc.):

import type { WebhookSubmitAction } from '@saastro/forms';

const webhookAction: WebhookSubmitAction = {
  type: 'webhook',
  name: 'zapier-webhook',
  url: 'https://hooks.zapier.com/hooks/catch/123456/abcdef/',
  headers: {
    'X-Custom-Header': 'value',
  },
  payloadTemplate: JSON.stringify({
    event: 'form_submission',
    data: {
      name: '{{name}}',
      email: '{{email}}',
    },
  }),
  secret: 'your-webhook-secret', // Deprecated in the browser — see below
};

Webhooks always POST JSON.

Webhook signature

secret is deprecated in the browser — it is not authentication. The form runs on the visitor’s device, so the secret ships in the schema (and the bundle, and the network tab) and any visitor can produce a valid signature. Setting it logs a console warning once per action. It stays for existing receivers, but the only place a signature means something is a server-side proxy: send the submission to your backend (or let the Hub worker dispatch it) and sign there.

When secret is set, three headers are sent:

Header Value
X-Webhook-Signature sha256=<hex HMAC-SHA256(payload)> — legacy, unchanged
X-Webhook-Timestamp Unix time in seconds when the request was built
X-Webhook-Signature-V2 t=<ts>,sha256=<hex HMAC-SHA256("<ts>.<payload>")>

A receiver that wants replay protection verifies X-Webhook-Signature-V2 and rejects requests whose t is outside a short window (e.g. 5 minutes):

// Receiver (Node / Workers)
const [t, sig] = req.headers.get('x-webhook-signature-v2')!.split(',');
const ts = t.slice(2), expected = sig.slice(7);
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error('stale');
const mac = await hmacSha256Hex(SECRET, `${ts}.${rawBody}`);
if (!timingSafeEqual(mac, expected)) throw new Error('bad signature');

Email Action

Send email notifications on form submission:

import type { EmailSubmitAction } from '@saastro/forms';

const emailAction: EmailSubmitAction = {
  type: 'email',
  name: 'admin-notification',
  provider: 'resend', // 'smtp' | 'sendgrid' | 'resend' | 'mailgun'
  apiKey: 'your-api-key',
  to: 'admin@example.com',
  cc: ['sales@example.com'],
  from: 'noreply@example.com',
  replyTo: '{{email}}',
  subject: 'New contact from {{name}}',
  template: 'default',
};

// With custom HTML template
const customEmailAction: EmailSubmitAction = {
  type: 'email',
  name: 'welcome-email',
  provider: 'sendgrid',
  apiKey: process.env.SENDGRID_API_KEY,
  to: '{{email}}',
  from: 'hello@example.com',
  subject: 'Welcome, {{name}}!',
  template: 'custom',
  customTemplate: `
    <h1>Welcome, {{name}}!</h1>
    <p>Thanks for signing up.</p>
    <p>Your registered email: {{email}}</p>
  `,
};

Default endpoints per provider (used when endpoint is not set):

Provider Default endpoint
smtp /api/send-email (your own server route)
sendgrid https://api.sendgrid.com/v3/mail/send
resend https://api.resend.com/emails
mailgun endpoint required (the URL embeds your domain) — the action throws without it

Careful with API keys in the browser. Email actions call the provider’s HTTP API directly from the client, so any apiKey you configure ships in your JavaScript bundle. For production, prefer provider: 'smtp' pointing at your own server endpoint, or send the data to your backend with an HTTP/webhook action and email from there. See Secrets in the browser.

Escaping (since 0.21). {{tokens}} in customTemplate and in the default template are HTML-escaped — the value comes from the visitor and the email is opened by an administrator, so a <img onerror> typed into a field arrives as text. subject is plain text and is not escaped.


Secrets in the browser

A form config runs on the visitor’s device. Whatever it contains is visible to every visitor: the schema JSON (localStorage in the builder, the export, the Hub database), the bundle, the network tab. These fields therefore cannot be secrets, no matter how they are entered:

Where Field
http action auth.token, auth.password, any endpoint.headers entry whose name looks like a credential (Authorization, X-API-Key, *token*, *secret*…)
webhook action secret, credential-looking headers
email action apiKey
select/combobox/native-select credential-looking optionsSource.headers
databowlPlugin token (it becomes an http action with auth.token)

What the package does about it:

  • Runtime warning — when a <Form> mounts in a browser with any of these set, it logs one console.warn per formId listing the paths (never the values).
  • Builder — the Authentication and Webhook panels show a notice next to those inputs, and the TSX export redacts the values (replaced with REDACTED_SECRET, with a comment header listing what was removed). The JSON export is left intact so existing Hub forms keep working; the export panel shows a warning instead.
  • HelpersfindClientSecrets(config) returns a ClientSecretHit[] (path, keys, kind: ClientSecretKind) and redactClientSecrets(config) returns a redacted copy plus the hits. Use them in your own tooling or CI:
import { findClientSecrets } from '@saastro/forms';

const hits = findClientSecrets(config);
if (hits.length) throw new Error(`secrets in form config: ${hits.map((h) => h.path).join(', ')}`);

The fix is always the same: a server-side proxy that injects the credential — the Hub worker for hosted forms (integration actions, hubPlugin), or your own endpoint for HTTP/webhook/email actions.


Custom Action

Run any async function:

import type { CustomSubmitAction } from '@saastro/forms';

const customAction: CustomSubmitAction = {
  type: 'custom',
  name: 'analytics-track',
  handler: async (values) => {
    await analytics.track('form_submitted', {
      formId: 'contact',
      email: values.email,
    });
    return { success: true };
  },
};

Integration Action (Server-Side Only)

integration actions are declarative references to integrations resolved server-side by a hosted backend (such as the hosted submit service used by <HubForm>). The browser runtime does not execute them: if an integration action reaches the client dispatcher, it is recorded as a failed result (Unknown action type: integration).

interface IntegrationSubmitAction {
  type: 'integration';
  name: string;
  integrationRef: string; // server-side integration id
  fieldMapping?: Record<string, string>;
}

Don’t add integration actions to forms that submit from the browser — they only make sense in schemas processed by a server that knows how to dispatch them.


Triggers

Actions can be triggered at different points:

Trigger When It Fires
onSubmit When the form is submitted (last step)
onStepEnter When entering a step (stepId; any step if omitted)
onStepExit When leaving a step (stepId; any step if omitted)
onFieldChange When a field value changes (fieldName; any field if omitted — debounceMs defaults to 300)
onFieldBlur When a field loses focus (fieldName; any field if omitted)
onDelay After a period of user inactivity (delayMs, default 30000)
manual Only when called programmatically — see Advanced

Examples

// On form submit (default)
trigger: { type: 'onSubmit' }

// When entering step 2
trigger: { type: 'onStepEnter', stepId: 'step2' }

// When email field changes (with debounce)
trigger: { type: 'onFieldChange', fieldName: 'email', debounceMs: 500 }

// After 2 seconds of inactivity (autosave)
trigger: { type: 'onDelay', delayMs: 2000 }

Inside a rendered form, manual actions can be fired with the triggerManual function returned by useSubmitActionTriggers.


Conditional Execution

Only run an action when certain conditions are met:

const premiumAction: SubmitActionNode = {
  id: 'premium-webhook',
  action: webhookAction,
  trigger: { type: 'onSubmit' },
  condition: {
    field: 'plan',
    operator: 'equals',
    value: 'premium',
  },
};

Conditions are evaluated against the original form field names — before any fieldMapping is applied. An action skipped by its condition counts as successful.

Condition Operators

Operator Description
equals Exact match (===)
notEquals Not equal
contains String contains (both values must be strings)
notContains String doesn’t contain
greaterThan Number comparison
lessThan Number comparison
greaterThanOrEqual Number greater than or equal
lessThanOrEqual Number less than or equal
isTrue Value is true (also accepts 'true', 1)
isFalse Value is false (also accepts 'false', 0)
isEmpty undefined, null, '', or empty array
isNotEmpty Field has a non-empty value

Execution Modes

When you have multiple actions, control how they execute:

submitExecution: {
  mode: 'sequential',      // or 'parallel' (default: 'sequential')
  stopOnFirstError: true,  // sequential only (default: false)
}

Sequential

Actions run one after another, sorted by their order (default 0). Use when order matters. With stopOnFirstError: true, a failed action stops the remaining ones — unless that action has continueOnError: true.

Parallel

Actions run simultaneously. Faster, but no guaranteed order and stopOnFirstError has no effect.

Note: regardless of mode, a failing action fails the whole submission and the form shows its error state — unless that action sets continueOnError: true, in which case its failure is recorded in the result but the submit proceeds (see the pipeline).

SubmitExecutionConfig.globalTimeout caps the entire action pipeline: if it elapses, the submit fails with a clear timeout error. Combine it with the per-action timeout on HTTP actions for finer control.


Field-Level Transforms

Field-level transforms normalize a field’s value before submission, regardless of which submit action consumes it. They run as the very first step in the submission pipeline:

form values (react-hook-form)
  → applyFieldTransforms()          ← field-level (this section)
  → pluginManager.transformValues() ← form-level (plugins)
  → onBeforeSubmit hook             ← plugin validation
  → per action: applyFieldMapping() ← action-level (field mapping)
  → API call

Use field-level transforms for normalizations that should always apply — trimming whitespace, lowercasing emails, formatting dates. Use action-level fieldMapping for API-specific renaming and transforms.

Using with FieldBuilder

const config = FormBuilder.create('contact')
  .addField('email', (f) =>
    f.type('email').label('Email').required().email().transform('trim', 'lowercase'),
  )
  .addField('phone', (f) => f.type('tel').label('Phone').required().transform('trim'))
  .addField('slug', (f) =>
    f
      .type('text')
      .label('Slug')
      .required()
      .transform((v) => String(v).replace(/\s+/g, '-').toLowerCase()),
  )
  .addStep('main', ['email', 'phone', 'slug'])
  .build();

Transform Formats

// Single built-in transform
.transform('trim')

// Multiple built-in transforms (chained left-to-right)
.transform('trim', 'lowercase')

// Custom function
.transform((value) => String(value).replace(/[^0-9]/g, ''))

// Mixed built-in + function (composed into a single function)
.transform('trim', (v) => String(v).toUpperCase())

Raw Config

You can also set transform directly on field config objects:

fields: {
  email: {
    type: 'email',
    label: 'Email',
    schema: { required: true, format: 'email' },
    transform: ['trim', 'lowercase'],
  },
  phone: {
    type: 'tel',
    label: 'Phone',
    schema: { required: true },
    transform: 'trim',
  },
}

Three Levels of Transforms

Level Where When to Use
Field field.transform('trim') Universal normalizations (trim, lowercase, dateISO)
Form Plugin transformValues() Cross-field logic (compute fullName from first+last)
Action fieldMapping.fields.x.transform API-specific formatting (rename + transform per API)

Field Mapping

Field mapping transforms form values before they’re sent to a submit action. This is useful when your API expects different field names or value formats than what the form uses.

Each submit action can have its own fieldMapping — so a single form can send data to multiple APIs with different schemas.

Simple Rename

The simplest format maps form field names to API field names:

const action: SubmitActionNode = {
  id: 'crm-sync',
  action: {
    type: 'http',
    name: 'submit-to-crm',
    endpoint: { url: '/api/leads', method: 'POST' },
    body: { format: 'json' },
  },
  trigger: { type: 'onSubmit' },
  // Simple rename: form field → API field
  fieldMapping: {
    firstName: 'first_name',
    lastName: 'last_name',
    phone: 'phone_number',
    email: 'email_address',
  },
};

With the simple format, unmapped fields pass through unchanged.

Advanced Field Mapping

For more control, use the advanced FieldMapping format with transforms, injection, exclusion, and passthrough control:

import type { FieldMapping } from '@saastro/forms';

const mapping: FieldMapping = {
  // Rename + transform fields
  fields: {
    firstName: 'first_name', // simple rename
    birthDate: { to: 'birth_date', transform: 'dateYMD' }, // rename + transform
    acceptsTerms: { to: 'accepts_terms', transform: 'booleanString' },
  },
  // Inject static or computed values
  inject: {
    campaign_id: 'spring-launch', // static value
    timestamp: { $resolver: 'timestamp' }, // ISO timestamp
    hostname: { $resolver: 'hostname' }, // current hostname
    utm_source: { $resolver: 'urlParam', param: 'utm_source', fallback: 'organic' },
  },
  // Exclude fields from the payload
  exclude: ['internal_notes', 'debug_flag'],
  // If false, only mapped fields are sent (default: true)
  passthrough: true,
};

const action: SubmitActionNode = {
  id: 'api-submit',
  action: httpAction,
  trigger: { type: 'onSubmit' },
  fieldMapping: mapping,
};

Built-in Transforms

Apply value transforms per-field before sending:

Transform Input Output Description
toString 42 "42" Convert to string
toNumber "42" 42 Convert to number
toBoolean "yes" true Convert to boolean
booleanString true "true" Boolean to "true"/"false"
dateISO Date "2026-02-19T..." ISO 8601 string
dateYMD Date "2026-02-19" YYYY-MM-DD format
dateDMY Date "19/02/2026" DD/MM/YYYY format
dateTimestamp Date 1771459200000 Unix timestamp (ms)
trim " hello " "hello" Trim whitespace
lowercase "Hello" "hello" Lowercase string
uppercase "Hello" "HELLO" Uppercase string
emptyToNull "" / undefined null Empty values to null
fields: {
  email: { to: 'email_address', transform: 'trim' },
  birthDate: { to: 'dob', transform: 'dateYMD' },
  hasInsurance: { to: 'insured', transform: 'booleanString' },
  phone: { to: 'phone_number', transform: 'emptyToNull' },
}

You can also use a custom transform function:

fields: {
  fullName: {
    to: 'name',
    transform: (value) => String(value).split(' ')[0], // first name only
  },
}

Resolvers

Inject dynamic computed values that aren’t form fields. There are 8 resolver variants:

Resolver Output Description
timestamp "2026-02-19T14:30:00.000Z" Current ISO timestamp
hostname "example.com" Current window.location.hostname
pageUrl "https://example.com/pricing" Full page URL (window.location.href)
referrer "https://google.com/" Referring page (document.referrer)
userAgent "Mozilla/5.0 ..." Browser user agent string
ip "203.0.113.7" Client IP fetched from endpoint (default https://api.ipify.org?format=json); fallback on failure
urlParam URL parameter value Query-string parameter (param), with optional fallback
custom Any value Run a custom function (fn) — the only resolver that isn’t JSON-serializable
inject: {
  // Static value
  source: 'landing-page',

  // Current timestamp
  submitted_at: { $resolver: 'timestamp' },

  // Current hostname and full page URL
  site: { $resolver: 'hostname' },
  page: { $resolver: 'pageUrl' },

  // Read ?utm_source= from URL, fallback to "organic"
  utm_source: { $resolver: 'urlParam', param: 'utm_source', fallback: 'organic' },

  // Client IP — fetched from api.ipify.org unless you set `endpoint`
  ip_address: { $resolver: 'ip', fallback: 'unknown' },

  // Custom function
  session_id: { $resolver: 'custom', fn: () => getSessionId() },
}

The same resolvers (minus custom) power hidden fields, which capture values like UTM parameters as part of the form itself.

Using Field Mapping with FormBuilder

The submitAction() builder method accepts fieldMapping as an option:

const config = FormBuilder.create('lead')
  .addField('firstName', (f) => f.type('text').label('First name').required())
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addField('birthDate', (f) => f.type('date').label('Date of birth').optional())
  .addStep('main', ['firstName', 'email', 'birthDate'])
  .submitAction(
    'api',
    {
      type: 'http',
      name: 'submit-lead',
      endpoint: { url: '/api/leads', method: 'POST' },
      body: { format: 'json' },
    },
    'onSubmit',
    {
      fieldMapping: {
        fields: {
          firstName: 'first_name',
          birthDate: { to: 'birth_date', transform: 'dateYMD' },
        },
        inject: {
          campaign_id: 'spring-launch',
          timestamp: { $resolver: 'timestamp' },
        },
      },
    },
  )
  .build();

Utility Functions

The applyFieldMapping function is exported for use in custom plugins or handlers. It is async (inject resolvers run in parallel), so remember to await it:

import { applyFieldMapping } from '@saastro/forms';

const raw = { firstName: 'Ana', birthDate: new Date('2000-01-15'), accepts: true };

const mapped = await applyFieldMapping(raw, {
  fields: {
    firstName: 'first_name',
    birthDate: { to: 'birth_date', transform: 'dateYMD' },
    accepts: { to: 'accepts_terms', transform: 'booleanString' },
  },
  inject: { source: 'web' },
});
// { first_name: "Ana", birth_date: "2000-01-15", accepts_terms: "true", source: "web" }

For synchronous dry-runs (e.g. debug previews) use applyFieldMappingSync — async resolvers return placeholder strings ('[fetching IP...]', '[custom resolver]') instead of real values.


Legacy: config.submit and defaultSubmit

Before submit actions existed, forms were configured with a single submit config. It still works — it’s the fallback when no onSubmit-triggered actions are defined — but it’s deprecated for new code.

type: 'custom'

Bring your own submit function. Throwing inside it surfaces in the form’s error UI:

const config = FormBuilder.create('contact')
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['email'])
  .submit({
    type: 'custom',
    onSubmit: async (values) => {
      const res = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(values),
      });
      if (!res.ok) throw new Error('Request failed');
    },
  })
  .build();

type: 'default' / 'default-no-recaptcha'

The exported defaultSubmit({ values, config }) function POSTs a fixed JSON shape to your endpoint:

submit: {
  type: 'default-no-recaptcha',
  endpoint: { url: 'https://api.example.com/forms', clientId: 'my-site' },
}
{
  "clientId": "my-site",
  "formId": "contact",
  "data": { "email": "ana@example.com" }
}
  • clientId defaults to 'test' when not set.
  • Your endpoint must accept exactly this JSON shape — this path is only useful if you build a compatible receiver.
  • With type: 'default' and recaptcha: { siteKey, action }, the submit waits for window.grecaptcha (up to 10s) and includes a recaptchaToken. This path no longer loads the reCAPTCHA script itself — register recaptchaPlugin (recommended) or add the script tag yourself; without it, the submit fails after the timeout with a clear error.
  • On failure it rethrows, so the form’s error state renders and the standard error toast fires.

For new code, use submit actions or type: 'custom' instead.


Advanced: Executing Submit Actions Programmatically

Everything the form runs internally is exported, so you can execute actions outside the form lifecycle — from a plugin, an event handler, or your own submit flow.

Orchestrator Functions

Function Description
executeSubmitActions(actions, values, config) Runs an array of SubmitActionNodes sorted by order, sequentially or in parallel per config.submitExecution. Returns a SubmitActionsResult.
executeSubmitActionsByTrigger(config, triggerType, values, triggerValue?) Filters config.submitActions by trigger and executes the matches. Returns an empty all-successful result when nothing matches.
getActionsByTrigger(config, triggerType, triggerValue?) Returns the enabled SubmitActionNodes matching a trigger type; triggerValue is a step id or field name for step/field triggers.

Executor Functions

Function Description
executeSubmitAction(action, values) Executes a single SubmitAction, dispatching by type. Throws for unknown types (including integration).
executeHttpAction(action, values) HTTP executor — auth headers, body formats, AbortController timeout (default 30000 ms), optional retry.
executeWebhookAction(action, values) Webhook executor — POST JSON or payloadTemplate, optional HMAC-SHA256 X-Webhook-Signature header.
executeEmailAction(action, values) Email executor — provider-specific payload and endpoint.
executeCustomAction(action, values) Calls action.handler(values).

Field Mapping Helpers

Function Description
applyFieldMapping(values, mapping) Async. Rename + transform + passthrough + exclude + inject (resolvers run in parallel).
applyFieldMappingSync(values, mapping) Sync variant for dry-runs; async resolvers return placeholder strings.
applyFieldTransforms(fields, values) Applies field-level transform configs over a values object.
applyTransform(value, transform) Applies a single transform (built-in name or custom function).
applyBuiltinTransform(value, transform) Applies one of the 12 built-in transforms by name.
resolveValue(resolver) Async. Resolves a FieldResolver to its value.
resolveValueSync(resolver) Sync variant with placeholders for ip / custom.
isAdvancedMapping(mapping) Type guard: advanced FieldMapping vs simple rename record.

Worked Example

import {
  executeSubmitActions,
  type FormConfig,
  type SubmitActionNode,
} from '@saastro/forms';

const actions: SubmitActionNode[] = [
  {
    id: 'crm',
    order: 1,
    action: {
      type: 'http',
      name: 'crm-sync',
      endpoint: { url: 'https://api.example.com/leads', method: 'POST' },
      body: { format: 'json' },
    },
    trigger: { type: 'manual' },
    fieldMapping: { firstName: 'first_name' },
  },
  {
    id: 'log',
    order: 2,
    action: {
      type: 'custom',
      name: 'log-submission',
      handler: async (values) => {
        console.log('sent', values);
      },
    },
    trigger: { type: 'manual' },
    continueOnError: true,
  },
];

// executeSubmitActions only reads `submitExecution` from the config
const config: FormConfig = {
  formId: 'lead',
  fields: {},
  steps: {},
  submitExecution: { mode: 'sequential', stopOnFirstError: true },
};

const result = await executeSubmitActions(
  actions,
  { firstName: 'Ada', email: 'ada@example.com' },
  config,
);

console.log(result.allSuccessful, result.successCount, result.failureCount);
for (const r of result.results) {
  console.log(`${r.actionId}: ${r.success ? 'ok' : r.error?.message} (${r.durationMs}ms)`);
}

Per-action errors are caught and recorded in the corresponding SubmitActionResultexecuteSubmitActions itself doesn’t throw on action failures; inspect allSuccessful / results.


Hosted Backend

If you don’t want to run any backend, <HubForm> renders a remotely-configured form and submits it (including file uploads and captcha tokens) to the hosted endpoint at https://submit.saastro.io/v1. The lower-level createHubFormSubmit() helper produces a legacy CustomSubmitConfig you can plug into any FormConfig.


Complete Example

import type { FormConfig, SubmitActionNode } from '@saastro/forms';

const submitActions: Record<string, SubmitActionNode> = {
  'api-submit': {
    id: 'api-submit',
    order: 1,
    action: {
      type: 'http',
      name: 'submit-to-api',
      endpoint: { url: '/api/leads', method: 'POST' },
      body: { format: 'json' },
    },
    trigger: { type: 'onSubmit' },
    fieldMapping: {
      fields: {
        name: 'full_name',
        email: { to: 'email_address', transform: 'lowercase' },
      },
      inject: {
        source: 'website',
        submitted_at: { $resolver: 'timestamp' },
      },
    },
    continueOnError: false,
  },
  'admin-email': {
    id: 'admin-email',
    order: 2,
    action: {
      type: 'email',
      name: 'notify-admin',
      provider: 'smtp',
      endpoint: '/api/send-email', // your server route — keeps API keys off the client
      to: 'admin@example.com',
      from: 'forms@example.com',
      subject: 'New lead: {{name}}',
      template: 'default',
    },
    trigger: { type: 'onSubmit' },
    continueOnError: true,
  },
  'zapier-sync': {
    id: 'zapier-sync',
    order: 3,
    action: {
      type: 'webhook',
      name: 'sync-to-crm',
      url: 'https://hooks.zapier.com/...',
    },
    trigger: { type: 'onSubmit' },
    fieldMapping: {
      name: 'first_name',
      company: 'company_name',
    },
    condition: {
      field: 'company',
      operator: 'isNotEmpty',
      value: null,
    },
  },
};

const config: FormConfig = {
  formId: 'lead-form',
  fields: {
    name: { type: 'text', label: 'Name', schema: { required: true } },
    email: { type: 'email', label: 'Email', schema: { required: true, format: 'email' } },
    company: { type: 'text', label: 'Company', schema: { required: false } },
  },
  steps: {
    main: { id: 'main', fields: ['name', 'email', 'company'] },
  },
  submitActions,
  submitExecution: {
    mode: 'sequential',
    stopOnFirstError: true,
  },
};

API Reference

SubmitAction

type SubmitActionType = 'http' | 'webhook' | 'email' | 'custom' | 'integration';

type SubmitAction =
  | HttpSubmitAction
  | WebhookSubmitAction
  | EmailSubmitAction
  | CustomSubmitAction
  | IntegrationSubmitAction; // server-side only — never executed in the browser

SubmitActionNode

interface SubmitActionNode {
  id: string;
  action: SubmitAction;
  trigger: SubmitTrigger;
  condition?: SubmitActionCondition;
  fieldMapping?: FieldMappingConfig;
  order?: number; // default: 0
  continueOnError?: boolean;
  disabled?: boolean;
}

FieldMapping

// Simple format: rename only
type SimpleMapping = Record<string, string>;

// Advanced format
interface FieldMapping {
  fields?: Record<string, string | FieldMapEntry>;
  inject?: Record<string, unknown>;
  exclude?: string[];
  passthrough?: boolean; // default: true
}

interface FieldMapEntry {
  to: string;
  transform?: BuiltinTransform | ((value: unknown) => unknown);
}

// Union of both formats
type FieldMappingConfig = Record<string, string> | FieldMapping;

BuiltinTransform

type BuiltinTransform =
  | 'toString'
  | 'toNumber'
  | 'toBoolean'
  | 'booleanString'
  | 'dateISO'
  | 'dateYMD'
  | 'dateDMY'
  | 'dateTimestamp'
  | 'trim'
  | 'lowercase'
  | 'uppercase'
  | 'emptyToNull';

FieldResolver

type FieldResolver =
  | { $resolver: 'timestamp' }
  | { $resolver: 'hostname' }
  | { $resolver: 'urlParam'; param: string; fallback?: string }
  | { $resolver: 'ip'; endpoint?: string; fallback?: string }
  | { $resolver: 'pageUrl' }
  | { $resolver: 'referrer' }
  | { $resolver: 'userAgent' }
  | { $resolver: 'custom'; fn: () => unknown };

// JSON-serializable subset (everything except 'custom') — used by hidden fields
type SerializableFieldResolver = Exclude<FieldResolver, { $resolver: 'custom' }>;

SubmitTrigger

interface SubmitTrigger {
  type:
    | 'onSubmit'
    | 'onStepEnter'
    | 'onStepExit'
    | 'onFieldChange'
    | 'onFieldBlur'
    | 'onDelay'
    | 'manual';
  stepId?: string;
  fieldName?: string;
  delayMs?: number; // onDelay — default: 30000
  debounceMs?: number; // onFieldChange — default: 300
}

SubmitExecutionConfig

interface SubmitExecutionConfig {
  mode: 'sequential' | 'parallel'; // default: 'sequential'
  stopOnFirstError?: boolean; // default: false (sequential only)
  globalTimeout?: number; // ms cap for the WHOLE pipeline — submit fails on expiry
}

SubmitActionResult / SubmitActionsResult

interface SubmitActionResult {
  actionId: string;
  actionName: string;
  success: boolean;
  data?: unknown;
  error?: Error;
  durationMs: number;
  startedAt: Date;
  completedAt: Date;
}

interface SubmitActionsResult {
  results: SubmitActionResult[];
  allSuccessful: boolean;
  successCount: number;
  failureCount: number;
  totalDurationMs: number;
}