Stripe

Take one-time card payments inside an XPAC Forms form with Stripe Payment Element, using a fixed price or a total calculated from submitted answers.

Stripe is an add-on for Forms. It adds one Stripe Payment Element block to a form, takes a one-time card payment and stores a server-verified payment record beside the submission.

The price can be fixed or calculated by adding numeric values from fields in the form. Pricing rules are signed when the form is rendered, checked against the current published form and recalculated on the server before Stripe may confirm the payment. A visitor cannot choose the amount by changing the browser request.

Complete a test-mode round trip before taking real money

The automated suite exercises the browser runtime and the PHP integration against controlled Stripe-compatible test doubles. This repository has no Stripe credentials, so it has not completed a real Stripe test-mode or live-mode PaymentIntent round trip.

Before launch, use your own test keys and signed test webhook to cover a successful card, a declined card, a card that redirects for authentication, a visitor who does not return after paying and every form integration that fulfils the order. Compare the PaymentIntent id in Stripe with the id stored on the submission. Do not switch on Live mode until those checks pass.

A production payment form needs its webhook

The browser normally finishes a successful submission itself. If the visitor closes the tab, browser storage is unavailable or authentication returns in a different context, the signed Stripe webhook is what lets the site finish the submission from its short-lived server copy.

Configure the webhook endpoint and keep WordPress cron/Action Scheduler running. Without both, a card can be charged without its submission being completed automatically.

Where to find it

Site-wide keys, currency and webhook settings are at XPAC → Integrations → Stripe, also reachable from the plugin's Settings link. Price, address, appearance and redirect behavior belong to the Stripe Payment Element block in an individual form.

Forms has to be active

This add-on does nothing on its own. Without XPAC Forms, it registers no settings tab, block or payment handling and shows an administrator a dependency notice instead.

Production setup

Serve the payment page and WordPress REST API over HTTPS. Make sure the REST API, outgoing HTTPS requests and Action Scheduler are not blocked by a firewall, maintenance plugin or disabled cron runner.

In Stripe, copy the publishable and secret keys for test mode. Paste them at XPAC → Integrations → Stripe and leave Live mode off. A publishable key may reach the browser; a secret key must not.

Set the three-letter Currency used by every Stripe form on this site, such as usd, eur or gbp. Use a currency enabled for the connected account.

Create a Stripe webhook endpoint for https://example.com/wp-json/xpac/v1/form/stripe/webhook. Subscribe at least to payment_intent.succeeded. Also subscribing to payment_intent.payment_failed, payment_intent.processing and payment_intent.canceled gives the site its full reconciliation record. Paste that endpoint's whsec_… signing secret into Webhook signing secret.

Add one Stripe Payment Element block to a published form, set a valid price and test the complete flow. Test a redirect/authentication return and a closed-browser recovery, not only the fastest successful card.

When the test-mode checks pass, save the matching live publishable key, live secret key and the signing secret of the live-mode webhook endpoint. Then enable Live mode and repeat a controlled live check before opening the form to visitors.

SettingDefaultWhat it does
Live modeoffOff selects the test key pair; on selects the live pair. The selected publishable and secret keys must belong to the same Stripe mode or payment is disabled and an admin notice is shown.
Fraud detectiononLoads Stripe.js on every page so Stripe can collect advanced fraud signals while visitors browse. Off loads it only when the payment block renders. Account for the site-wide third-party request in your consent and privacy setup.
CurrencyusdLowercase three-letter currency used by every Stripe form. The PHP filter can override it. Anything outside exactly three ASCII letters fails closed instead of being truncated to another currency.
Webhook signing secretemptyVerifies Stripe's signature at the webhook route. Test and live endpoints normally have different secrets; change this when changing modes.
Test Publishable Key / Test Secret KeyemptyCredentials used while Live mode is off.
Live Publishable Key / Live Secret KeyemptyCredentials used while Live mode is on.

