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

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payments

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](#the-stripe-webhook) or the [reconciler](#if-a-payment-is-never-finished) stores the submission instead. A payment only ever makes one submission, however many of these arrive.

The [hosted page](https://docs.getquickforms.com/hosted-forms/) and the [embed widget](https://docs.getquickforms.com/embed/) 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.

```bash title="Switch payments on"
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](#the-stripe-webhook) 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](#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."

```json title="settings.payment"
{
  "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](https://docs.getquickforms.com/embed/#public-schema-json) includes everything a page needs to show the price and start a payment. It is `null` when payments are off and for sandbox previews.

```json title="schema.json"
{
  "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](https://docs.getquickforms.com/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](https://docs.getquickforms.com/spam-protection/#cloudflare-turnstile) or [Altcha](https://docs.getquickforms.com/spam-protection/#altcha), send the token here too, as `cf-turnstile-response` or `altcha`, or the `_pass` from a [direct upload](https://docs.getquickforms.com/file-uploads/). On a form that needs a [personal link](https://docs.getquickforms.com/personal-links/#only-accept-submissions-through-links), send the link as well, as `_link` or the `X-Quickforms-Link` header.

```bash title="Create a PaymentIntent"
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"}'
```

```json title="201 response"
{
  "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](https://docs.getquickforms.com/personal-links/) 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](https://docs.getquickforms.com/spam-protection/#rate-limiting), 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](https://docs.stripe.com/js) to collect the card details and confirm the payment. The card details go to Stripe, never to quickforms.

```js title="pay.js"
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.

```bash title="Submit"
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](#if-a-payment-is-never-finished). 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](https://docs.getquickforms.com/spam-protection/#geo-filters) and the [spam filter](https://docs.getquickforms.com/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`](https://docs.getquickforms.com/webhooks/#paymentneeds_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`:

```json title="meta.payment"
{
  "intent": "pi_...",
  "amount": 3000,
  "currency": "gbp",
  "status": "succeeded"
}
```

`meta` is returned with the submission by the [API](https://docs.getquickforms.com/api/#submissions). The `submission.created` event sent to [webhooks](https://docs.getquickforms.com/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](https://docs.getquickforms.com/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](https://docs.getquickforms.com/api/) or [MCP](https://docs.getquickforms.com/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.

Source: https://docs.getquickforms.com/payments/index.mdx
