This is the full file; follow it from Step 0.

OpenReceive agent directions (TanStack Start)

# READ THIS FIRST: this file is 39 KB and a summary drops required steps. Download it whole:
curl -fsSL https://openreceive.org/agent-directions/tanstack-start/full.md
# Skip the download only if you already have all of it: pasted, read from disk or fetched raw.

Step 0 in brief (Step 0 below has the details):

These directions describe OpenReceive 0.4.26.

Add OpenReceive to a TanStack Start application on Node — the app you are already working in, with its own Postgres. OpenReceive ships no TanStack Start package: the integration is one server route that hands each request to @openreceive/http. You do not need a copy of the OpenReceive source: the packages are on npm, and the TanStack Start recipe is appended to this file in full as the quickstart, so you can do the whole integration without fetching anything. Prefer the published packages and the handler they export — do not reimplement wallet RPC, settlement, or pricing.

On Lovable, or any app on Cloudflare Workers with Supabase, stop here and follow https://openreceive.org/agent-directions/lovable.md instead. These directions are for a server you run on Node.

Do not clone the OpenReceive repository into this app, and do not copy a demo’s models (ShopOrder, a signed-cookie visitor, an in-memory catalog) over tables that already exist. Find this application’s order, product, and user models — whatever they are actually named — and map the three hooks onto those.

Keep this application’s frontend framework, authentication and database. Pick the UI package that matches the frontend that is already here (@openreceive/react, /vue, /svelte, /angular, or /elements for plain HTML) — do not add React to a Vue app. Reuse the app’s existing session/auth in authorize and its existing database handle in storage.db.

What OpenReceive is

A payment library that runs inside YOUR server. It mounts as ONE catch-all server route (src/routes/openreceive.$.ts, with GET and POST server handlers, on Node) in the application you are editing, issues Lightning invoices against a wallet the merchant already controls, and calls back into your code when one settles. There is no OpenReceive account and no API key, and OpenReceive never holds the funds — the sats land in the wallet the merchant connected.

The one required credential is a receive-only NWC code (Nostr Wallet Connect): a string from the merchant’s wallet that can create invoices and read their status, and cannot spend. A swap provider (an “LSC” code) optionally lets the payer send USDT, USDC, ETH or SOL instead, converted into that same Lightning payment. Step 0 collects those credentials from the user; you write three hooks — authorize, amountFor, onPaid; OpenReceive supplies invoices, polling, settlement and the checkout UI. It never owns orders, users, prices, or fulfillment.

Step 0 — ask for the two codes, one question at a time

Before anything else, check one file: the project’s .env, if it exists. grep -E '^(NWC_URI|LSC_URI_PRIMARY)=.' .env | cut -d= -f1 names the codes it sets without printing them. Check with that, here and after each write, not by reading the file, and never print the values. A code that is already set is not asked for again; if both are set, skip to step 5 (making the server load the file), then the quickstart.

On a hosted builder (v0, Vercel, Replit, Lovable), the user may say instead that both codes are already set as the project’s environment variables or secrets. Believe them: do not ask for the codes, do not write .env, and 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 either: they already reach the app, and a line such as NWC_URI: ${NWC_URI} replaces them with an empty value.

Otherwise your next action is a question to the user. Do not install packages, edit the app, write .env.example, or search anywhere else before asking it. Do not read deploy config (compose files, Vercel or platform secrets), Docker containers, or process environments, and never run printenv. A new shop has neither code yet: hunting for one burns the turn and can leak other secrets into the transcript.

Two server-only URIs are needed before the quickstart:

