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

OpenReceive agent directions (Bolt)

READ THIS FIRST: this file is 28 KB and a summary drops required steps. Read all of it:
https://openreceive.org/agent-directions/bolt/full.md
If your fetch tool gave you a summary, fetch that URL again for the full text.

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

These directions describe OpenReceive 0.4.27.

Add OpenReceive to this Bolt app: a Vite React app on Bolt Cloud, with Bolt Database or a connected Supabase project as its database, and Supabase Edge Functions as its server functions. These directions are written for Bolt’s agent. Every step is a file edit, a package, a Supabase migration, a server function or a secret. Never ask the user to run a command. The Supabase Edge Functions recipe is appended to this file in full: its code is the code to follow.

An app with its own Node server (Express, Next.js and the like) is not this shape: follow that framework’s directions from https://openreceive.org/agents instead.

Do not clone the OpenReceive repository, and do not copy a demo’s models over tables that already exist. Find this app’s orders and products, whatever they are named, and map the hooks onto those. Keep this app’s UI, sign-in and database.

What OpenReceive is

A payment library that runs inside THIS app’s server code. On Bolt it is one server function, openreceive, which issues Lightning invoices against a wallet the merchant already controls and settles each payment into this app’s database. The page shows the drop-in checkout and calls the function. There is no OpenReceive account and no API key, and OpenReceive never holds the funds: the sats land in the merchant’s wallet.

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. You write two hooks in TypeScript, amountFor and authorize, and one SQL function, openreceive_on_paid, that marks the order paid. OpenReceive supplies invoices, polling, settlement and the checkout UI. It never owns orders, users, prices, or fulfillment.

The function keeps OpenReceive’s payment rows in this app’s database through Supabase’s HTTPS API, with the service role key Supabase gives every function. The page is on another site than the function, so it sends the buyer’s Supabase access token, and authorize checks it.

Step 0 — the codes are secrets the user adds

If the user says the codes are already saved as this project’s secrets (NWC_URI, and LSC_URI_PRIMARY unless they want Bitcoin only), believe them: do not ask for them, and go to Step 1. You cannot read a secret’s value, and you do not need to.

Otherwise, before anything else, your next action is a question to the user. Do not add packages, write migrations or edit files before asking it. A new shop has neither code yet.

Two secrets are needed. Only the user can add a secret: in Bolt, the database icon at the top → Secrets → Name and Value → Create secret.

Ask one question per message, and never repeat a code back.

  1. First message — the NWC code, and nothing else. Ask the user to add the secret NWC_URI 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. Then click the database icon at the top, open Secrets, and create a secret named NWC_URI with the code as its value. Tell me when it is saved.

  2. If the user pastes a code into the chat instead, do not write it into any file, code or log: you have no place to keep it. Thank them, and ask them to create the secret with that value in Secrets. Do not suggest replacing the code.
  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). Add it in Secrets as LSC_URI_PRIMARY — or say “Bitcoin only” and I will continue without it.

    Mention FixedFloat only if they already use it. Swaps on means the refund route back is part of this integration (the swap non-negotiable below).

  4. The database. Supabase gives every server function SUPABASE_URL, SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY. Do not ask for them or add them as secrets. If this project has no database yet, create it the way Bolt does when an app needs one.

Never put a code in .env (Vite builds VITE_ values into the page, and the file travels with the project), never give one a VITE_ prefix, and never write one into code or a log. Do not invent placeholder codes. Start Step 1 only once the user says NWC_URI is saved and LSC_URI_PRIMARY is saved or explicitly declined.

Step 1 — the packages

Add @openreceive/react at 0.4.27 or newer to the app’s package.json. The function imports npm:@openreceive/http@^0.4.27 and npm:@supabase/supabase-js@2 directly; add nothing else. No pg, no Postgres driver, no OpenReceive scaffold.

Step 2 — the migration

Fetch https://openreceive.org/guides/supabase-migration.md and copy its SQL block, unchanged, into one new migration in supabase/migrations/, then apply it the way this project applies migrations. Do not edit it, split it, rename anything in it, or drop its revoke lines. It creates openreceive_payments and openreceive_meta with row level security on and every grant revoked from anon and authenticated, the functions the server calls to write them, and openreceive_on_paid as a placeholder that refuses every settlement.

Step 3 — the order and its buyer

Step 4 — openreceive_on_paid and the price trigger

In a second new migration, replace the placeholder with the SQL that marks this app’s order paid, and add the price trigger from Step 3. Use create or replace, and this app’s own table, column and status names:

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   -- or just p_reference, if the ids are text
     and status = 'awaiting_payment';
end
$$;

