Skip to content

Payments

Take a Stripe payment before a submission is accepted, with your own Stripe keys and a fixed or answer-based price.

A form can take a payment before it accepts a submission, for bookings, orders or paid sign-ups. quickforms uses your own Stripe account: payments go straight to you, and quickforms checks with Stripe that each one went through before storing the submission.

The flow takes three steps:

  1. POST /f/{form}/payment with the answers and any Turnstile or Altcha token. quickforms checks them, saves them against a new Stripe PaymentIntent for the price they add up to, and answers with its details.
  2. Confirm the PaymentIntent in the browser with Stripe.js, so the card details go straight to Stripe.
  3. Submit the form to POST /f/{form} with the PaymentIntent ID as _payment_intent. quickforms checks the payment with Stripe and stores the submission from the saved answers.

Because the answers are saved in step 1, step 3 is not the only way to finish. If the tab closes or the network drops after paying, Stripe’s webhook or the reconciler stores the submission instead. A payment only ever makes one submission, however many of these arrive.

The hosted page and the embed widget do all three steps for you. They show the price as soon as the answers set it, put Stripe’s Payment Element above the submit button so card errors show on the form, and upload any files before the payment so the saved answers include them. Use the steps below when you build your own form.

Set it up

Payments are set per form. You need a publishable key and a secret key from your Stripe account. Test mode keys work too.

Switch payments onbash
curl -X PATCH https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"settings": {"payment": {"enabled": true, "publishable_key": "pk_test_...", "currency": "gbp", "amount": 2500, "description": "Booking for {{name}}"}}, "stripe_secret": "sk_test_..."}'

Your keys

Key Where Notes
settings.payment.publishable_key Form settings Must start with pk_. Public: returned in the form and in its public schema.
stripe_secret Top-level form attribute A secret or restricted key: sk_test_, sk_live_, rk_test_ or rk_live_ followed by letters and numbers. Write-only. Encrypted at rest and never returned.
  • The form’s stripe_secret_set is true once a secret key is saved.
  • Sending a blank stripe_secret keeps the saved one.
  • A stripe_secret in any other format gets a 422.
  • quickforms sets up a webhook endpoint in your Stripe account with this key.

Payment settings

Key Default Notes
settings.payment.enabled false Switches the payment step on.
settings.payment.currency gbp A three-letter currency code.
settings.payment.amount null A fixed price in the currency’s smallest unit (pence, cents), from 1 to 99999999.
settings.payment.amount_field null A field whose answer sets the price. See prices from an answer.
settings.payment.prices {} A map of answers to prices in the smallest unit, up to 100 entries. Sending it replaces the whole map.
settings.payment.description {{_form_name}} The PaymentIntent’s description, up to 300 characters. Can use {{field}} placeholders with any field name, and {{_form_name}}.

Switching payments on needs:

  • both keys, or saving gets a 422 on settings.payment.publishable_key with “Add your Stripe publishable key and secret key to take payments.”
  • a fixed amount or an amount_field, or saving gets a 422 on settings.payment.amount with “Set a fixed amount, or a field whose answer sets the price.”

Prices

A fixed price

Set amount and leave amount_field empty. Every submission pays the same, for example 2500 for £25.00 in gbp.

Prices from an answer

Set amount_field to the field that decides the price. When it is set, amount is not used. The price is worked out from the answer:

  1. If prices has any entries, the answer must be one of its keys and that price is used.
  2. If prices is empty and the field is a number field, the answer is the price in whole units and is multiplied by 100. An answer of 12.5 is 1250.
  3. Otherwise the answer has no price, and the request gets a 422 on that field with “Choose an option with a price.”
settings.paymentjson
{
  "enabled": true,
  "publishable_key": "pk_test_...",
  "currency": "gbp",
  "amount": null,
  "amount_field": "service",
  "prices": { "bike-fit": 5000, "frame-sizing": 3000 }
}

A price below 1 gets a 422 on _payment with “This form has no price set up.”

The payment flow

Public schema

When payments are on, the form’s public schema includes everything a page needs to show the price and start a payment. It is null when payments are off and for sandbox previews.

schema.jsonjson
{
  "payment": {
    "url": "https://YOUR-QUICKFORMS-HOST/f/FORM_ID/payment",
    "publishable_key": "pk_test_...",
    "currency": "gbp",
    "amount": null,
    "amount_field": "service",
    "prices": { "bike-fit": 5000, "frame-sizing": 3000 }
  }
}