Both keys in the selected pair are required. The block editor receives only a configured boolean and the settings-page URL; secret keys are not placed in the editor payload. Missing or mode-mismatched keys disable payment. A form that still contains the block then fails closed when submitted instead of accepting an unpaid entry. An invalid currency also clears configured, renders a configuration error and is refused before any PaymentIntent create or update call.

Add and price the block

BlockNameLimit
Stripe Payment Elementxp/form-field-stripe-payment-elementOne per form. The editor prevents a second copy, and the browser refuses a legacy form containing more than one. Two different Stripe forms may share a page.

The block's Price panel has two modes:

  • Fixed Price on charges the amount in Price, in the site-wide currency.
  • Fixed Price off adds the submitted numeric values of the selected Price based blocks. Choice fields therefore need numeric option values if they contribute to the total. Non-numeric values contribute nothing.

Password and Confirmation controls are not offered as price sources. A legacy configuration that still names one cannot recover that write-only answer on the server, so it fails the amount checks before card confirmation.

The editor shows the site-provided currency, minimum and numeric step, and the server enforces the same floor before confirmation. Stripe's minimum charge is currency- and settlement-currency-dependent; it is not universally 0.50 for every two-decimal currency or one unit for every zero-decimal currency. Verify the shown minimum against the connected account's settlement currency before launch. An empty or smaller fixed price renders a visible payment error and the form cannot be submitted as an unpaid ordinary form. A variable total below the same floor is refused before confirmation.

Stripe's ISK and UGX API compatibility rule is special: both are sent as two-decimal amounts ending in 00 (5 ISK or 5 UGX becomes API amount 500), but fractional major units are forbidden. Their editor step is therefore 1, and both fixed and variable fractional totals are rejected before confirmation. True zero-decimal currencies such as JPY are not multiplied by 100.

Pricing is not trusted from the page. The server creates a PaymentIntent from a signed pricing rule, retrieves it again before confirmation, binds it to the current form and client secret, validates the submitted non-file fields, and recalculates a variable amount. A stale form, changed pricing rule, unknown intent, wrong site or wrong form is refused before the card can be confirmed.

Other block controls

ControlDefaultWhat it does
Address ElementoffAdds Stripe's complete-address component.
Address Element ModeShippingLabels the address as shipping or billing.
Redirect UrlemptyUsed after a successful Forms submission only when the form itself has no enabled redirect. Empty and unsafe values are ignored.
ThemeStripeStripe, Night or Flat appearance for Stripe's own elements.
LayoutTabsTabs or Accordion for the Payment Element.
Default CollapsedoffStarts the selected layout collapsed when Stripe supports it.
Radio buttons / Spaced Accordion ItemsoffAccordion-only presentation choices.

The form's own matching redirect wins over the block's Redirect Url. If neither contains a safe URL, the visitor stays on the page and sees the form's success message. An empty block redirect does not send Auto Submit visitors to the home page.

What happens when somebody pays

  1. The page asks the site for a visitor-specific PaymentIntent. Cached HTML does not contain a shared client secret and merely carries the signed price rule. The route verifies that the current published form still contains that exact pricing block and reuses a pending intent for the same form attempt. New intent creation is limited to 20 per IP and form per hour by default.
  2. When the visitor submits, Forms validates the visible form in the browser.
  3. Before Stripe may charge, the site retrieves and verifies the intent, validates the current form and price, and stores a bounded recovery copy of the declared non-file answers and the current 20-character Forms attempt id.
  4. Stripe confirms the card in the browser. A direct succeeded result joins the normal Forms POST, including any files. A card that needs authentication can redirect away and back to the same form. The return URL carries the scoped form and attempt ids so the original server-bound attempt can be restored.
  5. The normal submit path asks Stripe for the intent again and accepts only succeeded with the same form, attempt, pricing rule, currency, exact amount and variable-price driver values. It writes the server-reported payment details, creates the entry and queues integrations.
  6. If the paid browser never completes that path, a signed payment_intent.succeeded webhook queues a recovery through Action Scheduler. Browser and worker claims are locked per intent, and a durable hidden intent marker prevents duplicate entries if a process stops between the entry write and reconciliation.

