Add bookings to a Supabase project
This page adds bookings to an app whose backend is Supabase. The browser keeps talking to Supabase as it does today; three Edge Functions talk to Bookrail with the secret key; and a table in your database follows every booking through a signed webhook, so your app can read, join and filter bookings with the Supabase client it already uses.
What you need first:
- A Supabase project and the Supabase CLI.
- A Bookrail key and one service.
npx bookrail signupgives you ask_test_key, andnpx bookrail init --template padelfollowed bynpx bookrail pushcreates a service: the Quickstart walks through both.npx bookrail services listprints thesvc_...id.
How this page is checked. Every TypeScript and SQL file on this page is extracted from this
markdown by packages/sdk-node/test/guides.test.ts. The SQL is run on Postgres; the functions
are type checked against @bookrail/node and run against a local Bookrail API, with a booking
made, the same slot refused, and the webhook delivered by the real dispatcher into the table,
in order and out of order. Two things are stand-ins, because the test runs on Node and not on
Supabase: Deno.env reads the test’s variables, and createClient from supabase-js becomes
a small adapter that sends the one rpc call on this page to Postgres as the service_role
role. The browser snippet is type checked and not run.
Why not a bookings table of your own
Section titled “Why not a bookings table of your own”The first thing an app on Supabase usually gets is a bookings table with a start and an
end, and an insert from the browser. It works in the demo. Then two people book the same
court in the same second: both reads see it free, both inserts succeed. The fix people reach
for next is a unique index on the start time, which stops two bookings that start together and
not two that overlap, and does nothing for a resource with a capacity of four, a lesson that
needs a room and a teacher at once, or a day with 23 hours in it.
Postgres can do this properly (an exclusion constraint over a time range is exactly the tool), and if you have one calendar and nothing else, Build or buy says honestly that a table is enough. Bookrail is that constraint and everything around it: capacity, schedules and time zones, holds, the policy frozen on each booking, refunds, and the webhooks. Your table below never needs a constraint on time, because nothing inserts into it except the webhook, and the webhook only reports bookings Bookrail has already accepted.
1. The secrets
Section titled “1. The secrets”supabase secrets set BOOKRAIL_SECRET_KEY=sk_test_... BOOKRAIL_SERVICE_ID=svc_... BOOKRAIL_WEBHOOK_SECRET=whsec_...Supabase secrets are environment
variables of your Edge Functions. They never reach the browser, and that is the only place a
Bookrail secret key may live: it can create, change and cancel every booking of the project. It
never goes in a VITE_ or NEXT_PUBLIC_ variable, in a table, or in the client code. For local
development, put the same three lines in supabase/functions/.env. BOOKRAIL_WEBHOOK_SECRET
comes from step 5.
2. One module shared by the functions
Section titled “2. One module shared by the functions”import Bookrail, { BookrailError } from 'npm:@bookrail/node';import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors';
export const bookrail = new Bookrail(Deno.env.get('BOOKRAIL_SECRET_KEY')!);export const SERVICE_ID = Deno.env.get('BOOKRAIL_SERVICE_ID')!;
export function json(body: unknown, status = 200): Response { return Response.json(body, { status, headers: corsHeaders });}
/** * A Bookrail error as the browser should see it: a status and a code. `fix`, when the API * sends one, is an instruction for whoever maintains the app, so it goes to the function log. */export function errorResponse(error: unknown): Response { if (!(error instanceof BookrailError)) throw error; console.error(`bookrail ${error.code}: ${error.message}`, { fix: error.fix, request_id: error.requestId, }); return json({ code: error.code }, error.status ?? 502);}@bookrail/node runs on Deno, which is what Edge Functions are. corsHeaders is the header set
Supabase publishes for functions called from a browser
(CORS).
3. Availability and booking, as two functions
Section titled “3. Availability and booking, as two functions”import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors';import { bookrail, errorResponse, json, SERVICE_ID } from '../_shared/bookrail.ts';
export default { async fetch(req: Request): Promise<Response> { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }); const { from, to } = (await req.json()) as { from: string; to: string }; try { const availability = await bookrail.availability.list({ service_id: SERVICE_ID, from, to }); return json({ timezone: availability.timezone, slots: (availability.slots ?? []).map((slot) => ({ start: slot.start, end: slot.end, duration_minutes: slot.duration_minutes, })), }); } catch (error) { return errorResponse(error); } },};import { corsHeaders } from 'npm:@supabase/supabase-js@^2/cors';import { bookrail, errorResponse, json, SERVICE_ID } from '../_shared/bookrail.ts';
interface BookingRequest { start: string; duration_minutes: number; name: string; email: string; /** One per attempt, made by the browser: a double click or a retry books once. */ attempt: string;}
export default { async fetch(req: Request): Promise<Response> { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }); const input = (await req.json()) as BookingRequest; try { const booking = await bookrail.bookings.create( { service_id: SERVICE_ID, start: input.start, duration_minutes: input.duration_minutes, customer: { email: input.email, name: input.name }, }, { idempotencyKey: `booking-form-${input.attempt}` }, ); return json({ id: booking.id, status: booking.status, start: booking.start }, 201); } catch (error) { return errorResponse(error); } },};from and to are instants with an explicit offset: a bare date is refused with
400 parameter_invalid, because midnight is not the same instant everywhere. A slot that went
while the customer was typing is 409 slot_unavailable, and exactly one of two simultaneous
requests for the last unit gets the booking. The idempotency key makes a retried request answer
with the first booking instead of making a second one.
From the browser, the functions are called with the Supabase client:
import { FunctionsHttpError, type SupabaseClient } from '@supabase/supabase-js';
export interface Slot { start: string; end: string; duration_minutes: number;}
/** The free slots of the visitor's day. The browser knows its own offset; the API needs one. */export async function freeSlots(supabase: SupabaseClient, date: string) { const from = new Date(`${date}T00:00`); const to = new Date(from); to.setDate(to.getDate() + 1); const { data, error } = await supabase.functions.invoke('availability', { body: { from: from.toISOString(), to: to.toISOString() }, }); if (error) throw error; return data as { timezone: string; slots: Slot[] };}
/** Books a slot. Answers the error code (`slot_unavailable`, ...) instead of throwing it. */export async function book( supabase: SupabaseClient, slot: Slot, customer: { name: string; email: string }, attempt: string,) { const { data, error } = await supabase.functions.invoke('book', { body: { start: slot.start, duration_minutes: slot.duration_minutes, ...customer, attempt }, }); if (error instanceof FunctionsHttpError) { const body = (await error.context.json()) as { code: string }; return { ok: false as const, code: body.code }; } if (error) throw error; return { ok: true as const, booking: data as { id: string; status: string; start: string } };}Create attempt with crypto.randomUUID() when the customer opens the form, and make a new one
only after an answer.
4. The table the webhook writes
Section titled “4. The table the webhook writes”-- What Bookrail knows about each booking, kept up to date by the bookrail-webhook function.-- Nothing else writes here, so there is no constraint on time: Bookrail enforces capacity.create table public.bookrail_bookings ( id text primary key, status text not null, start_at timestamptz not null, end_at timestamptz not null, customer_id text not null, event_seq bigint not null, updated_at timestamptz not null default now());
alter table public.bookrail_bookings enable row level security;grant select, insert, update on public.bookrail_bookings to service_role;
-- One write per delivery. Deliveries can arrive twice or out of order: a row only moves-- forward, to the state of a later event, never back to an older one.create function public.apply_bookrail_booking( p_id text, p_status text, p_start_at timestamptz, p_end_at timestamptz, p_customer_id text, p_event_seq bigint) returns voidlanguage sqlset search_path = ''as $$ insert into public.bookrail_bookings as b (id, status, start_at, end_at, customer_id, event_seq) values (p_id, p_status, p_start_at, p_end_at, p_customer_id, p_event_seq) on conflict (id) do update set status = excluded.status, start_at = excluded.start_at, end_at = excluded.end_at, event_seq = excluded.event_seq, updated_at = now() where b.event_seq < excluded.event_seq;$$;
revoke execute on function public.apply_bookrail_booking from public, anon, authenticated;grant execute on function public.apply_bookrail_booking to service_role;Row Level Security is on and there is no policy, so only the service_role reads or writes
the table until you add one: a select policy for authenticated is what lets a signed-in
user see bookings, and the right using clause depends on how your users relate to them.
The grants are written out because Supabase no longer grants a new table in public to every
role by itself: projects created since 30 May 2026 already behave this way, and existing projects
will from 30 October 2026
(Supabase changelog,
checked on 25 September 2026). The revoke matters just as much: without it a visitor holding
your publishable key could call the function and write a booking into your table.
Apply it with supabase db push, or paste it into the SQL editor.
5. The webhook function
Section titled “5. The webhook function”import { BookrailSignatureVerificationError, type Booking, type Event } from 'npm:@bookrail/node';import { createClient } from 'npm:@supabase/supabase-js@2';import { bookrail } from '../_shared/bookrail.ts';
const secretKeys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!) as Record<string, string>;const supabase = createClient(Deno.env.get('SUPABASE_URL')!, secretKeys['default']!);
export default { async fetch(req: Request): Promise<Response> { // The raw text, exactly as it arrived: a body that was parsed and re-encoded does not verify. const payload = await req.text();
let event: Event; try { event = bookrail.webhooks.constructEvent( payload, req.headers.get('bookrail-signature'), Deno.env.get('BOOKRAIL_WEBHOOK_SECRET')!, ); } catch (error) { if (error instanceof BookrailSignatureVerificationError) { return new Response('invalid signature', { status: 400 }); } throw error; }
if (event.type.startsWith('booking.')) { const booking = event.data.object as unknown as Booking; const { error } = await supabase.rpc('apply_bookrail_booking', { p_id: booking.id, p_status: booking.status, p_start_at: booking.start, p_end_at: booking.end, p_customer_id: booking.customer_id, p_event_seq: event.seq, }); // Anything but a 2xx is retried, from three seconds to a day later. if (error) return new Response(error.message, { status: 500 }); }
return new Response(null, { status: 204 }); },};SUPABASE_URL and SUPABASE_SECRET_KEYS are
set by Supabase in every Edge Function;
the secret key bypasses Row Level Security, which is why it stays in this function. On a project
that still uses the legacy keys, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') is the same thing.
Every delivery carries the whole booking, and event.seq grows with every change, which is what
lets the function above ignore a delivery that is older than the row.
A webhook arrives without a Supabase token, since Bookrail has no user session, so this one function must not require a JWT. The signature check above is its authentication:
[functions.bookrail-webhook]verify_jwt = falseSupabase documents the same setting for
webhooks from third parties. The other two
functions keep the default, which asks for a valid JWT in the Authorization header: with the
current publishable keys, that means the session of a signed-in user. If anonymous visitors should book, set verify_jwt = false for them too, and remember
that anybody can then call them, exactly as anybody can use a public booking page.
Deploy the three functions, then register the endpoint once with the key of the environment it is for. The script prints the signing secret, which is shown this one time:
supabase functions deploy availabilitysupabase functions deploy booksupabase functions deploy bookrail-webhookimport Bookrail from '@bookrail/node';
const bookrail = new Bookrail(process.env.BOOKRAIL_SECRET_KEY!);
const endpoint = await bookrail.webhooks.create({ url: 'https://your-project-ref.supabase.co/functions/v1/bookrail-webhook', events: ['booking.created', 'booking.cancelled', 'booking.rescheduled', 'booking.completed'],});
// Shown once. Set it with `supabase secrets set BOOKRAIL_WEBHOOK_SECRET=...` now.console.log(`${endpoint.id} ${endpoint.secret}`);Run it on your machine with npx tsx scripts/create-webhook.mts and the Bookrail key in the
environment, or use npx bookrail webhooks create --url ... --events booking.created. It is not
an Edge Function: it runs once.
Going live
Section titled “Going live”A person does this step, not an agent. Replace the three secrets with the live key
(sk_live_..., the other key npx bookrail signup gave you), the id of the service as
npx bookrail push --live created it, and the secret of a webhook endpoint created with the
live key: test and live share nothing, not even the endpoints. The table and the functions stay
as they are.
What this page leaves out
Section titled “What this page leaves out”- Payments.
payment: { mode: 'deposit' }onbookings.create, once a Stripe account is connected, and Stripe.js in the browser: Payments with Stripe. - Holds. Keeping the slot for ten minutes while a long form is filled in: Concepts.
- Cancelling. A fourth function that calls
bookrail.bookings.cancel(id), after checking that the signed-in user owns the booking. The webhook then moves the row tocancelledby itself.