Logo VelaStack

Enable payments

Add Stripe payments to your SvelteKit app. Scaffolds product and price sync, a checkout flow, and the server wiring required to go from zero to a working Stripe integration without hand-rolling the plumbing. When auth is also enabled, customers can save payment methods to their account for faster future purchases.

$ vela enable payments
data/hooks/stripe.pb.js
/// <reference path="../types.d.ts" />

cronAdd("stripe-products", "*/2 * * * *", () => {
  const utils = require(`${__hooks}/utils.js`);
  utils.triggerJob("/api/stripe/sync/products");
});

cronAdd("stripe-prices", "*/2 * * * *", () => {
  const utils = require(`${__hooks}/utils.js`);
  utils.triggerJob("/api/stripe/sync/prices");
});
data/hooks/utils.js
/// <reference path="../types.d.ts" />

module.exports = {
  triggerJob: (jobUrl) => {
    $http.send({
      method: "POST",
      url: `${$app.settings().meta.appURL}${jobUrl}`,
      headers: {
        Authorization: `Bearer ${$os.getenv("INTERNAL_JOB_SECRET")}`,
      },
    });
  },
};
src/lib/components/payments/confirm-dialog.svelte
<script lang="ts">
  import type { Stripe } from "@stripe/stripe-js";
  import { Button } from "$lib/components/ui/button";
  import * as Dialog from "$lib/components/ui/dialog";
  import { toast } from "svelte-sonner";
  import { page } from "$app/state";
  import { invalidate } from "$app/navigation";

  interface Props {
    stripe: Stripe | null;
    open: boolean;
    amount: number;
    currency: string;
    brand?: string;
    last4?: string;
    clientSecret?: string;
    onopenchange: (open: boolean) => void;
    onsuccess: () => void;
  }

  let {
    stripe,
    open = $bindable(),
    amount,
    currency,
    brand,
    last4,
    clientSecret,
    onopenchange,
    onsuccess,
  }: Props = $props();

  const brandNames = {
    amex: "American Express",
    cartes_bancaires: "Cartes Bancaires",
    diners: "Diners",
    discover: "Discover",
    eftpos_au: "Eftpos Au",
    jcb: "Jcb",
    link: "Link",
    mastercard: "Mastercard",
    unionpay: "Unionpay",
    visa: "Visa",
    unknown: "Unknown",
  };

  let isProcessing = $state(false);

  let amountFormatted = $derived.by(() => {
    let formatter = new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: currency,
    });
    return formatter.format(amount / 100);
  });

  const handleOpenChangeComplete = (isOpen: boolean) => {
    if (!isOpen) {
      resetDialog();
    }
    onopenchange(isOpen);
  };

  function resetDialog() {
    open = false;
    isProcessing = false;
  }

  async function handlePaymentMethod() {
    if (!stripe || !clientSecret) return;

    isProcessing = true;

    try {
      const { error: confirmError } = await stripe.confirmPayment({
        clientSecret,
        confirmParams: {
          return_url: page.url.toString(),
        },
        redirect: "if_required",
      });

      if (confirmError) {
        toast.error(confirmError.message || "Failed to pay for your purchase");
        return;
      }

      await invalidate("app:billing");
      toast.success("Payment successful");
      onsuccess();
      resetDialog();
    } catch (err) {
      console.error("Error paying for your purchase:", err);
      toast.error("Failed to pay for your purchase");
    } finally {
      isProcessing = false;
    }
  }
</script>

<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
  <Dialog.Content class="sm:max-w-[500px]">
    <Dialog.Header>
      <Dialog.Title>Confirm payment</Dialog.Title>
      <Dialog.Description>
        Are you sure you want to pay {amountFormatted} with your
        {brandNames[brand as keyof typeof brandNames]}
        credit card ending in **** {last4}?
      </Dialog.Description>
    </Dialog.Header>
    <Dialog.Footer>
      <Button variant="outline" onclick={resetDialog} disabled={isProcessing}
        >Cancel</Button
      >
      <Button onclick={handlePaymentMethod} disabled={isProcessing}>
        {isProcessing ? "Processing..." : "Pay Now"}
      </Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog.Root>
