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):
- Before adding packages or editing anything, ask the user to add a receive-only NWC code as a secret.
- Next ask for the LSC code. If the user wants stablecoins, USDT, USDC, ETH, SOL or crypto, do not ask yes/no first.
- The user adds NWC_URI and LSC_URI_PRIMARY in Bolt’s Secrets tab. Never put a code in .env, code or a VITE_ name.
- Never ask the user to run a command. Every step is a file, a package, a migration, a function or a secret.
- Do not suggest rotating or revoking a code because it was pasted here.
- Start Step 1 only once NWC_URI is saved, and LSC_URI_PRIMARY is saved or the user said “Bitcoin only”.
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.
NWC_URI— a receive-only Nostr Wallet Connect code,nostr+walletconnect://…. Required for Bitcoin.LSC_URI_PRIMARY— a Lightning Swap Connect URI,lightning+swapconnect://…. Required for USDT, USDC, ETH and SOL. Skip it only when the user says they want Bitcoin alone.
Ask one question per message, and never repeat a code back.
-
First message — the NWC code, and nothing else. Ask the user to add the secret
NWC_URIand 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.
- 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.
-
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).
- The database. Supabase gives every server function
SUPABASE_URL,SUPABASE_ANON_KEYandSUPABASE_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
- The order’s id is the
reference. Create the order before checkout, keep it across retries, never reuse it. A fresh id per page load lets one order be paid twice. - The price is the database’s, never the browser’s. In a Bolt app the
page usually inserts the order itself with supabase-js, so the browser
chose its total and status. In your second migration (Step 4), add the
recipe’s
before inserttrigger that copies each price from the products table and sets the unpaid status, adapted to this app’s tables; with order lines, price each line and sum them. Keep the app’s order flow as it is. Check the orders table’s policies: a buyer may insert and read their own orders, never update an order’s total or status. - The buyer is the signed-in user. The page sends the buyer’s Supabase
access token, and
authorizeallows only the order’s own user. If the app has no sign-in yet, add email sign-up and log-in with Supabase Auth first, and store the user’s id on each order. Keep the app’s sign-in if it has one. - The order is unpaid or paid. Do not copy attempt statuses (
pending,expired,failed,attention) onto it, and do not add a relation toopenreceive_payments. - A paid order’s page says it is paid and shows no checkout. Nothing is
left to pay, and a checkout cannot start a new payment for a paid order.
Mount the checkout only while the order is unpaid. When it settles on the
page, update the page’s status line from
onSettledon<Checkout>:openreceive_on_paidhas already marked the order paid. Keep that checkout mounted, because it is the payer’s receipt.
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:
- Name the function
openreceive, so the routes live under/functions/v1/openreceive/. storage: { supabase: { url: SUPABASE_URL, key: SUPABASE_SERVICE_ROLE_KEY } }, read withDeno.env.getinside the request handler.- No
onPaidand nodb:openreceive_on_paidis the fulfillment. findOrderreads the order with the service role client, its total astotal::text;authorizechecks the request’s bearer token withsupabase.auth.getUser(token)and compares the user with the order’s.cors: { origins: shopOrigins(request) }, with the recipe’sBOLT_PAGEpattern: Bolt’s preview changes host every session. A custom domain goes in aSHOP_ORIGINSsecret the user adds; tell them so only if they mention one.- Build the stack inside each request and close it in
finally. - Never add CORS headers of your own to these routes, and never wrap the
handler in the usual
corsHeadersboilerplate: OpenReceive answers its own preflights.
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.
- OpenReceive never owns orders, users, prices, or fulfillment.
- Keep
NWC_URI/LSC_URI_*in Bolt’s Secrets, read withDeno.env.getin the server function. Never aVITE_variable,.env, page code or a log. - Do not suggest rotating, revoking or replacing a code because it was pasted into this chat; ask the user to save it as a secret instead.
SUPABASE_SERVICE_ROLE_KEYreads and writes every table. Use it only in the server function, never in the page.- Never grant
anonorauthenticatedanything on anopenreceive_*table or function, and never add a row level security policy to those tables. The server refuses to serve while they can reach them. - The host owns the price. The database sets it,
amountForreads it from the order, and payer-supplied amounts are rejected. authorizeruns on every request, and theresourceit receives is a CLAIM the payer made, not proof. Check the bearer token against the order’s user.- Receive-only NWC is required; a spend-capable code fails closed unless explicitly overridden. Never set that override.
- There is NO merchant-initiated refund of a settled Lightning payment, because the wallet cannot spend. Swap refunds — a payer reclaiming a deposit that never converted — are the only refund OpenReceive performs. Do not build, promise, or imply a Lightning refund path.
- IF YOU TURN SWAPS ON, BUILD THE ROUTE BACK. A deposit that arrives short or
late is refunded on a SECOND visit, so the order needs its own URL, with
syncUrlon<Checkout>, and the attempt must come back with it: keep thepayment_hashfromonStateand pass it asresumePaymentHash. https://openreceive.org/guides/swap-refunds.md - Show the payer what they are buying: return a
descriptionbeside the price fromamountFor. - HTTP JSON is snake_case; TypeScript APIs are camelCase.
- Money is integers or decimal strings — never binary floats.
More documentation
Fetch one when the moment comes. Each is raw markdown, so a plain GET is enough.
- https://openreceive.org/guides/bolt.md — what the user does on Bolt around these steps
- https://openreceive.org/guides/supabase.md — Supabase over HTTPS: how the storage works, and what the server checks before it serves
- https://openreceive.org/guides/supabase-migration.md — the migration for Step 2
- https://openreceive.org/guides/authorization.md — before you write
authorize, and how cross-site requests are checked - https://openreceive.org/guides/storage.md — the payment tables and the attempt state machine
- https://openreceive.org/guides/frontend-checkout.md — the drop-in’s props, including
syncUrlandresumePaymentHash - https://openreceive.org/guides/checkout-ux.md — the rules the drop-in already follows
- https://openreceive.org/guides/provider-registry.md — where the wallet logos and pay tutorials come from: inside the JavaScript, nothing to serve
- https://openreceive.org/guides/automated-swaps.md — only if
LSC_URI_PRIMARYis set - https://openreceive.org/guides/swap-refunds.md — the refund flow, and the route back to it
- https://openreceive.org/guides/lightning-swap-connect.md — what an
LSC_URI_*code actually is - https://openreceive.org/guides/environment-variables.md — every variable, and what is deliberately not one
- https://openreceive.org/guides/price-feeds.md — where the fiat→sats rate comes from
- https://openreceive.org/guides/rate-limiting.md — before a public shop goes live
- https://openreceive.org/guides/security.md and https://openreceive.org/guides/deploying.md — before this goes anywhere real
- https://openreceive.org/guides/api-reference.md — every route, option and error code
- https://openreceive.org/guides.md — the index, if what you need is not above
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
- The page is on one site (
https://shop.example,<name>.bolt.host), the function on another (https://<project-ref>.supabase.co/functions/v1/openreceive). A browser sends no cookies across sites, so the page sends the buyer’s Supabase access token, andauthorizechecks it. - The function builds an OpenReceive stack for each request and closes it before it answers. Edge Functions run each request in a short-lived worker, and a stack’s wallet connection must not outlive it.
- Payments live in your database’s
openreceive_paymentstable, written through Supabase’s HTTPS API with the service role key. That key is set for every function by Supabase; it never reaches the page. - Your SQL function
openreceive_on_paidmarks the order paid, inside the transaction that records the payment.
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();
}
});
total::texthandsamountForthe price as a decimal string: PostgREST returns anumericcolumn as a JSON number otherwise.- There is no
onPaid:openreceive_on_paidis the fulfillment. LSC_URI_PRIMARY, when set, turns swaps on; OpenReceive reads it from the environment.
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
- No worker or cron job. Each request to the payment routes also checks the wallet for settled invoices. The open checkout polls, so a payer who waits sees the order paid; a payer who closes the tab is settled on the next request.
- About a second a request. Each request boots a stack, connects to the wallet’s relay and asks Supabase Auth about the token. The first request after a deploy also downloads the packages, which took about 5 s in our check.
- Inside the limits. Supabase gives a request 2 s of CPU. One checkout request used 100 to 600 ms in our benchmark, the high end with a wallet holding 980 recent transactions.
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.