It runs inside the transaction that records the payment, for the order’s first settled payment only. If it raises, nothing is recorded, and the next request retries it. Its WHERE on the unpaid status is the guard against a second fulfillment. Keep it to database writes.

Step 5 — the server function

Write supabase/functions/openreceive/index.ts exactly as the recipe’s “The function” section shows:

Step 6 — the checkout page

On the order’s page, render <Checkout> from @openreceive/react with prefix set to `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/openreceive` and fetch set to the recipe’s withSupabaseToken, and import @openreceive/react/styles.css there. The order’s page must have its own URL (a route such as /orders/:id). After the order is created, navigate to it. Use the drop-in; do not build your own checkout UI.

After setup — hand over, then stop

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.

If the checkout answers 503, the function’s log names the fix: usually the migration is not applied, or openreceive_on_paid is still the placeholder. A 403 “Cross-site requests are not accepted” means the page’s origin is not in shopOrigins; a 403 from authorize means the buyer is not signed in, or the order is someone else’s.

Setup ends here. Your last message starts “Setup is finished” and has at most five short lines: that a buyer signs in and places an order in the preview, the methods the checkout offers, and that a small real payment from their own wallet marks the order paid. Send nothing after it. Do not list what changed, offer more work, or end the message on a question. Name the methods: Bitcoin, plus USDT, USDC, ETH and SOL when LSC_URI_PRIMARY is saved. Do not mention minimums, and never say a coin will not work or will not be offered.

Non-negotiables

The recipe below has the code. These are the rules it cannot state for itself.

More documentation

Fetch one when the moment comes. Each is raw markdown, so a plain GET is enough.

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. Steps 0–6 above are the setup on Bolt and this recipe is their reference: its function and page are the code to write, and its supabase CLI commands are Bolt’s Secrets tab and Bolt’s own deploy. Where the two differ, the steps win. The page it comes from is https://openreceive.org/guides/supabase-edge-functions-recipe.

Supabase Edge Functions recipe