src/lib/components/payments/payment-button.svelte
<script lang="ts">
  import { Button } from "$lib/components/ui/button";
  import PaymentDialog from "$lib/components/payments/payment-dialog.svelte";
  import ConfirmDialog from "$lib/components/payments/confirm-dialog.svelte";
  import { useStripe } from "$lib/components/payments/use-stripe.svelte";
  import { usePaymentIntent } from "$lib/components/payments/use-payment-intent.svelte";
  import type { ButtonProps } from "$lib/components/ui/button";

  interface Props {
    priceId: string;
    user?: { email: string; name: string } | null;
    disabled?: boolean;
    isProcessing?: boolean;
    label?: string;
    processingLabel?: string;
    onsuccess?: () => void;
  }

  let {
    priceId,
    user,
    disabled = false,
    isProcessing = false,
    label = "Purchase",
    processingLabel = "Processing...",
    onsuccess,
    ...rest
  }: Props & ButtonProps = $props();

  const stripeManager = useStripe();
  const paymentIntentManager = usePaymentIntent();

  let mode = $state<"payment" | "confirm">("payment");
  let showDialog = $state(false);
  let amount = $state<number>(0);
  let currency = $state<string>("usd");
  let clientSecret = $state<string>();
  let brand = $state<string>();
  let last4 = $state<string>();

  async function handleBuyClick() {
    const result = await paymentIntentManager.fetchPaymentIntent({
      priceId,
    });

    if (!result) return;

    if (result.type === "confirm") {
      mode = "confirm";
      brand = result.brand;
      last4 = result.last4;
    } else if (result.type === "payment") {
      mode = "payment";
    }

    amount = result.amount || 0;
    currency = result.currency || "usd";
    clientSecret = result.clientSecret;
    showDialog = true;
  }

  function handleDialogOpenChange(open: boolean) {
    showDialog = open;
  }

  function handlePaymentSuccess() {
    onsuccess?.();
  }
</script>

<Button disabled={disabled || isProcessing} onclick={handleBuyClick} {...rest}>
  {#if isProcessing}
    {processingLabel}
  {:else}
    {label}
  {/if}
</Button>

{#if mode === "confirm"}
  <ConfirmDialog
    bind:open={showDialog}
    stripe={stripeManager.stripe}
    {amount}
    {currency}
    {brand}
    {last4}
    {clientSecret}
    onopenchange={handleDialogOpenChange}
    onsuccess={handlePaymentSuccess}
  />
{:else if mode === "payment"}
  <PaymentDialog
    bind:open={showDialog}
    stripe={stripeManager.stripe}
    {amount}
    {currency}
    {clientSecret}
    {user}
    onopenchange={handleDialogOpenChange}
    onsuccess={handlePaymentSuccess}
  />
{/if}
src/lib/components/payments/payment-dialog.svelte
<script lang="ts">
  import type { Stripe, StripeElements } from "@stripe/stripe-js";
  import { Button } from "$lib/components/ui/button";
  import * as Dialog from "$lib/components/ui/dialog";
  import { Input } from "$lib/components/ui/input";
  import { Label } from "$lib/components/ui/label";
  import { toast } from "svelte-sonner";
  import { page } from "$app/state";
  import { invalidate } from "$app/navigation";
  import StripePaymentForm from "./stripe-payment-form.svelte";

  interface Props {
    stripe: Stripe | null;
    open: boolean;
    amount: number;
    currency: string;
    clientSecret?: string;
    user?: { email: string; name: string } | null;
    onopenchange: (open: boolean) => void;
    onsuccess: () => void;
  }

  let {
    stripe,
    open = $bindable(),
    amount,
    currency,
    clientSecret,
    user = null,
    onopenchange,
    onsuccess,
  }: Props = $props();

  let isProcessing = $state(false);
  let cardComplete = $state(false);
  let savePaymentMethod = $state(false);
  let email = $state<string>();
  let formRef = $state<{
    getElements: () => StripeElements | null;
  }>();

  let amountFormatted = $derived.by(() => {
    let formatter = new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: currency,
    });
    return formatter.format(amount / 100);
  });

  const handleOpenChangeComplete = (isOpen: boolean) => {
    if (!isOpen) {
      resetDialog();
    }
    onopenchange(isOpen);
  };

  function resetDialog() {
    open = false;
    isProcessing = false;
    cardComplete = false;
  }

  function handleCardChange(complete: boolean) {
    cardComplete = complete;
  }

  async function handlePaymentMethod() {
    if (!stripe || !formRef || !cardComplete || !clientSecret) return;

    const elements = formRef.getElements();
    if (!elements) return;

    isProcessing = true;

    try {
      const { error: submitError } = await elements.submit();
      if (submitError) {
        toast.error(submitError.message || "Failed to pay for your purchase");
        return;
      }

      const { error: confirmError } = await stripe.confirmPayment({
        elements,
        clientSecret,
        confirmParams: {
          return_url: page.url.toString(),
          payment_method_data: {
            billing_details: {
              email: email || user?.email,
            },
            allow_redisplay: savePaymentMethod ? "always" : "unspecified",
          },
        },
        redirect: "if_required",
      });

      if (confirmError) {
        toast.error(confirmError.message || "Failed to pay for your purchase");
        return;
      }

      await invalidate("app:billing");
      toast.success("Payment successful");
      onsuccess();
      resetDialog();
    } catch (err) {
      console.error("Error paying for your purchase:", err);
      toast.error("Failed to pay for your purchase");
    } finally {
      isProcessing = false;
    }
  }
