Skip to main content

Payments

Core Forms 4.1 introduced payment-required forms, and 4.4 expands them. Pick a provider, set an amount, and the form either redirects submitters to a secure hosted checkout on submit or — with Stripe — collects payment inline on the page. The submission stays in pending_payment status until the provider posts a webhook back confirming the charge — at that point the normal form actions (email notifications, Mailchimp, webhooks, etc.) fire.

Supported providers

Provider API Test mode
Stripe Checkout Sessions (hosted) + Payment Intents (inline Payment Element) Stripe test keys
PayPal Orders v2 Sandbox
Razorpay Payment Links hosted checkout Razorpay test keys
Polar.sh Checkouts Polar sandbox

All four are production REST integrations — no third-party SDKs are bundled, just wp_remote_post calls and webhook signature verification. (Stripe's inline flow loads Stripe.js in the browser for the Payment Element itself; the server side remains plain REST.)

Configuring a provider

  1. Open Core Forms → Settings, scroll to the Payments section.
  2. Choose Test mode while you're wiring things up.
  3. Paste the API credentials for the provider(s) you want to use. Each provider has both _test and _live slots so you can switch modes without re-entering keys. For Razorpay that means test/live Key Id and Key Secret pairs plus a single webhook secret.
  4. Configure a webhook in the provider's dashboard pointing to the URL shown beside each provider:
  • Stripe: https://your-site/wp-json/cf/v1/payments/stripe/webhook
  • PayPal: https://your-site/wp-json/cf/v1/payments/paypal/webhook
  • Razorpay: https://your-site/wp-json/cf/v1/payments/razorpay/webhook
  • Polar.sh: https://your-site/wp-json/cf/v1/payments/polar/webhook

Subscribe to these events:

  • Stripe: checkout.session.completed, checkout.session.expired, charge.refunded, plus payment_intent.succeeded and payment_intent.payment_failed if you use the inline flow
  • PayPal: CHECKOUT.ORDER.APPROVED, PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.DENIED
  • Razorpay: payment_link.paid, payment_link.cancelled, payment.captured, payment.failed, refund.processed
  • Polar.sh: order.created, order.refunded, checkout.updated
  1. Save the webhook signing secret back into Core Forms.

Configuring a form

  1. Edit a form, switch to the Payment tab.
  2. Tick Require payment.
  3. Pick a provider — providers without configured credentials show (not configured) so you can spot mistakes.
  4. Set the Amount. Either a fixed amount in major units (19.99) or a field reference like [total] to charge whatever value the user enters. The data-variable system from 4.0 is fully supported. With Stripe you can also define a product list instead — see below.
  5. Pick a Currency (ISO-4217 — USD, EUR, GBP, INR, JPY, etc.).
  6. Optionally fill in a Description (shown on the checkout page) and a Redirect after payment URL.

Stripe payment flows

Stripe forms choose between two flows on the Payment tab:

  • Hosted Checkout (default) — the browser is redirected to a Stripe Checkout page, pays there, and returns. Identical in shape to the PayPal / Razorpay / Polar flows.
  • Inline Payment Element — no redirect. On submit, Core Forms saves the submission, creates a PaymentIntent server-side, and mounts Stripe's Payment Element directly inside the form with a "Pay securely" button. The visitor never leaves the page.

Either way, only one PaymentIntent (or Checkout Session) exists per submission: the create call carries an idempotency key derived from the submission ID, so retries and double-clicks can't produce duplicate charges. And in both flows the success actions still wait for the signed Stripe webhook — the on-page confirmation is a UX hint, never the source of truth.

Product picker

Instead of a single fixed amount, a Stripe form can offer a choice of products. Under Form → Payment, enter one product per line as Name|price|description:

Starter|19.00|Starter plan access
Pro|49.00|Pro plan access

Core Forms prepends a required "Choose a product" dropdown to the form. The browser submits only the product index — never the price. The server resolves the amount and description from the saved form configuration, so a tampered request can't change what gets charged. The selected product's description also becomes the Stripe charge description (data variables work here too).

Flow

[user submits form]
       │
       ▼
[Forms::process()]   honeypot, nonce, validation, save submission
       │
       ▼
[Payments::create_checkout()]   POST to provider, store payment_id
       │
       ▼
[ JSON response { redirect_url: <provider checkout URL> } ]
       │
       ▼
[browser navigates to provider]   user pays
       │
       ▼
[provider POSTs webhook]
       │
       ▼
[Payments::handle_webhook()]   verify signature, find submission
       │
       ▼
[mark submission as paid]   re-fire cf_form_success + actions loop

Stripe's inline flow follows the same shape, except the middle steps happen on the page: instead of a redirect_url, the JSON response carries the PaymentIntent's client secret, the Payment Element mounts inside the form, and the visitor confirms payment in place. The webhook steps are identical.

The webhook is the source of truth. The user's return URL is treated as a hint only — if a webhook hasn't arrived yet, the submission stays in pending_payment and the actions don't fire. This means a malicious user who tampers with the return URL can't trick the plugin into firing the post-success actions for an unpaid submission.

Hooks

cf_payment_completed

Fires once a webhook confirms a successful payment. Parameters: Submission $submission, Form $form. Use this for payment-only logic that shouldn't run on free submissions.

add_action( 'cf_payment_completed', function ( $submission, $form ) {
    error_log( 'Paid form ' . $form->slug . ' (sub#' . $submission->id . ')' );
}, 10, 2 );

cf_payments_orchestrator

Returns the payments orchestrator. Use it to access registered gateways or to record additional payment attempts.

$payments = apply_filters( 'cf_payments_orchestrator', null );
if ( $payments ) {
    $stripe = $payments->get_gateway( 'stripe' );
}

Payment statuses

Status Meaning
pending_payment Submission saved, user redirected to checkout.
paid Webhook confirmed payment. Form actions fired.
payment_failed Provider returned a failure or the user cancelled.
refunded A subsequent webhook reported a refund.

The submission table also stores payment_provider, payment_id, payment_amount (in minor units, e.g. cents), and payment_currency for reporting and dispute lookups.

Testing locally

WordPress's REST API is accessible via localhost:8080/wp-json/... but provider webhook endpoints need a public URL. Use ngrok or localcan.com to tunnel a public hostname to your dev install, then register the webhook URL in each provider's dashboard.