OpenReceive ships no Edge Functions package. A Supabase Edge Function takes a Web-standard Request and returns a Response, and so does @openreceive/http, so the integration is one function named openreceive plus the React checkout on your page. The function creates each Lightning invoice, the payment goes straight to your wallet, and payment attempts are stored in your Supabase database through its HTTPS API. 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 is how Bolt apps take payments: Bolt’s server functions are Supabase Edge Functions, and its pages are a Vite React app on another site. The code below was checked with @openreceive/* 0.4.27 in Supabase’s edge-runtime v1.77.4, behind a stand-in for Supabase’s API gateway with verify_jwt on, with Supabase Auth v2.197 and PostgREST 14. A signed-in buyer’s page, served as https://<name>.bolt.host, got a Lightning invoice through the function, the same invoice after a reload, and a paid order once the wallet settled it. A stranger, the bare anon key and an unlisted origin were refused, and a price the browser wrote into its order was replaced by the catalog’s.

Use @openreceive/* 0.4.27 or newer: the function needs the cors option.

How it fits together

1. The migration

Save the SQL from Supabase migration, unchanged, as a new migration (supabase/migrations/<timestamp>_openreceive.sql) and apply it. It creates OpenReceive’s tables with row level security on and every grant revoked from anon and authenticated, and the functions the server calls to write them.

2. openreceive_on_paid

In a migration of your own, replace the migration’s placeholder with the SQL that marks your order paid:

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 = 'awaiting_payment';
end
$$;

It runs for the order’s first settled payment only, inside the transaction that records it, so a failure here records nothing and the next request retries. The status condition is the guard against a second fulfillment.

3. Keep the price out of the browser’s hands

amountFor charges what the order row says. In many Supabase apps the page inserts the order itself, under a row level security policy, so the browser also chose its price and status. Make the database set both. With one product per order:

create or replace function public.orders_take_catalog_price() returns trigger
language plpgsql set search_path = public as $$
begin
  select p.name, p.price, p.currency
    into new.title, new.total, new.currency
    from public.products p
   where p.id = new.product_id;
  if not found then
    raise exception 'unknown product %', new.product_id;
  end if;
  new.status := 'awaiting_payment';
  return new;
end
$$;

create trigger orders_take_catalog_price
  before insert on public.orders
  for each row execute function public.orders_take_catalog_price();

With order lines, do the same for each line and sum them into the order. Or compute the amount in amountFor from the catalog instead of reading the order’s total. Either way, the browser must not be able to update an order’s total or status: no update policy for authenticated on those columns.

4. The function

supabase/functions/openreceive/index.ts. Name it openreceive: Supabase hands a function its requests under /<name>/, and the routes live under /openreceive/.

import { createStack } from "npm:@openreceive/http@^0.4.27";
import { createClient } from "npm:@supabase/supabase-js@2";

const env = (name: string) => Deno.env.get(name) ?? "";

// The pages that call this function from another site. Bolt serves its
// preview from a new *.local-credentialless.webcontainer-api.io host each
// session and a published app from <name>.bolt.host; drop BOLT_PAGE when
// your pages are elsewhere. List any other origin in the SHOP_ORIGINS
// secret, comma-separated: "https://shop.example".
const BOLT_PAGE = /^https:\/\/[a-z0-9-]+\.(?:bolt\.host|local-credentialless\.webcontainer-api\.io)$/;

function shopOrigins(request: Request): string[] {
  const listed = env("SHOP_ORIGINS").split(",").map((origin) => origin.trim()).filter(Boolean);
  const origin = request.headers.get("origin") ?? "";
  return BOLT_PAGE.test(origin) ? [...listed, origin] : listed;
}

Deno.serve(async (request) => {
  // Supabase sets SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY for every function.
  const supabase = createClient(env("SUPABASE_URL"), env("SUPABASE_SERVICE_ROLE_KEY"), {
    auth: { persistSession: false },
  });
  const findOrder = async (id: string) => {
    const { data } = await supabase
      .from("orders")
      .select("id, user_id, status, title, currency, total::text")
      .eq("id", id)
      .maybeSingle();
    return data;
  };

  const stack = createStack({
    wallet: { nwc: env("NWC_URI") },
    storage: {
      supabase: { url: env("SUPABASE_URL"), key: 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 === "awaiting_payment"
        ? { currency: order.currency, value: order.total, description: order.title }
        : null;
    },
    // Only the signed-in buyer may pay for their order.
    authorize: async ({ request, resource }) => {
      const token = request.headers.get("authorization")?.replace(/^Bearer /, "");
      if (!token || !resource.reference) return false;
      const { data } = await supabase.auth.getUser(token);
      const order = await findOrder(resource.reference);
      return Boolean(data.user && order && order.user_id === data.user.id);
    },
    cors: { origins: shopOrigins(request) },
  });
  try {
    return await stack.handler(request);
  } finally {
    await stack.close();
  }
});

5. Which pages may call it

cors takes exact origins: scheme, host and port, with no path or trailing slash. A listed origin passes the cross-site check, and the function answers its preflights. Every other origin is refused. shopOrigins lists the request’s own origin when it is a Bolt page, because a Bolt preview’s host changes from session to session.

The origin list is not what keeps one buyer out of another’s order: the token is. OpenReceive never allows credentials across sites, so no cookie reaches the function, and a page on any listed origin can only send the tokens it holds. authorize decides with the token, on every request. Never answer these routes with CORS headers of your own, and never remove the token check. More: Cross-site requests.

6. The secrets

The function reads the wallet code, and the swap provider’s code for swaps, from the project’s secrets:

supabase secrets set NWC_URI="nostr+walletconnect://…"
supabase secrets set LSC_URI_PRIMARY="lightning+swapconnect://…"
supabase functions deploy openreceive

On Bolt, add them under the database icon → Secrets; Bolt deploys the function itself. A secret never goes in .env, which Vite builds into the page, or in a VITE_ variable.

7. The checkout page

The page points the checkout at the function and sends the buyer’s token on every request it makes, polling and swaps included. Only the React <Checkout> takes a fetch, so the page must use it.

import { Checkout } from "@openreceive/react";
import "@openreceive/react/styles.css";
import { supabase } from "../lib/supabase";

// The payment routes run in the `openreceive` server function.
const prefix = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/openreceive`;

// Every checkout request carries the buyer's Supabase session.
const withSupabaseToken: typeof fetch = async (input, init) => {
  const { data } = await supabase.auth.getSession();
  const headers = new Headers(init?.headers);
  if (data.session) headers.set("Authorization", `Bearer ${data.session.access_token}`);
  return fetch(input, { ...init, headers });
};

// On the order's page, while the order is unpaid:
<Checkout
  reference={order.id}
  prefix={prefix}
  fetch={withSupabaseToken}
  onSettled={() => setOrder({ ...order, status: "paid" })}
/>;

The buyer must be signed in: the token is their Supabase session. Mount the checkout only for an unpaid order, and keep it mounted once it settles on the page, as the payer’s receipt.

How it runs

What we test

OpenReceive’s CI runs both storage routes in Supabase’s edge-runtime under the hosted limits, including a cross-site page with a bearer token. The trial harness runs coding agents through a Bolt-shaped shop on a local Supabase stack (see How we test platforms). We have not deployed to Supabase’s hosted platform: its own API gateway, its verify_jwt check and its handling of preflights are untested. If something there does not work as this recipe says, open an issue.