The user never edits an environment file. They paste each code into the chat; you store it. Ask one question per message.

  1. First message — the NWC code, and nothing else. Ask for it and walk them through getting it:

    To receive payments I need a receive-only wallet code. In Rizful: open the menu, tap NWC, choose Receive-only NWC code, and tap Copy (https://openreceive.org/get_a_nwc_code_to_receive_payments). If you would rather run your own wallet, Alby Hub works too: Connections → Add Connection → Read Only. Paste the code here and I will store it.

    Do not mention .env, exports, or “tell me when it’s set”.

  2. When they paste it. If it does not start with nostr+walletconnect://, ask them to copy the receive-only code again. Otherwise write NWC_URI=<paste> into the project’s .env with your file-editing tool, creating the file if needed. Never write it with a shell command (echo, printf, a heredoc, python -c): the command line shows the code. Make sure .gitignore covers .env (and .dockerignore, if the app has one). Never echo the value, commit it, or put it in client code. Reply only that it is saved, then ask the next question.
  3. Second message — swaps. If the user asked for stablecoins, USDT, USDC, ETH, SOL, altcoins or “crypto” (as in “Bitcoin and stablecoin payments”), this message IS the walkthrough below: send it as it is, and do not ask yes or no first. Otherwise ask whether payers should also be able to pay with USDT, USDC, ETH or SOL, then give the walkthrough. The walkthrough:

    Go to https://lightning-swap.com, sign in for API keys, create a key, and copy the whole URI (https://openreceive.org/set_up_swap_provider). Paste it here and I will store it — or say “Bitcoin only” and I will continue without it.

    Mention FixedFloat only if they already use it.

  4. When they paste it. If it does not start with lightning+swapconnect://, ask them to copy it again. Otherwise add LSC_URI_PRIMARY=<paste> to the same .env with the file-editing tool, never a shell command. Swaps are now on, so build the refund route back (the swap non-negotiable below) as part of this integration. If they chose Bitcoin only, leave LSC_URI_PRIMARY unset and skip that route.
  5. Make the server load the file — yourself. OpenReceive reads process.env. vite dev loads .env into it, but the built server does not: start it with node --env-file=.env .output/server/index.mjs. When the app is started with Docker Compose, give the service env_file: .env. Never give a code a VITE_ prefix: that puts it in the browser bundle. Restart the server after writing the file. Under Compose, recreate the container with docker compose up -d: docker compose restart keeps its old environment.

Do not invent placeholder URIs. Start the quickstart only once NWC_URI is saved and LSC_URI_PRIMARY is saved or explicitly declined. The first boot runs the receive-only preflight: if it reports spend methods such as pay_invoice, remove NWC_URI from .env and ask for a receive-only code again. Never set the spend-capable override to get past it.

Upgrading an existing install

This is not the opening move of a new integration. When OpenReceive is ALREADY installed here, npx openreceive doctor reports every credential as set/unset (never the value) and probes the wallet. Check the installed @openreceive/node and @openreceive/browser against the release named at the top of this file: the headless display models below do not exist in older versions, and the first tile click throws with nothing saying why. Upgrade first.

Only then start the quickstart.

After the quickstart — hand over, then stop

The quickstart is done when npx openreceive doctor --db <file-or-url> --url <app url> is clean and this app serves the checkout page for one of its orders. Doctor names any failed check and exits nonzero; fix it before going on. Doctor reads .env itself. Never source it into a shell: the & in a code splits the value and prints the pieces.

Give the user that checkout link. The browser check of that page (payment-method icons, wallet logos, a pay tutorial) is theirs: name it in one line of your closing message, and do not run it yourself.

You cannot pay the invoice: the code is receive-only. Do not pay, settle or mark an order paid, and do not look for a way to (a wallet control port, a test endpoint, another wallet). If the user wants a real settlement test, they pay on that link from their own wallet, and onPaid marks the order paid.

Setup ends here. Your last message starts “Setup is finished” and has at most five short lines (about 80 characters each): the link, the methods it offers, and one line of what to look at. Send nothing after it. Do not list what changed, copy out the quickstart’s browser checklist, offer more work, or end the message on a question.

Non-negotiables

The quickstart below has the code. These are the rules it cannot state for itself, and they hold for every integration.

Your tables, not ours

npx openreceive scaffold payments emits openreceive_payments and openreceive_meta for THIS application’s database. That is the whole persistence OpenReceive needs. It does not replace your orders, users, or products, and you do not join them.

If the database is Supabase

Supabase is Postgres, so the tables above go there like any others. Three things differ; https://openreceive.org/guides/supabase.md has each in full.

npx openreceive scaffold payments --supabase writes the HTTPS-API route, which is for Cloudflare Workers only. A Node server uses the connection above.

If you build your own checkout UI

The drop-ins (<Checkout>, <openreceive-checkout>) already obey all of this. This list is the short form of https://openreceive.org/guides/checkout-ux.md, for a UI built on @openreceive/browser/headless. Read that before writing components.

More documentation

Fetch one when the moment comes. Each is raw markdown, so a plain GET is enough; drop the .md for the same page a person would read.

Questions, or a problem with the library itself: https://openreceive.org/contact


The quickstart, in full

Inlined verbatim so this file needs no network access. On Node, the one-stack module in the non-negotiables above replaces its per-request handleOpenReceive, and its On Supabase (Lovable) section does not apply. The page it comes from is https://openreceive.org/guides/tanstack-start-recipe.

TanStack Start recipe

OpenReceive ships no TanStack Start package. A TanStack Start server route takes a Web-standard Request and returns a Response, and so does @openreceive/http, so the integration is the route below. Your server code creates each Lightning invoice, the payment goes straight to your wallet, and payment attempts are stored in your app’s Postgres database. There is no OpenReceive account and no background worker.

Optional swaps let customers pay with USDT, USDC, SOL, and ETH. A swap provider you configure converts the payment to BTC over Lightning, and it settles into the same wallet. Available assets and networks depend on the provider.

This recipe was checked with @tanstack/react-start 1.168 and @openreceive/* 0.4.19. The app was built with Lovable’s Vite config for Cloudflare Workers and run under workerd: a buyer’s checkout showed a real Lightning invoice, and a stranger was refused. The Supabase route below runs in OpenReceive’s CI as a Worker, against Supabase’s own Postgres image and API server: a buyer’s invoice, a stranger refused, one live attempt under concurrent requests, and a payment that openreceive_on_paid fulfills once.

npm install @openreceive/http @openreceive/react pg

Where it runs

Workers ties every socket to the request that opened it, and forbids network calls at import. So the route builds its database pool and its OpenReceive stack inside each request, and closes both before it responds. Each request then opens its own wallet connection; in our test a checkout request took one to four seconds. The same code works on Node.

The payment route

Keep the handler in a .server.ts module. Route files are also bundled for the browser, so the route loads the module only when a request arrives:

// src/routes/openreceive.$.ts — every route under /openreceive/
import { createFileRoute } from "@tanstack/react-router";

async function openReceive({ request }: { request: Request }): Promise<Response> {
  const { handleOpenReceive } = await import("@/lib/openreceive.server");
  return handleOpenReceive(request);
}

export const Route = createFileRoute("/openreceive/$")({
  server: { handlers: { GET: openReceive, POST: openReceive } },
});
// src/lib/openreceive.server.ts
import { createStack } from "@openreceive/http";
import pg from "pg";
import { findOrder, currentVisitor } from "./orders.server"; // your own orders

export async function handleOpenReceive(request: Request): Promise<Response> {
  // Read process.env inside the handler: on Workers it is empty at import.
  const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
  const stack = createStack({
    wallet: { nwc: process.env.NWC_URI ?? "" },
    storage: {
      db: pool,
      // Runs once, inside the transaction that records the payment.
      onPaid: async ({ reference, paidAt, query }) => {
        await query(
          "UPDATE orders SET state = 'paid', paid_at = $1 WHERE id = $2 AND state = 'awaiting_payment'",
          [paidAt, reference],
        );
      },
    },
    // The price comes from the order row, never from the request.
    amountFor: async (reference) => {
      const order = await findOrder(pool, reference);
      return order?.state === "awaiting_payment"
        ? { currency: order.currency, value: order.amount, description: order.title }
        : null;
    },
    // Only the buyer may pay for their order.
    authorize: async ({ request: incoming, resource }) => {
      const order = resource.reference ? await findOrder(pool, resource.reference) : undefined;
      const visitor = currentVisitor(incoming);
      return Boolean(order && visitor && order.visitor === visitor);
    },
    // Cloudflare sets cf-connecting-ip on every request, and a client cannot.
    // On Node behind your own proxy, read the header that proxy sets.
    rateLimiting: {
      ip: ({ request: incoming }) => incoming.headers.get("cf-connecting-ip") ?? undefined,
    },
  });
  try {
    return await stack.handler(request, { native: request });
  } finally {
    await stack.close();
    await pool.end();
  }
}

authorize sees the incoming request with its cookies, so check it with the session your app already has. The Next.js quickstart explains each hook. They are the same here.

Create OpenReceive’s two tables with your migrations, or run paymentsSchemaSql("postgres") from @openreceive/http once. It is idempotent. See Payment storage.

On Supabase (Lovable)

On Supabase the route keeps payments in your Supabase database through its HTTPS API, with the project’s secret key. There is no pg pool. Use @openreceive/* 0.4.24 or newer:

npm install @openreceive/http @openreceive/react
  1. Apply the migration from npx openreceive scaffold payments --supabase. It creates OpenReceive’s tables, locked away from the browser’s Supabase key, and the functions that write them.
  2. Replace its placeholder openreceive_on_paid with the SQL that marks your order paid. It runs inside the transaction that records the payment, for the order’s first payment only, so a failure there records nothing:

    create or replace function public.openreceive_on_paid(
      p_reference text, p_payment_hash text, p_paid_at bigint
    ) returns void language plpgsql as $$
    begin
      update public.orders set status = 'paid'
       where id = p_reference::uuid and status = 'pending';
    end
    $$;
    
  3. Write the route’s server module:
// src/lib/openreceive.server.ts
import { createStack } from "@openreceive/http";
import { findOrder, currentBuyer } from "./orders.server"; // your own orders

export async function handleOpenReceive(request: Request): Promise<Response> {
  // Read process.env inside the handler: on Workers it is empty at import.
  // Lovable sets SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY for server code.
  const stack = createStack({
    wallet: { nwc: process.env.NWC_URI ?? "" },
    storage: {
      supabase: {
        url: process.env.SUPABASE_URL ?? "",
        key: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
      },
    },
    // The price comes from the order row, never from the request.
    amountFor: async (reference) => {
      const order = await findOrder(reference);
      return order?.status === "pending"
        ? { currency: order.currency, value: order.total, description: order.title }
        : null;
    },
    // Only the buyer may pay for their order.
    authorize: async ({ request: incoming, resource }) => {
      const order = resource.reference ? await findOrder(resource.reference) : null;
      const buyer = currentBuyer(incoming);
      return Boolean(order && buyer && order.buyer_token === buyer);
    },
    rateLimiting: {
      ip: ({ request: incoming }) => incoming.headers.get("cf-connecting-ip") ?? undefined,
    },
  });
  try {
    return await stack.handler(request, { native: request });
  } finally {
    await stack.close();
  }
}

There is no onPaid: openreceive_on_paid is the fulfillment. findOrder reads the order with your server-side Supabase client; in a Lovable app that is supabaseAdmin from @/integrations/supabase/client.server.

The checkout calls the payment routes from the browser with the page’s cookies, and nothing else. A Lovable app keeps its Supabase sign-in in the browser, not in a cookie, so authorize cannot see who is signed in. Give the buyer a cookie of their own instead: create orders in a server function that sets an HttpOnly buyer cookie (a random value, kept across orders) and stores the same value on the order as buyer_token. currentBuyer reads it back from the request’s cookie header.

Before it serves, the server checks the database, and the payment routes answer 503 until the migration is applied and openreceive_on_paid is yours; the log names the fix. More: Supabase over HTTPS.

The checkout page

// src/routes/checkout.$reference.tsx
import { Checkout } from "@openreceive/react";
import "@openreceive/react/styles.css";
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/checkout/$reference")({
  component: OrderCheckout,
});

function OrderCheckout() {
  const { reference } = Route.useParams();
  return <Checkout reference={reference} prefix="/openreceive" />;
}

It renders on the server and starts the checkout in the browser. The payer picks Bitcoin and gets the invoice.

Settings

Set them as server secrets, never with a VITE_ prefix, which would put them in the browser bundle.

No worker or cron job is needed. Each request to the payment routes also checks the wallet for settled invoices, through a lock in your database. A payer who closes the tab is settled on the next request.