Custom Integration

Payment Integration

Payment integrations extend Payment. They are used by Payment fields to collect payment details, authorize or tokenize payment information in the browser, then complete the payment on the server while Formie processes the submission.

Payment integrations are field-driven. A user creates the payment integration in Formie’s plugin settings, adds a Payment field to a form, then chooses that provider in the Payment field’s settings.

The browser and server pieces work together:

  1. The user creates and configures the payment provider in Formie’s plugin settings.
  2. The user adds a Payment field to a form and selects that provider.
  3. renderFieldHtml() renders the provider’s Payment field template.
  4. getBrowserModule() registers any browser module the provider needs.
  5. The browser module mounts the provider UI and writes the token, payment id or authorisation value into hidden Payment field inputs.
  6. Before submission, the module can block the request if the payment UI has not produced the required value.
  7. The inherited processPayment() saves the payment attempt and acquires its lock, then calls your protected executePayment() method, which returns a typed PaymentDecision.
  8. If the provider uses redirects, challenges or webhooks, the integration handles the follow-up provider response and updates the payment record.

When a saved payment completes through reconciliation or a verified webhook, Formie compares its amount and currency with the current submission before completing the form. getPaymentAmount($submission) must return the amount in the major currency units stored on the payment record. Its default implementation preserves older getAmount() overrides. New providers should treat getPaymentAmount() as the major-unit contract and convert explicitly at the API boundary. Stripe and Opayo expose getProviderAmount() for their API units; their deprecated getAmount() methods retain the old unit semantics. getCurrency($submission) resolves the configured fixed or dynamic currency, falling back to the field’s currency setting.

PHP Integration

use Craft;
use craft\helpers\App;
use craft\helpers\Json;
use verbb\formie\Formie;
use verbb\formie\base\Integration;
use verbb\formie\base\Payment;
use verbb\formie\elements\Submission;
use verbb\formie\models\Payment as PaymentModel;
use verbb\formie\models\PaymentDecision;
use verbb\formie\models\BrowserModule;
use verbb\formie\models\BrowserModuleContext;
use Throwable;

class ExamplePayment extends Payment
{
    public ?string $publishableKey = null;
    public ?string $secretKey = null;

    public static function displayName(): string
    {
        return Craft::t('formie', 'Example Payment');
    }

    public function hasValidSettings(): bool
    {
        return App::parseEnv($this->publishableKey) && App::parseEnv($this->secretKey);
    }

    public function getBrowserModule(BrowserModuleContext $context): ?BrowserModule
    {
        if (!$this->hasValidSettings()) {
            return null;
        }

        $this->setField($context->field);

        return new BrowserModule([
            'moduleId' => 'example:example-payment',
            'config' => [
                'publishableKey' => App::parseEnv($this->publishableKey),
                'amountType' => $this->getFieldSetting('amountType'),
                'amountFixed' => $this->getFieldSetting('amountFixed'),
                'amountVariable' => $this->normalizeBrowserFieldReference($this->getFieldSetting('amountVariable')),
                'currency' => $this->getFieldSetting('currency'),
                'requiredInputSuffixes' => ['examplePaymentToken'],
                'waitForValueMs' => 2500,
            ],
        ]);
    }

    protected function executePayment(Submission $submission): PaymentDecision
    {
        $token = $this->getPaymentFieldPayload($submission)->string('examplePaymentToken');
        if (!$token) {
            return PaymentDecision::failed('Payment details are incomplete.', $this->handle);
        }

        return \verbb\formie\helpers\PaymentAttempt::run(
            $this,
            $submission,
            $this->getPaymentAmount($submission),
            $this->getCurrency($submission),
            ['secretKey' => $this->secretKey],
            function (PaymentModel $payment, \verbb\formie\helpers\PaymentAttempt $attempt) use ($token, $submission): PaymentDecision {
                $money = \verbb\formie\models\PaymentMoney::fromDecimal($payment->amount, $payment->currency);
                $payload = ['token' => $token, 'amount' => $money->minor, 'currency' => $money->currency];
                $response = $attempt->request(
                    $payload,
                    fn() => $this->request('POST', 'https://payments.example.test/v1/payments', ['json' => $payload]),
                    fn(array $result) => $result['id'] ?? null,
                );
                // Verify the provider's identity, status, amount and currency here.
                // This example API returns integer minor-unit strings.
                if (($response['status'] ?? null) !== 'paid'
                    || !\verbb\formie\models\PaymentMoney::fromMinor($response['amount'], $response['currency'])->equals($money)) {
                    return PaymentDecision::unknown('Payment needs reconciliation.', $this->handle);
                }
                $payment->reference = $response['id'];
                $payment->response = $response;
                $payment->status = PaymentModel::STATUS_SUCCEEDED;
                Formie::$plugin->getPayments()->savePayment($payment);
                return PaymentDecision::succeeded($this->handle, $payment->reference);
            },
        );
    }
}