1. Create a PaymentIntent

Post the answers to POST /f/{form}/payment, as JSON or form data. They are checked against the form’s validation rules first, so field errors show before anyone is asked for a card. File fields are only checked here when they carry a direct upload’s upl_... id: the slot must exist and hold its file, and it is kept for this payment. A file sent as multipart can only come with the real submission.

Send _attempt (8 to 64 letters, digits, - or _, the same for every retry of one visit) to reuse the same PaymentIntent when someone corrects an answer and tries again. Without it, the same answers from the same address count as a retry. A PaymentIntent that has been paid is never reused.

If the form uses Turnstile or Altcha, send the token here too, as cf-turnstile-response or altcha, or the _pass from a direct upload. On a form that needs a personal link, send the link as well, as _link or the X-Quickforms-Link header.

Create a PaymentIntentbash
curl -X POST https://YOUR-QUICKFORMS-HOST/f/FORM_ID/payment \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": "Ada", "email": "ada@example.com", "service": "frame-sizing"}'
201 responsejson
{
  "client_secret": "pi_..._secret_...",
  "payment_intent": "pi_...",
  "publishable_key": "pk_test_...",
  "amount": 3000,
  "currency": "gbp",
  "pass": "eyJmb3JtIjoi..."
}

pass is null when the form has no Turnstile or Altcha, for sandbox requests, and when the request itself used a _pass. It lasts 15 minutes by default (QUICKFORMS_PASS_MINUTES), works for this form only and works once.

quickforms creates the PaymentIntent with your secret key, with:

  • the amount and currency (lower case)
  • automatic payment methods switched on
  • the description, with placeholders filled in
  • receipt_email set to the first email field with an answer
  • metadata quickforms_form_id (the form’s ID) and quickforms_sandbox ("true" or "false")
Status When
201 The PaymentIntent was created.
404 The form is switched off, not published (for live requests), or doesn’t take payments: {"message": "This form does not take payments."}
403, 404, 409, 410 The personal link is missing or can’t be used. The body has a code such as link_required.
422 An answer is invalid, the answer has no price, the Turnstile or Altcha check failed, or Stripe couldn’t create the PaymentIntent (“Payments are not available right now. Please try again.” on _payment).
429 Too many requests. This endpoint shares the submission rate limit, and each form can create 300 PaymentIntents an hour by default (QUICKFORMS_PAYMENT_INTENTS_PER_HOUR).

2. Confirm it with Stripe.js

Use the client_secret and publishable_key with Stripe.js to collect the card details and confirm the payment. The card details go to Stripe, never to quickforms.

pay.jsjs
const stripe = Stripe(intent.publishable_key);
const elements = stripe.elements({ clientSecret: intent.client_secret });
elements.create('payment').mount('#payment-element');

// When the person clicks pay:
const { error, paymentIntent } = await stripe.confirmPayment({
  elements,
  redirect: 'if_required',
  confirmParams: { return_url: window.location.href },
});

3. Submit with the PaymentIntent

Submit to POST /f/{form} with the PaymentIntent ID as _payment_intent. The answers saved in step 1 are used: if different answers are sent now, the saved ones win. Files sent as multipart, for fields with no saved upload, are checked and kept here.

Submitbash
curl -X POST https://YOUR-QUICKFORMS-HOST/f/FORM_ID \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"_payment_intent": "pi_..."}'

_payment_intent is a control field: it is never stored in the submission’s data. A bot check is not needed here, as the payment passed one in step 1, and nothing that can expire after paying (a pass, a personal link, an upload slot, the form being switched off) can turn the submission away.

If the webhook got there first, this answers exactly as a normal submit would, for the submission it stored.

Status When
201 The submission was stored, now or earlier.
202 The payment is still processing, or it went through but needs attention. The body has a message. Don’t pay again.
422 The payment hasn’t gone through, or a file sent with this request failed its check.
503 Stripe couldn’t be reached. The record is kept, so try again.

What’s checked on submit

