# Example: Stripe webhooks

> A complete Stripe webhook handler as a SupaHost Edge Function, with signature verification via constructEventAsync, idempotent processing and database updates.

Source: https://supahost.app/docs/functions/stripe-webhooks · Updated 2026-11-11

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.

```sql
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:

```ts
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.

```ts
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

1. In the Stripe Dashboard, open **Developers → Webhooks** and add an endpoint with the URL above.
2. Select the events `checkout.session.completed`, `checkout.session.async_payment_succeeded` and `checkout.session.expired`.
3. Copy the endpoint's **signing secret** into the `STRIPE_WEBHOOK_SECRET` secret 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](https://docs.stripe.com/stripe-cli), trigger an event in test mode. Stripe delivers it to your registered endpoint:

```bash
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.** `constructEventAsync` needs the body exactly as sent. Calling `req.json()` first would break verification.
- **Async verification.** Deno uses Web Crypto, which is asynchronous, so use `constructEventAsync` with `createSubtleCryptoProvider()` rather than `constructEvent`.
- **Idempotency.** Stripe can deliver the same event more than once. The primary key on `stripe_events` makes 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.** `400` for bad signatures (Stripe won't fix those by retrying), `500` for your own failures (so Stripe does retry).

## Related

- [Stripe](/docs/integrations/stripe)
- [Authentication and JWT verification](/docs/functions/auth)
- [Environment variables and secrets](/docs/functions/secrets)
- [Edge Function returns 401](/kb/function-401)