The example uses a provider without documented idempotent retries: PaymentAttempt records that the request was sent, so a lost response becomes unknown and cannot blindly issue another charge. For an API with a documented idempotency window, use the existing DeliveryAttempt resource claim with a stable key, bounded retry window and authenticated lookup. Never derive a fresh key just because the response timed out.

The base payment integration calls the before/after payment events. Implement executePayment() without calling those hooks yourself. The before event runs before creating a payment intent. The after event can block submission completion, but it cannot change a failed or uncertain payment into a success, undo a charge, or cause a successful payment to be charged again. It also runs when a stored successful payment is used to retry completion.

Money And Outcomes

Payment.amountMinor stores the amount in minor currency units as an integer string, avoiding PHP integer overflow. The currency determines how many decimal places it uses. For example, PaymentMoney::fromDecimal('25.01', 'USD') gives minor === '2501'; decimal() returns the major-unit amount, and fromMinor() converts in the other direction. Payment.amount also exposes the major-unit decimal string, but comparisons use amountMinor.

Formie rejects excess nonzero fractional digits, scientific notation and invalid currencies. For provider APIs that require an integer, use integer() to check for overflow. The server checks the amount independently of any preview shown in the browser.

Payment::getState() returns the PaymentStatus enum; status returns its string value. A payment waiting for browser interaction has status requiresAction, while a successfully settled payment has status succeeded. A redirect is an action the browser must perform, rather than a payment status.

Return a PaymentDecision from your provider. Its status is a PaymentDecisionStatus enum, which toArray() converts to a string. Use succeeded(), requiresAction(), pending(), failed(), cancelled() or unknown() to describe the result. For requiresAction(), supply an immutable action built with PaymentAction::redirect(), confirm(), challenge() or initialize(). Its PaymentResumeMode tells the browser what to do next: RESUBMIT, RETURN or POLL.

REST, GraphQL and server-rendered responses all expose a payment object containing status, provider, reference, message and action. Use it to decide whether to show a loading state or redirect. Unknown outcomes leave the submission incomplete; cancellation returns payment.status: cancelled. A timeout must not be reported as a decline, because the provider may already have charged the customer.

Account Identity

Override getPaymentAccountIdentity() to return the provider's stable merchant/account identifier and getPaymentEnvironment() when the provider distinguishes live and test accounts differently. Formie hashes that identity with the connection and environment through getPaymentAccountFingerprint(). Moneris, Opayo, PayWay, Bpoint and Square use their configured account/store/location identifiers. Stripe resolves the account during connection verification and caches it by a one-way credential key; reverify the connection after rotating its key. Providers without a verified stable identifier use a conservative credential fingerprint and cannot transparently rotate credentials on unresolved attempts. Raw credentials are never persisted as the account identity.

Payment and subscription provider-reference lookups are scoped by integration and account fingerprint. Unscoped legacy lookups return no result when ambiguous. Financial account snapshots cannot change during an ordinary save. Historical rows without provable ownership remain unbound after migration: a trusted operator can run php craft formie/payments/verify-account payment <id> "verification note" --confirmed (or subscription) only after checking the original resource in the original merchant account and configuring that same account. This audited operation cannot replace an existing fingerprint, change money or settle an outcome.

Transactions And Replay