Whichever of the browser, the webhook or the reconciler gets there first, one row lock makes sure a PaymentIntent is only completed once, and the same checks run:

  1. quickforms retrieves the PaymentIntent from Stripe with your secret key. Nothing in a webhook or a browser request is trusted for the amount or the answers.
  2. Its status is succeeded.
  3. Its quickforms_form_id metadata is this form’s ID, and quickforms_sandbox matches.
  4. Its amount and currency match what was saved in step 1, worked out from the answers before the payment. A price changed afterwards does not count against it.

Each PaymentIntent can pay for one submission only, even one that has since been deleted.

Then the submission is stored from the saved answers the usual way, so the geo and disposable email filters and the spam filter still apply and a paid submission can be held for review.

The Stripe webhook

Each form with payments on has its own webhook endpoint at POST /f/{form}/payment/webhook, on the forms domain when one is set. quickforms creates it in your Stripe account with your secret key when you save payment settings, replaces it when the key changes and removes it when payments are turned off. Its signing secret is encrypted and never shown.

It listens for payment_intent.succeeded, checks the Stripe-Signature header with a 5 minute timestamp tolerance, and answers 200 for anything it doesn’t recognise. Replays are safe.

A restricted key without permission to write webhook endpoints still saves. The dashboard shows a notice on the payment settings, and the reconciler does the webhook’s job instead. Local addresses (localhost, .test and .localhost) never get an endpoint, because Stripe can’t reach them.

If a payment is never finished

quickforms:reconcile-payments runs every five minutes. It looks up saved payments older than 5 minutes (QUICKFORMS_PAYMENT_RECONCILE_AFTER_MINUTES) with Stripe and:

  • stores the submission for any that are paid
  • cancels any still unpaid after 60 minutes (QUICKFORMS_PAYMENT_EXPIRE_AFTER_MINUTES) and marks them expired
  • leaves alone any that are still processing, or that Stripe can’t answer for because of an outage
  • clears the answers from completed, expired and resolved records after 30 days (QUICKFORMS_PAYMENT_RETENTION_DAYS)

A payment that went through but can’t be turned into a submission is marked needs_attention and shown under Logs in the dashboard, with its answers. Select the payment.needs_attention webhook event to hear about it straight away. That covers a form that has since been deleted or suspended, a mismatch with what Stripe holds, or a submission that keeps failing to save. These records keep their answers until someone resolves them.

Resolving a payment that needs attention

Team owners and admins can open the record in Logs to see the saved answers and a link to the payment in Stripe, then choose Mark as resolved, with an optional note of up to 500 characters. Resolving is a record that someone has dealt with it: it doesn’t create a submission or refund the payment.

A resolved record leaves the attention list and shows under the Resolved payments filter in Logs, with who resolved it and when. Its answers are cleared by the same retention as completed and expired records.

Until it’s resolved, the record stops quickforms:purge-deleted permanently deleting its form. A deleted team’s other forms are still purged, and the team goes once its forms are gone. A record stops holding back the purge 12 months after it was created (QUICKFORMS_PAYMENT_ATTENTION_MAX_MONTHS), and is then deleted with its form.

Redirect-based payment methods

When a payment method sends the visitor away and back, they land on the page they came from, which asks quickforms for the result and shows the form’s success message. If they never come back, the webhook or the reconciler still finishes it. Files uploaded with direct uploads are kept; a file that could only be sent as multipart is lost if the visitor doesn’t return, and the submission is stored without it, with its field listed in meta.payment.missing_files.

What’s stored

A paid submission stores the PaymentIntent ID in payment_intent, and the payment details in meta.payment:

meta.paymentjson
{
  "intent": "pi_...",
  "amount": 3000,
  "currency": "gbp",
  "status": "succeeded"
}

meta is returned with the submission by the API. The submission.created event sent to webhooks includes it as submission.payment.

When the payment step is skipped

The payment step only applies to live submissions to the public endpoint, POST /f/{form}. It is skipped for:

  • sandbox submissions with no _payment_intent, so you can test a paid form without paying. Stripe is not called.
  • submissions you send yourself from the dashboard, the API or MCP.

POST /f/{form}/payment does work with the sandbox key, and against the form’s draft. It still creates a real PaymentIntent with your secret key, marked quickforms_sandbox: "true" in its metadata, so use test mode keys while you build. Paying it completes as a sandbox submission, and a sandbox payment can’t pay for a live one.

Type to search…

↑↓ navigate↵ selectEsc close