# Submit & Actions

URL: https://docs.forms.saastro.io/docs/submit
> 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. **Callbacks** — `onSuccess`/`onError` for simple handling
2. **Submit Actions** — declarative system for HTTP, webhooks, emails, custom handlers, and **field mapping**
3. **Hosted backend** — [`<HubForm>`](/docs/hub-form) 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.
- **Redirect** — `config.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](/docs/i18n), or globally with `setDefaultMessages` (Spanish apps: `setDefaultMessages(es)`).

---

## Simple Callbacks

The simplest way to react to a submission using FormBuilder:

```tsx

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](#submit-actions-system) (or a legacy `submit` config with `type: 'custom'`) instead, and keep `onSuccess` for side effects like analytics.

### Success/Error Messages

```tsx
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

```tsx
.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](#integration-action-server-side-only) |

---

## HTTP Action

Send form data to a REST API:

```tsx

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

```tsx
// 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

```tsx
// 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.):

```tsx

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:

```tsx

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.

---

## Custom Action

Run any async function:

```tsx

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>`](/docs/hub-form)). 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`).

```tsx
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](#advanced-executing-submit-actions-programmatically) |

### Examples

```tsx
// 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`](/docs/use-submit-action-triggers).

---

## Conditional Execution

Only run an action when certain conditions are met:

```tsx
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:

```tsx
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](#the-submission-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](#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

```tsx
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

```tsx
// 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:

```tsx
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:

```tsx
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:

```tsx

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          |

```tsx
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:

```tsx
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                 |

```tsx
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](/docs/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:

```tsx
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:

```tsx

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:

```tsx
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:

```tsx
submit: {
  type: 'default-no-recaptcha',
  endpoint: { url: 'https://api.example.com/forms', clientId: 'my-site' },
}
```

```json
{
  "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`](/docs/plugins) (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 `SubmitActionNode`s 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 `SubmitActionNode`s 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

```tsx
  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 `SubmitActionResult` — `executeSubmitActions` itself doesn't throw on action failures; inspect `allSuccessful` / `results`.

---

## Hosted Backend

If you don't want to run any backend, [`<HubForm>`](/docs/hub-form) 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

```tsx

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

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

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

### SubmitActionNode

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

### FieldMapping

```tsx
// 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

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

### FieldResolver

```tsx
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

```tsx
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

```tsx
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

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