S

Submit & Actions

Configure form submission handlers, HTTP endpoints, webhooks, email notifications, and field mapping transforms.

Submit & Actions

@saastro/forms provides two ways to handle form submission:

  1. Callbacks - Use onSuccess/onError for simple handling
  2. Submit Actions - Declarative system for HTTP, webhooks, emails, custom handlers, and field mapping

Simple Callbacks

The simplest way to handle form submission using FormBuilder:

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

const config = FormBuilder.create('contact')
  .addField('email', (f) => f.type('email').label('Email').required())
  .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} />;
}

Success/Error Messages

const config = FormBuilder.create('contact')
  .addField('email', (f) => f.type('email').label('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:

Action Types

TypeUse Case
httpREST API calls with full configuration
webhookSend data to external services (Zapier, Make, etc.)
emailSend notification emails
customRun custom async functions

HTTP Action

Send form data to a REST API:

import { FormBuilder, type SubmitActionNode, type 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,
};

// 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',
}

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
};

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>
  `,
};

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 };
  },
};

Triggers

Actions can be triggered at different points:

TriggerWhen It Fires
onSubmitWhen form is submitted
onStepEnterWhen entering a specific step
onStepExitWhen leaving a specific step
onFieldChangeWhen a field value changes
onFieldBlurWhen a field loses focus
onDelayAfter X ms of inactivity
manualOnly when called programmatically

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 }

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',
  },
};

Condition Operators

OperatorDescription
equalsExact match
notEqualsNot equal
containsString contains
notContainsString doesn’t contain
greaterThanNumber comparison
lessThanNumber comparison
greaterThanOrEqualNumber greater than or equal
lessThanOrEqualNumber less than or equal
isTrueValue is truthy
isFalseValue is falsy
isEmptyField is empty/null/undefined
isNotEmptyField has value

Execution Modes

When you have multiple actions, control how they execute:

submitExecution: {
  mode: 'sequential', // or 'parallel'
  stopOnFirstError: true, // Only for sequential
  globalTimeout: 60000,
}

Sequential

Actions run one after another. Use when order matters.

Parallel

Actions run simultaneously. Faster but no guaranteed order.


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: {
    nombre: 'first_name',
    apellido: 'last_name',
    telefono: 'phone',
    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: {
    nombre: 'first_name', // simple rename
    fecha_nacimiento: { to: 'birth_date', transform: 'dateYMD' }, // rename + transform
    acepta_terminos: { to: 'accepts_terms', transform: 'booleanString' },
  },
  // Inject static or computed values
  inject: {
    campaign_id: '10653', // 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' },
  birth_date: { to: 'dob', transform: 'dateYMD' },
  has_insurance: { 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:

ResolverOutputDescription
timestamp"2026-02-19T14:30:00.000Z"Current ISO timestamp
hostname"example.com"Current window.location.hostname
urlParamURL parameter valueRead from query string
customAny valueRun a custom function
inject: {
  // Static value
  source: 'landing-page',

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

  // Current hostname
  site: { $resolver: 'hostname' },

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

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

Using Field Mapping with FormBuilder

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

const config = FormBuilder.create('lead')
  .addField('nombre', (f) => f.type('text').label('Nombre'))
  .addField('email', (f) => f.type('email').label('Email'))
  .addField('fecha', (f) => f.type('date').label('Fecha de nacimiento'))
  .addStep('main', ['nombre', 'email', 'fecha'])
  .submitAction(
    'api',
    {
      type: 'http',
      name: 'submit-lead',
      endpoint: { url: '/api/leads', method: 'POST' },
      body: { format: 'json' },
    },
    'onSubmit',
    {
      fieldMapping: {
        fields: {
          nombre: 'first_name',
          fecha: { to: 'birth_date', transform: 'dateYMD' },
        },
        inject: {
          campaign_id: '10653',
          timestamp: { $resolver: 'timestamp' },
        },
      },
    },
  )
  .build();

Utility Functions

The applyFieldMapping function is exported for use in custom plugins or handlers:

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

const raw = { nombre: 'Ana', fecha: new Date('2000-01-15'), acepta: true };

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

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: 'resend',
      apiKey: process.env.RESEND_API_KEY!,
      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: {
      nombre: 'first_name',
      telefono: 'phone_number',
    },
    condition: {
      field: 'company',
      operator: 'isNotEmpty',
      value: null,
    },
  },
};

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

API Reference

SubmitActionNode

interface SubmitActionNode {
  id: string;
  action: SubmitAction;
  trigger: SubmitTrigger;
  condition?: SubmitActionCondition;
  fieldMapping?: FieldMappingConfig;
  order?: number;
  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: 'custom'; fn: () => unknown };

SubmitTrigger

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

SubmitExecutionConfig

interface SubmitExecutionConfig {
  mode: 'sequential' | 'parallel';
  stopOnFirstError?: boolean;
  globalTimeout?: number;
}