Skip to content

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 signup gives you a sk_test_ key, and npx bookrail init --template padel followed by npx bookrail push creates a service: the Quickstart walks through both. npx bookrail services list prints the svc_... 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.

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.

Terminal window
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.

supabase/functions/_shared/bookrail.ts
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”
supabase/functions/availability/index.ts
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);
}
},
};
supabase/functions/book/index.ts
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:

src/lib/bookings.ts
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.

supabase/migrations/20261001000000_bookrail_bookings.sql
-- 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 void
language sql
set 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.

supabase/functions/bookrail-webhook/index.ts
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:

supabase/config.toml
[functions.bookrail-webhook]
verify_jwt = false

Supabase 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:

Terminal window
supabase functions deploy availability
supabase functions deploy book
supabase functions deploy bookrail-webhook
scripts/create-webhook.mts
import 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.

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.

  • Payments. payment: { mode: 'deposit' } on bookings.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 to cancelled by itself.