</script>

<Dialog.Root bind:open onOpenChangeComplete={handleOpenChangeComplete}>
  <Dialog.Content class="sm:max-w-[500px]">
    <Dialog.Header>
      <Dialog.Title>{amountFormatted}</Dialog.Title>
      <Dialog.Description
        >Enter your card details to pay for your purchase.</Dialog.Description
      >
    </Dialog.Header>
    {#if !user}
      <div class="space-y-2">
        <Label for="email">Email</Label>
        <Input type="email" bind:value={email} />
      </div>
    {/if}
    <div>
      {#if stripe && open}
        <StripePaymentForm
          bind:this={formRef}
          mode="payment"
          {stripe}
          {amount}
          {currency}
          {savePaymentMethod}
          {user}
          onchange={handleCardChange}
        />
      {/if}
    </div>
    {#if user}
      <div class="flex items-center gap-2">
        <input
          type="checkbox"
          id="savePaymentMethod"
          bind:checked={savePaymentMethod}
        />
        <label for="savePaymentMethod" class="text-sm text-muted-foreground">
          Save payment method for future purchases
        </label>
      </div>
    {/if}
    <Dialog.Footer>
      <Button variant="outline" onclick={resetDialog} disabled={isProcessing}
        >Cancel</Button
      >
      <Button
        onclick={handlePaymentMethod}
        disabled={!cardComplete || isProcessing}
      >
        {isProcessing ? "Processing..." : "Pay Now"}
      </Button>
    </Dialog.Footer>
  </Dialog.Content>
</Dialog.Root>
src/lib/components/payments/stripe-constants.ts
export const TERMS_AUTO = {
  applePay: "auto" as const,
  auBecsDebit: "auto" as const,
  bancontact: "auto" as const,
  card: "auto" as const,
  cashapp: "auto" as const,
  googlePay: "auto" as const,
  ideal: "auto" as const,
  paypal: "auto" as const,
  sepaDebit: "auto" as const,
  sofort: "auto" as const,
  usBankAccount: "auto" as const,
};

export const TERMS_NEVER = {
  applePay: "never" as const,
  auBecsDebit: "never" as const,
  bancontact: "never" as const,
  card: "never" as const,
  cashapp: "never" as const,
  googlePay: "never" as const,
  ideal: "never" as const,
  paypal: "never" as const,
  sepaDebit: "never" as const,
  sofort: "never" as const,
  usBankAccount: "never" as const,
};
src/lib/components/payments/stripe-payment-form.svelte
<script lang="ts">
  import type {
    Stripe,
    StripeElements,
    StripePaymentElement,
    StripeElementsOptionsMode,
  } from "@stripe/stripe-js";
  import { mode as modeWatcher } from "mode-watcher";
  import { TERMS_AUTO, TERMS_NEVER } from "./stripe-constants";
  import { onMount } from "svelte";

  interface Props {
    stripe: Stripe;
    mode: "payment" | "setup";
    amount?: number;
    currency?: string;
    savePaymentMethod?: boolean;
    user?: { email: string; name: string } | null;
    onchange: (complete: boolean) => void;
  }

  let {
    stripe,
    mode,
    amount = 0,
    currency = "usd",
    savePaymentMethod = false,
    user,
    onchange,
  }: Props = $props();

  let cardContainer = $state<HTMLDivElement | null>(null);
  let elements = $state<StripeElements | null>(null);
  let paymentElement = $state<StripePaymentElement | null>(null);

  function initializeElements() {
    if (!stripe) return;

    // Clean up previous elements
    if (paymentElement) {
      paymentElement.destroy();
      paymentElement = null;
    }

    const appearance = {
      theme:
        modeWatcher.current === "dark"
          ? ("night" as const)
          : ("stripe" as const),
      inputs: "condensed" as const,
      labels: "floating" as const,
    };

    elements = stripe.elements({
      mode,
      appearance,
      currency,
      ...(mode === "payment" && {
        amount: amount,
      }),
      ...(user || mode === "setup"
        ? {
            setupFutureUsage: "off_session" as const,
          }
        : {}),
    } as StripeElementsOptionsMode);

    paymentElement = elements.create("payment", {
      layout: "tabs",
      defaultValues: {
        billingDetails: {
          email: user?.email,
          name: user?.name,
        },
      },
      terms: savePaymentMethod ? TERMS_AUTO : TERMS_NEVER,
    });

    if (cardContainer && paymentElement) {
      paymentElement.mount(cardContainer);
      paymentElement.on("change", (event: any) => {
        onchange(event.complete);
      });
    }
  }

  // Initialize elements when component mounts
  onMount(() => {
    initializeElements();

    return () => {
      if (paymentElement) {
        paymentElement.destroy();
      }
    };
  });

  // Update terms when savePaymentMethod changes
  $effect(() => {
    if (paymentElement) {
      paymentElement.update({
        terms: savePaymentMethod ? TERMS_AUTO : TERMS_NEVER,
      });
    }
  });

  export function getElements() {
    return elements;
  }
</script>

<div bind:this={cardContainer}></div>
src/lib/components/payments/use-payment-intent.svelte.ts
import { toast } from "svelte-sonner";

export interface PaymentIntentResult {
  type: "payment" | "confirm";
  amount?: number;
  currency?: string;
  clientSecret?: string;
  brand?: string;
  last4?: string;
}

export function usePaymentIntent() {
  let isLoading = $state(false);

  async function fetchPaymentIntent({
    priceId,
  }: {
    priceId: string;
  }): Promise<PaymentIntentResult | null> {
    isLoading = true;

    const res = await fetch("/api/stripe/payment-intent", {
      method: "POST",
      body: JSON.stringify({
        priceId: priceId,
      }),
      headers: {
        "Content-Type": "application/json",
      },
    });

    if (!res.ok) {
      toast.error("Failed to initialize payment");
      return null;
    }

    return res.json();
  }

  async function fetchSetupIntent(): Promise<PaymentIntentResult | null> {
    isLoading = true;

    const res = await fetch("/api/stripe/setup-intent", {
      method: "POST",
    });

    if (!res.ok) {
      toast.error("Failed to initialize setup");
      return null;
    }

    return res.json();
  }

  return {
    get isLoading() {
      return isLoading;
    },
    fetchPaymentIntent,
    fetchSetupIntent,
  };
}
src/lib/components/payments/use-stripe.svelte.ts
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { toast } from "svelte-sonner";
import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from "$env/static/public";

export function useStripe() {
  let stripe = $state<Stripe | null>(null);
  let isLoading = $state(true);
  let error = $state<string | null>(null);

  async function initialize() {
    try {
      stripe = await loadStripe(PUBLIC_STRIPE_PUBLISHABLE_KEY);
      isLoading = false;
    } catch (err) {
      console.error("Failed to load Stripe:", err);
      error = "Failed to initialize payment system";
      toast.error("Failed to initialize payment system");
      isLoading = false;
    }
  }

  // Auto-initialize
  initialize();

  return {
    get stripe() {
      return stripe;
    },
    get isLoading() {
      return isLoading;
    },
    get error() {
      return error;
    },
  };
}
src/lib/stripe.ts
import { STRIPE_SECRET_KEY } from "$env/static/private";
import Stripe from "stripe";

const stripe = new Stripe(STRIPE_SECRET_KEY, {
  apiVersion: "2025-09-30.clover",
});

export default stripe;
src/routes/api/stripe/payment-intent/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import type { Stripe } from "stripe";

const createPaymentIntent = async (price: Stripe.Price) => {
  if (price.unit_amount === null || price.unit_amount === undefined) {
    console.error("Price does not have a per unit amount");
    throw new Error("Price does not have a per unit amount");
  }

  const intent = await stripe.paymentIntents.create({
    amount: price.unit_amount,
    currency: price.currency,
    automatic_payment_methods: {
      enabled: true,
      allow_redirects: "never",
    },
  });

  if (!intent.client_secret) {
    console.error("Payment intent created without client secret");
    throw new Error("Failed to create payment intent");
  }

  return {
    type: "payment",
    clientSecret: intent.client_secret,
    amount: price.unit_amount,
    currency: price.currency,
  };
};

export const POST = async ({ request, locals }) => {
  let body;
  try {
    body = await request.json();
  } catch (error) {
    console.error("Invalid JSON in request body");
    return json({ error: "Invalid request body" }, { status: 400 });
  }

  const { priceId } = body as { priceId?: string };

  // Validate required fields
  if (!priceId || typeof priceId !== "string" || priceId === "") {
    console.error("Missing or invalid priceId");
    return json({ error: "Price ID is required" }, { status: 400 });
  }

  // Retrieve price from Stripe
  let price;
  try {
    price = await stripe.prices.retrieve(priceId);
  } catch (error) {
    console.error("Failed to retrieve price from Stripe:", priceId);
    return json(
      { error: "Invalid price ID or price not found" },
      { status: 404 },
    );
  }

  // Validate price is active
  if (!price.active) {
    console.error("Price is not active:", priceId);
    return json(
      { error: "This price is no longer available" },
      { status: 400 },
    );
  }

  try {
    const res = await createPaymentIntent(price);
    return json(res, { status: 200 });
  } catch (error) {
    console.error("Error creating payment intent for price:", priceId);
    const errorMessage =
      error instanceof Error
        ? error.message
        : "Failed to create payment intent";
    return json({ error: errorMessage }, { status: 500 });
  }
};
src/routes/api/stripe/payment-intent/server.test.ts
import { describe, it, expect, beforeAll } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
import stripe from "$lib/stripe";
import type Stripe from "stripe";

describe("POST /api/stripe/payment-intent", () => {
  let product: Stripe.Product;
  let activePrice: Stripe.Price;
  let inactivePrice: Stripe.Price;

  beforeAll(async () => {
    const productName = "Test Product - Payment Intent";
    const products = await stripe.products.search({
      query: `name:"${productName}" AND metadata["test"]:"true"`,
      limit: 1,
    });

    if (products.data.length > 0) {
      product = products.data[0];

      const prices = await stripe.prices.list({
        product: product.id,
        active: true,
        limit: 1,
      });

      if (prices.data.length > 0) {
        activePrice = prices.data[0];
      }

      const inactivePrices = await stripe.prices.list({
        product: product.id,
        active: false,
        limit: 1,
      });

      if (inactivePrices.data.length > 0) {
        inactivePrice = inactivePrices.data[0];
      }
    }

    // Create if not found or no active price
    if (!product) {
      product = await stripe.products.create({
        name: productName,
        metadata: {
          test: "true",
          test_type: "payment-intent",
        },
      });
    }

    if (!activePrice) {
      activePrice = await stripe.prices.create({
        product: product.id,
        unit_amount: 1000,
        currency: "usd",
        metadata: {
          test: "true",
          test_type: "payment-intent",
        },
      });
    }

    if (!inactivePrice) {
      inactivePrice = await stripe.prices.create({
        product: product.id,
        unit_amount: 2000,
        currency: "usd",
        active: false,
        metadata: {
          test: "true",
          test_type: "payment-intent-inactive",
        },
      });
    }
  });

  describe("Request validation", () => {
    it("should return 400 if request body is not valid JSON", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .set("Content-Type", "application/json")
        .send("invalid json");

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Invalid request body");
    });

    it("should return 400 if priceId is missing", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({});

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Price ID is required");
    });

    it("should return 400 if priceId is not a string", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: 12345 });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Price ID is required");
    });

    it("should return 400 if priceId is an empty string", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: "" });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("Price ID is required");
    });

    it("should return 404 if price does not exist", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: "price_nonexistent123" });

      expect(response.status).toBe(404);
      expect(response.body.error).toBe("Invalid price ID or price not found");
    });

    it("should return 400 if price is not active", async (context) => {
      const response = await context.agent
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: inactivePrice.id });

      expect(response.status).toBe(400);
      expect(response.body.error).toBe("This price is no longer available");
    });
  });

  describe("Guest user (unauthenticated)", () => {
    it("should create payment intent for guest user", async (context) => {
      const response = await context.request
        .post("/api/stripe/payment-intent" satisfies Match<RouteId>)
        .send({ priceId: activePrice.id });

      expect(response.status).toBe(200);
      expect(response.body.type).toBe("payment");
      expect(response.body.clientSecret).toBeDefined();
      expect(response.body.amount).toBe(1000);
      expect(response.body.currency).toBe("usd");
      expect(response.body.brand).toBeUndefined();
      expect(response.body.last4).toBeUndefined();

      // Verify the payment intent was created in Stripe
      const intentId = response.body.clientSecret.split("_secret_")[0];
      const intent = await stripe.paymentIntents.retrieve(intentId);
      expect(intent.amount).toBe(1000);
      expect(intent.currency).toBe("usd");
      expect(intent.customer).toBeNull();

      // Clean up
      await stripe.paymentIntents.cancel(intent.id);
    });
  });
});
src/routes/api/stripe/sync/prices/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { INTERNAL_JOB_SECRET } from "$env/static/private";

