You are viewing beta documentation for Formie 4.x.
Custom Integration

Overview

You can add custom integrations by registering an integration class with Formie. Pick the base class that matches the integration you are building, then implement the pieces that are specific to your provider.

namespace modules\sitemodule;

use modules\sitemodule\ExampleAddressProvider;
use modules\sitemodule\ExampleAutomation;
use modules\sitemodule\ExampleCaptcha;
use modules\sitemodule\ExampleCrm;
use modules\sitemodule\ExampleElement;
use modules\sitemodule\ExampleEmailMarketing;
use modules\sitemodule\ExampleHelpDesk;
use modules\sitemodule\ExampleMessaging;
use modules\sitemodule\ExampleMiscellaneous;
use modules\sitemodule\ExamplePayment;
use verbb\formie\events\RegisterIntegrationsEvent;
use verbb\formie\services\Integrations;
use yii\base\Event;

Event::on(Integrations::class, Integrations::EVENT_REGISTER_INTEGRATIONS, function(RegisterIntegrationsEvent $event) {
    $event->addressProviders[] = ExampleAddressProvider::class;
    $event->automations[] = ExampleAutomation::class;
    $event->captchas[] = ExampleCaptcha::class;
    $event->crm[] = ExampleCrm::class;
    $event->elements[] = ExampleElement::class;
    $event->emailMarketing[] = ExampleEmailMarketing::class;
    $event->helpDesk[] = ExampleHelpDesk::class;
    $event->messaging[] = ExampleMessaging::class;
    $event->miscellaneous[] = ExampleMiscellaneous::class;
    $event->payments[] = ExamplePayment::class;
});

Integration Types

Most integrations should extend one of Formie’s integration base classes.

TypeBase classUse
Address providerAddressProviderAddress autocomplete providers used by Address fields. See Address Provider Integration.
AutomationAutomationAutomation integrations that send submission payloads to another service. See Automation Integration.
CaptchaCaptchaSpam-protection integrations that validate submissions. See Captcha Integration.
CRMCrmCRM integrations with one or more mappable provider objects. See CRM Integration.
ElementElementIntegrations that create or update Craft elements. See Element Integration.
Email marketingEmailMarketingSubscriber/list integrations with a selected list and field mapping. See Email Marketing Integration.
Help deskHelpDeskTicket or conversation integrations. See Help Desk Integration.
MessagingMessagingMessage-posting integrations such as chat or notification tools. See Messaging Integration.
MiscellaneousMiscellaneousIntegrations that do not fit a more specific pattern. See Miscellaneous Integration.
PaymentPaymentPayment provider integrations used by Payment fields. See Payment Integration.

OAuth can apply to several integration types. See OAuth Integration if your provider needs users to connect an account before Formie can send or fetch data.

Common Methods

Integration results are stored in an IntegrationRunResults, not on the Submission element. During integration and notification execution, Formie scopes reference resolution to the active run. To inspect a particular run explicitly, use Formie::$plugin->getIntegrationDispatcher()->loadContext($submission, $executionUid). Omitting the identity outside an active run returns an empty context; it does not select the latest result. Use the executionUid from the delivery event or attempt you are inspecting.

Most integration classes define a few common methods.

MethodUse
displayName()The integration name shown in the control panel.
getDescription()A short description shown in integration selection screens.
getIconUrl()The icon URL shown in the control panel. Many core integrations use Formie’s default icon path for their category.
defineClient()Creates the Guzzle client used by request() and deliverPayload().
fetchConnection()Checks whether the integration can connect to the provider.
fetchConfig()Fetches provider data used by the form builder, such as lists, fields, channels or element layouts.
defineFormSettingsSchema()Defines the integration settings shown inside a form’s Integrations tab.
#[FormIntegrationSetting]Annotates existing properties that Formie may hydrate for each form.
execute(IntegrationRunContext $context)Sends or saves data through the explicit dispatch capability and returns an IntegrationResult.

getSettingsHtml() renders plugin-level integration settings in Formie’s settings area. Use defineFormSettingsSchema() for the settings shown on each form.

Form Settings Schema

Integrations use schema for the form builder UI. Start with parent::defineFormSettingsSchema($form) so the standard enabled setting is included, then append your own fields.

use verbb\formie\base\FormInterface;
use verbb\formie\helpers\SchemaHelper;

protected function defineFormSettingsSchema(FormInterface $form): array
{
    $schema = parent::defineFormSettingsSchema($form);

    $schema[] = SchemaHelper::textField([
        'label' => Craft::t('formie', 'URL'),
        'instructions' => Craft::t('formie', 'Enter the URL that will be triggered when a submission is made.'),
        'name' => 'url',
        'required' => true,
    ]);

    return $schema;
}

Annotate every existing property that forms may configure. Inherited annotations are included. Schema nodes, validation rules and method overrides cannot grant access to other properties.

use verbb\formie\attributes\FormIntegrationSetting;

#[FormIntegrationSetting]
public ?string $url = null;

#[FormIntegrationSetting]
public ?array $fieldMapping = null;

