> For the complete documentation index, see [llms.txt](https://kb.craftformswp.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kb.craftformswp.com/documentation/for-developers/developer-hooks-reference.md).

# Developer Hooks Reference

Extension points for integrating with CraftForms from custom plugins, themes, or scripts.

### JavaScript Events <a href="#javascript-events" id="javascript-events"></a>

The form web component (`<craftforms-form>`) dispatches custom events at every stage of the form lifecycle. Each event fires in **two places simultaneously**:

* **On the element** — listen on the specific form element
* **On `window`** — listen globally, prefixed with `craftforms-form-`

```js
// Listen on a specific form
document.querySelector('craftforms-form').addEventListener('onsuccess', handler);

// Listen globally (all forms on the page)
window.addEventListener('craftforms-form-onsuccess', handler);
```

***

#### Event: `set-elements` <a href="#event-set-elements" id="event-set-elements"></a>

Fires once after the form's DOM is fully traversed and all fields, notifications, and payment elements are registered. Use this to hook in any third-party logic that needs a reference to registered fields.

```js
formEl.addEventListener('set-elements', () => {
    // form is ready, fields are registered
});
```

`detail`: none

***

#### Event: `onsubmit` <a href="#event-onsubmit" id="event-onsubmit"></a>

Fires immediately when the submit button is clicked, **before** client-side validation runs. Useful for analytics or pre-submit transformations.

```js
formEl.addEventListener('onsubmit', (e) => {
    const { data, uid } = e.detail;
    // data = current form data snapshot (see Data Object below)
    // uid  = internal form instance ID
});
```

`detail`: `{ data, uid }`

> **Note:** This fires even if validation will fail — it is not a signal that submission will proceed.

***

#### Event: `beforevalidate` <a href="#event-beforevalidate" id="event-beforevalidate"></a>

Fires before every validation cycle (which runs on every field change, debounced 100 ms, and again on submit). Use this to inject dynamic validation context before rules are evaluated.

```js
formEl.addEventListener('beforevalidate', (e) => {
    const { data, uid } = e.detail;
});
```

`detail`: `{ data, uid }`

***

#### Event: `oninput` <a href="#event-oninput" id="event-oninput"></a>

Fires debounced (100 ms) after any field value changes. Useful for live price display, external integrations, or real-time feedback outside the form.

```js
formEl.addEventListener('oninput', (e) => {
    const { data, uid } = e.detail;
    console.log('Current price:', data.price);
});
```

`detail`: `{ data, uid }`

***

#### Event: `ondisable` <a href="#event-ondisable" id="event-ondisable"></a>

Fires when the set of disabled fields changes (driven by Disable Field Logic rules). Use this to sync external UI that depends on which fields are active.

```js
formEl.addEventListener('ondisable', (e) => {
    const { data, uid } = e.detail;
    // data.isDisabled — true if any fields are currently disabled
    // data.disabled   — string[] of disabled field names
});
```

`detail`: `{ data: { isDisabled: boolean, disabled: string[] }, uid }`

***

#### Event: `spam-detected` <a href="#event-spam-detected" id="event-spam-detected"></a>

Fires when the client-side spam score reaches or exceeds 50 and the submission is blocked. Use this for logging or showing a custom message.

```js
formEl.addEventListener('spam-detected', (e) => {
    const { score, breakdown, signals, uid } = e.detail;
    // score     — numeric 0–100
    // breakdown — { noMouseMovement: 20, noFocusEvents: 25, … }
    // signals   — raw sensor readings
});
```

`detail`: `{ score, breakdown, signals, uid }`

***

#### Event: `onsuccess` <a href="#event-onsuccess" id="event-onsuccess"></a>

Fires after the server returns `"success": true`. This is the primary hook for post-submission actions: analytics, redirects, showing custom UI, etc.

```js
formEl.addEventListener('onsuccess', (e) => {
    const { data, uid } = e.detail;
    // data = raw server response object
    // data.data.successMsg — message configured in the form
    console.log('Form submitted!', data);
});
```

`detail`: `{ data: serverResponse, uid }`

**Redirect after success (custom JS):**

```js
formEl.addEventListener('onsuccess', (e) => {
    window.location.href = '/thank-you';
});
```

***

#### Event: `onfail` <a href="#event-onfail" id="event-onfail"></a>

Fires after the server returns `"success": false` (validation failure, server error, etc.).

```js
formEl.addEventListener('onfail', (e) => {
    const { data, uid } = e.detail;
    // data.data.errorMsg — error message from server
});
```

`detail`: `{ data: serverResponse, uid }`

***

#### Event: `response` <a href="#event-response" id="event-response"></a>

Fires on **every** server response, regardless of success or failure. Use this instead of listening to both `onsuccess` and `onfail` when you need a single handler.

```js
formEl.addEventListener('response', (e) => {
    const { data, uid } = e.detail;
    const success = data?.success;
    // handle either outcome
});
```

`detail`: `{ data: serverResponse, uid }`

***

#### Event: `craftforms-payment-initiated` <a href="#event-craftforms-payment-initiated" id="event-craftforms-payment-initiated"></a>

Fires when the server response contains a `redirect` key (e.g., a Stripe Checkout session URL). The browser will navigate to that URL \~600 ms after this event fires.

```js
formEl.addEventListener('craftforms-payment-initiated', (e) => {
    const { url, uid, data } = e.detail;
    // url  — the gateway redirect URL
    // data — full server response
    console.log('Redirecting to payment:', url);
});
```

`detail`: `{ url: string, uid, data: serverResponse }`

***

#### Event: `craftforms-order-created` <a href="#event-craftforms-order-created" id="event-craftforms-order-created"></a>

Fires alongside `craftforms-payment-initiated` when the server also returns an `order_token`.

```js
formEl.addEventListener('craftforms-order-created', (e) => {
    const { order_token, uid } = e.detail;
});
```

`detail`: `{ order_token: string, uid }`

***

#### Event: `nextstep-click` <a href="#event-nextstep-click" id="event-nextstep-click"></a>

Fires when the user clicks "Next" in a multi-step form. Fires before the step transition, so you can inspect the current data state.

```js
formEl.addEventListener('nextstep-click', (e) => {
    const { data, uid } = e.detail;
});
```

`detail`: `{ data, uid }`

***

#### API Ready Event <a href="#api-ready-event" id="api-ready-event"></a>

Fires on `document` once — when the CraftForms frontend API is fully initialised and available at `window.CraftForms.API`. Use this as the safe entry point for any script that needs to interact with the API before forms mount.

```js
document.addEventListener('craftforms.frontend.api.ready', () => {
    // window.CraftForms.API.helpers is now populated
});
```

***

#### The `data` Object (shared shape) <a href="#the-data-object-shared-shape" id="the-data-object-shared-shape"></a>

All events that carry form state pass a `data` object with this shape:

```js
{
    error:           {},   // validation errors keyed by field name: { fieldName: 'message' }
    errors:          [],   // same as array: [{ name: 'fieldName', msg: 'message' }]
    field:           {},   // raw field values merged with pool variables
    formatted:       {},   // formatted submit data (if formatSubmitData is configured)
    transformations: {},   // computed smart variable outputs
    price:           0,    // calculated price as a number
    form: {                // display namespace — pool vars stripped of leading underscore
        booking_capacity: 5,
        stock_qty: 10,
        // ...other injected variables
    }
}
```

***

#### Injecting Variables into the Form Pool <a href="#injecting-variables-into-the-form-pool" id="injecting-variables-into-the-form-pool"></a>

Web components and external scripts can push arbitrary variables into the form's resolved data pool at any time. These variables become available in formulas, validation rules, and display/disable logic.

```js
const formEl = document.querySelector('craftforms-form');

// Inject a single variable
formEl.dispatchEvent(new CustomEvent('craftforms-meta-update', {
    bubbles: true,
    detail: { key: '_stock_qty', value: 42 }
}));

// Inject multiple variables at once
formEl.dispatchEvent(new CustomEvent('craftforms-meta-update', {
    bubbles: true,
    detail: {
        vars: {
            _stock_qty: 42,
            _slot_price: 29.99
        }
    }
}));
```

Underscore-prefixed keys (e.g. `_stock_qty`) are accessible in formulas as `_stock_qty` and in the `form` display namespace as `form.stock_qty` (underscore stripped).

***

### Server Response Format <a href="#server-response-format" id="server-response-format"></a>

The form component interprets the REST API response JSON to determine what to show or do next.

#### Success response <a href="#success-response" id="success-response"></a>

```json
{
    "success": true,
    "data": {
        "successMsg": "Thank you! We received your message."
    }
}
```

Triggers: `onsuccess` event, success notification.

#### Error response <a href="#error-response" id="error-response"></a>

```json
{
    "success": false,
    "data": {
        "errorMsg": "Something went wrong. Please try again.",
        "errorType": "generic"
    }
}
```

Triggers: `onfail` event, alert notification.

Special `errorType` values that bypass the custom fail message configured on the notification block:

* `booking_unavailable` — slot/resource no longer available
* `out_of_stock` — stock depleted between form load and submit

#### Redirect response <a href="#redirect-response" id="redirect-response"></a>

Including a top-level `redirect` key in the response triggers client-side navigation. The `message` string (optional) is shown briefly in the notification block before the browser navigates.

```json
{
    "success": true,
    "redirect": "https://checkout.example.com/pay/session_xyz",
    "message": "Redirecting to payment…",
    "order_token": "optional_token_string"
}
```

Triggers: `craftforms-payment-initiated` event → browser navigates after 600 ms. If `order_token` is present, also triggers: `craftforms-order-created` event.

> **Custom redirect from a submit action:** Submit actions (`execute()`) return `true` or `WP_Error` — they do not return the HTTP response. To send a redirect response, you need to hook into `craftforms_submission_processed` at a low priority (after actions run) and override the response, or build a custom REST endpoint. The built-in redirect mechanism is used by the Stripe Checkout integration.

***

### PHP Hooks <a href="#php-hooks" id="php-hooks"></a>

#### `craftforms_register_submit_actions` <a href="#craftforms_register_submit_actions" id="craftforms_register_submit_actions"></a>

**Type:** `do_action`\
**Since:** 1.3.0\
**Parameters:** `$manager` — `\CraftForms\Submit_Actions_Manager`\
**Fires:** During plugin initialisation, before any form is processed.

The primary hook for registering custom submit actions. Your action class must extend `Abstract_Submit_Action` (see [Custom Submit Actions](http://localhost:63342/markdownPreview/741844830/markdown-preview-index-9om48hjjpcena5d3guhs62knuk.html#custom-submit-actions) below).

```php
add_action('craftforms_register_submit_actions', function(\CraftForms\Submit_Actions_Manager $manager) {
    $manager->register_action(new My_Plugin\My_Custom_Action());
}, 10, 1);
```

***

#### `craftforms_pre_submit` <a href="#craftforms_pre_submit" id="craftforms_pre_submit"></a>

**Type:** `apply_filters`\
**Since:** 1.4.1\
**Parameters:** `$gate` (`null` by default), `$submission_data` (array — raw, pre-sanitisation params), `$form_post` (WP\_Post), `$form_meta` (array), `$request` (WP\_REST\_Request)\
**Fires:** Inside `submit_form()` **after** the security gates pass (nonce / same-origin / external-submission checks) but **before** any expensive or side-effecting work — file uploads, price calculation, and the entire submit-action chain (email / save / webhook / add-to-cart).

The submission **veto gate**. Return a `WP_Error` to **abort** the submission: its message is returned to the client as `errorMsg`, and its `'status'` error-data (default `403`) sets the HTTP response code. Return `null` (or any non-`WP_Error`) to **allow** the submission to proceed. Because the default is `null`, this hook is purely additive — adding it changes nothing unless something hooks it.

This is the canonical extension point for **anti-abuse and access control**: rate limiting, duplicate-value throttling, server-side spam-score re-checks, honeypot enforcement, IP allow/deny lists, or any "should this submission run at all?" decision. It runs early enough that a rejection costs nothing — no files are stored, no price is computed, no actions fire.

```php
// Example: rate-limit a form to 3 submissions per IP per hour, return HTTP 429 when exceeded.
add_filter('craftforms_pre_submit', function ($gate, $submission_data, $form_post, $form_meta, $request) {
    if (is_wp_error($gate)) {
        return $gate; // an earlier callback already vetoed — don't override it
    }

    $ip  = $_SERVER['REMOTE_ADDR'] ?? ''; // NOTE: behind Cloudflare/proxy this is the CDN — see caveat
    $key = 'cf_rl_' . $form_post->ID . '_' . md5($ip . wp_salt());

    $count = (int) get_transient($key);
    if ($count >= 3) {
        return new \WP_Error(
            'rate_limited',
            __('Too many submissions. Please try again later.', 'my-plugin'),
            ['status' => 429]
        );
    }

    set_transient($key, $count + 1, HOUR_IN_SECONDS); // fixed window via TTL
    return $gate; // null — allow
}, 10, 5);
```

> **`$submission_data` is raw** (the unsanitised REST params, with `_nonce` and system keys still present). Sanitise any value you read — e.g. normalise a phone/email before a duplicate-value check. **IP caveat:** `REMOTE_ADDR` is the CDN/reverse-proxy address behind Cloudflare etc.; only trust `X-Forwarded-For`/`CF-Connecting-IP` when you control the proxy chain (they are spoofable otherwise). For privacy/GDPR, key transients on **salted hashes**, never raw IPs or personal data.

***

#### `craftforms_submission_processed` <a href="#craftforms_submission_processed" id="craftforms_submission_processed"></a>

**Type:** `do_action`\
**Since:** 1.3.0\
**Parameters:** `$form_post` (WP\_Post), `$data` (array), `$form_meta` (array)\
**Fires:** After server-side validation passes, before the success response is sent. This is what triggers the submit actions manager internally.

Use this hook if you need to run logic on every valid submission without creating a full submit action class.

```php
add_action('craftforms_submission_processed', function(WP_Post $form_post, array $data, array $form_meta) {
    // $form_post->ID    — form post ID
    // $form_post->post_title — form title
    // $data             — sanitised field data (see Submission Data below)
    // $form_meta        — full form configuration array
    error_log('Form submitted: ' . $form_post->post_title);
}, 10, 3);
```

***

#### `craftforms_submit_actions_completed` <a href="#craftforms_submit_actions_completed" id="craftforms_submit_actions_completed"></a>

**Type:** `do_action`\
**Since:** 1.3.0\
**Parameters:** `$form_post` (WP\_Post), `$data` (array), `$errors` (array)\
**Fires:** After all configured submit actions have executed (regardless of individual action results).

Use this for monitoring, logging, or triggering side effects that must run after all actions complete.

```php
add_action('craftforms_submit_actions_completed', function(WP_Post $form_post, array $data, array $errors) {
    if (!empty($errors)) {
        // One or more actions returned WP_Error
        foreach ($errors as $error) {
            error_log('Submit action failed: ' . $error->get_error_message());
        }
    }
}, 10, 3);
```

***

#### `craftforms_order_created` (PRO) <a href="#craftforms_order_created-pro" id="craftforms_order_created-pro"></a>

**Type:** `do_action`\
**Since:** 1.3.0\
**Parameters:** `$order_id` (int), `$order_token` (string), `$order_data` (array)\
**Fires:** When a CraftForms order post is created (PRO only, triggered by the `create_order` action).

```php
add_action('craftforms_order_created', function(int $order_id, string $order_token, array $order_data) {
    // sync to external CRM, send to ERP, etc.
}, 10, 3);
```

***

### Submission Data Structure <a href="#submission-data-structure" id="submission-data-structure"></a>

The `$data` array passed to hooks and `execute()` contains one entry per submitted field:

```php
[
    'field_name' => [
        'value'        => mixed,   // raw value as submitted
        'displayValue' => string,  // human-readable formatted value
        'displayName'  => string,  // field label shown in admin
        'type'         => string,  // field type (text, email, select, etc.)
    ],
    // ...more fields...

    // System fields — underscore-prefixed, present when relevant:
    '_price'           => float|null,   // calculated price
    '_price_formatted' => string|null,  // e.g. "$49.00"
    '_currency'        => string|null,  // ISO 4217 code, e.g. "USD"
    '_resource_id'     => int|null,     // linked catalog item ID (PRO)
]
```

***

### Custom Submit Actions <a href="#custom-submit-actions" id="custom-submit-actions"></a>

#### What is a Submit Action? <a href="#what-is-a-submit-action" id="what-is-a-submit-action"></a>

A submit action is a PHP class that runs after a valid form submission. Multiple actions can be chained on a single form (send email → save submission → notify Slack). They are configured per-form in the Gutenberg block editor.

#### Creating a Submit Action <a href="#creating-a-submit-action" id="creating-a-submit-action"></a>

1. Extend `CraftForms\SubmitActions\Abstract_Submit_Action`
2. Implement the 6 abstract methods
3. Register via `craftforms_register_submit_actions`

```php
<?php

namespace My_Plugin;

use CraftForms\SubmitActions\Abstract_Submit_Action;

class My_Custom_Action extends Abstract_Submit_Action {

    public function get_slug(): string {
        return 'my_custom_action'; // unique — must not collide with built-in slugs
    }

    public function get_title(): string {
        return 'My Custom Action';
    }

    public function get_description(): string {
        return 'Does something custom after form submission.';
    }

    public function get_icon(): string {
        return 'dashicons-admin-tools'; // any dashicon name
    }

    public function get_settings_schema(): array {
        return [
            [
                'id'          => 'endpoint',
                'type'        => 'url',
                'label'       => 'Webhook URL',
                'required'    => true,
                'placeholder' => 'https://hooks.example.com/...',
            ],
            [
                'id'      => 'include_price',
                'type'    => 'checkbox',
                'label'   => 'Include calculated price',
                'default' => true,
            ],
        ];
    }

    public function execute(\WP_Post $form_post, array $data, array $settings, array $form_meta): bool|\WP_Error {
        $endpoint = $settings['endpoint'] ?? '';
        if (empty($endpoint)) {
            return new \WP_Error('missing_endpoint', 'Webhook URL is required.');
        }

        // Use built-in helpers to build a flat mustache context
        $context     = $this->build_context($form_post, $data);
        $flat        = $this->flatten_context($context);

        $payload = [
            'form'   => $form_post->post_title,
            'fields' => [],
        ];

        foreach ($data as $field_name => $field_data) {
            if (str_starts_with($field_name, '_')) continue; // skip system fields
            $payload['fields'][$field_name] = $field_data['displayValue'] ?? '';
        }

        if (!empty($settings['include_price']) && isset($data['_price'])) {
            $payload['price'] = $data['_price'];
        }

        $response = wp_remote_post($endpoint, [
            'headers' => ['Content-Type' => 'application/json'],
            'body'    => wp_json_encode($payload),
            'timeout' => 10,
        ]);

        if (is_wp_error($response)) {
            return $response;
        }

        return true;
    }
}

// Register the action
add_action('craftforms_register_submit_actions', function(\CraftForms\Submit_Actions_Manager $manager) {
    $manager->register_action(new My_Custom_Action());
});
```

#### Settings Schema Field Types <a href="#settings-schema-field-types" id="settings-schema-field-types"></a>

| `type`     | Notes                                                                    |
| ---------- | ------------------------------------------------------------------------ |
| `text`     | Plain text input; supports Mustache placeholders (`{{email.fieldname}}`) |
| `textarea` | Multi-line text; add `'rows' => 6` for taller field                      |
| `email`    | Text input with email format validation                                  |
| `url`      | Text input with URL format validation                                    |
| `number`   | Numeric input with numeric validation                                    |
| `checkbox` | Boolean toggle; set `'default' => true/false`                            |
| `select`   | Dropdown; provide `'options' => [['value'=>'...','label'=>'...']]`       |
| `info`     | Read-only display field (use `'content'` for HTML)                       |

#### Available Helper Methods (from `Abstract_Submit_Action`) <a href="#available-helper-methods-from-abstract_submit_action" id="available-helper-methods-from-abstract_submit_action"></a>

```php
// Build a nested context array (email / site / post sections)
$context = $this->build_context($form_post, $data);

// Flatten to dot-notation keys for Mustache rendering
$flat = $this->flatten_context($context);
// → ['email.name' => 'John', 'site.name' => 'My Site', 'post.title' => '...', ...]

// Get SMTP config (decrypted password) for a stored SMTP server post ID
$smtp = $this->get_smtp_config($settings['smtp_server']);

// Write to the plugin's email log CPT
$this->create_email_log($recipient, $subject, $body, 'success', 'My Transport');

// Convert ISO 4217 currency code to symbol
$symbol = $this->currency_code_to_symbol('USD'); // → '$'
```

#### Mustache Placeholders Available in Settings <a href="#mustache-placeholders-available-in-settings" id="mustache-placeholders-available-in-settings"></a>

When rendering settings strings through the built-in email system, the following placeholders are available:

| Placeholder            | Value                                         |
| ---------------------- | --------------------------------------------- |
| `{{email.field_name}}` | Submitted field value (display format)        |
| `{{email.price}}`      | Formatted price (e.g. `$49.00`)               |
| `{{email.price_raw}}`  | Raw price number                              |
| `{{email.currency}}`   | Currency symbol (e.g. `$`)                    |
| `{{site.name}}`        | WordPress site name                           |
| `{{site.url}}`         | Site URL                                      |
| `{{site.admin_email}}` | Admin email                                   |
| `{{post.title}}`       | Title of the page the form was submitted from |
| `{{post.url}}`         | URL of that page                              |

#### Return Values from `execute()` <a href="#return-values-from-execute" id="return-values-from-execute"></a>

```php
return true;                                             // success — chain continues
return new \WP_Error('code', 'Human-readable message'); // non-fatal — error logged, chain continues
```

Fatal errors (which abort the entire action chain) are reserved for internal actions. Custom actions always use the non-fatal path.

#### Hidden Actions <a href="#hidden-actions" id="hidden-actions"></a>

Set `is_hidden()` to return `true` to prevent an action from appearing in the Gutenberg action picker. The action can still be dispatched programmatically or auto-appended by other code.

```php
public function is_hidden(): bool {
    return true;
}
```

***

### See Also <a href="#see-also" id="see-also"></a>

* `includes/submit-actions/class-example-action.php` — minimal working boilerplate
* `includes/submit-actions/abstract-submit-action.php` — full base class with all helpers
* `includes/class-submit-actions-manager.php` — registration and execution engine