export const POST = async ({ request, locals }) => {
  const authHeader = request.headers.get("Authorization");
  if (authHeader !== `Bearer ${INTERNAL_JOB_SECRET}`) {
    return json({ error: "Unauthorized" }, { status: 401 });
  }

  const prices = await stripe.prices.list();

  for (const price of prices.data) {
    try {
      await locals.admin
        .collection("stripe_prices")
        .getFirstListItem(`id = "${price.id}"`);
      continue;
    } catch {}

    await locals.admin.collection("stripe_prices").create({
      id: price.id,
      billing_scheme: price.billing_scheme,
      currency: price.currency,
      product: price.product,
      recurring: price.recurring,
      type: price.type,
      unit_amount: price.unit_amount,
      active: price.active,
    });
  }

  console.log("Prices synced");
  return json({ message: "Prices synced" });
};
src/routes/api/stripe/sync/products/+server.ts
import { json } from "@sveltejs/kit";
import stripe from "$lib/stripe";
import { INTERNAL_JOB_SECRET } from "$env/static/private";

export const POST = async ({ request, locals }) => {
  const authHeader = request.headers.get("Authorization");
  if (authHeader !== `Bearer ${INTERNAL_JOB_SECRET}`) {
    return json({ error: "Unauthorized" }, { status: 401 });
  }

  const products = await stripe.products.list();

  for (const product of products.data) {
    try {
      await locals.admin
        .collection("stripe_products")
        .getFirstListItem(`id = "${product.id}"`);
      continue;
    } catch {}

    await locals.admin.collection("stripe_products").create({
      id: product.id,
      name: product.name,
      active: product.active,
      default_price: product.default_price,
    });
  }

  console.log("Products synced");
  return json({ message: "Products synced" });
};
src/routes/webhooks/stripe/+server.ts
import Stripe from "stripe";
import { json } from "@sveltejs/kit";
import { STRIPE_WEBHOOK_SECRET } from "$env/static/private";
import stripe from "$lib/stripe";
import { handlePaymentIntentSucceeded } from "./handlers/payment-intent/succeeded";