For registered integrations, Formie discards undeclared form values before saving or populating the integration. Settings for an integration whose class is temporarily unavailable are retained as opaque data to avoid destructive form saves, but Formie does not hydrate them into an integration instance. This keeps plugin-level settings such as API keys and base URLs separate from form-level mappings and options.

If a declared form setting contains an outbound URL, send to it with requestPublicEndpoint() or deliverPayloadToPublicEndpoint(). These methods use a credential-free client, reject private and reserved network targets, disable redirects and pin DNS resolution. Continue using request() and deliverPayload() for the integration provider's fixed API endpoints.

Many integrations also use field mapping. The helper expects provider fields that have already been fetched into IntegrationConfig.

protected function defineFormSettingsSchema(FormInterface $form): array
{
    $schema = parent::defineFormSettingsSchema($form);
    $schema[] = $this->getOptInFieldSchema();

    $schema[] = $this->getIntegrationFieldMappingField([
        'name' => 'contactFieldMapping',
        'dataLabel' => 'Contact',
        'dataKey' => 'contact',
    ]);

    return $schema;
}

For a broader explanation of schema nodes, helpers, conditions and layout, see Schema.

Integration Config

Use fetchConfig() to fetch data the form builder needs before a user configures the integration on a form. This data is cached by getConfig() and refreshed when the form builder asks Formie to refresh integration data.

use verbb\formie\models\IntegrationField;
use verbb\formie\models\IntegrationConfig;

public function fetchConfig(): IntegrationConfig
{
    $contactFields = [
        new IntegrationField([
            'handle' => 'email',
            'name' => Craft::t('formie', 'Email'),
            'required' => true,
        ]),
        new IntegrationField([
            'handle' => 'firstName',
            'name' => Craft::t('formie', 'First Name'),
        ]),
    ];

    return new IntegrationConfig([
        'contact' => $contactFields,
    ]);
}

IntegrationConfig can contain plain arrays, IntegrationField instances, and IntegrationCollection instances. Email marketing integrations often return lists, each with its own fields.

Integration Option Sources

If your integration caches selectable provider fields, you can expose them as dynamic option lists for Dropdown, Radio and Checkboxes fields. Declare sources in defineOptionSources() on the integration class.

See Option Sources for storage shapes, builder labels, testing, and examples from Mailchimp and CRM integrations.

use verbb\formie\models\IntegrationCollection;
use verbb\formie\models\IntegrationField;
use verbb\formie\models\IntegrationConfig;

public function fetchConfig(): IntegrationConfig
{
    $config = [];
    $lists = $this->request('GET', 'lists');

    foreach ($lists as $list) {
        $config['lists'][] = new IntegrationCollection([
            'id' => (string)$list['id'],
            'name' => $list['name'],
            'fields' => [
                new IntegrationField([
                    'handle' => 'email',
                    'name' => Craft::t('formie', 'Email'),
                    'required' => true,
                ]),
            ],
        ]);
    }

    return new IntegrationConfig($config);
}

Integration Fields

IntegrationField represents a field from the provider or destination system. Formie uses it to build field-mapping schema and to convert Formie values into the format the provider expects.

AttributeUse
handleThe provider field identifier.
nameThe provider field label.
typeThe value type Formie should convert to.
sourceTypeThe original provider or Craft field type, when useful.
requiredWhether the mapping should be required.
defaultValueA default value for the integration field.
optionsOptions for selectable provider fields.
dataExtra provider-specific metadata.

If type is omitted, Formie treats the field as TYPE_STRING. Available types are TYPE_STRING, TYPE_NUMBER, TYPE_FLOAT, TYPE_BOOLEAN, TYPE_DATE, TYPE_DATETIME, TYPE_DATECLASS, TYPE_ARRAY and TYPE_PHONE.

Sending Payloads

Dispatchable integrations implement DispatchableIntegrationInterface; the CRM, Email Marketing, Automation, Element, Help Desk, Messaging and Miscellaneous bases already implement it. Use execute(IntegrationRunContext $context) for a custom implementation. The context supplies the submission, immutable execution identity, attempt UID, prior results from the same run, and execution-local delivery state. Use getFieldMappingValues() to resolve the configured form mapping, and deliverPayload() when sending to a remote endpoint so Formie can run the before/after payload events. Invoke integrations through IntegrationRunner so authority, policy and retry checks apply. The inherited sendPayload() facade and overridden Formie 3 methods are adapted in compatibility traits.

use verbb\formie\base\Integration;
use verbb\formie\elements\Submission;
use Throwable;

public function execute(\verbb\formie\models\IntegrationRunContext $context): \verbb\formie\models\IntegrationResult
{
    $this->beginRun($context);
    $submission = $context->submission;
    $this->beginPayloadDelivery($submission);
    try {
        $fieldValues = $this->getFieldMappingValues($submission, $this->fieldMapping);

        $payload = [
            'contact' => $fieldValues,
        ];

        $response = $this->deliverPayload($submission, 'contacts', $payload);

        if ($response === false) {
            return $this->resultForPayload(false);
        }
    } catch (Throwable $e) {
        Integration::apiError($this, $e);

        return $this->resultForPayload(false);
    }

    return $this->resultForPayload(true);
}

