# Recipe: Supabase

For a project whose server code is Supabase Edge Functions (Deno), with any front end: React, Vue, Svelte, or a Lovable or Bolt app. This file has the code; [the playbook](https://checkouts.md/playbook.md) has the reasons, every error code and what to tell the person. Follow both.

| | |
|---|---|
| Server code runs in | Two Edge Functions: `railbed-checkout` and `railbed-webhook` |
| Database | The project's Supabase Postgres; one SQL function applies a payment atomically |
| Webhook URL | `https://<project-ref>.supabase.co/functions/v1/railbed-webhook` |
| Connect | `npx checkouts.md login --env-path supabase/functions/.env --live-to env:supabase/functions/.env.production` |

## What this adds

| File | What it does |
|---|---|
| `supabase/migrations/<timestamp>_railbed.sql` | The `orders` and `railbed_events` tables (row level security on, no public access) and `railbed_apply_payment()` |
| `supabase/functions/_shared/railbed.ts` | Products, the API client, Web Crypto signature verification, CORS headers |
| `supabase/functions/railbed-checkout/index.ts` | Creates an order and its checkout session; reports an order's status |
| `supabase/functions/railbed-webhook/index.ts` | Verifies, re-reads the payment, applies it once |
| `supabase/config.toml` | Turns off Supabase's JWT check for the webhook only |
| `src/components/PayButton.tsx`, `src/pages/CheckoutSuccess.tsx` | The Pay button and the success page (React; adapt to the project's framework) |
| `CHECKOUTS.md` | How this project takes payments |

## Environment

| Name | Local (`supabase/functions/.env`, gitignored) | Production (`supabase secrets set`) |
|---|---|---|
| `RAILBED_SECRET_KEY` | `rb_test_…` | `rb_live_…` |
| `RAILBED_WEBHOOK_SECRET` | `whsec_…` of the Test endpoint | `whsec_…` of the Live endpoint |
| `APP_URL` | The front end in development, e.g. `http://localhost:5173` | The published front end, `https://…` |
| `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY` | Provided by Supabase to every function | Provided |

The service role key and Railbed's secrets stay in the functions. The browser has only the project's public Supabase key, which can't read the two tables.

## 1. Connect

```bash
npx checkouts.md login --env-path supabase/functions/.env --live-to env:supabase/functions/.env.production
```

Relay the link and code to the person and wait for the command to finish. Both files are added to `.gitignore`. `supabase functions serve` reads `supabase/functions/.env`; production secrets are set from the other file at go-live.

Add the front end's development address to the local file too, so local sessions get a `success_url` (it isn't secret): `echo 'APP_URL=http://localhost:5173' >> supabase/functions/.env`, with the project's dev port.

App builders that can't run the CLI: see [lovable](https://checkouts.md/recipes/lovable.md) and [bolt](https://checkouts.md/recipes/bolt.md) for how the keys get into the project's secrets.

## 2. Database

`supabase/migrations/20261014000000_railbed.sql` (any later timestamp; or run it in the SQL editor):

```sql
create table if not exists public.orders (
  id          text primary key,                  -- order_…: sent as reference and in the Idempotency-Key
  product_id  text not null,
  amount      text not null,                     -- "49.00"
  currency    text not null,
  livemode    boolean not null,
  status      text not null default 'awaiting_payment',
  payment_id  text unique,                       -- pay_…
  user_id     uuid,                              -- auth.users.id of the buyer, when signed in
  created_at  timestamptz not null default now(),
  paid_at     timestamptz
);

create table if not exists public.railbed_events (
  id          text primary key,                  -- evt_…
  type        text not null,
  payment_id  text,
  received_at timestamptz not null default now()
);

-- Only the functions (service role) read and write these. To let signed-in buyers read their own orders:
--   create policy "Buyers read their orders" on public.orders for select to authenticated using (user_id = auth.uid());
alter table public.orders enable row level security;
alter table public.railbed_events enable row level security;

-- Records a verified event and applies the re-read payment to its order, exactly once, in one transaction.
create or replace function public.railbed_apply_payment(
  p_event_id text,
  p_event_type text,
  p_order_id text,
  p_payment_id text,
  p_status text,
  p_paid_at timestamptz
) returns text
language plpgsql
set search_path = public
as $$
declare
  changed integer;
begin
  insert into railbed_events (id, type, payment_id) values (p_event_id, p_event_type, p_payment_id)
    on conflict (id) do nothing;
  if not found then
    return 'duplicate';
  end if;
  if p_status = 'paid' then
    update orders set status = 'paid', paid_at = p_paid_at
      where id = p_order_id and payment_id = p_payment_id and status <> 'paid';
    get diagnostics changed = row_count;
    if changed = 1 then
      -- Grant what the order bought here, in this same transaction. This is the one project-specific piece, e.g.:
      --   update profiles set plan = 'pro' where id = (select user_id from orders where id = p_order_id);
      return 'fulfilled';
    end if;
  elsif p_status in ('held', 'failed', 'expired') then
    update orders set status = p_status where id = p_order_id and status <> 'paid';
  end if;
  return 'recorded';
end;
$$;

revoke all on function public.railbed_apply_payment(text, text, text, text, text, timestamptz) from public, anon, authenticated;
grant execute on function public.railbed_apply_payment(text, text, text, text, text, timestamptz) to service_role;
```

```bash
supabase start             # if the local stack isn't running yet
supabase migration up      # local: applies the new migration to the local database
```

## 3. The code

`supabase/functions/_shared/railbed.ts`:

```ts
export type Product = { id: string; name: string; amount: string; currency: string };

// Prices live here, on the server, as decimal strings (R3). The browser only ever sends an id.
export const PRODUCTS: Record<string, Product> = {
  pro: { id: 'pro', name: 'Pro plan', amount: '49.00', currency: 'USD' },
};

export function findProduct(id: unknown): Product | null {
  return typeof id === 'string' && Object.prototype.hasOwnProperty.call(PRODUCTS, id) ? PRODUCTS[id] : null;
}

export type RailbedPayment = {
  id: string;
  status: string;
  livemode: boolean;
  amount: string;
  currency: string;
  reference: string | null;
  metadata: Record<string, string> | null;
  paid_at: number | null;
};

export class RailbedError extends Error {
  status: number;
  code: string;
  constructor(status: number, code: string, message: string) {
    super(message);
    this.name = 'RailbedError';
    this.status = status;
    this.code = code;
  }
}

/** Calls the Railbed API with the secret key. Server code only. */
export async function railbed<T>(path: string, init: { body?: unknown; idempotencyKey?: string } = {}): Promise<T> {
  const key = Deno.env.get('RAILBED_SECRET_KEY');
  if (!key) throw new Error('RAILBED_SECRET_KEY is not set');
  const headers: Record<string, string> = { Authorization: `Bearer ${key}`, Accept: 'application/json' };
  if (init.body !== undefined) headers['Content-Type'] = 'application/json';
  if (init.idempotencyKey) headers['Idempotency-Key'] = init.idempotencyKey;
  const res = await fetch(`${Deno.env.get('RAILBED_API_BASE') ?? 'https://pay.railbed.io/v1'}${path}`, {
    method: init.body === undefined ? 'GET' : 'POST',
    headers,
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
    signal: AbortSignal.timeout(15_000),
  });
  const data = await res.json().catch(() => null);
  if (res.ok) return data as T;
  throw new RailbedError(res.status, data?.error?.code ?? 'unknown', data?.error?.message ?? `Railbed answered ${res.status}`);
}

export const isLiveKey = () => (Deno.env.get('RAILBED_SECRET_KEY') ?? '').startsWith('rb_live_');

/** Railbed-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, one or more v1 (two during a secret roll). */
export async function verifyRailbedSignature(rawBody: string, header: string | null, secret: string, toleranceSeconds = 300): Promise<boolean> {
  const match = /^t=(\d{1,12})((?:,v1=[0-9a-f]{64})+)$/.exec(header ?? '');
  if (!match) return false;
  const [, timestamp, signatures] = match;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
  const signed = enc.encode(`${timestamp}.${rawBody}`);
  for (const part of signatures.slice(1).split(',')) {
    const bytes = new Uint8Array(part.slice(3).match(/../g)!.map((h) => parseInt(h, 16)));
    if (await crypto.subtle.verify('HMAC', key, bytes, signed)) return true; // constant time
  }
  return false;
}

/** For the function the browser calls. The webhook needs none: Railbed's servers don't use CORS. */
export const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
};
```

`supabase/functions/railbed-checkout/index.ts`:

```ts
import { createClient } from 'npm:@supabase/supabase-js@2';
import { corsHeaders, findProduct, isLiveKey, railbed, RailbedError } from '../_shared/railbed.ts';

const db = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } });

/** The signed-in buyer, if any: supabase.functions.invoke sends their access token. */
async function buyerId(req: Request): Promise<string | null> {
  const token = req.headers.get('Authorization')?.replace(/^Bearer\s+/i, '');
  if (!token) return null;
  const { data } = await db.auth.getUser(token);
  return data.user?.id ?? null;
}

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
  if (req.method !== 'POST') return json({ error: 'Use POST.' }, 405);
  const input = await req.json().catch(() => ({}));

  // { order: "order_…" }: the order's status, for the success page. Never marks anything paid (R2).
  if (typeof input.order === 'string') {
    const { data, error } = await db.from('orders').select('status').eq('id', input.order).maybeSingle();
    if (error) return json({ error: 'Try again.' }, 500);
    return data ? json({ status: data.status }) : json({ error: 'Not found.' }, 404);
  }

  // { product: "pro" }: a new order and its checkout session.
  const product = findProduct(input.product);
  if (!product) return json({ error: 'Unknown product.' }, 400);
  const orderId = `order_${crypto.randomUUID()}`;
  const { error: insertError } = await db.from('orders').insert({
    id: orderId,
    product_id: product.id,
    amount: product.amount,
    currency: product.currency,
    livemode: isLiveKey(),
    user_id: await buyerId(req),
  });
  if (insertError) {
    console.error('Railbed: order not saved', insertError.message);
    return json({ error: "We couldn't start the payment. Please try again." }, 500);
  }

  const appUrl = (Deno.env.get('APP_URL') ?? '').replace(/\/+$/, '');
  try {
    const session = await railbed<{ id: string; url: string }>('/checkout_sessions', {
      idempotencyKey: orderId,
      body: {
        amount: product.amount,
        currency: product.currency,
        description: product.name,
        reference: orderId,
        metadata: { order_id: orderId, product_id: product.id },
        ...(appUrl ? { success_url: `${appUrl}/checkout/success?order={REFERENCE}`, cancel_url: `${appUrl}/` } : {}),
      },
    });
    // Without the payment id the webhook can't find this order, so don't send the buyer to pay.
    const { error: saveError } = await db.from('orders').update({ payment_id: session.id }).eq('id', orderId);
    if (saveError) {
      console.error('Railbed: payment id not saved', orderId, saveError.message);
      return json({ error: "We couldn't start the payment. Please try again." }, 500);
    }
    return json({ url: session.url, order: orderId });
  } catch (error) {
    console.error('Railbed: checkout session not created', orderId, error instanceof RailbedError ? error.code : error);
    return json({ error: "We couldn't start the payment. Please try again." }, 502);
  }
});
```

`supabase/functions/railbed-webhook/index.ts`:

```ts
import { createClient } from 'npm:@supabase/supabase-js@2';
import { isLiveKey, railbed, RailbedError, verifyRailbedSignature, type RailbedPayment } from '../_shared/railbed.ts';

const db = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);

type RailbedEvent = {
  id: string;
  type: string;
  livemode: boolean;
  data: { payment: { id: string; metadata?: Record<string, string> | null } | null };
};

Deno.serve(async (req) => {
  if (req.method !== 'POST') return new Response(null, { status: 405, headers: { Allow: 'POST' } });
  const raw = await req.text(); // the exact bytes Railbed signed: verify before parsing
  const secret = Deno.env.get('RAILBED_WEBHOOK_SECRET');
  if (!secret || !(await verifyRailbedSignature(raw, req.headers.get('Railbed-Signature'), secret))) {
    return new Response('Invalid signature', { status: 400 });
  }
  const event = JSON.parse(raw) as RailbedEvent;
  if (!event.type.startsWith('payment.') || !event.data.payment) return new Response(null, { status: 200 });
  if (event.livemode !== isLiveKey()) {
    console.error('Railbed webhook: mode mismatch. RAILBED_SECRET_KEY and RAILBED_WEBHOOK_SECRET must be the same mode.');
    return new Response('Mode mismatch', { status: 500 });
  }

  let payment: RailbedPayment;
  try {
    payment = await railbed<RailbedPayment>(`/payments/${encodeURIComponent(event.data.payment.id)}`);
  } catch (error) {
    // "Send test event" samples carry a made-up payment the API doesn't know: acknowledge them.
    const sample = !event.livemode && event.data.payment.metadata?.sample === 'true';
    if (error instanceof RailbedError && error.status === 404 && sample) return new Response(null, { status: 200 });
    console.error('Railbed webhook: payment not read', event.id, error instanceof RailbedError ? error.code : error);
    return new Response('Try again later', { status: 500 });
  }

  const { data: order, error: readError } = await db
    .from('orders')
    .select('id, amount, currency, livemode')
    .eq('payment_id', payment.id)
    .maybeSingle();
  if (readError) return new Response('Try again later', { status: 500 });
  if (!order) return new Response(null, { status: 200 }); // not a payment this app created
  if (
    payment.livemode !== order.livemode ||
    payment.reference !== order.id ||
    payment.amount !== order.amount ||
    payment.currency !== order.currency
  ) {
    console.error('Railbed webhook: payment does not match its order', payment.id, order.id);
    return new Response(null, { status: 200 });
  }

  const { data: result, error } = await db.rpc('railbed_apply_payment', {
    p_event_id: event.id,
    p_event_type: event.type,
    p_order_id: order.id,
    p_payment_id: payment.id,
    p_status: payment.status,
    p_paid_at: new Date((payment.paid_at ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
  });
  if (error) {
    console.error('Railbed webhook: not applied; Railbed will retry', error.message);
    return new Response('Try again later', { status: 500 });
  }
  console.log(`Railbed webhook ${event.type} ${event.id}: ${result}`);
  return new Response(null, { status: 200 });
});
```

`supabase/config.toml`, so Supabase lets Railbed's signed request through (the function checks the signature itself):

```toml
[functions.railbed-webhook]
verify_jwt = false
```

`src/components/PayButton.tsx` (use the project's Supabase client import):

```tsx
import { useState } from 'react';
import { supabase } from '@/lib/supabase';

export function PayButton({ product, label }: { product: string; label: string }) {
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function pay() {
    setBusy(true);
    setError(null);
    const { data, error } = await supabase.functions.invoke('railbed-checkout', { body: { product } });
    if (error || !data?.url) {
      setBusy(false);
      setError("We couldn't start the payment. Please try again.");
      return;
    }
    window.location.assign(data.url); // Railbed's hosted checkout
  }

  return (
    <>
      <button type="button" onClick={pay} disabled={busy}>{busy ? 'Opening checkout…' : label}</button>
      {error && <p role="alert">{error}</p>}
    </>
  );
}
```

Use it where the project sells: `<PayButton product="pro" label="Pay $49.00" />`.

`src/pages/CheckoutSuccess.tsx`, routed at `/checkout/success` in the project's router:

```tsx
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';

const MESSAGES: Record<string, string> = {
  paid: 'Payment received. Your order is confirmed.',
  held: 'Your payment is being reviewed. We’ll email you once it’s done.',
  failed: 'This payment didn’t complete.',
  expired: 'This payment didn’t complete.',
};

// Reads the order's status. It never marks anything paid: only the webhook does (R2).
export default function CheckoutSuccess() {
  const order = new URLSearchParams(window.location.search).get('order');
  const [message, setMessage] = useState('Confirming your payment…');

  useEffect(() => {
    if (!order) {
      setMessage('We couldn’t find that order.');
      return;
    }
    let tries = 0;
    let timer: ReturnType<typeof setTimeout>;
    async function check() {
      const { data, error } = await supabase.functions.invoke('railbed-checkout', { body: { order } });
      if (error?.context?.status === 404) return setMessage('We couldn’t find that order.');
      if (data?.status && MESSAGES[data.status]) return setMessage(MESSAGES[data.status]);
      if (++tries < 40) timer = setTimeout(check, 3000);
    }
    check();
    return () => clearTimeout(timer);
  }, [order]);

  return (
    <main>
      <h1>{message}</h1>
    </main>
  );
}
```

## 4. The webhook endpoint

```bash
supabase start
supabase functions serve                           # reads supabase/functions/.env
cloudflared tunnel --url http://localhost:54321    # in a second terminal; prints https://….trycloudflare.com
npx checkouts.md webhook https://<tunnel>.trycloudflare.com/functions/v1/railbed-webhook --env-path supabase/functions/.env
```

The CLI writes `RAILBED_WEBHOOK_SECRET` into `supabase/functions/.env`. Restart `supabase functions serve`. Locally, `serve` checks JWTs unless `config.toml` turns them off for the function (above), or run it with `--no-verify-jwt`.

## 5. Test it

1. Create an order through the function, as the button does (the local anon key is printed by `supabase status`):

   ```bash
   curl -s -X POST http://localhost:54321/functions/v1/railbed-checkout \
     -H "Authorization: Bearer $SUPABASE_ANON_KEY" -H 'Content-Type: application/json' -d '{"product":"pro"}'
   ```

   It answers `{ "url": "https://pay.railbed.io/p/pay_…", "order": "order_…" }`.
2. Pay it: open the `url` and choose **Simulate successful payment**, or run the playbook's `simulate.mjs`: `node simulate.mjs pay_… paid --env-path supabase/functions/.env`.
3. Check: the function's log shows `Railbed webhook payment.paid evt_…: fulfilled`; in Studio (`http://localhost:54323`) the order is `paid`; the success page says it's confirmed.
4. Run the other scenarios in the playbook's [7.2](https://checkouts.md/playbook.md#72-the-scenarios), including `replay.mjs` with `--env-path supabase/functions/.env`.
5. Record the ids under Evidence in `CHECKOUTS.md`, and delete the tunnel's endpoint.

## 6. Go live

1. Apply the migration to the hosted project: `supabase db push` (after `supabase link`).
2. Add `APP_URL=https://…` (the published front end) to `supabase/functions/.env.production`, then set the production secrets from the file, which prints no values: `supabase secrets set --env-file supabase/functions/.env.production`.
3. Deploy: `supabase functions deploy railbed-checkout` and `supabase functions deploy railbed-webhook --no-verify-jwt`.
4. Add the Live endpoint: `npx checkouts.md webhook https://<project-ref>.supabase.co/functions/v1/railbed-webhook --live --live-to env:supabase/functions/.env.production`. It writes the Live `RAILBED_WEBHOOK_SECRET` into that file beside the Live key; set the secrets again from it (step 2).
5. Check: `npx checkouts.md status --env-path supabase/functions/.env --live-to env:supabase/functions/.env.production` (the same files as `login`; without them it looks in `.env` and `.env.production`); `curl -s -o /dev/null -w '%{http_code}\n' -X POST https://<project-ref>.supabase.co/functions/v1/railbed-webhook -d '{}'` answers `400`.
6. Set `mode: live` in `CHECKOUTS.md`, add the Live endpoint's id under Wiring and the checks under Evidence. The first real payment is the final evidence.

## 7. CHECKOUTS.md

Fill in [the template](https://checkouts.md/templates/CHECKOUTS.md) with `webhook: /functions/v1/railbed-webhook`. The Wiring section for this recipe:

```markdown
## Wiring

- `supabase/functions/_shared/railbed.ts`: the products and their prices, the API client and signature verification.
- `supabase/functions/railbed-checkout/index.ts`: `{ product }` saves the order and creates the checkout session (Idempotency-Key = order id); `{ order }` returns its status.
- `supabase/functions/railbed-webhook/index.ts`: verifies Railbed-Signature, re-reads the payment, applies it once with `railbed_apply_payment()`.
- `supabase/migrations/…_railbed.sql`: the tables (row level security on) and `railbed_apply_payment()`, where the grant is.
- `src/components/PayButton.tsx`, `src/pages/CheckoutSuccess.tsx`: the button, and the status page (never marks paid).
- Webhook endpoints: Test `we_…` (tunnel, removed after testing), Live `we_…`, at `/functions/v1/railbed-webhook`.
- Secrets: `RAILBED_SECRET_KEY`, `RAILBED_WEBHOOK_SECRET`, `APP_URL`. Test in `supabase/functions/.env` (gitignored); Live as Supabase function secrets.
```

## Notes

- **The webhook must not require a JWT.** Railbed's request carries its signature, not a Supabase token: without `verify_jwt = false` (or `--no-verify-jwt`) every delivery fails with `401`.
- **Don't call Railbed from the browser** with `fetch`: the secret key would be in the bundle, and the API refuses browser calls anyway. The browser calls `railbed-checkout`.
- **Projects on the new Supabase API keys** (publishable and secret keys instead of anon and service role): use the secret key the project already gives its functions in place of `SUPABASE_SERVICE_ROLE_KEY`. If calls to `railbed-checkout` then fail with `401`, its JWT check doesn't accept the new keys: set `verify_jwt = false` for it too, and keep `buyerId()`, which checks the signed-in user itself.