processing is never treated as paid. The form tells the visitor not to pay again and waits for Stripe's succeeded webhook. This release creates card-only PaymentIntents; delayed Dashboard payment methods are not enabled by the add-on.

Messages after a redirect

Stripe resultVisitor behavior
succeeded, browser snapshot presentThe saved answers are submitted through the normal Forms path.
succeeded, no readable browser snapshotThe visitor is told the payment was received and not to pay again; webhook recovery uses the server copy.
processingThe visitor is told payment is pending and not to pay again; the form is gated while recovery waits for Stripe.
requires_payment_method, requires_confirmation or requires_actionThe visitor sees an incomplete-payment message and the same intent is remounted for another attempt.
canceledThe visitor sees an incomplete-payment message and a new intent is created for a later attempt.
Retrieval, unknown-status or setup failureThe form shows an actionable error and fails closed. If status cannot be checked after a possible charge, it specifically tells the visitor not to pay again yet and to contact the owner.

A decline returned directly by confirmPayment shows Stripe's escaped decline or validation message and keeps the form available for correction. REST calls have a 30-second deadline; the payment callback is bounded at ten minutes, so an extension or network failure cannot leave the submit controls busy forever. After the matching form copies a redirect result, Stripe's five return parameters are removed from the current history entry while unrelated query parameters and the fragment remain. Malformed or unowned marked returns fail closed and are cleaned as well, so a client secret is not left in the visible URL.

Recovery, privacy and file uploads

The pre-confirmation recovery payload is a WordPress transient keyed by a hash of the PaymentIntent id. It contains the form id, declared delivery-safe answers and claim state. It deliberately excludes:

  • File Upload bytes and file answers;
  • password and confirmation fields, which remain write-only;
  • the Stripe field itself, unknown fields and reserved internal keys.

The default lifetime is one day. xpac_forms_stripe_payload_ttl may reduce it or raise a smaller value, but the result is clamped to 300–86,400 seconds. Updating a claim does not extend the original deadline. A reconciled payment removes the transient.

Preparation also refuses a recovery payload over 200 fields or 256 KiB of JSON, one nested more than five levels or one containing over 2,000 nodes. The refusal happens before card confirmation and no oversized transient is stored.

That copy can contain names, email addresses and every other ordinary submitted answer, so include it in the site's privacy inventory and backup/host review. It is not covered by the Forms submission-retention setting because it exists before there is a submission.

The browser also makes a best-effort localStorage snapshot scoped to form id and PaymentIntent id. It captures the exact prepared POST data rather than rereading a possibly changed DOM after confirmation. It can contain sensitive answers and base64 file content, is readable by same-origin scripts and can be denied, cleared or quota-limited. It is an optimization for the returning browser, not the durable recovery source.

Webhook recovery cannot recreate upload bytes

A normal direct payment submission includes its File Upload fields. A redirect can also restore files when the same browser still has its snapshot. The server recovery worker cannot reconstruct file bytes from JSON.

On an optional File Upload, automatic recovery completes the other answers without that file. On a required File Upload, normal validation refuses the recovered submission, retries are bounded and the successful payment remains unreconciled for manual handling. Do not use a required upload on a payment form unless that operational outcome is acceptable and monitored.

The worker retries a failed recovery at most three times. It runs the normal Submission pipeline and disables only the browser-bound reCAPTCHA and Cloudflare captcha checks; availability, uniqueness, validation, entry writing and integrations still apply. A form change or downstream failure can therefore leave a paid intent for manual review instead of manufacturing an invalid entry.

Reconcile payments and submissions

A payment form adds Stripe Payment Info to its submissions. The stored record comes from Stripe's API, not from a visitor-authored JSON blob, and includes the intent id, succeeded status, paid and expected amounts, currency, mode, time, payment method, verification flag and amount_matches result.

A succeeded intent whose amount, amount received, currency, form, attempt, pricing rule or price-driving answers differ is refused before the claim, entry and integrations. It remains visible as an unreconciled payment for manual handling; the add-on does not manufacture a submission whose paid amount is wrong.

