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.