Example: Stripe webhooks
A complete Stripe webhook handler as a SupaHost Edge Function, with signature verification via constructEventAsync, idempotent processing and database updates.
This example marks bookings as paid when Stripe Checkout completes. It verifies Stripe's signature, ignores duplicate deliveries, and updates the database with the service_role key. You can adapt the event handling to subscriptions, invoices or anything else Stripe sends.
1. Tables
Run this as a migration or in Studio's SQL editor. stripe_events records which events have been handled, so a retried delivery isn't processed twice.
create table public.bookings (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
room text not null,
starts_at timestamptz not null,
status text not null default 'pending'
check (status in ('pending', 'paid', 'expired')),
stripe_checkout_session_id text unique,
paid_at timestamptz,
created_at timestamptz not null default now()
);
alter table public.bookings enable row level security;
create policy "Guests read their own bookings"
on public.bookings for select
to authenticated
using ((select auth.uid()) = user_id);
create table public.stripe_events (
id text primary key, -- Stripe's event id, evt_...
type text not null,
received_at timestamptz not null default now()
);
-- RLS on with no policies: only service_role (the webhook) can touch it.
alter table public.stripe_events enable row level security;
When your app creates the Checkout Session, put the booking's id in its metadata so the webhook can find it:
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: "price_123", quantity: 1 }],
success_url: "https://bookings.example.com/thanks",
cancel_url: "https://bookings.example.com/cancelled",
metadata: { booking_id: booking.id },
});
2. Secrets
In Dashboard → your project → Functions tab → Secrets, add:
| Name | Value |
|---|---|
STRIPE_SECRET_KEY |
Your Stripe secret key (sk_test_... while testing). |
STRIPE_WEBHOOK_SECRET |
The endpoint's signing secret (whsec_...), from step 4. You can add it after creating the endpoint. |
3. The function
Create a function named stripe-webhook and turn Require a JWT off: Stripe can't send your project's JWT, so the Stripe signature is what protects this endpoint.
import Stripe from "npm:stripe@18";
import { createClient } from "jsr:@supabase/supabase-js@2";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, {
// Use fetch and Web Crypto, which Deno provides.
httpClient: Stripe.createFetchHttpClient(),
});
const cryptoProvider = Stripe.createSubtleCryptoProvider();
const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!;
const admin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
async function markPaid(session: Stripe.Checkout.Session) {
const bookingId = session.metadata?.booking_id;
if (!bookingId) {
console.warn(`Checkout session ${session.id} has no booking_id; ignoring`);
return;
}
if (session.payment_status !== "paid") return; // e.g. a bank transfer still pending
const { error } = await admin
.from("bookings")
.update({ status: "paid", paid_at: new Date().toISOString(), stripe_checkout_session_id: session.id })
.eq("id", bookingId);
if (error) throw new Error(`Couldn't update booking ${bookingId}: ${error.message}`);
}
async function markExpired(session: Stripe.Checkout.Session) {
const bookingId = session.metadata?.booking_id;
if (!bookingId) return;
const { error } = await admin
.from("bookings")
.update({ status: "expired" })
.eq("id", bookingId)
.eq("status", "pending");
if (error) throw new Error(`Couldn't expire booking ${bookingId}: ${error.message}`);
}
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const signature = req.headers.get("Stripe-Signature");
if (!signature) {
return new Response("Missing Stripe-Signature header", { status: 400 });
}
// The signature covers the exact bytes Stripe sent: read the raw text, don't parse it first.
const payload = await req.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(payload, signature, webhookSecret, undefined, cryptoProvider);
} catch (err) {
console.error("Signature verification failed:", (err as Error).message);
return new Response("Invalid signature", { status: 400 });
}
// Claim the event. A duplicate insert means we've already handled it.
const { error: claimError } = await admin.from("stripe_events").insert({ id: event.id, type: event.type });
if (claimError) {
if (claimError.code === "23505") {
return Response.json({ received: true, duplicate: true });
}
console.error("Couldn't record event", claimError);
return new Response("Database error", { status: 500 });
}
try {
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
await markPaid(event.data.object as Stripe.Checkout.Session);
break;
case "checkout.session.expired":
await markExpired(event.data.object as Stripe.Checkout.Session);
break;
default:
console.log(`Unhandled event type ${event.type}`);
}
} catch (err) {
console.error(`Failed to handle ${event.type} (${event.id}):`, (err as Error).message);
// Release the claim so Stripe's retry can try again.
await admin.from("stripe_events").delete().eq("id", event.id);
return new Response("Handler error", { status: 500 });
}
return Response.json({ received: true });
});
Click Deploy function and wait for Live · v1. The URL is:
https://app-one.acme.supahost.space/functions/v1/stripe-webhook
4. Register the endpoint in Stripe
- In the Stripe Dashboard, open Developers → Webhooks and add an endpoint with the URL above.
- Select the events
checkout.session.completed,checkout.session.async_payment_succeededandcheckout.session.expired. - Copy the endpoint's signing secret into the
STRIPE_WEBHOOK_SECRETsecret on SupaHost. It reaches the function within about a minute; no redeploy needed.
Test mode and live mode have different endpoints and signing secrets. Use test keys first.
5. Test it
With the Stripe CLI, trigger an event in test mode. Stripe delivers it to your registered endpoint:
stripe trigger checkout.session.completed
That test session has no booking_id, so the function logs "has no booking_id; ignoring" and answers 200. Check the Logs card on the Functions tab, and the endpoint's delivery list in Stripe. For an end-to-end test, create a real test-mode Checkout Session for a booking and pay with Stripe's test card.
Why it's built this way
- Raw body.
constructEventAsyncneeds the body exactly as sent. Callingreq.json()first would break verification. - Async verification. Deno uses Web Crypto, which is asynchronous, so use
constructEventAsyncwithcreateSubtleCryptoProvider()rather thanconstructEvent. - Idempotency. Stripe can deliver the same event more than once. The primary key on
stripe_eventsmakes the second delivery a no-op. - Fast answers. Stripe expects a 2xx response quickly and retries failures. Keep handlers short; push slow work (emails, PDFs) onto a queue.
- Status codes.
400for bad signatures (Stripe won't fix those by retrying),500for your own failures (so Stripe does retry).