The complete payment flow validates the form before card capture. This prevents a customer from being charged for a submission that would later fail reCAPTCHA, required-field, or server validation. Payment is a PRO field: the admin palette renders it locked on Free, REST/AJAX save paths normalize crafted Free-plan payment/map/calendar/calculation fields, public render filters unavailable saved fields, and the submission handler skips unavailable field types after downgrade.
Stage 1 — Frontend: Initialize Stripe Elements
var stripe = Stripe( window.formforgeStripeKey );
var elements = stripe.elements();
var cardElement = elements.create( 'card', {
style: {
base: { fontSize: '16px', color: '#1e293b' },
invalid: { color: '#ef4444' },
}
} );
// fieldName is the data-field-name of the .formforge-payment-field container
cardElement.mount( '#' + fieldName + '_card_element' );Stage 2 — AJAX: Validate Submission First
var validationData = new FormData( formElement );
validationData.append( 'action', 'formforge_validate_submission' );
fetch( formforgeFront.ajaxUrl, {
method: 'POST',
body: validationData
} ).then( function( response ) {
return response.json();
} ).then( function( response ) {
if ( ! response.success ) {
showError( response.data.message );
return;
}
// Continue to PaymentIntent creation only after validation passes.
} );Validation errors carry routing metadata when possible:
| Response key | Meaning |
|---|---|
field_id | Field-level validation error; Conversational Mode can jump to that field’s step. |
payment_field_id | Payment-specific error; Conversational Mode can jump to the Stripe field and show the error near the Card Element. |
| neither key | Global submit/server/reCAPTCHA error; keep the visitor on the current step and show a global error. |
Stage 3 — AJAX: Create PaymentIntent
jQuery.post( formforgeFront.ajaxUrl, {
action: 'formforge_create_payment_intent',
nonce: formforgeFront.nonce,
amount: 2500,
currency: 'usd',
form_id: 1
}, function( response ) {
if ( response.success ) {
var clientSecret = response.data.client_secret;
}
} );Current frontend code sends a FormData payload built from the form instead of only the scalar amount/currency fields. This lets the server see the posted Calculation source for dynamic payments and reject a missing, stale, negative, below-minimum, or mismatched dynamic amount before Stripe sees a PaymentIntent request.
Stage 4 — Frontend: Confirm Payment
stripe.confirmCardPayment( clientSecret, {
payment_method: { card: cardElement }
} ).then( function( result ) {
if ( result.error ) {
showError( result.error.message );
} else if ( result.paymentIntent.status === 'succeeded' ) {
// Write only to hidden inputs inside the payment field wrapper.
// Do not use a broad document.getElementById(fieldName) target:
// duplicate/colliding IDs can point at a visible Email input.
paymentField.querySelector( 'input[type="hidden"]#field_payment_payment_intent' )
.value = result.paymentIntent.id;
document.querySelector( '.formforge-form' ).submit();
}
} );The browser runtime scopes the PaymentIntent write to hidden inputs inside .formforge-payment-field. This keeps optional Email fields empty when the visitor did not provide an email, even if legacy/AI-created markup has a colliding DOM id. Conversational mode sets a visible loading state on .ff-conv-submit-btn before it clicks the hidden submit button, then clears that state on validation or server errors.
The PaymentIntent id the browser posts is not taken on trust. When the form is submitted, the Pro add-on asks Stripe for that payment with the site’s own secret key, and the submission is saved as paid only when Stripe says the payment is paid, made for this form and this payment field, in the currency of the field, for the amount of the form (one smallest currency unit of difference is allowed; a calculated amount whose formula the server cannot repeat is held only to the total the browser reported, and the form editor tells you so when you save the form), and not already attached to another submission. A payment made in Stripe’s test mode is not accepted on a site in live mode. A payment that fails one of these checks is refused: the submission is not saved and the visitor reads “Payment could not be verified. If you were charged, please contact us before trying again.” Two cases are saved: if Stripe cannot be asked, the submission shows Payment not confirmed, and if the payment is for another amount, it shows Paid amount differs. Not paid is set only later, when Stripe is asked again about a saved submission and holds no completed payment for it.
Stage 5 — Server: Stripe API Call
POST https://api.stripe.com/v1/payment_intents
Authorization: Bearer sk_test_...
Content-Type: application/x-www-form-urlencoded
amount=2500¤cy=usd&automatic_payment_methods[enabled]=true&metadata[form_id]=1—