const processedEvents = new Set<string>();

async function verifyWebhookSignature(
  request: Request,
  webhookSecret: string,
): Promise<Stripe.Event> {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    throw new Error("Missing stripe-signature header");
  }

  if (!webhookSecret) {
    throw new Error("Missing webhook secret");
  }

  try {
    return stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    throw new Error("Invalid signature");
  }
}

export const POST = async ({ request, locals }) => {
  try {
    const event = await verifyWebhookSignature(request, STRIPE_WEBHOOK_SECRET);

    if (processedEvents.has(event.id)) {
      console.log(`Event ${event.id} already processed, skipping`);
      return json({ received: true, message: "Event already processed" });
    }

    processedEvents.add(event.id);

    console.log(`Processing webhook event: ${event.type} (${event.id})`);

    switch (event.type) {
      case "payment_intent.succeeded":
        await handlePaymentIntentSucceeded(event.data.object, locals);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return json({
      received: true,
      eventId: event.id,
      eventType: event.type,
    });
  } catch (error) {
    console.error("Webhook processing error:", error);

    return json(
      {
        error: "Webhook processing failed",
        message: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 400 },
    );
  }
};
src/routes/webhooks/stripe/handlers/payment-intent/server.test.ts
import { describe, it, expect } from "vitest";
import stripe from "$lib/stripe";
import { handlePaymentIntentSucceeded } from "./succeeded";

describe("payment_intent.succeeded", () => {
  it("handles a payment intent successfully", async (context) => {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: 2000,
      currency: "usd",
    });
    const locals = { admin: context.admin } as App.Locals;
    const result = await handlePaymentIntentSucceeded(paymentIntent, locals);

    expect(result).toEqual({});
  });
});
src/routes/webhooks/stripe/handlers/payment-intent/succeeded.ts
import Stripe from "stripe";

