diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt
index 5691116d..36cf0307 100644
--- a/docs/.well-known/llms-full.txt
+++ b/docs/.well-known/llms-full.txt
@@ -4695,7 +4695,7 @@ const filenameByMimeType = {
'audio/wav': 'dictation.wav',
'audio/ogg': 'dictation.ogg'
};
-const maxRecordingMs = 60000;
+const maxRecordingMs = 5 * 60 * 1000; // Example UI cap; choose a duration appropriate to the app.
let stream;
let recorder;
@@ -4864,7 +4864,7 @@ async function startRecording() {
recorder.start();
phase = 'recording';
recordingTimer = window.setTimeout(stopAndTranscribe, maxRecordingMs);
- setStatus('Recording. It will stop after one minute.');
+ setStatus('Recording. It will stop after five minutes.');
} catch (error) {
showError(error);
reset();
@@ -19368,6 +19368,11 @@ Adding payments to your apps has the following four requirements:
3. A **Data Source** is created with a specific structure to manage the list of products you want the app users to be able to buy.
4. **Custom code** is added in your app screen to let users buy the products and complete the **checkout process** using our simple JS APIs.
+Optionally, an app can also configure **payment fulfilment** so Fliplet records a
+completed payment onto one of your data source rows from the Stripe webhook, rather
+than relying on the buyer's browser returning to your app. See
+[Recording a payment when the buyer's browser does not come back](#recording-a-payment-when-the-buyers-browser-does-not-come-back).
+
---
## Configuration
@@ -19421,10 +19426,24 @@ The previous JS API (`Fliplet.Payments.Configuration.update`) returns a `webhook
2. Click `Add endpoint`
3. Add the value you got from `webhookUrl` in the `Endpoint URL` field. The value has a format similar to this URL: `https://api.fliplet.com/v1/billing/webhook/apps/8a85a2edc3f3a774ac06f`
4. Choose the following events to be sent:
+ - `checkout.session.completed`
+ - `checkout.session.async_payment_succeeded`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.subscription.created`
+The two `checkout.session` events are what let Fliplet record a completed payment on
+its own, without depending on the buyer's browser coming back to your app. Enable both:
+`completed` covers the ordinary card path, and `async_payment_succeeded` is the
+settlement of a delayed payment method, which can arrive minutes or days later. An
+endpoint subscribed only to the `customer.subscription` events will never record a
+one-off checkout.
+
+Fliplet does not act on `checkout.session.async_payment_failed`. If a delayed payment
+method fails after your screens have already marked a row as paid, that row is not
+reverted. Check this before you offer delayed methods such as SEPA Direct Debit or
+Klarna, and handle failed settlements yourself.
+

@@ -19515,14 +19534,16 @@ Fliplet.Payments.Products.get().then(function (products) {
quantity: 2
}
]
- }).then(function onCheckoutCompleted(response) {
+ }).then(function onCheckoutCompleted(session) {
// The checkout session has been completed.
// The user was successfully charged for the product.
-
- // response.transactionDetails
+ //
+ // Resolves with the checkout session: id, currency, customer,
+ // customer_details and customer_email.
+ console.log(session.id);
}, function onCheckoutFailed(err) {
- // The checkout session has been canceled
- // or could not be completed
+ // The checkout did not complete. See "Telling a failed payment
+ // from an unfinished one" below before showing this to a buyer.
});
});
});
@@ -19530,6 +19551,242 @@ Fliplet.Payments.Products.get().then(function (products) {
---
+## Recording a payment when the buyer's browser does not come back
+
+Everything in the example above runs in the buyer's browser. If that browser closes,
+loses its connection, or is put to sleep by the phone before Stripe's confirmation is
+handled, the payment succeeds in Stripe while your app never learns about it. The
+buyer is charged, their order stays pending, and nothing you can write in the page
+fixes it — the code that would react is in the page that has gone away.
+
+Fliplet can record these payments for you from the Stripe webhook instead. Configure
+`paymentFulfilment` on the app and Fliplet will mark the row itself when Stripe
+confirms the payment.
+
+This only covers checkout sessions created with `Fliplet.Payments.Checkout.create()`.
+Fliplet marks each of those sessions when it creates them, and the webhook ignores any
+session without that mark, including ones that set `client_reference_id`. Sessions
+from Stripe Payment Links, the Stripe Dashboard or another integration are never
+recorded.
+
+`paymentFulfilment` is an **app setting**, and it takes this shape:
+
+```js
+{
+ "paymentFulfilment": {
+ "dataSourceId": 123456,
+ "statusColumn": "Payment Status",
+ "paidValue": "Paid",
+
+ // Optional columns, filled only where the row leaves them blank
+ "sessionColumn": "Stripe Session ID",
+ "paymentIntentColumn": "Stripe Payment Intent ID",
+ "customerColumn": "Stripe Customer ID",
+
+ // Required guards -- a target missing either will not fulfil
+ "expectedCurrency": "eur",
+ "minimumAmountTotal": 100,
+
+ // Runs the data source's own update hooks for the recorded payment.
+ // Off by default -- read "Update hooks" below before turning it on.
+ "runUpdateHooks": false
+ }
+}
+```
+
+`expectedCurrency` and `minimumAmountTotal` (in the currency's smallest unit) are
+mandatory. A session in another currency, or one that collected less than
+`minimumAmountTotal`, will not mark a row paid.
+
+`minimumAmountTotal` is **one minimum for the whole app**. Fliplet does not compare
+the amount charged with the price of the particular row being paid for. If your app
+sells more than one price, a checkout for the cheaper item can mark a row for the more
+expensive one as paid, as long as it clears the minimum. Set `minimumAmountTotal` to
+your lowest genuine price, and do not rely on it to protect higher-priced rows. Those
+need their own check that the amount paid matches the row.
+
+---
+
+### Update hooks
+
+When `runUpdateHooks` is `true`, the webhook's write fires the data source's update
+hooks, such as confirmation emails or workflow calls. Your screens usually write the
+same row when the buyer returns from Stripe, and that write fires the same hooks. An
+update hook with no condition therefore runs twice: two confirmation emails, or a
+workflow called twice.
+
+Before turning `runUpdateHooks` on, check every update hook on the data source. Each
+one should fire only on the change it cares about, for example when `Payment Status`
+becomes `Paid`, and be safe to run twice. Decide whether the webhook or your screens
+send the confirmation, not both.
+
+---
+
+### What the data source has to satisfy
+
+The data source you point at must belong to the **master app**, the one you edit in
+Studio. Fliplet will not write into a data source owned by any other app, including
+the app's own published copy or an unrelated app in the same organization.
+`statusColumn` must also be a real column on that data source. Fliplet only checks
+this when the data source has a column list. If the list is empty, a misspelled
+`statusColumn` is not caught: the value is written under that misspelled key and the
+real status column stays unchanged.
+
+Both of these fail **silently from the app's point of view**: the payment is not
+recorded, no error reaches your screens, and the row is left exactly as it was. If
+payments stop being recorded after a change, check these before anything else.
+
+This matters most when an app is **copied**. The copy inherits `paymentFulfilment`
+verbatim, still pointing at the original app's data source, so nothing is recorded
+until you repoint `dataSourceId` at the copy's own data source. Checkout fails too, and
+that one is not silent: the row named by `client_reference_id` does not exist in the
+configured data source, so the ownership check refuses it with a `403`.
+
+---
+
+### Setting it on the app
+
+Save it with the RESTful API, against the **master app's ID** (the app you edit in
+Studio), authenticated as a Studio user with edit rights on that app. See
+[Saving settings](v3/app-settings#saving-settings) for how to make the request.
+
+```
+POST v1/apps/:masterAppId/settings
+```
+
+```json
+{
+ "paymentFulfilment": {
+ "dataSourceId": 123456,
+ "statusColumn": "Payment Status",
+ "paidValue": "Paid",
+ "sessionColumn": "Stripe Session ID",
+ "paymentIntentColumn": "Stripe Payment Intent ID",
+ "customerColumn": "Stripe Customer ID",
+ "expectedCurrency": "eur",
+ "minimumAmountTotal": 100
+ }
+}
+```
+
+The endpoint returns a `403` for a published app's ID, so always use the master's.
+
+The request merges into the app's existing settings **at the top level only**: other
+settings are left alone, but `paymentFulfilment` itself is replaced as a whole. Always
+send the complete object. Sending only the fields you want to change deletes the rest,
+and a value without `expectedCurrency` and `minimumAmountTotal` stops fulfilment
+working.
+
+
Do not use Fliplet.App.Settings.set() for this. It saves to whichever app the code is running in. In a published app that is the published copy, which the endpoint refuses with a 403. In Studio preview and Fliplet Viewer it makes no request at all: it only changes the settings held in the page, the promise resolves, and nothing is saved.
+
+#### Which copy of the setting is used
+
+Checkout and the webhook do not read the setting from the same app:
+
+- **Checkout** (the ownership check, `ownershipTokenColumn`, and the data source the
+ session is tied to) uses the **published app's** copy, which it receives when you
+ publish.
+- **The webhook** (the guards, `statusColumn`, `paidValue`, the optional columns and
+ `runUpdateHooks`) always uses the **master app's** copy. Changes to those fields
+ apply to the next payment, without republishing.
+
+The two copies must name the same `dataSourceId`. Each session is tied to the data
+source the published app named at checkout, and the webhook ignores a session whose
+data source does not match the master's. If you change `dataSourceId` on the master and
+do not republish, every payment is **silently not recorded**. **Republish whenever you
+change `paymentFulfilment`**, so both copies stay the same.
+
+`Fliplet.App.Settings.get('paymentFulfilment')` in the published app shows the
+checkout copy, not the one the webhook uses. To see the webhook's copy, read the master
+app's settings with `GET v1/apps/:masterAppId/settings`.
+
+Which row gets marked is taken from `client_reference_id` on the checkout session, so
+your checkout call must set it:
+
+```js
+Fliplet.Payments.Checkout.create({
+ mode: 'payment',
+ line_items: lineItems,
+ client_reference_id: entryId.toString()
+});
+```
+
+---
+
+### Proving the buyer owns the row
+
+`client_reference_id` arrives from the browser, and entry IDs are sequential and
+guessable. Fliplet therefore checks, before creating the session, that the caller is
+entitled to the row they named — otherwise a buyer could pay against someone else's
+order and have it fulfilled on their behalf.
+
+The check passes in either of two ways.
+
+**By the data source's access rules.** If your buyers sign in to the app, and the
+rules allow that user to update their own row, nothing further is needed.
+
+**By a per-row token.** Apps whose buyers have no account cannot satisfy any rule —
+with no identity there is nothing for `loggedIn` or a user rule to match. For those
+apps, name a column holding a per-row secret the buyer already has, such as a
+registration UUID or order reference:
+
+```js
+{
+ "paymentFulfilment": {
+ // ...
+ "ownershipTokenColumn": "Registration Unique ID"
+ }
+}
+```
+
+and present that value when creating the session:
+
+```js
+Fliplet.Payments.Checkout.create({
+ mode: 'payment',
+ line_items: lineItems,
+ client_reference_id: entryId.toString(),
+ flPurchaseToken: registrationUniqueId
+});
+```
+
+The stored value must be at least 16 bytes long (16 characters for plain ASCII values
+such as UUIDs). A short or blank column authorises nothing, or every row with an empty
+token would be claimable. `flPurchaseToken` is removed from the payload before it is forwarded to Stripe, so the secret is never
+handed to a third party. A `Fl-Purchase-Token` request header is accepted too, for
+callers that can set one.
+
+If neither route succeeds the checkout is refused with a `403`, and the buyer is never
+sent to Stripe.
+
+> **Enabling `paymentFulfilment` on an existing app turns this check on for the first
+> time.** If your app has an `ownershipTokenColumn` but its screens do not yet send
+> `flPurchaseToken`, and its access rules do not grant the buyer an update, every
+> checkout will start failing. Ship the token first, then enable fulfilment.
+
+---
+
+### Telling a failed payment from an unfinished one
+
+When a checkout does not complete, the rejection carries one of two reasons, and they
+mean different things:
+
+- `app.payments.error.paymentIncomplete` — Stripe told us this checkout ended without
+ a payment. A verdict.
+- `app.payments.error.paymentPending` — we stopped watching before Stripe committed
+ either way. The absence of a verdict; the payment may still succeed.
+
+Treat them differently. Telling a buyer their card was not charged while the charge is
+still in flight is how a second charge happens. On `paymentPending`, tell the buyer the
+payment is still being confirmed and that they should not pay again — if the payment
+does go through, the webhook records it.
+
+Note also that a blocked pop-up surfaces as `paymentIncomplete`, because no Stripe page
+ever opened. If buyers report this without having seen a payment form, check the
+browser's pop-up blocker before looking at the payment itself.
+
+---
+
## Advanced functionality
### Check if payments have been configured for an app
diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json
index 5ef83462..df97c2e1 100644
--- a/docs/.well-known/llms-v3-libraries.json
+++ b/docs/.well-known/llms-v3-libraries.json
@@ -1,6 +1,6 @@
{
"version": 1,
- "generatedAt": "2026-09-03T21:44:55.240Z",
+ "generatedAt": "2026-10-01T12:00:21.685Z",
"libraries": [
{
"package": "fliplet-analytics-spa",
@@ -294,7 +294,11 @@
"order",
"product",
"cart",
- "donation"
+ "donation",
+ "payment fulfilment",
+ "client_reference_id",
+ "checkout.session.completed",
+ "purchase token"
],
"category": "commerce"
},
diff --git a/docs/API/fliplet-payments.md b/docs/API/fliplet-payments.md
index 8034763b..afbc6087 100644
--- a/docs/API/fliplet-payments.md
+++ b/docs/API/fliplet-payments.md
@@ -6,7 +6,7 @@ tags: [js-api, payments]
v3_relevant: true
deprecated: false
category: commerce
-capabilities: [payments, stripe, checkout, subscription, recurring billing, billing, refund, webhook, price id, customer portal, payment intent, ecommerce, order, product, cart, donation]
+capabilities: [payments, stripe, checkout, subscription, recurring billing, billing, refund, webhook, price id, customer portal, payment intent, ecommerce, order, product, cart, donation, payment fulfilment, client_reference_id, checkout.session.completed, purchase token]
---
# `Fliplet.Payments`
@@ -34,6 +34,11 @@ Adding payments to your apps has the following four requirements:
3. A **Data Source** is created with a specific structure to manage the list of products you want the app users to be able to buy.
4. **Custom code** is added in your app screen to let users buy the products and complete the **checkout process** using our simple JS APIs.
+Optionally, an app can also configure **payment fulfilment** so Fliplet records a
+completed payment onto one of your data source rows from the Stripe webhook, rather
+than relying on the buyer's browser returning to your app. See
+[Recording a payment when the buyer's browser does not come back](#recording-a-payment-when-the-buyers-browser-does-not-come-back).
+
---
## Configuration
@@ -87,10 +92,24 @@ The previous JS API (`Fliplet.Payments.Configuration.update`) returns a `webhook
2. Click `Add endpoint`
3. Add the value you got from `webhookUrl` in the `Endpoint URL` field. The value has a format similar to this URL: `https://api.fliplet.com/v1/billing/webhook/apps/8a85a2edc3f3a774ac06f`
4. Choose the following events to be sent:
+ - `checkout.session.completed`
+ - `checkout.session.async_payment_succeeded`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `customer.subscription.created`
+The two `checkout.session` events are what let Fliplet record a completed payment on
+its own, without depending on the buyer's browser coming back to your app. Enable both:
+`completed` covers the ordinary card path, and `async_payment_succeeded` is the
+settlement of a delayed payment method, which can arrive minutes or days later. An
+endpoint subscribed only to the `customer.subscription` events will never record a
+one-off checkout.
+
+Fliplet does not act on `checkout.session.async_payment_failed`. If a delayed payment
+method fails after your screens have already marked a row as paid, that row is not
+reverted. Check this before you offer delayed methods such as SEPA Direct Debit or
+Klarna, and handle failed settlements yourself.
+

@@ -181,14 +200,16 @@ Fliplet.Payments.Products.get().then(function (products) {
quantity: 2
}
]
- }).then(function onCheckoutCompleted(response) {
+ }).then(function onCheckoutCompleted(session) {
// The checkout session has been completed.
// The user was successfully charged for the product.
-
- // response.transactionDetails
+ //
+ // Resolves with the checkout session: id, currency, customer,
+ // customer_details and customer_email.
+ console.log(session.id);
}, function onCheckoutFailed(err) {
- // The checkout session has been canceled
- // or could not be completed
+ // The checkout did not complete. See "Telling a failed payment
+ // from an unfinished one" below before showing this to a buyer.
});
});
});
@@ -196,6 +217,242 @@ Fliplet.Payments.Products.get().then(function (products) {
---
+## Recording a payment when the buyer's browser does not come back
+
+Everything in the example above runs in the buyer's browser. If that browser closes,
+loses its connection, or is put to sleep by the phone before Stripe's confirmation is
+handled, the payment succeeds in Stripe while your app never learns about it. The
+buyer is charged, their order stays pending, and nothing you can write in the page
+fixes it — the code that would react is in the page that has gone away.
+
+Fliplet can record these payments for you from the Stripe webhook instead. Configure
+`paymentFulfilment` on the app and Fliplet will mark the row itself when Stripe
+confirms the payment.
+
+This only covers checkout sessions created with `Fliplet.Payments.Checkout.create()`.
+Fliplet marks each of those sessions when it creates them, and the webhook ignores any
+session without that mark, including ones that set `client_reference_id`. Sessions
+from Stripe Payment Links, the Stripe Dashboard or another integration are never
+recorded.
+
+`paymentFulfilment` is an **app setting**, and it takes this shape:
+
+```js
+{
+ "paymentFulfilment": {
+ "dataSourceId": 123456,
+ "statusColumn": "Payment Status",
+ "paidValue": "Paid",
+
+ // Optional columns, filled only where the row leaves them blank
+ "sessionColumn": "Stripe Session ID",
+ "paymentIntentColumn": "Stripe Payment Intent ID",
+ "customerColumn": "Stripe Customer ID",
+
+ // Required guards -- a target missing either will not fulfil
+ "expectedCurrency": "eur",
+ "minimumAmountTotal": 100,
+
+ // Runs the data source's own update hooks for the recorded payment.
+ // Off by default -- read "Update hooks" below before turning it on.
+ "runUpdateHooks": false
+ }
+}
+```
+
+`expectedCurrency` and `minimumAmountTotal` (in the currency's smallest unit) are
+mandatory. A session in another currency, or one that collected less than
+`minimumAmountTotal`, will not mark a row paid.
+
+`minimumAmountTotal` is **one minimum for the whole app**. Fliplet does not compare
+the amount charged with the price of the particular row being paid for. If your app
+sells more than one price, a checkout for the cheaper item can mark a row for the more
+expensive one as paid, as long as it clears the minimum. Set `minimumAmountTotal` to
+your lowest genuine price, and do not rely on it to protect higher-priced rows. Those
+need their own check that the amount paid matches the row.
+
+---
+
+### Update hooks
+
+When `runUpdateHooks` is `true`, the webhook's write fires the data source's update
+hooks, such as confirmation emails or workflow calls. Your screens usually write the
+same row when the buyer returns from Stripe, and that write fires the same hooks. An
+update hook with no condition therefore runs twice: two confirmation emails, or a
+workflow called twice.
+
+Before turning `runUpdateHooks` on, check every update hook on the data source. Each
+one should fire only on the change it cares about, for example when `Payment Status`
+becomes `Paid`, and be safe to run twice. Decide whether the webhook or your screens
+send the confirmation, not both.
+
+---
+
+### What the data source has to satisfy
+
+The data source you point at must belong to the **master app**, the one you edit in
+Studio. Fliplet will not write into a data source owned by any other app, including
+the app's own published copy or an unrelated app in the same organization.
+`statusColumn` must also be a real column on that data source. Fliplet only checks
+this when the data source has a column list. If the list is empty, a misspelled
+`statusColumn` is not caught: the value is written under that misspelled key and the
+real status column stays unchanged.
+
+Both of these fail **silently from the app's point of view**: the payment is not
+recorded, no error reaches your screens, and the row is left exactly as it was. If
+payments stop being recorded after a change, check these before anything else.
+
+This matters most when an app is **copied**. The copy inherits `paymentFulfilment`
+verbatim, still pointing at the original app's data source, so nothing is recorded
+until you repoint `dataSourceId` at the copy's own data source. Checkout fails too, and
+that one is not silent: the row named by `client_reference_id` does not exist in the
+configured data source, so the ownership check refuses it with a `403`.
+
+---
+
+### Setting it on the app
+
+Save it with the RESTful API, against the **master app's ID** (the app you edit in
+Studio), authenticated as a Studio user with edit rights on that app. See
+[Saving settings](v3/app-settings#saving-settings) for how to make the request.
+
+```
+POST v1/apps/:masterAppId/settings
+```
+
+```json
+{
+ "paymentFulfilment": {
+ "dataSourceId": 123456,
+ "statusColumn": "Payment Status",
+ "paidValue": "Paid",
+ "sessionColumn": "Stripe Session ID",
+ "paymentIntentColumn": "Stripe Payment Intent ID",
+ "customerColumn": "Stripe Customer ID",
+ "expectedCurrency": "eur",
+ "minimumAmountTotal": 100
+ }
+}
+```
+
+The endpoint returns a `403` for a published app's ID, so always use the master's.
+
+The request merges into the app's existing settings **at the top level only**: other
+settings are left alone, but `paymentFulfilment` itself is replaced as a whole. Always
+send the complete object. Sending only the fields you want to change deletes the rest,
+and a value without `expectedCurrency` and `minimumAmountTotal` stops fulfilment
+working.
+
+Do not use Fliplet.App.Settings.set() for this. It saves to whichever app the code is running in. In a published app that is the published copy, which the endpoint refuses with a 403. In Studio preview and Fliplet Viewer it makes no request at all: it only changes the settings held in the page, the promise resolves, and nothing is saved.
+
+#### Which copy of the setting is used
+
+Checkout and the webhook do not read the setting from the same app:
+
+- **Checkout** (the ownership check, `ownershipTokenColumn`, and the data source the
+ session is tied to) uses the **published app's** copy, which it receives when you
+ publish.
+- **The webhook** (the guards, `statusColumn`, `paidValue`, the optional columns and
+ `runUpdateHooks`) always uses the **master app's** copy. Changes to those fields
+ apply to the next payment, without republishing.
+
+The two copies must name the same `dataSourceId`. Each session is tied to the data
+source the published app named at checkout, and the webhook ignores a session whose
+data source does not match the master's. If you change `dataSourceId` on the master and
+do not republish, every payment is **silently not recorded**. **Republish whenever you
+change `paymentFulfilment`**, so both copies stay the same.
+
+`Fliplet.App.Settings.get('paymentFulfilment')` in the published app shows the
+checkout copy, not the one the webhook uses. To see the webhook's copy, read the master
+app's settings with `GET v1/apps/:masterAppId/settings`.
+
+Which row gets marked is taken from `client_reference_id` on the checkout session, so
+your checkout call must set it:
+
+```js
+Fliplet.Payments.Checkout.create({
+ mode: 'payment',
+ line_items: lineItems,
+ client_reference_id: entryId.toString()
+});
+```
+
+---
+
+### Proving the buyer owns the row
+
+`client_reference_id` arrives from the browser, and entry IDs are sequential and
+guessable. Fliplet therefore checks, before creating the session, that the caller is
+entitled to the row they named — otherwise a buyer could pay against someone else's
+order and have it fulfilled on their behalf.
+
+The check passes in either of two ways.
+
+**By the data source's access rules.** If your buyers sign in to the app, and the
+rules allow that user to update their own row, nothing further is needed.
+
+**By a per-row token.** Apps whose buyers have no account cannot satisfy any rule —
+with no identity there is nothing for `loggedIn` or a user rule to match. For those
+apps, name a column holding a per-row secret the buyer already has, such as a
+registration UUID or order reference:
+
+```js
+{
+ "paymentFulfilment": {
+ // ...
+ "ownershipTokenColumn": "Registration Unique ID"
+ }
+}
+```
+
+and present that value when creating the session:
+
+```js
+Fliplet.Payments.Checkout.create({
+ mode: 'payment',
+ line_items: lineItems,
+ client_reference_id: entryId.toString(),
+ flPurchaseToken: registrationUniqueId
+});
+```
+
+The stored value must be at least 16 bytes long (16 characters for plain ASCII values
+such as UUIDs). A short or blank column authorises nothing, or every row with an empty
+token would be claimable. `flPurchaseToken` is removed from the payload before it is forwarded to Stripe, so the secret is never
+handed to a third party. A `Fl-Purchase-Token` request header is accepted too, for
+callers that can set one.
+
+If neither route succeeds the checkout is refused with a `403`, and the buyer is never
+sent to Stripe.
+
+> **Enabling `paymentFulfilment` on an existing app turns this check on for the first
+> time.** If your app has an `ownershipTokenColumn` but its screens do not yet send
+> `flPurchaseToken`, and its access rules do not grant the buyer an update, every
+> checkout will start failing. Ship the token first, then enable fulfilment.
+
+---
+
+### Telling a failed payment from an unfinished one
+
+When a checkout does not complete, the rejection carries one of two reasons, and they
+mean different things:
+
+- `app.payments.error.paymentIncomplete` — Stripe told us this checkout ended without
+ a payment. A verdict.
+- `app.payments.error.paymentPending` — we stopped watching before Stripe committed
+ either way. The absence of a verdict; the payment may still succeed.
+
+Treat them differently. Telling a buyer their card was not charged while the charge is
+still in flight is how a second charge happens. On `paymentPending`, tell the buyer the
+payment is still being confirmed and that they should not pay again — if the payment
+does go through, the webhook records it.
+
+Note also that a blocked pop-up surfaces as `paymentIncomplete`, because no Stripe page
+ever opened. If buyers report this without having seen a payment form, check the
+browser's pop-up blocker before looking at the payment itself.
+
+---
+
## Advanced functionality
### Check if payments have been configured for an app