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
- Open Core Forms → Settings, scroll to the Payments section.
- Choose Test mode while you're wiring things up.
- Paste the API credentials for the provider(s) you want to use. Each
provider has both
_testand_liveslots 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. - 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, pluspayment_intent.succeededandpayment_intent.payment_failedif 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
- Save the webhook signing secret back into Core Forms.
Configuring a form
- Edit a form, switch to the Payment tab.
- Tick Require payment.
- Pick a provider — providers without configured credentials show
(not configured)so you can spot mistakes. - 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. - Pick a Currency (ISO-4217 —
USD,EUR,GBP,INR,JPY, etc.). - 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.