/**
 * Handles payment_intent.succeeded event
 */
export async function handlePaymentIntentSucceeded(
  paymentIntent: Stripe.PaymentIntent,
  locals: App.Locals,
) {
  return {};
}
src/routes/webhooks/stripe/server.test.ts
import { describe, it, expect } from "vitest";
import type { RouteId } from "./$types";
import type { Match } from "@velastack/pocketbase";
import type Stripe from "stripe";
import stripe from "$lib/stripe";

describe("POST /webhooks/stripe", () => {
  it("accepts valid webhook signature", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);
    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

    const signature = stripe.webhooks.generateTestHeaderString({
      payload,
      secret: webhookSecret,
    });

    const response = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", signature)
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response.status).toBe(200);
    expect(response.body.received).toBe(true);
    expect(response.body.eventId).toBe(event.id);
    expect(response.body.eventType).toBe("test.event");
  });

  it("rejects invalid webhook signature", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);

    const response = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", "invalid_signature")
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response.status).toBe(400);
    expect(response.body.error).toBe("Webhook processing failed");
  });

  it("rejects request without signature header", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);

    const response = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response.status).toBe(400);
    expect(response.body.error).toBe("Webhook processing failed");
    expect(response.body.message).toBe("Missing stripe-signature header");
  });

  it("handles duplicate webhook events", async (context) => {
    const event = {
      id: `evt_test_${Date.now()}`,
      object: "event",
      api_version: "2024-11-20.acacia",
      created: Math.floor(Date.now() / 1000),
      type: "test.event",
      data: {
        object: {},
      },
      livemode: false,
      pending_webhooks: 0,
      request: {
        id: null,
        idempotency_key: null,
      },
    } as unknown as Stripe.Event;

    const payload = JSON.stringify(event);
    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
    const signature = stripe.webhooks.generateTestHeaderString({
      payload,
      secret: webhookSecret,
    });

    // Send first webhook
    const response1 = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", signature)
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response1.status).toBe(200);
    expect(response1.body.received).toBe(true);

    // Send duplicate webhook
    const response2 = await context.request
      .post("/webhooks/stripe" satisfies Match<RouteId>)
      .set("stripe-signature", signature)
      .send(payload)
      .set("Content-Type", "application/json");

    expect(response2.status).toBe(200);
    expect(response2.body.received).toBe(true);
    expect(response2.body.message).toBe("Event already processed");
  });
});
src/routes/(public)/payment/+page.svelte
<script lang="ts">
  import PaymentButton from "$lib/components/payments/payment-button.svelte";
</script>

<section data-role="content">
  <PaymentButton priceId="price_preview_placeholder" />
</section>