openreceive 0.4.25 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,492 @@
1
+ This is the full file; follow it from Step 0.
2
+
3
+ # OpenReceive agent directions (mobile apps)
4
+
5
+ ```sh
6
+ # READ THIS FIRST: this file is 26 KB and a summary drops required steps. Download it whole:
7
+ curl -fsSL https://openreceive.org/agent-directions/mobile/full.md
8
+ # Skip the download only if you already have all of it: pasted, read from disk or fetched raw.
9
+ ```
10
+
11
+ **Step 0 in brief** (Step 0 below has the details):
12
+
13
+ - Find the app's server first: OpenReceive runs there, and the app only opens its checkout page.
14
+ - Before installing or editing anything, ask the user for a receive-only NWC code. One question per message.
15
+ - Next ask for the LSC code. If the user wants stablecoins, USDT, USDC, ETH, SOL or crypto, do not ask yes/no first.
16
+ - Write each code into the server's env file yourself. Never into the app, its config or an EXPO_PUBLIC_ name.
17
+ - The app never decides paid: it asks the server. A link back to the app is navigation only.
18
+ - Do not suggest rotating or revoking a code because it was pasted here.
19
+
20
+ These directions describe OpenReceive 0.4.27.
21
+
22
+ Add Bitcoin checkout, with optional USDT, USDC, ETH and SOL, to a mobile app:
23
+ React Native or Expo, Flutter, Swift or Kotlin. OpenReceive has no mobile SDK
24
+ and an app does not need one. OpenReceive runs on the app's server, which keeps
25
+ the wallet code and serves a checkout page. The app opens that page in a
26
+ browser tab and asks its server whether the order is paid. The mobile apps
27
+ guide is appended to this file in full.
28
+
29
+ So this is two jobs: add OpenReceive to the app's server with that server's
30
+ own agent directions, then make the app open the checkout and read the result.
31
+ Do not install `@openreceive/*` packages into the app itself, and do not build
32
+ a native payment screen.
33
+
34
+ ## Step 0 — what the app sells, where its server is, and the two codes
35
+
36
+ 1. **What does the app sell?** Read the app's product, cart and order code.
37
+ Physical goods, and services used outside the app (food, rides, tickets,
38
+ lodging), may be paid this way in every region. Digital goods, subscriptions, unlocks, and
39
+ tips tied to content must use the store's in-app purchase, except through a
40
+ link-out program (the guide's "What the app stores allow" below). If the app
41
+ sells digital goods, stop and tell the user that, with that section's table,
42
+ before you build anything. Go on only if they say a link-out program covers
43
+ them.
44
+ 2. **Find the app's server.** The app's API base URL (in its config, its env,
45
+ or its API client) names it. It may be in this workspace (a `server/`,
46
+ `api/` or `backend/` folder) or in another project. Name its stack, and the
47
+ directions that go with it. For Express they are
48
+ https://openreceive.org/agent-directions/node/full.md, and for another stack
49
+ put its name in place of `node`: `fastify`, `next`, `nestjs`, `fastapi`,
50
+ `django`, `flask`, `rails`, `php`, `laravel`, `sveltekit`, `nuxt`,
51
+ `astro`, `react-router` or `tanstack-start`.
52
+ - **The server is not in this workspace.** OpenReceive is added there, by
53
+ whoever edits it. Tell the user so, give them that URL for the agent that
54
+ edits the server, and stop. Come back for Step 2 once the server serves
55
+ the checkout link.
56
+ - **The app has no server of its own,** only Supabase or Firebase. OpenReceive
57
+ needs one, with a Postgres database: Supabase's, or a new one for a
58
+ Firestore app. Ask the user which stack to add, suggest one in a language
59
+ the team already uses, and add it in this workspace.
60
+
61
+ Do not edit the app or the server yet.
62
+
63
+ Then your next action is a question to the user. Do not search the app, the
64
+ server, deploy config, Docker containers or process environments for a code,
65
+ and never run `printenv`: a new shop has none, and hunting for one can leak
66
+ other secrets into the transcript. If the server's env file already sets
67
+ them, `grep -E '^(NWC_URI|LSC_URI_PRIMARY)=.' <file> | cut -d= -f1` names the
68
+ ones it sets without printing them; a code that is set is not asked for again.
69
+
70
+ Two codes are needed, and both live only in the server's environment:
71
+
72
+ - `NWC_URI`: a receive-only Nostr Wallet Connect code,
73
+ `nostr+walletconnect://…`. Required for Bitcoin.
74
+ - `LSC_URI_PRIMARY`: a Lightning Swap Connect URI,
75
+ `lightning+swapconnect://…`. Required for USDT, USDC, ETH and SOL. Skip it
76
+ only when the user says they want Bitcoin alone.
77
+
78
+ Never put either in the app: not in its code, `app.json`, `Info.plist`, a
79
+ `BuildConfig` field, a bundled `.env`, or any `EXPO_PUBLIC_` name. Anyone who
80
+ downloads an app can read what it ships with.
81
+
82
+ The user never edits an environment file. They paste each code into this chat;
83
+ you store it. Ask one question per message.
84
+
85
+ 1. **First message — the NWC code, and nothing else.** Ask for it and walk them
86
+ through getting it:
87
+
88
+ > To receive payments I need a receive-only wallet code. In Rizful: open
89
+ > the menu, tap NWC, choose Receive-only NWC code, and tap Copy
90
+ > (https://openreceive.org/get_a_nwc_code_to_receive_payments). If you would
91
+ > rather run your own wallet, Alby Hub works too: Connections → Add
92
+ > Connection → Read Only. Paste the code here and I will store it.
93
+
94
+ 2. **When they paste it.** If it does not start with `nostr+walletconnect://`,
95
+ ask them to copy the receive-only code again. Otherwise write
96
+ `NWC_URI=<paste>` into the server's env file with your file-editing tool:
97
+ `.env.local` in a Next.js server, `.env` in the server's root for every
98
+ other stack. Never write it with a shell command, which shows the code.
99
+ Make sure `.gitignore` covers the file. Never echo the value. Reply only
100
+ that it is saved, then ask the next question.
101
+ 3. **Second message — swaps.** If the user asked for stablecoins, USDT, USDC,
102
+ ETH, SOL, altcoins or "crypto", this message IS the walkthrough below: send
103
+ it as it is, and do not ask yes or no first. Otherwise ask whether payers
104
+ should also be able to pay with USDT, USDC, ETH or SOL, then give the
105
+ walkthrough:
106
+
107
+ > Go to https://lightning-swap.com, sign in for API keys, create a key, and
108
+ > copy the whole URI (https://openreceive.org/set_up_swap_provider). Paste
109
+ > it here and I will store it — or say "Bitcoin only" and I will continue
110
+ > without it.
111
+
112
+ 4. **When they paste it.** If it does not start with
113
+ `lightning+swapconnect://`, ask them to copy it again. Otherwise add
114
+ `LSC_URI_PRIMARY=<paste>` to the same file, the same way. Swaps run on the
115
+ checkout page, so the app needs nothing more for them.
116
+
117
+ ## Step 1 — the server
118
+
119
+ Download the server's directions with your shell and follow them, from the
120
+ rest of their Step 0 (loading the env file, restarting) through their
121
+ quickstart and hand-over checks. The two codes are already saved, so skip
122
+ their questions. Their checkout page, in a desktop browser, comes first.
123
+
124
+ Then add the mobile half, from the guide's "The checkout link" below, in that
125
+ stack's own idiom. The guide's code is Express; translate it:
126
+
127
+ - A route the app calls with its own sign-in, which returns a one-time link to
128
+ the checkout page for one of that user's orders. Store only a hash of the
129
+ link's token, for ten minutes and one use.
130
+ - The page route: it consumes the token, sets an HttpOnly cookie for that
131
+ order's payer, redirects to itself without the token, and serves the
132
+ checkout, plus a link back to the app once it settles.
133
+ - `authorize` also lets in the browser that holds that order's cookie. If the
134
+ server serves a website too, keep its own check: either one passes.
135
+
136
+ ## Step 2 — the app
137
+
138
+ Add the guide's snippet for the app's platform, from "Open the checkout from
139
+ the app" below: ask the server for the link, open it in a browser tab
140
+ (`expo-web-browser`, `url_launcher` in `inAppBrowserView`,
141
+ `SFSafariViewController`, or Custom Tabs), and ask the server for the order
142
+ when the payer comes back: by the link back, by closing the tab, or by
143
+ returning the app to the foreground. If the app has no URL scheme for the link
144
+ back, add one.
145
+
146
+ ## After the integration — hand over, then stop
147
+
148
+ The server side is done when its directions' checks pass, the checkout-link
149
+ route answers for a signed-in user's order, the link opens the checkout in a
150
+ desktop browser, and the same link answers 404 the second time. The app side
151
+ is done when it builds and its pay action opens the page.
152
+
153
+ You cannot pay the invoice: the code is receive-only. Do not pay, settle or
154
+ mark an order paid, and do not look for a way to. In one line of your closing
155
+ message, give the user the real check: on a phone, pay a small order from
156
+ their own wallet, come back to the app, and see the order shown as paid. Then
157
+ stop.
158
+
159
+ ## Non-negotiables
160
+
161
+ - Do not suggest rotating, revoking or replacing a code because it was pasted
162
+ into this chat; that is the supported path.
163
+ - The codes live only in the server's environment, never in the app or its
164
+ build config.
165
+ - The app never decides "paid", from a link back, a timer or the page's
166
+ contents. It asks the server, which `onPaid` updates when the payment
167
+ settles.
168
+ - No native checkout screen, swap picker or wallet list in the app. The
169
+ checkout, its swap screens and its refund way back belong on the page.
170
+ - A new one-time link each time the app opens the page; never reuse one.
171
+ - No digital goods through the checkout outside a store's link-out program.
172
+
173
+ ## More documentation
174
+
175
+ Fetch one when the moment comes. Each is raw markdown, so a plain GET is
176
+ enough; drop the `.md` for the same page a person would read.
177
+
178
+ - https://openreceive.org/guides/authorization.md — how `authorize` sees the request, before you add the cookie check
179
+ - https://openreceive.org/guides/frontend-checkout.md — the checkout page's element and React component, and their settled event
180
+ - https://openreceive.org/guides/supabase.md — when the database is Supabase
181
+ - https://openreceive.org/guides/serverless.md — when the server is a function host: Vercel, Netlify, AWS Lambda, Cloud Run
182
+ - https://openreceive.org/guides/swap-refunds.md — the refund flow on the page, before you turn swaps on
183
+ - https://openreceive.org/guides/lightning-swap-connect.md — what an `LSC_URI_*` code actually is
184
+ - https://openreceive.org/guides/security.md and https://openreceive.org/guides/deploying.md — before this goes anywhere real
185
+ - https://openreceive.org/guides.md — the index, if what you need is not above
186
+
187
+ Questions, or a problem with the library itself:
188
+ https://openreceive.org/contact
189
+
190
+ ---
191
+
192
+ ## The quickstart, in full
193
+
194
+ Inlined verbatim so this file needs no network access. Steps 1 and 2 above say when to use each part: its server code is an example in Express, for the server's own stack.
195
+ The page it comes from is https://openreceive.org/guides/mobile-apps.
196
+
197
+ ## Bitcoin checkout in mobile apps
198
+
199
+ OpenReceive has no mobile SDK, and an app does not need one. Your server keeps
200
+ the wallet code and creates each Lightning invoice. The app opens a checkout
201
+ page that your server serves, and the payer pays from their own wallet app. The
202
+ payment goes straight to your wallet. There is no OpenReceive account, no API
203
+ key and no processor in between.
204
+
205
+ Optional swaps let customers pay with **USDT, USDC, SOL, and ETH**. A swap
206
+ provider you configure converts the payment to **BTC over Lightning**, and it
207
+ settles into the same wallet. Available assets and networks depend on the
208
+ provider. Swaps run on the checkout page, so the app needs nothing extra for
209
+ them.
210
+
211
+ This guide covers React Native and Expo, Flutter, Swift and Kotlin apps. Before
212
+ you build, read [What the app stores allow](#what-the-app-stores-allow): what
213
+ you sell decides whether the app may take the payment at all.
214
+
215
+ ### How it works
216
+
217
+ 1. The app asks your server for a checkout link for one order, with the app's
218
+ own sign-in.
219
+ 2. Your server makes a one-time link to its checkout page for that order.
220
+ 3. The app opens the link in a browser tab: Safari View Controller on iOS,
221
+ Custom Tabs on Android.
222
+ 4. The page swaps the one-time link for a cookie that lets this browser pay
223
+ this order, and shows the checkout. The payer pays with Lightning, or with a
224
+ swap.
225
+ 5. Your server's `onPaid` hook marks the order paid when the wallet confirms the
226
+ payment.
227
+ 6. The payer comes back to the app, by closing the tab or with a link back. The
228
+ app asks your server whether the order is paid.
229
+
230
+ Two rules hold throughout:
231
+
232
+ - **The wallet code never ships in the app.** `NWC_URI` and `LSC_URI_PRIMARY`
233
+ live only in your server's environment. Anything inside an app package can be
234
+ read by anyone who downloads it.
235
+ - **The app trusts only your server.** A link back to the app says the payer
236
+ is done looking at the page, not that they paid. The order's paid state comes
237
+ from your server, which `onPaid` updates inside the settlement transaction.
238
+
239
+ ### What the app stores allow
240
+
241
+ Both stores make digital purchases go through their own billing, and both
242
+ exempt physical goods and real-world services. Bitcoin is no exception: Apple's
243
+ guideline 3.1.1 names "cryptocurrencies and cryptocurrency wallets" among the
244
+ mechanisms an app may not use to unlock content or features.
245
+
246
+ | What the payment is for | Apple (iOS) | Google Play | Checkout in the app |
247
+ | --- | --- | --- | --- |
248
+ | Physical goods, and services used outside the app (food, rides, tickets, lodging, bill pay) | Must not use in-app purchase (3.1.3(e)) | Must not use Play billing | Yes, every region |
249
+ | Real-time one-to-one services (tutoring, consultations, fitness training) | Other payment methods allowed (3.1.3(d)). One-to-few and one-to-many services must use in-app purchase | Not on Google's list of exceptions; check the policy | iOS yes; check Google first |
250
+ | A gift from one person to another | Allowed without in-app purchase if it is optional and 100% goes to the receiver. A gift tied to digital content must use in-app purchase (3.2.1(vii)) | Peer-to-peer payments must not use Play billing | Yes, with care |
251
+ | Donations | In the app only for Apple-approved nonprofits that also offer Apple Pay (3.2.1(vi)). Otherwise collect outside the app, such as in Safari (3.2.2(iv)) | Tax-exempt donations must not use Play billing | Android yes; iOS in Safari |
252
+ | Digital goods, subscriptions, unlocks, tips tied to content | In-app purchase, except through a link-out program below | Play billing, except through a program below | Only through a link-out program |
253
+
254
+ Sources: [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/),
255
+ [Google Play Payments policy](https://support.google.com/googleplay/android-developer/answer/9858738).
256
+
257
+ #### Digital goods: the link-out programs
258
+
259
+ Some storefronts let an app send the buyer to a website to pay for digital
260
+ goods. The store still takes a commission on those sales, and that commission
261
+ applies when the buyer pays in bitcoin too.
262
+
263
+ | Storefront | Apple | Google Play |
264
+ | --- | --- | --- |
265
+ | United States | No entitlement needed for buttons and links to the web (3.1.1(a)). A court barred Apple from charging a commission on these purchases in 2025. The appeals court has since said Apple may charge some commission, and the case is still before the courts ([Apple's 10-Q, June 2026](https://www.sec.gov/Archives/edgar/data/0000320193/000032019326000020/aapl-20260627.htm)) | [External content links program](https://support.google.com/googleplay/android-developer/answer/16470497): enroll first, then pay service fees; reporting and fees start October 1, 2026 |
266
+ | European Union | [Out-of-app offers](https://developer.apple.com/support/communication-and-promotion-of-offers-on-the-app-store-in-the-eu/): 15% commission (10% for small businesses and for subscriptions after their first year) from October 1, 2026 | [External offers program](https://support.google.com/googleplay/android-developer/answer/14372887): enroll first, then pay service fees |
267
+ | Japan | [Out-of-app offers](https://developer.apple.com/support/payment-options-on-the-app-store-in-japan/), iOS 26.2 or later: 15% (10%) | Not covered here |
268
+ | Brazil | [Out-of-app offers](https://developer.apple.com/support/payment-options-on-the-app-store-in-brazil), iOS 26.5 or later, buyers 18 or older: 15% (10%) | Not covered here |
269
+ | Everywhere else | In-app purchase only | Play billing only |
270
+
271
+ In the EU, Japan and Brazil, Apple's commission covers sales made within 7 days
272
+ of the link tap. The app must also offer in-app purchase on the same screen, at
273
+ least as prominently as the link, and show Apple's disclosure sheet before it
274
+ opens the browser. Open the link in the browser app, not a tab inside the app:
275
+ the Japan and Brazil terms say so. Kids-category apps may not link out to a
276
+ website at all.
277
+
278
+ The table is a summary of the stores' pages as of October 2026, and both
279
+ stores change these terms often. It is not legal advice. Read the linked pages
280
+ before you submit a build. We have not seen an App Store or Google Play review
281
+ decision about a Lightning checkout for physical goods, either way.
282
+
283
+ ### Your server
284
+
285
+ The checkout page and the payment routes run on your server, from any
286
+ OpenReceive quickstart: [Express](https://openreceive.org/guides/quickstart-node.md),
287
+ [Fastify](https://openreceive.org/guides/quickstart-fastify.md), [Next.js](https://openreceive.org/guides/quickstart-next.md),
288
+ [NestJS](https://openreceive.org/guides/quickstart-nestjs.md), [Rails](https://openreceive.org/guides/quickstart-rails.md),
289
+ [FastAPI](https://openreceive.org/guides/quickstart-fastapi.md), [Django](https://openreceive.org/guides/quickstart-django.md),
290
+ [Flask](https://openreceive.org/guides/quickstart-flask.md), [PHP](https://openreceive.org/guides/quickstart-php.md) or
291
+ [Laravel](https://openreceive.org/guides/quickstart-laravel.md).
292
+
293
+ An app built on a backend-as-a-service has functions instead of a server.
294
+ On Supabase, see [Bitcoin checkout with Supabase](https://openreceive.org/guides/supabase.md). For function
295
+ hosts such as Vercel, Netlify, AWS Lambda and Cloud Run, see
296
+ [Bitcoin checkout on serverless hosts](https://openreceive.org/guides/serverless.md). An app whose only
297
+ database is Firestore needs a Postgres database for its payment records
298
+ (Supabase or Neon both have free plans), because OpenReceive stores them in
299
+ SQL.
300
+
301
+ ### The checkout link
302
+
303
+ The app is signed in to your API, usually with a token. The browser tab the app
304
+ opens is not: it has no access to the app's token. So the server hands the app
305
+ a one-time link, and the page turns it into a cookie that only this order's
306
+ payer holds. OpenReceive mints no tokens. This code is yours.
307
+
308
+ With Express, after the [Express quickstart](https://openreceive.org/guides/quickstart-node.md), and
309
+ `cookie-parser` mounted:
310
+
311
+ ```ts
312
+ import { createHash, randomBytes } from "node:crypto";
313
+
314
+ const hash = (value: string) => createHash("sha256").update(value).digest("hex");
315
+
316
+ // The app asks for a link to pay one of its user's orders.
317
+ app.post("/api/orders/:id/checkout-link", requireAppUser, async (req, res) => {
318
+ const order = await orders.find(req.params.id);
319
+ if (!order || order.userId !== req.user.id) return res.sendStatus(404);
320
+ const once = randomBytes(32).toString("base64url");
321
+ // Store only a hash, valid for ten minutes and for one use.
322
+ await orders.saveLinkToken(order.id, hash(once), Date.now() + 10 * 60_000);
323
+ res.json({ url: `https://shop.example.com/pay/${order.id}?t=${once}` });
324
+ });
325
+
326
+ // The browser tab opens the link. The one-time token becomes a cookie.
327
+ app.get("/pay/:id", async (req, res) => {
328
+ const order = await orders.find(req.params.id);
329
+ if (!order) return res.sendStatus(404);
330
+ if (typeof req.query.t === "string") {
331
+ // Consumes the token: a second visit with the same link gets nothing.
332
+ if (!(await orders.useLinkToken(order.id, hash(req.query.t)))) return res.sendStatus(404);
333
+ const buyer = randomBytes(32).toString("base64url");
334
+ await orders.saveBuyer(order.id, hash(buyer));
335
+ res.cookie(`pay_${order.id}`, buyer, { httpOnly: true, secure: true, sameSite: "lax" });
336
+ return res.redirect(303, `/pay/${order.id}`); // the token leaves the address bar
337
+ }
338
+ res.send(checkoutPage(order.id)); // the HTML below
339
+ });
340
+ ```
341
+
342
+ Then `authorize`, in the options you pass `openReceiveExpress`, lets in only
343
+ the browser that holds that order's cookie:
344
+
345
+ ```ts
346
+ authorize: async ({ native, resource }) => {
347
+ const buyer = native.cookies[`pay_${resource.reference}`];
348
+ return typeof buyer === "string" && (await orders.buyerMatches(resource.reference, hash(buyer)));
349
+ },
350
+ ```
351
+
352
+ The page is the [standalone checkout](https://openreceive.org/guides/frontend-checkout.md#without-a-bundler)
353
+ plus a way back to the app once the order is paid:
354
+
355
+ ```html
356
+ <link rel="stylesheet" href="/static/openreceive/openreceive-checkout.css" />
357
+ <script type="module" src="/static/openreceive/openreceive-checkout.js"></script>
358
+
359
+ <openreceive-checkout id="checkout" reference="ord_123" prefix="/openreceive">
360
+ </openreceive-checkout>
361
+ <p id="done" hidden>Paid. <a href="myshop://orders/ord_123">Back to the app</a></p>
362
+ <script>
363
+ document.getElementById("checkout").addEventListener("openreceive-settled", () => {
364
+ document.getElementById("done").hidden = false;
365
+ });
366
+ </script>
367
+ ```
368
+
369
+ `myshop://` is the app's own URL scheme. A universal link (iOS) or App Link
370
+ (Android) works too, but only on a different domain from the checkout page:
371
+ iOS keeps a link to the page's own domain in the browser.
372
+
373
+ A React page works the same way with `<Checkout>` and its `onSettled` prop.
374
+ See [Frontend checkout](https://openreceive.org/guides/frontend-checkout.md).
375
+
376
+ ### Open the checkout from the app
377
+
378
+ Each snippet asks the server for the link, opens it, and checks the order with
379
+ the server when the payer comes back. `api`, `token` and `fetchOrder` stand for
380
+ the app's own client code.
381
+
382
+ React Native with Expo (`expo-web-browser`):
383
+
384
+ ```ts
385
+ import * as WebBrowser from "expo-web-browser";
386
+ import * as Linking from "expo-linking";
387
+
388
+ export async function pay(orderId: string) {
389
+ const res = await fetch(`${api}/api/orders/${orderId}/checkout-link`, {
390
+ method: "POST",
391
+ headers: { Authorization: `Bearer ${token}` },
392
+ });
393
+ const { url } = await res.json();
394
+ // Safari View Controller on iOS, Custom Tabs on Android. On iOS this
395
+ // resolves when the payer closes it; on Android, as soon as it opens.
396
+ await WebBrowser.openBrowserAsync(url);
397
+ }
398
+
399
+ // The "Back to the app" link: close the tab (iOS) and check the order.
400
+ Linking.addEventListener("url", ({ url }) => {
401
+ const { path } = Linking.parse(url);
402
+ if (path?.startsWith("orders/")) {
403
+ WebBrowser.dismissBrowser();
404
+ fetchOrder(path.slice("orders/".length));
405
+ }
406
+ });
407
+ // Also check the order when the app returns to the foreground
408
+ // (AppState "active"), for a payer who closed the tab instead.
409
+ ```
410
+
411
+ Flutter (`url_launcher`):
412
+
413
+ ```dart
414
+ import 'dart:convert';
415
+ import 'package:http/http.dart' as http;
416
+ import 'package:url_launcher/url_launcher.dart';
417
+
418
+ Future<void> pay(String orderId) async {
419
+ final res = await http.post(
420
+ Uri.parse('$api/api/orders/$orderId/checkout-link'),
421
+ headers: {'Authorization': 'Bearer $token'},
422
+ );
423
+ final url = Uri.parse(jsonDecode(res.body)['url'] as String);
424
+ // Custom Tabs on Android, Safari View Controller on iOS.
425
+ await launchUrl(url, mode: LaunchMode.inAppBrowserView);
426
+ }
427
+ // Check the order when the app resumes (a WidgetsBindingObserver's
428
+ // didChangeAppLifecycleState) and when myshop://orders/<id> arrives.
429
+ ```
430
+
431
+ Swift (`SafariServices`):
432
+
433
+ ```swift
434
+ import SafariServices
435
+
436
+ final class PayController: NSObject, SFSafariViewControllerDelegate {
437
+ func pay(orderId: String, from presenter: UIViewController) async throws {
438
+ var request = URLRequest(url: api.appending(path: "api/orders/\(orderId)/checkout-link"))
439
+ request.httpMethod = "POST"
440
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
441
+ let (data, _) = try await URLSession.shared.data(for: request)
442
+ let link = try JSONDecoder().decode(CheckoutLink.self, from: data)
443
+ let safari = SFSafariViewController(url: link.url)
444
+ safari.delegate = self
445
+ presenter.present(safari, animated: true)
446
+ }
447
+
448
+ // The payer closed the tab: ask the server whether the order is paid.
449
+ func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
450
+ Task { await fetchOrder() }
451
+ }
452
+ }
453
+
454
+ struct CheckoutLink: Decodable { let url: URL }
455
+ ```
456
+
457
+ Kotlin (`androidx.browser`):
458
+
459
+ ```kotlin
460
+ import androidx.browser.customtabs.CustomTabsIntent
461
+
462
+ suspend fun pay(activity: Activity, orderId: String) {
463
+ val url = api.checkoutLink(orderId) // POST /api/orders/{id}/checkout-link with the app's token
464
+ CustomTabsIntent.Builder().build().launchUrl(activity, Uri.parse(url))
465
+ }
466
+
467
+ // In the activity: the payer is back, so ask the server.
468
+ override fun onResume() {
469
+ super.onResume()
470
+ viewModel.refreshOrder()
471
+ }
472
+ ```
473
+
474
+ For a digital purchase in a link-out program, open the link in the browser app
475
+ instead, through the store's own API: Apple's `ExternalPurchaseCustomLink` in the
476
+ EU, Japan and Brazil, a plain link in the US storefront, and Google's external
477
+ links APIs once enrolled. See the links in
478
+ [the program table](#digital-goods-the-link-out-programs).
479
+
480
+ ### What not to do
481
+
482
+ - Do not build the payment into a native screen with a swap picker. The swap
483
+ flow, its refund screens and its way back belong on the checkout page.
484
+ - Do not decide "paid" in the app, from a link back, a timer or the page's
485
+ contents. Ask your server.
486
+ - Do not reuse a checkout link. Make a new one each time the app opens the
487
+ page; the cookie keeps a payer who reloads the page in.
488
+ - Do not sell digital goods through the checkout outside the link-out
489
+ programs. A build that does is likely to be rejected.
490
+
491
+ To test the server side before you ship the app, see
492
+ [Testing your OpenReceive integration](https://openreceive.org/guides/host-testing.md).
@@ -3,7 +3,7 @@ This is the full file; follow it from Step 0.
3
3
  # OpenReceive agent directions (NestJS)
4
4
 
5
5
  ```sh
6
- # READ THIS FIRST: this file is 41 KB and a summary drops required steps. Download it whole:
6
+ # READ THIS FIRST: this file is 43 KB and a summary drops required steps. Download it whole:
7
7
  curl -fsSL https://openreceive.org/agent-directions/nestjs/full.md
8
8
  # Skip the download only if you already have all of it: pasted, read from disk or fetched raw.
9
9
  ```
@@ -17,7 +17,7 @@ curl -fsSL https://openreceive.org/agent-directions/nestjs/full.md
17
17
  - Do not suggest rotating or revoking a code because it was pasted here.
18
18
  - Start the quickstart only once the NWC code is saved, and the LSC code is saved or the user said "Bitcoin only".
19
19
 
20
- These directions describe OpenReceive 0.4.25.
20
+ These directions describe OpenReceive 0.4.27.
21
21
 
22
22
  Add OpenReceive to a NestJS application — the app you are already working in,
23
23
  on Nest's default Express platform. You do not need a copy of the OpenReceive
@@ -68,7 +68,9 @@ server load the file), then the quickstart.
68
68
  On a hosted builder (v0, Vercel, Replit, Lovable), the user may say instead
69
69
  that both codes are already set as the project's environment variables or
70
70
  secrets. Believe them: do not ask for the codes, do not write `.env`, and
71
- do not read or print the variables. Skip step 5's file loading: the platform already puts them in `process.env`.
71
+ do not read or print the variables. Skip step 5's file loading: the platform already puts them in `process.env`. Do not add them to a compose file or any other deploy config
72
+ either: they already reach the app, and a line such as `NWC_URI: ${NWC_URI}`
73
+ replaces them with an empty value.
72
74
 
73
75
  Otherwise your next action is a question to the user. Do not install packages,
74
76
  edit the app, write `.env.example`, or search anywhere else before asking
@@ -129,6 +131,8 @@ you store it. Ask one question per message.
129
131
  it. Otherwise put `import "dotenv/config";` first in `src/main.ts`, as the
130
132
  quickstart does. When the app is started with Docker Compose, give the
131
133
  service `env_file: .env`. Restart the server after writing the file.
134
+ Under Compose, recreate the container with `docker compose up -d`:
135
+ `docker compose restart` keeps its old environment.
132
136
 
133
137
  Do not invent placeholder URIs. Start the quickstart only once `NWC_URI` is
134
138
  saved and `LSC_URI_PRIMARY` is saved or explicitly declined. The first boot
@@ -186,10 +190,9 @@ The quickstart below has the code. These are the rules it cannot state for
186
190
  itself, and they hold for every integration.
187
191
 
188
192
  - Mount OpenReceive with `app.use(openReceiveExpress(...))` in `src/main.ts`:
189
- after the app's own session, cookie-parser or Passport middleware, before
190
- `app.listen()`, with `app.useBodyParser("json")` right before it. Nest adds
191
- its own JSON parser only at listen, after the mount, so without that line
192
- every checkout request fails with a 500.
193
+ after the app's own session, cookie-parser or Passport middleware, and
194
+ before `app.listen()`. The adapter reads the JSON body itself, so it needs
195
+ no `app.useBodyParser("json")`.
193
196
  - Nest's guards, interceptors and pipes do not run for middleware. `authorize`
194
197
  makes the same ownership check the app's order page makes, by calling the
195
198
  service or helper its guard uses. Do not wrap OpenReceive in a controller to
@@ -253,7 +256,12 @@ itself, and they hold for every integration.
253
256
  such window. On the drop-ins: `resumePaymentHash`, fed from `onState`, on
254
257
  `<Checkout>`; the `resume-payment-hash` attribute, fed from the
255
258
  `openreceive-state` event (`event.detail.state.payment_hash`), on
256
- `<openreceive-checkout>`. https://openreceive.org/guides/swap-refunds.md
259
+ `<openreceive-checkout>`.
260
+ That visit can come hours later, and `authorize` must still know the payer:
261
+ a session that expired in between answers 403 and strands the deposit. On a
262
+ guest shop, tie the order to a long-lived signed cookie, or put a secret
263
+ per-order token in its URL that your page trades for that cookie.
264
+ https://openreceive.org/guides/swap-refunds.md
257
265
  - Show the payer WHAT THEY ARE BUYING. Return an optional `description` beside
258
266
  the price from `amountFor` and both drop-ins render it above the amount.
259
267
  Without it the checkout is a QR and "$1.00" with no sign of what the dollar
@@ -305,6 +313,13 @@ products, and you do not join them.
305
313
  expired invoice does not cancel the order; a later checkout may mint another
306
314
  attempt. The library refuses a new checkout under a reference that already
307
315
  settled (409).
316
+ - **A paid order's page says it is paid and shows no checkout.** Nothing is left
317
+ to pay, and a checkout cannot start a new payment for a paid order. Mount the
318
+ checkout only while the order is unpaid. When it settles on the page, update
319
+ the page's status line from `onSettled` on `<Checkout>`, or the
320
+ `openreceive-settled` event on `<openreceive-checkout>`: `onPaid` has already
321
+ marked the order paid. Keep that checkout mounted, because it is the payer's
322
+ receipt.
308
323
  - **Pass this app's `db` handle.** Do not add a Prisma/Drizzle relation from
309
324
  Order to `openreceive_payments`, and do not implement `PaymentRepository`
310
325
  unless no supported handle can reach this database. `reference` is not unique
@@ -317,6 +332,30 @@ products, and you do not join them.
317
332
  only in the hook; emails, jobs, and pushes after commit. Placeholder style is
318
333
  the dialect you declared: `?` on sqlite, `$1` on postgres.
319
334
 
335
+ ### If the database is Supabase
336
+
337
+ Supabase is Postgres, so the tables above go there like any others. Three
338
+ things differ; https://openreceive.org/guides/supabase.md has each in full.
339
+
340
+ - Connect through its transaction pooler (port `6543`, user
341
+ `postgres.<project-ref>`). Run migrations through the session pooler
342
+ (port `5432` on the same host).
343
+ - Verify Supabase's certificate authority: delete `sslmode` from the URL and
344
+ give `pg` `ssl: { ca }`, the text of the project's `prod-ca-2021.crt` (an
345
+ environment variable such as `SUPABASE_CA_CERT`; it is not a secret). Never
346
+ `rejectUnauthorized: false`.
347
+ - After the migration, close both tables to Supabase's Data API, or anyone
348
+ with the project's anon key can read `swap_data`:
349
+
350
+ ```sql
351
+ alter table openreceive_payments enable row level security;
352
+ alter table openreceive_meta enable row level security;
353
+ revoke all on table openreceive_payments, openreceive_meta from anon, authenticated;
354
+ ```
355
+
356
+ `npx openreceive scaffold payments --supabase` writes the HTTPS-API route,
357
+ which is for Cloudflare Workers only. A Node server uses the connection above.
358
+
320
359
  ## If you build your own checkout UI
321
360
 
322
361
  The drop-ins (`<Checkout>`, `<openreceive-checkout>`) already obey all of this.
@@ -428,17 +467,20 @@ NestJS on its default Express platform. Requires Node ≥ 22 and NestJS ≥ 10.
428
467
 
429
468
  OpenReceive mounts in `main.ts` as Express middleware, with
430
469
  `@openreceive/express`, the adapter the [Express quickstart](https://openreceive.org/guides/quickstart-node.md)
431
- uses. There is no Nest module to import. Three things differ from a plain
470
+ uses. There is no Nest module to import. Two things differ from a plain
432
471
  Express app, and this page is about them:
433
472
 
434
- - Nest adds its JSON body parser only when the app starts listening, after
435
- anything `main.ts` mounts. Add it before the mount with
436
- `app.useBodyParser("json")`.
437
473
  - The hooks reach the app's own services through `app.get()`: the order
438
474
  service, and the TypeORM `DataSource` or the Prisma service.
439
475
  - Guards, interceptors and pipes run for controllers, not for middleware. So
440
476
  `authorize` makes the same check your guard makes.
441
477
 
478
+ Nest adds its JSON body parser only when the app starts listening, after
479
+ anything `main.ts` mounts. From 0.4.27 the adapter reads the body itself, so
480
+ the mount needs nothing for it. With 0.4.26 or older, add
481
+ `app.useBodyParser("json")` right before the mount, or every checkout answers
482
+ 500.
483
+
442
484
  ### 1. Install
443
485
 
444
486
  ```sh
@@ -542,9 +584,6 @@ async function bootstrap() {
542
584
  // stay above the mount, so the session is on the request when authorize runs.
543
585
  const orders = app.get(OrdersService);
544
586
  const auth = app.get(AuthService);
545
- // Nest adds its JSON body parser only at listen, after this mount. Without
546
- // this line every checkout request fails with a 500.
547
- app.useBodyParser("json");
548
587
  app.use(
549
588
  openReceiveExpress({
550
589
  wallet: { nwc: process.env.NWC_URI! }, // receive-only NWC code; your app refuses to start otherwise