Formie first commits the incomplete submission and payment attempt to the database, then contacts the provider outside that transaction. It saves a successful provider response separately while the payment remains in the processing state. A second transaction saves the payment result, the receipt linking it to the submission update, and the decision to complete the submission together.

If that second transaction rolls back, authorised payment replay can use the saved response without charging again. Recurring invoice payments are separate records and cannot satisfy the payment required for the initial submission.

Replay uses the existing PAYMENT_REPLAY submission command, authority, version checks and operation receipts. It does not repopulate or revalidate the original form. It does verify the saved payment against the current amount and currency requirement. A successful charge may therefore remain associated with an incomplete submission that needs operator review.

Endpoint Contracts

EndpointAuthority and behavior
payment-webhooks/process-webhookStable integration UID plus adapter-authenticated PaymentWebhookCommand; CSRF exempt; request body limited to 1 MiB; durable receipt before asynchronous handling or acknowledgement.
payment-return/indexpayment.return capability resolved by PaymentReturnCommand; reconciles when due and redirects to the status page. Browser parameters cannot confirm payment.
payment-status/status, payment-status/poll-statusRead-only payment.status capability. Polling reconciles only when the stored cadence is due; the browser cannot force a provider request. Both endpoints are rate limited.
payment-sessions/initializePaymentSessionCommand; POST with CSRF and an expiring form/site/field/integration session capability. Currently used by Opayo.
payment-challenges/completeOpayo's cross-origin POST; challenge-only payment capability. A sent challenge is not posted again after an uncertain response.
payment-subscriptions/cancelCancelSubscriptionCommand; cancellation-only capability, GET confirmation, CSRF-protected POST and authority rechecked under the cancellation lock.

Webhook Evidence

Payment adapters authenticate and normalize provider input; Formie owns receipt storage, deduplication, locking, retries and diagnostics. Implement verifyWebhook(PaymentWebhookCommand $request): VerifiedWebhookBatch to verify the exact request bytes before returning one or more normalized VerifiedWebhook events. Give every event the provider's stable event ID. Use getWebhookAccountFingerprint() with the provider environment and account ID where one is available. Otherwise pass a one-way fingerprint derived from the account credential, never the raw secret, so reconnecting the integration to another account creates a separate event namespace. Include only headers needed as verification evidence.

Implement handleWebhook(PaymentWebhookReceipt $receipt): void to update payments and subscriptions from a verified event. It receives the saved, normalised payload rather than the public request. Before updating a record, confirm that the provider resource belongs to its saved integration and account. Use Formie's payment and subscription services: they check ownership under a lock, preserve the original amount and prevent terminal states (such as a successful payment or cancelled subscription) from being reversed by later updates.

Invalid authentication or malformed payloads return a non-2xx response without creating a verified receipt. Once the complete verified batch has been saved, Formie acknowledges the provider and processes each receipt through the queue. Duplicate receipts are acknowledged without repeating completed actions. If handling later fails, Formie schedules a limited number of delayed retries and records a diagnostic; the provider does not need to resend the event to recover from a queue failure.

Payment success and terminal subscription states cannot be reversed by later events. Subscription updates read the provider's current state rather than applying an older event's snapshot.

The original request body, normalised payloads and required headers are encrypted with the Formie security key. Keep that key with your database backups. The ordinary support view shows only limited event identifiers and resource references, with a marker where private content has been removed. Raw evidence is available only to a trusted console operator or a user with integration-management permission; it is excluded from the ordinary console receipt listing. See Console Commands for inspection and recovery.

Financial history and webhook receipts are retained indefinitely; normal cleanup does not remove them. Each receipt's history keeps the latest 100 state transitions.

GoCardless deduplicates per event even when a retry batches it with other events; the first authenticated complete request body is retained as the verification evidence. Mollie uses a payment-specific secret plus an authenticated server lookup, and records each provider status observation. Keep provider secrets and capability URLs out of public logs.

Subscriptions

Use prepareSubscription() before remote creation. The setup or mandate is saved as a separate operation and does not represent money collected; call recordRecurring() for every actual invoice or charge. Payment::getIsMonetary() and getOperation() distinguish subscriptionSetup from recurringCharge. A trial or successful mandate must not be presented as money collected.