The webhook keeps the most recent 500 tracked intent records in the xpac_stripe_webhook_payments option and preserves whether each is reconciled. Succeeded intents without a matching completed submission raise an admin notice. The notice is a backstop, not an order-management screen: compare its count and the stored intent ids against the Stripe Dashboard and your fulfilment system.

At launch and after changing keys, currency, form pricing, webhook configuration or caching infrastructure, check all of the following:

  • a successful test payment creates exactly one submission and one set of integrations;
  • a decline, missing payment element or unverifiable status creates no unpaid submission;
  • an authentication redirect returns to the correct form when two payment forms share a page;
  • closing the browser after payment lets the signed webhook create the entry;
  • no unreconciled-payment notice remains without an understood Stripe-side payment;
  • the WordPress Scheduled Actions screen has no stuck xpac-stripe recovery jobs.

Supported scope and compatibility

This add-on supports one-time, card-only PaymentIntents. It does not provide subscriptions, saved payment methods, coupons, Stripe Checkout, Tax, Billing Portal, Connect, an admin refund workflow or a receipts UI. Card-wallet availability is not a supported contract and has not been verified end to end. Refunds and authoritative payment/fee records remain in Stripe.

Do not combine the Stripe Payment Element with Forms Auto Submit. Auto Submit writes from a signed GET without an interactive PaymentIntent and the payment gate correctly refuses it. Use an ordinary interactive form for payments.

For developers

Routes

All three routes are public because a signed-out visitor must be able to pay. The first two authenticate the server-rendered price and PaymentIntent rather than a WordPress user; the webhook authenticates Stripe's raw-body signature.

Method and routePurpose
POST /wp-json/xpac/v1/form/stripe/create-payment-intentAccepts signed pricing attributes and creates a visitor-specific intent. It accepts no client amount.
POST /wp-json/xpac/v1/form/stripe/update-payment-intentAccepts id, clientSecret, formId, instanceId and fields; verifies and binds the intent and attempt, validates safe fields, recalculates variable pricing and persists recovery state before confirmation.
POST /wp-json/xpac/v1/form/stripe/webhookVerifies Stripe-Signature, records site-owned intent events and queues recovery after payment_intent.succeeded.

The create and update calls send the current Forms nonce when one is available, but route safety does not rely on a cache-sensitive nonce. The price signature, client-secret comparison, site marker, current form, pricing rule and Stripe API state are the authority checks.

Settings, storage and hooks

Settings are fields in the shared xpac option: stripe_environment, stripe_fraud_detection, stripe_currency, stripe_webhook_secret, stripe_test_publishable_key, stripe_test_secret_key, stripe_live_publishable_key and stripe_live_secret_key. Per-form settings are block attributes in post content.

The package fires six documented extension hooks:

  • xpac_forms_stripe_allow_legacy_intent is a disabled-by-default, one-day migration escape hatch for an already-succeeded intent created before the new form/instance metadata existed. Enabling it temporarily relaxes those binding protections; do not treat it as normal configuration;
  • xpac_forms_stripe_currency filters the three-letter currency;
  • xpac_forms_stripe_client replaces the StripeClient-compatible object for an installation-wide proxy or controlled integration test;
  • xpac_forms_stripe_payload_ttl filters the bounded recovery lifetime;
  • xpac_forms_stripe_create_rate_limit filters the hourly per-IP/per-form intent creation limit; the result is clamped to 1–100;
  • xpac_forms_stripe_webhook runs after a valid, site-owned webhook event has been verified.

The generated Stripe hook reference has the signatures and call sites. xpac_stripe_recover_submission is the internal Action Scheduler hook, in the xpac-stripe group.

The standalone plugin requires PHP 8.0 or newer and constrains stripe/stripe-php to compatible releases in major 20 (^20.0). A fresh extraction is therefore protected from an unreviewed future major, but it is not promised to install exactly 20.0.0.

On this page