deliverPayload() sends through request(), then triggers Formie’s payload events. It also enforces the opt-in field configured by getOptInFieldSchema().

API Clients

For non-OAuth integrations, override defineClient() and return a Guzzle client. request() will use this client and decode JSON responses when possible.

use craft\helpers\App;
use GuzzleHttp\Client;

protected function defineClient(): Client
{
    return Craft::createGuzzleClient([
        'base_uri' => 'https://api.provider.test/v1/',
        'headers' => [
            'Authorization' => 'Bearer ' . App::parseEnv($this->apiKey),
        ],
    ]);
}

If the provider has a connection test endpoint, implement fetchConnection().

use verbb\formie\base\Integration;
use Throwable;

public function fetchConnection(): bool
{
    try {
        $response = $this->request('GET', 'me');

        return (bool)($response['id'] ?? false);
    } catch (Throwable $e) {
        Integration::apiError($this, $e);

        return false;
    }
}

Type Guides

The integration type pages cover the details that differ between base classes:

Configuration and Delivery Ownership

Integration owns the global connection and provider behavior. FormIntegration is Formie's immutable binding of enabled state, conditions, opt-in, trigger policy, execution lane and annotated provider-specific settings; extensions do not create a binding subclass. Common policy does not require property annotations. Triggers are stored under settings.integrations.<handle>.trigger; the beta integrationPolicies tree is migrated and removed. The dispatch plan supplies ordered lanes, projected onto each runtime binding. Formie clones the connection for each binding and attempt. Avoid static mutable provider state and clear additional client caches in __clone() after calling the parent implementation. Formie 3 opt-in/condition property access delegates through a compatibility trait; assign an updated conditions array rather than mutating a nested value in place.

IntegrationConfig stores versioned non-secret builder metadata, its fetch time and invalidation key. Metadata is stale after 24 hours but remains available for display until an explicit refresh. Editing a connection invalidates its metadata. Only inert field and collection metadata is hydrated; arbitrary class names are rejected. IntegrationField remains the mapping-field model. Environment references are preferred for credentials. Literal global settings and permitted per-form secrets are encrypted at rest using Formie's Craft-compatible security key; retain that key when restoring data.

Annotate sensitive properties with #[\verbb\formie\attributes\Sensitive], including credentials whose names do not contain “secret” or “token”. Inherited annotations are recognized. This metadata supplies known values for log, delivery-result, support-bundle and configuration-cache redaction, and for encryption of permitted per-form settings. It does not grant form-builder assignment: that still requires #[FormIntegrationSetting]. Native credentials are annotated explicitly; conservative name-based recognition remains for older third-party integrations. Prefer environment references for project-managed credentials and never deliberately return credentials as integration metadata.

Integrations registers and persists connections. IntegrationDispatcher plans lanes and notification timing. IntegrationRunner executes bindings using an immutable IntegrationExecutionContext. Run through these services rather than calling a cached provider directly.

Results and Safe Retries

Return IntegrationResult from providers. succeeded() confirms completion; skipped() records ineligibility or cancellation; rejected() means validation or the provider refused the operation; failed($code, true) permits retry only when the operation is known not to have occurred; unknown() requires reconciliation. An IntegrationBatchResult retains every step result. Returning an arbitrary array or truthy object does not establish success.

The inherited execute(IntegrationRunContext $context) boundary initializes execution-local state and calls the protected executePayload(Submission $submission) hook used by native category integrations. Override that hook for ordinary provider implementations, or implement the explicit capability directly when the context is needed. Public downstream data belongs in IntegrationResult::withOutputs(); do not write output into a provider's mutable context array. Prior results are available as IntegrationRunResults on the run context. Provider responses remain internal evidence rather than the execution result.

Formie records a durable root attempt before executing a provider and a child attempt before each write made through request(), requestPublicEndpoint() or requestWithProviderClient(). Confirmed child responses are encrypted and replayed to dependent steps; their payload hashes must match. Succeeded writes are never repeated, and unknown writes block further runs until an authorized operator confirms the outcome. HTTP errors retain their response contract so providers can handle documented duplicate-record responses. Additional HTTP clients must use requestWithProviderClient() with their fixed configured origin. For an API that writes using GET, explicitly wrap the call with executeDeliveryWrite($method, $url, $options, $send, true).

Custom SDKs that bypass these helpers receive the coarse root guard only. Before publishing a provider, wrap each SDK side effect in a named child operation using DeliveryAttempts::write() and the runner's execution context and root attempt. Never mark an uncertain transport error as retryable. A confirmed response that has expired cannot be used to resume dependent operations automatically.

Payment providers extend base\Payment and use their separate payment state machine.

Queue jobs contain an attempt UID only. Do not attach submission objects, credentials, payloads or debug data to jobs. Append bounded checkpoints through DeliveryAttempts::checkpoint() instead. See Integration Dispatch and Policies for operator recovery and Integration Events for semantic extension events.