Translate provider data into an immutable SubscriptionSnapshot and pass it to Subscriptions::applySnapshot(). The SubscriptionStatus values are pending, trialing, active, pastDue, paused, cancelled, failed, completed and unknown. A subscription scheduled for cancellation stays active with cancelAt and cancellationMode set. Include the provider's update time and stable event ID where available so Formie can reject stale updates and duplicates and prevent a subscription in a terminal state, such as cancelled or completed, from returning to an earlier state.

providerData contains selected operational identifiers and flags. Full provider observations are encrypted separately in subscription diagnostics and excluded from model serialisation. They follow deliveryEvidenceRetentionDays; pending, unknown, failed and past-due evidence is retained so an operator can resolve the outcome. Evidence above 2 MiB is explicitly marked as truncated.

currentPeriodEndsAt is the paid-through period boundary, nextPaymentAt is a separately known upcoming charge date, providerUpdatedAt orders provider observations, and lastSyncedAt records when Formie applied one. Stripe does not infer a charge date from period end; GoCardless can supply an actual upcoming charge date. Null means unknown, not immediately due.

Override cancelSubscriptionSnapshot() and declare the SubscriptionCancellationMode values your provider supports. Generate customer links with Subscription::getCancelUrl(), optionally passing a supported mode. The link only authorises that mode: a period-end cancellation link cannot be changed to cancel immediately. Formie saves the cancellation request before contacting the provider, so a lost response becomes unknown and is not blindly retried. Stripe defaults to atPeriodEnd; GoCardless supports immediate. See the payment upgrade guidance for providers using older cancellation methods.

SubscriptionPlan is an optional reference to a plan in a particular provider account. It includes the minor-unit amount, currency, billing interval/count and provider state. A handle is a label, not a globally unique identifier. Subscriptions save their own commercial terms, so later changes to the plan or form do not rewrite an existing subscription's terms.

deleteSubscription() archives the subscription. A payment integration cannot be disconnected or deleted while it owns a manageable subscription. Financial records in terminal states and their saved account details remain available for support and reconciliation.

Payment Field Template

Payment integrations render their field template from integrations/payments/{handle}/field. The exact markup depends on the provider, but most provider templates include a hidden base input for field errors, one or more hidden transport inputs, and a placeholder for the provider UI.

{{ fieldtag('fieldInput') }}

<input type="hidden" name="{{ field.getHtmlName() }}">
<input type="hidden" name="{{ field.getHtmlName('examplePaymentToken') }}">

<div data-example-payment-card></div>

The hidden input suffix should match the value written by the browser module and the value read in executePayment().

Browser Modules

Payment provider modules should usually use definePaymentModule() from @verbb/formie-browser. It gives you shared services for updating hidden payment inputs, showing payment errors, resolving dynamic amounts and currencies, and participating in Formie’s browser payment stage.

import { definePaymentModule } from '@verbb/formie-browser';

type ExamplePaymentApi = {
  mountCard: (container: HTMLElement, options: Record<string, unknown>) => {
    tokenize: () => Promise<{ ok: boolean; token?: string; message?: string }>;
    destroy: () => void;
  };
};

type ExamplePaymentOptions = {
  publishableKey?: string | null;
  amountType?: string | null;
  amountFixed?: string | number | null;
  amountVariable?: string | null;
  currency?: string | null;
};

declare global {
  interface Window {
    ExamplePayment?: ExamplePaymentApi;
  }
}

async function loadExamplePaymentApi(): Promise<ExamplePaymentApi> {
  if (window.ExamplePayment) {
    return window.ExamplePayment;
  }

  await new Promise<void>((resolve, reject) => {
    const script = document.createElement('script');

    script.src = 'https://payments.example.test/sdk.js';
    script.async = true;
    script.onload = () => resolve();
    script.onerror = () => reject(new Error('Unable to load Example Payment.'));
    document.head.appendChild(script);
  });

  if (!window.ExamplePayment) {
    throw new Error('Example Payment API was not available after loading.');
  }

  return window.ExamplePayment;
}

