Submit & Actions

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

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

TypeUse Case
httpREST API calls with full configuration
webhookSend data to external services (Zapier, Make, etc.)
emailSend notification emails via provider APIs
customRun custom async functions
integrationDeclarative 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}}'
    }
  }),
}

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', // Optional HMAC signature
};

Webhooks always POST JSON. When secret is set, the payload is signed with HMAC-SHA256 and the signature is sent in the X-Webhook-Signature: sha256=<hex> header so the receiver can verify authenticity.


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: '[email protected]',
  cc: ['[email protected]'],
  from: '[email protected]',
  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: '[email protected]',
  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):

ProviderDefault endpoint
smtp/api/send-email (your own server route)
sendgridhttps://api.sendgrid.com/v3/mail/send
resendhttps://api.resend.com/emails
mailgunendpoint 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.


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:

TriggerWhen It Fires
onSubmitWhen the form is submitted (last step)
onStepEnterWhen entering a step (stepId; any step if omitted)
onStepExitWhen leaving a step (stepId; any step if omitted)
onFieldChangeWhen a field value changes (fieldName; any field if omitted — debounceMs defaults to 300)
onFieldBlurWhen a field loses focus (fieldName; any field if omitted)
onDelayAfter a period of user inactivity (delayMs, default 30000)
manualOnly 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

OperatorDescription
equalsExact match (===)
notEqualsNot equal
containsString contains (both values must be strings)
notContainsString doesn’t contain
greaterThanNumber comparison
lessThanNumber comparison
greaterThanOrEqualNumber greater than or equal
lessThanOrEqualNumber less than or equal
isTrueValue is true (also accepts 'true', 1)
isFalseValue is false (also accepts 'false', 0)
isEmptyundefined, null, '', or empty array
isNotEmptyField 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

LevelWhereWhen to Use
Fieldfield.transform('trim')Universal normalizations (trim, lowercase, dateISO)
FormPlugin transformValues()Cross-field logic (compute fullName from first+last)
ActionfieldMapping.fields.x.transformAPI-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:

TransformInputOutputDescription
toString42"42"Convert to string
toNumber"42"42Convert to number
toBoolean"yes"trueConvert to boolean
booleanStringtrue"true"Boolean to "true"/"false"
dateISODate"2026-02-19T..."ISO 8601 string
dateYMDDate"2026-02-19"YYYY-MM-DD format
dateDMYDate"19/02/2026"DD/MM/YYYY format
dateTimestampDate1771459200000Unix timestamp (ms)
trim" hello ""hello"Trim whitespace
lowercase"Hello""hello"Lowercase string
uppercase"Hello""HELLO"Uppercase string
emptyToNull"" / undefinednullEmpty 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:

ResolverOutputDescription
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
urlParamURL parameter valueQuery-string parameter (param), with optional fallback
customAny valueRun 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": "[email protected]" }
}
  • 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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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: '[email protected]' },
  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: '[email protected]',
      from: '[email protected]',
      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;
}