docs(payments): document payment fulfilment and the checkout webhook events - #295
zeryabkhan91 wants to merge 8 commits into
Conversation
…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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5
Deploying fliplet-cli with
|
| Latest commit: |
3ec72be
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://883246df.fliplet-cli.pages.dev |
| Branch Preview URL: | https://docs-ps-2138-payments-fulfil.fliplet-cli.pages.dev |
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5
Leaves runUpdateHooks described in the settings shape, without the separate guidance section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5
Arpanexe
left a comment
There was a problem hiding this comment.
Code review (via /fliplet-pr-review)
Review Basis
Review basis: subject=a3138eff87bf30e9cb8ae7365c10f46118b5e5a3 standards=fliplet-engineering@0.15.5 revision=unknown source=fliplet-pr-review digest=sha256:6be97d58979ac8e8
Summary
This adds the missing public documentation for webhook-driven payment fulfilment, checkout ownership checks, payment outcomes, and regenerates the agent indexes. The structure is substantially clearer, but several copyable instructions currently give unsafe or inaccurate guarantees.
Regression Risk
High: developers and AI agents following this page could silently disable fulfilment, accept underpayment for higher-priced orders, or trigger duplicate customer workflows.
Context from Prior Reviews
There were no existing reviews or inline threads on this PR. Related implementation history matters here: Fliplet/fliplet-api#8543 requires auditing update hooks before enabling them; #8565 says only Fliplet-minted checkout sessions can fulfil; and still-open #8572 fixes the blank optional-column behavior that this PR already describes as working. No relevant recent merge touched the source payments page. The local incident-query helper is installed, but its corpus is unavailable.
Acceptance Criteria
PS-2138 has no enumerated acceptance criteria. Its stated outcome is that a completed payment produces a persisted registration, ticket, and email. This documentation is only a partial follow-up: it does not configure the affected app, wire its confirmation hook, or recover the 13 affected registrations, and the instructions cannot safely support that outcome until the inline issues are corrected.
Suggestions
- Describe the token threshold as at least 16 UTF-8 bytes; the implementation checks
Buffer.length, not JavaScript character count. - The base is correct, but
docs/PS-2138-payments-fulfilmentdoes not follow the generic-repositoryfeature/{TICKET}-{slug}branch convention.
CI Status
Green — CircleCI build and Validate docs succeeded.
Verdict
REQUEST_CHANGES — 3 Critical issues and 3 Warnings are attached inline. The Critical items can cause silent payment failures or underpayment exposure.
| } | ||
| ``` | ||
|
|
||
| `expectedCurrency` and `minimumAmountTotal` (in the currency's smallest unit) are |
There was a problem hiding this comment.
Critical — this promises per-order price protection that the implementation does not provide.
Observed defect: a cheaper checkout can fulfil a more expensive order even though this paragraph says it cannot.
Mechanism: minimumAmountTotal is one app-wide floor. The webhook only checks amount_total >= minimumAmountTotal; it never compares the charge with the particular row or product. Because line_items are caller-supplied, any legitimate cheaper checkout above the floor can satisfy the guard.
Required outcome: describe this as a global lower bound, not per-order price binding. Multi-tier apps need a server-side price-to-row binding or must not rely on this guard to protect higher-priced rows.
There was a problem hiding this comment.
Agreed, fixed in 1531642. The paragraph now describes minimumAmountTotal as one minimum for the whole app: the webhook only checks amount_total >= minimumAmountTotal (libs/app-payments.js:246) and never compares the charge with the row being paid for. It also warns that apps selling more than one price can have a cheaper checkout mark a pricier row paid, and that those rows need their own amount check.
| ``` | ||
|
|
||
| ```json | ||
| { "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid" } } |
There was a problem hiding this comment.
Critical — the copyable REST payload disables fulfilment.
Both omitted fields are mandatory: current fliplet-api refuses every webhook target without expectedCurrency and minimumAmountTotal. The settings route also replaces the complete paymentFulfilment value rather than recursively merging its fields, so copying this over an existing configuration can delete valid guards.
Include the complete minimum valid object, and clarify below that only top-level app settings are merged.
| { "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid" } } | |
| { "paymentFulfilment": { "dataSourceId": 123456, "statusColumn": "Payment Status", "paidValue": "Paid", "expectedCurrency": "eur", "minimumAmountTotal": 100 } } |
There was a problem hiding this comment.
Agreed, fixed in 1531642. The REST payload now includes expectedCurrency and minimumAmountTotal. The text now says the merge is top-level only (routes/v1/apps.js assigns settings[key] = body[key]), so paymentFulfilment is replaced as a whole: always send the complete object, or the guards already saved are lost.
| "statusColumn": "Payment Status", | ||
| "paidValue": "Paid", | ||
|
|
||
| // Optional columns, filled only where the row leaves them blank |
There was a problem hiding this comment.
Critical — this documents behavior that is not yet present on fliplet-api@master.
Form Builder pre-creates configured columns as "". The current SQL merge resolves by key presence and lets the stored empty key override the optional Stripe value, so fields such as Stripe Session ID remain blank. Apps that reconcile by session ID then cannot see the recovered payment.
This is the exact defect in still-open Fliplet/fliplet-api#8572. Make that fix an explicit merge/deployment dependency, or document the current limitation until it ships.
There was a problem hiding this comment.
I think this one was already outdated when the review was posted. #8572 merged to master at 09:52Z on 09-02, about 13 minutes before this review (10:05Z). Master now only fills optional keys where the stored value is blank: WHERE coalesce(data->>o.key, '') = '' in libs/app-payments.js. So a pre-created "" Stripe Session ID does get filled, and the doc matches current behaviour. I have left this line unchanged.
| customerColumn: 'Stripe Customer ID', | ||
| expectedCurrency: 'eur', | ||
| minimumAmountTotal: 100, | ||
| runUpdateHooks: true |
There was a problem hiding this comment.
Warning — the copyable example opts into duplicate side effects without the required hook audit.
The webhook write and the browser's normal write can both run update hooks. An unconditioned hook therefore sends two confirmation emails or calls a workflow twice. Fliplet/fliplet-api#8543 and its release instructions explicitly require checking this before enabling hooks.
Omit/default this to false in the example and explain that hooks must be conditioned/idempotent, with one deliberate owner for confirmation behavior, before opting in.
There was a problem hiding this comment.
Agreed, fixed in 1531642. runUpdateHooks is now false in the settings shape and removed from the JS example, which matches the API default (if (!target.runUpdateHooks)). I also added an "Update hooks" section: the webhook write and the browser write can both fire the hooks, so each update hook must fire only on the change it cares about and be safe to run twice, and one side (webhook or screens) should own the confirmation.
| 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 |
There was a problem hiding this comment.
Warning — the absolute promise omits the server-issued binding requirement.
The webhook accepts only sessions carrying the signed metadata.flFulfilment binding minted by Fliplet's POST /checkout path. Sessions created through Payment Links, Stripe Dashboard, or another integration are ignored even when they have client_reference_id.
State explicitly that this feature supports sessions created through Fliplet.Payments.Checkout.create(), not arbitrary Stripe checkout sessions.
There was a problem hiding this comment.
Agreed, fixed in 1531642. The section now says fulfilment only covers sessions created with Fliplet.Payments.Checkout.create(). The webhook ignores any session without the metadata.flFulfilment mark, even if it sets client_reference_id, so Payment Links, Stripe Dashboard and other integrations are never recorded.
|
|
||
| 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 |
There was a problem hiding this comment.
Warning — this introduces delayed-payment support without its failure-path limitation.
The API records checkout.session.async_payment_succeeded but does not handle checkout.session.async_payment_failed. A browser flow that already marked the row Paid is therefore not reverted when a delayed method later fails; this is a known gap in the implementation PR.
Add the limitation before readers interpret this as complete support for enabling SEPA, Klarna, or other delayed-notification methods.
There was a problem hiding this comment.
Agreed, fixed in 1531642. Added a note below the event list: Fliplet does not act on checkout.session.async_payment_failed, so a row the screens already marked paid is not reverted when a delayed method fails. Readers should check this before offering SEPA/Klarna and handle failed settlements themselves.
- 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) <noreply@anthropic.com>
Resolved the llms-v3-libraries.json conflict by regenerating the agent indexes with docs/bin/build-agent-indexes.mjs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@Arpanexe thanks for the review. I have replied on each inline finding: five are fixed in 1531642, and the blank-optional-columns one was already fixed on master by #8572. On the two suggestions:
I also merged master in (a067807) and regenerated the agent indexes to clear the conflict. Ready for another look. |
galisufyan-327
left a comment
There was a problem hiding this comment.
Code review (via /fliplet-pr-review)
Review Basis
Review basis: subject=a067807bc74916dc9a3938b9a373281a2604a241 standards=fliplet-engineering@0.16.0 revision=07f719b175c2e024d0367c40db57fdfdc8b228d6+dirty source=fliplet-pr-review digest=sha256:b54eff56f86bcdda
Summary
Follow-up review of the payments page after the REQUEST_CHANGES at a3138ef. The page now documents fulfilment, the checkout webhook events, the mandatory guards, the ownership check and paymentPending vs paymentIncomplete, and drops the non-existent response.transactionDetails. I checked every claim against Fliplet/fliplet-api@master (c69fa50d5).
Regression Risk
Docs only, so nothing at runtime. The risk is readers and AI agents copying the "Setting it on the app" instructions. Two of them could lead someone setting up or debugging fulfilment to save settings that never take effect, or to read the wrong copy of the setting.
Prior findings
| Prior finding | Status | Evidence |
|---|---|---|
C1 minimumAmountTotal described as per-order protection |
resolved | L267–272; matches libs/app-payments.js:246 |
| C2 REST payload missing the guards; settings merge semantics | resolved | L339, L342–345; matches routes/v1/apps.js settings[key] = body[key] |
| C3 blank optional columns depend on #8572 | superseded | #8572 merged 2026-09-02T09:52Z; master has WHERE coalesce(data->>o.key, '') = '' |
W1 runUpdateHooks: true in the example |
resolved | L258 + the "Update hooks" section, which matches the double-fire notes in app-payments.js |
| W2 claimed every Stripe session is fulfilled | resolved | L232–236 |
W3 async_payment_failed not handled |
resolved | L108–111 |
| S: token length is 16 bytes, not 16 characters | resolved | L415; MIN_OWNERSHIP_TOKEN_LENGTH = 16 compared on Buffer length |
New findings
Two Warnings, inline. Both are in the "Setting it on the app" section, which shipped before the earlier review and wasn't covered by it.
Suggestions
- L416 is one long line; rewrap it to match the section.
- L295–296: the
statusColumncheck only runs when the data source has a non-emptycolumnsarray (app-payments.js:301-302). If that array is empty, a misspelled column is silently created. Worth half a sentence. - Regenerate
llms-full.txtafter the fixes.
Acceptance Criteria
PS-2138 has no listed acceptance criteria. As a docs follow-up, the page now matches master apart from the two settings-resolution points inline.
CI Status
Green: Validate docs, CircleCI build and Cloudflare Pages all pass.
Verdict
COMMENT. All earlier blocking findings are resolved or superseded. The two new Warnings should be fixed before approval. The earlier REQUEST_CHANGES needs dismissing or a re-review by a human.
| `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. |
There was a problem hiding this comment.
Warning: the JS example has no context where it both saves and is accepted.
Defect: On a live published app, Fliplet.App.Settings.set() gets a 403. In preview or Viewer it does nothing (L352–355). So the JS path saves nothing in both places this page sends readers.
Mechanism:
fliplet-coreApp.Settings.set()posts tov1/apps/<Fliplet.Env.get('appId')>/settings/.- On a published app, that id is the published copy.
POST /v1/apps/:id/settingshasapp.masterOnly, which returns403whenever the app has amasterAppId(libs/middlewares/app.js:196-198).- L349–350 already says the endpoint rejects a published app, so this line contradicts it.
- The example at L316–329 only attaches
.then, so the rejection goes unnoticed.
Required outcome: Point readers at the RESTful call (or an authenticated Studio-side request) against the master app id. Drop "run it on the live app", or say it only works where appId is the master. Add a .catch to the example.
There was a problem hiding this comment.
Agreed, fixed in 3ec72be. Confirmed App.Settings.set() posts to v1/apps/<Env appId>/settings/ and the route has app.masterOnly (routes/v1/apps.js:3117), so on a published app it gets a 403, and in preview or Viewer it never sends a request. I removed the JS example. "Setting it on the app" now uses only POST v1/apps/:masterAppId/settings with the complete object, links to Saving settings, says a published app id returns 403, and has a warning explaining why Fliplet.App.Settings.set() must not be used for this. "Run it on the live app" is gone.
| **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. |
There was a problem hiding this comment.
Warning: wrong account of which app's settings take effect.
Defect: At webhook time the published copy's paymentFulfilment is never read. Only the master's is.
Mechanism:
- The webhook's
context.appIdis the master id. Credentials and the binding are keyed onmasterAppId || id(routes/v1/apps-billing.js:847,:1068). - So in
recordAppPayment,appis the master andmasterAppisnull. resolveFulfilmenttherefore only sees the master's value, andallowedAppIdsis just[master.id].POST /checkout, by contrast, resolves fromreq.flApp(the published copy), whose own value wins there.
Consequences:
- Changes on the master to the guards,
statusColumn,paidValueorrunUpdateHookstake effect at the webhook immediately, without republishing. The page says the opposite. - If the copy's
dataSourceIdis stale, the binding minted at checkout (binding.dataSourceId) no longer matches the master's target (app-payments.js:211-213), and every payment is silently skipped. This is the real reason republishing matters, and it is the failure worth documenting. Settings.get('paymentFulfilment')on the published app shows the checkout-side copy, not the value the webhook uses, so the suggested diagnostic can mislead.- Same root cause at L293–294: "or its published copy" is wrong. A data source owned by the published copy fails
allowedAppIdsand is silently not recorded.
Required outcome:
- Say the webhook uses the master's value, while checkout (ownership check,
ownershipTokenColumn, the binding'sdataSourceId) uses the published copy's value. - Say the two must agree, and that a mismatched
dataSourceIdsilently records nothing. Hence: republish. - Drop "or its published copy" from L293–294.
There was a problem hiding this comment.
Agreed, fixed in 3ec72be. Confirmed: context.appId is the master (credentials are keyed payments-app-<masterAppId || id>, and the binding is minted with the same id), so recordAppPayment loads the master, masterApp is null, and allowedAppIds is [master.id]. POST /checkout resolves from req.flApp.
Changes:
- New subsection "Which copy of the setting is used": checkout (ownership check,
ownershipTokenColumn, the binding'sdataSourceId) uses the published copy; the webhook (guards,statusColumn,paidValue, optional columns,runUpdateHooks) uses the master's, and changes to those apply without republishing. - The two copies must name the same
dataSourceId. A mismatch silently records nothing, and that is now given as the reason to republish. Settings.get()on the published app is flagged as showing the checkout copy; to see the webhook's copy, readGET v1/apps/:masterAppId/settings.- Dropped "or its published copy" from the data-source rules: it must belong to the master.
I also checked that publishing copies paymentFulfilment unchanged (it is not in CLONE_OMIT_SETTINGS), and that a settings save copies only allowWebEmbed to the published app straight away.
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) <noreply@anthropic.com>
|
@galisufyan-327 thanks for the follow-up. Both warnings are fixed in 3ec72be; I have replied inline on each. Your three suggestions are done too:
Ready for another look. |
Updates
docs/API/fliplet-payments.md, which predates server-side payment fulfilment.Why
The page documents neither the feature nor the two things an app has to do to use it safely — and one existing instruction is actively wrong for anyone accepting one-off payments.
What changed
Webhook events. The page listed only the three
customer.subscription.*events. An app set up by following it would never have a one-off checkout recorded, because the API only acts on:Both are now listed, with a note on why
async_payment_succeededmatters (delayed payment methods settle later).paymentFulfilment. New section covering what it does, that it belongs on the master app, the mandatoryexpectedCurrency/minimumAmountTotalguards, and thatclient_reference_idis what names the row.The ownership check. New section on why the checkout route verifies the caller is entitled to the row it names, and the two ways that check passes — the data source's access rules, or
ownershipTokenColumn+flPurchaseTokenfor apps whose buyers have no account. Includes the 16-character minimum, and that the token is stripped before the payload reaches Stripe.It also carries the upgrade warning, which is the part most likely to catch people out:
paymentIncompletevspaymentPending. These mean different things — a verdict versus the absence of one — and telling a buyer their card was not charged while the charge is in flight is how a second charge happens. Also notes that a blocked pop-up surfaces aspaymentIncomplete, which is a common source of confused reports.Correction. The checkout example documented
response.transactionDetails. The API returns no such field —Checkout.create()resolves with the checkout session (id,currency,customer,customer_details,customer_email).Verification
Every statement was checked against
Fliplet/fliplet-api@master:libs/app-payments.jsfor the event list, the fulfilment settings and the merge behaviour;routes/v1/apps-billing.jsfor the ownership check and token handling;public/assets/fliplet-payments/1.0/payments.jsfor what the promise resolves and rejects with.🤖 Generated with Claude Code
https://claude.ai/code/session_01LyqiysfcPv4FXp1ZMSUoe5