export default definePaymentModule<ExamplePaymentOptions, ExamplePaymentApi, ReturnType<ExamplePaymentApi['mountCard']>>({
  moduleId: 'example:example-payment',
  defaultRequiredInputSuffixes: ['examplePaymentToken'],

  load: () => {
    return loadExamplePaymentApi();
  },

  mount: ({ api, field, services, provider }) => {
    const container = field.querySelector<HTMLElement>('[data-example-payment-card]');
    const amount = services.resolveAmount({
      type: provider.amountType,
      fixed: provider.amountFixed,
      variable: provider.amountVariable,
    });

    if (!container || !provider.publishableKey) {
      throw new Error('Example Payment is not configured.');
    }

    if (!amount.ok) {
      services.addError(amount.error);
    }

    return api.mountCard(container, {
      publishableKey: provider.publishableKey,
      amount: amount.ok ? amount.value : null,
      currency: provider.currency || 'AUD',
    });
  },

  onBeforePayment: async ({ widget, services }) => {
    if (!widget) {
      services.addError('Payment form is not ready.');

      return false;
    }

    const result = await widget.tokenize();

    if (result.ok && result.token) {
      services.updateInputs('examplePaymentToken', result.token);

      return true;
    }

    services.addError(result.message || 'Payment could not be authorized.');

    return false;
  },

  onAfterSubmit: async ({ services }) => {
    services.updateInputs('examplePaymentToken', '');
  },

  unmount: ({ widget }) => {
    widget.destroy();
  },
});

onBeforePayment() runs before Formie dispatches the submission. Use it when the provider needs to tokenize card details, confirm a wallet payment or produce an id that the server must receive. Return false to stop submission and keep the user on the form.

Methods

Payment integrations commonly use these methods:

MethodUse
renderFieldHtml()Renders the provider’s Payment field template.
getBrowserModule()Registers browser behaviour for the Payment field.
executePayment()Provider implementation called by processPayment() after it saves the attempt and acquires the lock.
getPaymentAmount()Resolves the amount in major currency units. Override it to customise the amount, calling its parent to extend the field-based calculation. Provider API conversions must be explicit.
getCurrency()Resolves the configured fixed or dynamic currency from the Payment field.
getPaymentFieldPayload()Reads provider-specific hidden input values from the submitted Payment field.
addFieldError()Adds an error to the Payment field when processing fails.
supportsWebhooks()Whether the provider can send webhook updates.
verifyWebhook()Authenticates exact request evidence and returns normalized provider events without changing payment state.
handleWebhook()Applies one durable normalized receipt under Formie's processing lock.
refreshPayment()Public payment-refresh action; calls the protected refreshPaymentState() hook and adapts legacy getTransaction() overrides. Never trusts browser return fields.
requiresAjaxSubmission()Whether forms using this provider must submit with Ajax.
getWebhookUrl()Returns the stable integration-UID webhook URL Formie exposes for the payment provider.

Refresh and Builder Hooks

Implement refreshPaymentState(Payment $payment): void to fetch authenticated provider state and persist verified payment updates. Here Payment is verbb\formie\models\Payment, not the integration base. The service calls refreshPayment() during reconciliation. The old getTransaction() and getTransactionStatus() entry points remain callable; provider-specific status adapters retain their existing return handling and error behaviour.

Payment integrations may implement defineFormBuilderGeneralSchema(), defineFormBuilderSettingsSchema() and defineFormBuilderAppearanceSchema() to configure the Payment field. Their defaults return empty arrays, as does getPaymentSubFields($field). These established hooks allow older untyped return signatures.

getGqlPaymentInputFieldKeys($field) supplies payment input keys to GraphQL. Override the protected getOptionalGqlPaymentInputFieldKeys() hook for additional optional inputs. The older Graphql spellings remain compatible. Stripe’s createPaymentReturnUrl($submission) prepares a payment attempt and issues a return token; its old getReturnUrl() name remains an adapter. Other providers’ URL-only helpers do not imply attempt creation.