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, ajax_save() downgrades crafted Free-plan payment/map/calendar fields to text, and public render shows a PRO lock message if an old saved field exists after downgrade.
Stage 1 — Frontend: Initialize Stripe Elements
var stripe = Stripe( formforgeStripe.publishableKey );
var elements = stripe.elements();
var cardElement = elements.create( 'card', {
style: {
base: { fontSize: '16px', color: '#1e293b' },
invalid: { color: '#ef4444' },
}
} );
cardElement.mount( '#formforge-stripe-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.
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—