From 9bd80d024da0f44b08d5016aa831f4bebbb04c24 Mon Sep 17 00:00:00 2001 From: Zeryab Khan Date: Wed, 2 Sep 2026 12:48:12 +0500 Subject: [PATCH 1/7] docs(payments): document payment fulfilment and the checkout webhook events The page predates server-side payment fulfilment, so it documented neither the feature nor the two things an app must do to use it safely. - Adds checkout.session.completed and checkout.session.async_payment_succeeded to the webhook events. The page previously listed only the three customer.subscription events, so an app configured by following it could never have a one-off checkout recorded. - Documents paymentFulfilment: where it goes, the mandatory currency and amount guards, and that client_reference_id names the row. - Documents the ownership check on the checkout route, both routes through it, and ownershipTokenColumn / flPurchaseToken for apps whose buyers have no account. Includes the upgrade warning: enabling fulfilment on an existing app turns the check on for the first time, so the token has to ship first. - Separates paymentIncomplete from paymentPending, and says why an app must not present the second as a failed payment. - Corrects the checkout example, which documented a response.transactionDetails that the API does not return; it resolves with the checkout session. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5 --- docs/API/fliplet-payments.md | 156 +++++++++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/docs/API/fliplet-payments.md b/docs/API/fliplet-payments.md index 8034763b..54d30306 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,19 @@ 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. + ![Stripe webhook](../assets/img/stripe-webhook.png) @@ -181,14 +195,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 +212,134 @@ 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. + +Set it on the **master app** (the app you edit in Studio, not a published copy): + +```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 + "runUpdateHooks": true + } +} +``` + +`expectedCurrency` and `minimumAmountTotal` (in the currency's smallest unit) are +mandatory. They ensure a session cannot mark a row paid unless it actually collected +the money you expected, so a cheap or wrong-currency session cannot fulfil an +expensive order. + +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 characters — 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 From 49ecacab486bef13cdd9bbbbde3371d80019ba96 Mon Sep 17 00:00:00 2001 From: Zeryab Khan Date: Wed, 2 Sep 2026 12:54:35 +0500 Subject: [PATCH 2/7] docs(payments): show how to save the paymentFulfilment setting The section gave the shape of paymentFulfilment but never how to set it. Adds Fliplet.App.Settings.set() and the RESTful equivalent, plus the three things that decide whether the saved value is the one that takes effect: it must be the master app; Settings.set() from Studio preview or Viewer resolves without saving anything; and a published app's own copy takes precedence, so it needs republishing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5 --- docs/API/fliplet-payments.md | 58 +++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/docs/API/fliplet-payments.md b/docs/API/fliplet-payments.md index 54d30306..6876993a 100644 --- a/docs/API/fliplet-payments.md +++ b/docs/API/fliplet-payments.md @@ -224,7 +224,7 @@ Fliplet can record these payments for you from the Stripe webhook instead. Confi `paymentFulfilment` on the app and Fliplet will mark the row itself when Stripe confirms the payment. -Set it on the **master app** (the app you edit in Studio, not a published copy): +`paymentFulfilment` is an **app setting**, and it takes this shape: ```js { @@ -253,6 +253,62 @@ mandatory. They ensure a session cannot mark a row paid unless it actually colle the money you expected, so a cheap or wrong-currency session cannot fulfil an expensive order. +--- + +### Setting it on the app + +Save it like any other app setting, as a Studio user with edit rights on the app: + +```js +// Run once, as a logged in Studio user +Fliplet.App.Settings.set({ + 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, + runUpdateHooks: true + } +}).then(function () { + // Saved. The next completed checkout will be recorded. +}); +``` + +or over the RESTful API: + +``` +POST v1/apps/:appId/settings +``` + +```json +{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid" } } +``` + +Both merge into the app's existing settings rather than replacing them, so other +settings are left alone. + +Three things decide whether the value you save is the one that takes effect: + +**It must be the master app.** The endpoint rejects a published app, so run this +against the app you edit in Studio. + +**Do not run it from Studio preview or Fliplet Viewer.** When +`Fliplet.Env.get('development') === true`, `Fliplet.App.Settings.set()` skips the +network call and mutates `window.ENV.appSettings` in memory — the promise resolves, +nothing is saved, and it looks like it worked. Run it on the live app, or use the +RESTful API. + +**Republish after changing it.** A published app carries its own copy of the setting, +and that copy takes precedence over the master's. Editing the master without +republishing leaves the published app serving the older value. + +To check what an app is really using, read it back with +`Fliplet.App.Settings.get('paymentFulfilment')` on the app you are testing. + Which row gets marked is taken from `client_reference_id` on the checkout session, so your checkout call must set it: From a6bb2c8859e1d8931e63766e25cd3987c373b589 Mon Sep 17 00:00:00 2001 From: Zeryab Khan Date: Wed, 2 Sep 2026 13:03:04 +0500 Subject: [PATCH 3/7] docs(payments): regenerate the agent indexes Runs bin/build-agent-indexes.mjs so llms-full.txt and llms-v3-libraries.json carry the payments changes, matching how other docs commits update them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5 --- docs/.well-known/llms-full.txt | 210 +++++++++++++++++++++++- docs/.well-known/llms-v3-libraries.json | 8 +- 2 files changed, 211 insertions(+), 7 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 738d8dd2..8404129e 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -19287,6 +19287,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 @@ -19340,10 +19345,19 @@ 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. + ![Stripe webhook](../assets/img/stripe-webhook.png) @@ -19434,14 +19448,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. }); }); }); @@ -19449,6 +19465,190 @@ 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. + +`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 + "runUpdateHooks": true + } +} +``` + +`expectedCurrency` and `minimumAmountTotal` (in the currency's smallest unit) are +mandatory. They ensure a session cannot mark a row paid unless it actually collected +the money you expected, so a cheap or wrong-currency session cannot fulfil an +expensive order. + +--- + +### Setting it on the app + +Save it like any other app setting, as a Studio user with edit rights on the app: + +```js +// Run once, as a logged in Studio user +Fliplet.App.Settings.set({ + 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, + runUpdateHooks: true + } +}).then(function () { + // Saved. The next completed checkout will be recorded. +}); +``` + +or over the RESTful API: + +``` +POST v1/apps/:appId/settings +``` + +```json +{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid" } } +``` + +Both merge into the app's existing settings rather than replacing them, so other +settings are left alone. + +Three things decide whether the value you save is the one that takes effect: + +**It must be the master app.** The endpoint rejects a published app, so run this +against the app you edit in Studio. + +**Do not run it from Studio preview or Fliplet Viewer.** When +`Fliplet.Env.get('development') === true`, `Fliplet.App.Settings.set()` skips the +network call and mutates `window.ENV.appSettings` in memory — the promise resolves, +nothing is saved, and it looks like it worked. Run it on the live app, or use the +RESTful API. + +**Republish after changing it.** A published app carries its own copy of the setting, +and that copy takes precedence over the master's. Editing the master without +republishing leaves the published app serving the older value. + +To check what an app is really using, read it back with +`Fliplet.App.Settings.get('paymentFulfilment')` on the app you are testing. + +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 characters — 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 af1b41a3..9ef2743a 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-07-22T13:41:33.000Z", + "generatedAt": "2026-09-02T08:02:23.913Z", "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" }, From 4352bbbace6a8e326b0dfe21016d035637d2d2ae Mon Sep 17 00:00:00 2001 From: Zeryab Khan Date: Wed, 2 Sep 2026 13:31:23 +0500 Subject: [PATCH 4/7] docs(payments): document the silent fulfilment skips and the hook traps Three behaviours found while configuring this on a live app, none of them previously written down and each one silent when it bites. - The target data source must belong to the app doing the checkout, and statusColumn must be a real column on it. Both refusals are invisible to the app: nothing is recorded and no error surfaces. Calls out app copies specifically, since a copy inherits paymentFulfilment still pointing at the original app's data source. - A hook declaring triggers never runs from this path, which identifies itself as 'webhook'. Component-created hooks are commonly scoped to a widget id, which a webhook cannot match. - Enabling runUpdateHooks alongside a confirmation already sent by the app's own screens produces two messages, because neither sender can see the other. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5 --- docs/.well-known/llms-full.txt | 42 +++++++++++++++++++++++++ docs/.well-known/llms-v3-libraries.json | 2 +- docs/API/fliplet-payments.md | 42 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 8404129e..fa818aef 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -19508,6 +19508,48 @@ expensive order. --- +### What the data source has to satisfy + +The data source you point at must belong to the app doing the checkout — its own ID, +its master, or its published copy. Fliplet will not write into a data source owned by +an unrelated app, even one in the same organization. `statusColumn` must also be a +real column on that data source. + +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`. + +--- + +### Running your data source hooks + +`runUpdateHooks: true` runs the data source's own `update` hooks for the recorded +payment, so your app can react to a payment it would otherwise never have seen. Two +things are worth checking before you turn it on. + +**A hook scoped to `triggers` will not run.** This path identifies itself as +`webhook`. A hook that declares `triggers` only fires when the current source appears +in that list, and hooks created by components are commonly scoped to a widget ID, +which a webhook can never match. Leave the hook untriggered, or add `webhook` to its +`triggers` explicitly. A hook skipped this way is silent — it simply never runs. + +**Check you are not sending the same thing twice.** On the happy path the buyer's +browser also writes to the row a few seconds later. A hook with `conditions` is skipped +on that second write, because the value it tests did not change; an unconditioned hook +runs on both. Separately, if your screens already send a confirmation after payment, a +hook that also sends one produces two: the two paths cannot see each other, so neither +can tell that the other has already sent. Prefer a single sender that decides from the +row itself — for example a column recording when the message went out — over two +senders that each look correct in isolation. + +--- + ### Setting it on the app Save it like any other app setting, as a Studio user with edit rights on the app: diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index 9ef2743a..49743ddb 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-02T08:02:23.913Z", + "generatedAt": "2026-09-02T08:31:11.407Z", "libraries": [ { "package": "fliplet-analytics-spa", diff --git a/docs/API/fliplet-payments.md b/docs/API/fliplet-payments.md index 6876993a..3f99532d 100644 --- a/docs/API/fliplet-payments.md +++ b/docs/API/fliplet-payments.md @@ -255,6 +255,48 @@ expensive order. --- +### What the data source has to satisfy + +The data source you point at must belong to the app doing the checkout — its own ID, +its master, or its published copy. Fliplet will not write into a data source owned by +an unrelated app, even one in the same organization. `statusColumn` must also be a +real column on that data source. + +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`. + +--- + +### Running your data source hooks + +`runUpdateHooks: true` runs the data source's own `update` hooks for the recorded +payment, so your app can react to a payment it would otherwise never have seen. Two +things are worth checking before you turn it on. + +**A hook scoped to `triggers` will not run.** This path identifies itself as +`webhook`. A hook that declares `triggers` only fires when the current source appears +in that list, and hooks created by components are commonly scoped to a widget ID, +which a webhook can never match. Leave the hook untriggered, or add `webhook` to its +`triggers` explicitly. A hook skipped this way is silent — it simply never runs. + +**Check you are not sending the same thing twice.** On the happy path the buyer's +browser also writes to the row a few seconds later. A hook with `conditions` is skipped +on that second write, because the value it tests did not change; an unconditioned hook +runs on both. Separately, if your screens already send a confirmation after payment, a +hook that also sends one produces two: the two paths cannot see each other, so neither +can tell that the other has already sent. Prefer a single sender that decides from the +row itself — for example a column recording when the message went out — over two +senders that each look correct in isolation. + +--- + ### Setting it on the app Save it like any other app setting, as a Studio user with edit rights on the app: From a3138eff87bf30e9cb8ae7365c10f46118b5e5a3 Mon Sep 17 00:00:00 2001 From: Zeryab Khan Date: Wed, 2 Sep 2026 13:36:32 +0500 Subject: [PATCH 5/7] docs(payments): drop the data source hooks section Leaves runUpdateHooks described in the settings shape, without the separate guidance section. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5 --- docs/.well-known/llms-full.txt | 23 ----------------------- docs/.well-known/llms-v3-libraries.json | 2 +- docs/API/fliplet-payments.md | 23 ----------------------- 3 files changed, 1 insertion(+), 47 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index fa818aef..d74a847a 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -19527,29 +19527,6 @@ configured data source, so the ownership check refuses it with a `403`. --- -### Running your data source hooks - -`runUpdateHooks: true` runs the data source's own `update` hooks for the recorded -payment, so your app can react to a payment it would otherwise never have seen. Two -things are worth checking before you turn it on. - -**A hook scoped to `triggers` will not run.** This path identifies itself as -`webhook`. A hook that declares `triggers` only fires when the current source appears -in that list, and hooks created by components are commonly scoped to a widget ID, -which a webhook can never match. Leave the hook untriggered, or add `webhook` to its -`triggers` explicitly. A hook skipped this way is silent — it simply never runs. - -**Check you are not sending the same thing twice.** On the happy path the buyer's -browser also writes to the row a few seconds later. A hook with `conditions` is skipped -on that second write, because the value it tests did not change; an unconditioned hook -runs on both. Separately, if your screens already send a confirmation after payment, a -hook that also sends one produces two: the two paths cannot see each other, so neither -can tell that the other has already sent. Prefer a single sender that decides from the -row itself — for example a column recording when the message went out — over two -senders that each look correct in isolation. - ---- - ### Setting it on the app Save it like any other app setting, as a Studio user with edit rights on the app: diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index 49743ddb..0d582248 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-02T08:31:11.407Z", + "generatedAt": "2026-09-02T08:36:32.630Z", "libraries": [ { "package": "fliplet-analytics-spa", diff --git a/docs/API/fliplet-payments.md b/docs/API/fliplet-payments.md index 3f99532d..7ed167a4 100644 --- a/docs/API/fliplet-payments.md +++ b/docs/API/fliplet-payments.md @@ -274,29 +274,6 @@ configured data source, so the ownership check refuses it with a `403`. --- -### Running your data source hooks - -`runUpdateHooks: true` runs the data source's own `update` hooks for the recorded -payment, so your app can react to a payment it would otherwise never have seen. Two -things are worth checking before you turn it on. - -**A hook scoped to `triggers` will not run.** This path identifies itself as -`webhook`. A hook that declares `triggers` only fires when the current source appears -in that list, and hooks created by components are commonly scoped to a widget ID, -which a webhook can never match. Leave the hook untriggered, or add `webhook` to its -`triggers` explicitly. A hook skipped this way is silent — it simply never runs. - -**Check you are not sending the same thing twice.** On the happy path the buyer's -browser also writes to the row a few seconds later. A hook with `conditions` is skipped -on that second write, because the value it tests did not change; an unconditioned hook -runs on both. Separately, if your screens already send a confirmation after payment, a -hook that also sends one produces two: the two paths cannot see each other, so neither -can tell that the other has already sent. Prefer a single sender that decides from the -row itself — for example a column recording when the message went out — over two -senders that each look correct in isolation. - ---- - ### Setting it on the app Save it like any other app setting, as a Studio user with edit rights on the app: From 1531642266f0e2671a463711bcda36323b452026 Mon Sep 17 00:00:00 2001 From: Zeryab Khan Date: Wed, 30 Sep 2026 15:12:21 +0500 Subject: [PATCH 6/7] docs(payments): correct fulfilment guarantees per review - minimumAmountTotal is one app-wide minimum, not per-order price protection - REST example now carries the mandatory guards; settings merge is top-level only, so paymentFulfilment is replaced as a whole - runUpdateHooks off in the examples, with a section on making hooks safe to run twice - fulfilment only covers sessions created via Checkout.create() - async_payment_failed is not handled; paid rows are not reverted - ownership token minimum is 16 bytes Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/.well-known/llms-full.txt | 58 ++++++++++++++++++++----- docs/.well-known/llms-v3-libraries.json | 2 +- docs/API/fliplet-payments.md | 58 ++++++++++++++++++++----- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index d74a847a..4d0d7655 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -19358,6 +19358,11 @@ settlement of a delayed payment method, which can arrive minutes or days later. 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. + ![Stripe webhook](../assets/img/stripe-webhook.png) @@ -19477,6 +19482,12 @@ Fliplet can record these payments for you from the Stripe webhook instead. Confi `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 @@ -19495,16 +19506,38 @@ confirms the payment. "expectedCurrency": "eur", "minimumAmountTotal": 100, - // Runs the data source's own update hooks for the recorded payment - "runUpdateHooks": true + // 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. They ensure a session cannot mark a row paid unless it actually collected -the money you expected, so a cheap or wrong-currency session cannot fulfil an -expensive order. +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. --- @@ -19542,8 +19575,7 @@ Fliplet.App.Settings.set({ paymentIntentColumn: 'Stripe Payment Intent ID', customerColumn: 'Stripe Customer ID', expectedCurrency: 'eur', - minimumAmountTotal: 100, - runUpdateHooks: true + minimumAmountTotal: 100 } }).then(function () { // Saved. The next completed checkout will be recorded. @@ -19557,11 +19589,13 @@ POST v1/apps/:appId/settings ``` ```json -{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid" } } +{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid", "expectedCurrency": "eur", "minimumAmountTotal": 100 } } ``` -Both merge into the app's existing settings rather than replacing them, so other -settings are left alone. +Both merge 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. Three things decide whether the value you save is the one that takes effect: @@ -19631,8 +19665,8 @@ Fliplet.Payments.Checkout.create({ }); ``` -The stored value must be at least 16 characters — a short or blank column authorises -nothing, or every row with an empty token would be claimable. `flPurchaseToken` is +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. diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index 0d582248..f2d64607 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-02T08:36:32.630Z", + "generatedAt": "2026-09-30T10:12:03.474Z", "libraries": [ { "package": "fliplet-analytics-spa", diff --git a/docs/API/fliplet-payments.md b/docs/API/fliplet-payments.md index 7ed167a4..c7554b30 100644 --- a/docs/API/fliplet-payments.md +++ b/docs/API/fliplet-payments.md @@ -105,6 +105,11 @@ settlement of a delayed payment method, which can arrive minutes or days later. 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. + ![Stripe webhook](../assets/img/stripe-webhook.png) @@ -224,6 +229,12 @@ Fliplet can record these payments for you from the Stripe webhook instead. Confi `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 @@ -242,16 +253,38 @@ confirms the payment. "expectedCurrency": "eur", "minimumAmountTotal": 100, - // Runs the data source's own update hooks for the recorded payment - "runUpdateHooks": true + // 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. They ensure a session cannot mark a row paid unless it actually collected -the money you expected, so a cheap or wrong-currency session cannot fulfil an -expensive order. +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. --- @@ -289,8 +322,7 @@ Fliplet.App.Settings.set({ paymentIntentColumn: 'Stripe Payment Intent ID', customerColumn: 'Stripe Customer ID', expectedCurrency: 'eur', - minimumAmountTotal: 100, - runUpdateHooks: true + minimumAmountTotal: 100 } }).then(function () { // Saved. The next completed checkout will be recorded. @@ -304,11 +336,13 @@ POST v1/apps/:appId/settings ``` ```json -{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid" } } +{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid", "expectedCurrency": "eur", "minimumAmountTotal": 100 } } ``` -Both merge into the app's existing settings rather than replacing them, so other -settings are left alone. +Both merge 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. Three things decide whether the value you save is the one that takes effect: @@ -378,8 +412,8 @@ Fliplet.Payments.Checkout.create({ }); ``` -The stored value must be at least 16 characters — a short or blank column authorises -nothing, or every row with an empty token would be claimable. `flPurchaseToken` is +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. From 3ec72be14bcaabf56aec9650994efc14be4ab857 Mon Sep 17 00:00:00 2001 From: Zeryab Khan Date: Thu, 1 Oct 2026 17:00:31 +0500 Subject: [PATCH 7/7] docs(payments): say which app's settings checkout and the webhook read Per the follow-up review: - Save paymentFulfilment over REST against the master app id; drop the Fliplet.App.Settings.set() example, which 403s on a published app and saves nothing in preview/Viewer - The webhook only reads the master's value, checkout reads the published copy's; a dataSourceId mismatch silently records nothing, hence republish - The data source must belong to the master, not the published copy - statusColumn is only validated when the data source has a column list - Rewrap the token paragraph Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/.well-known/llms-full.txt | 96 +++++++++++++------------ docs/.well-known/llms-v3-libraries.json | 2 +- docs/API/fliplet-payments.md | 96 +++++++++++++------------ 3 files changed, 101 insertions(+), 93 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 222c99a4..36cf0307 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -19624,10 +19624,13 @@ send the confirmation, not both. ### What the data source has to satisfy -The data source you point at must belong to the app doing the checkout — its own ID, -its master, or its published copy. Fliplet will not write into a data source owned by -an unrelated app, even one in the same organization. `statusColumn` must also be a -real column on that data source. +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 @@ -19643,58 +19646,59 @@ configured data source, so the ownership check refuses it with a `403`. ### Setting it on the app -Save it like any other app setting, as a Studio user with edit rights on the app: - -```js -// Run once, as a logged in Studio user -Fliplet.App.Settings.set({ - 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 - } -}).then(function () { - // Saved. The next completed checkout will be recorded. -}); -``` - -or over the RESTful API: +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/:appId/settings +POST v1/apps/:masterAppId/settings ``` ```json -{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid", "expectedCurrency": "eur", "minimumAmountTotal": 100 } } +{ + "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 + } +} ``` -Both merge 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. +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.

-Three things decide whether the value you save is the one that takes effect: +#### Which copy of the setting is used -**It must be the master app.** The endpoint rejects a published app, so run this -against the app you edit in Studio. +Checkout and the webhook do not read the setting from the same app: -**Do not run it from Studio preview or Fliplet Viewer.** When -`Fliplet.Env.get('development') === true`, `Fliplet.App.Settings.set()` skips the -network call and mutates `window.ENV.appSettings` in memory — the promise resolves, -nothing is saved, and it looks like it worked. Run it on the live app, or use the -RESTful API. +- **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. -**Republish after changing it.** A published app carries its own copy of the setting, -and that copy takes precedence over the master's. Editing the master without -republishing leaves the published app serving the older value. +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. -To check what an app is really using, read it back with -`Fliplet.App.Settings.get('paymentFulfilment')` on the app you are testing. +`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: @@ -19747,8 +19751,8 @@ Fliplet.Payments.Checkout.create({ ``` 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 +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. diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index 3991f649..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-30T10:48:18.650Z", + "generatedAt": "2026-10-01T12:00:21.685Z", "libraries": [ { "package": "fliplet-analytics-spa", diff --git a/docs/API/fliplet-payments.md b/docs/API/fliplet-payments.md index c7554b30..afbc6087 100644 --- a/docs/API/fliplet-payments.md +++ b/docs/API/fliplet-payments.md @@ -290,10 +290,13 @@ send the confirmation, not both. ### What the data source has to satisfy -The data source you point at must belong to the app doing the checkout — its own ID, -its master, or its published copy. Fliplet will not write into a data source owned by -an unrelated app, even one in the same organization. `statusColumn` must also be a -real column on that data source. +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 @@ -309,58 +312,59 @@ configured data source, so the ownership check refuses it with a `403`. ### Setting it on the app -Save it like any other app setting, as a Studio user with edit rights on the app: - -```js -// Run once, as a logged in Studio user -Fliplet.App.Settings.set({ - 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 - } -}).then(function () { - // Saved. The next completed checkout will be recorded. -}); -``` - -or over the RESTful API: +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/:appId/settings +POST v1/apps/:masterAppId/settings ``` ```json -{ "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid", "expectedCurrency": "eur", "minimumAmountTotal": 100 } } +{ + "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 + } +} ``` -Both merge 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. +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.

-Three things decide whether the value you save is the one that takes effect: +#### Which copy of the setting is used -**It must be the master app.** The endpoint rejects a published app, so run this -against the app you edit in Studio. +Checkout and the webhook do not read the setting from the same app: -**Do not run it from Studio preview or Fliplet Viewer.** When -`Fliplet.Env.get('development') === true`, `Fliplet.App.Settings.set()` skips the -network call and mutates `window.ENV.appSettings` in memory — the promise resolves, -nothing is saved, and it looks like it worked. Run it on the live app, or use the -RESTful API. +- **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. -**Republish after changing it.** A published app carries its own copy of the setting, -and that copy takes precedence over the master's. Editing the master without -republishing leaves the published app serving the older value. +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. -To check what an app is really using, read it back with -`Fliplet.App.Settings.get('paymentFulfilment')` on the app you are testing. +`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: @@ -413,8 +417,8 @@ Fliplet.Payments.Checkout.create({ ``` 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 +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.