Tutorial 7 min read

How to Add Stripe Payments to Your Astro Site

A step-by-step guide to integrating Stripe checkout sessions, webhooks, and success pages into an Astro site. Covers server-side API routes, webhook verification, and production deployment.

Paul Chamberlain

Creator • 28 March 2025

Credit card and laptop showing a checkout interface for online payments

Adding payments to a static site sounds contradictory. Astro generates HTML at build time, and payment processing requires server-side logic, secret keys, and webhook handlers. But Astro’s server endpoints solve this cleanly. You can keep your marketing pages static while running server-side payment logic through API routes.

This guide walks through the full integration: creating checkout sessions, handling webhooks, and building success and cancellation pages.

Prerequisites

You need a Stripe account and your API keys. Get them from the Stripe Dashboard. You will use the secret key server-side and the publishable key client-side.

Install the Stripe SDK:

bun add stripe

Set up your environment variables:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Step 1: Configure Astro for Server Endpoints

Astro needs an adapter to run server-side code. For Vercel deployment:

bun add @astrojs/vercel

Update your Astro config to use hybrid rendering. This keeps most pages static while allowing specific routes to run on the server:

import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';

export default defineConfig({
  output: 'hybrid',
  adapter: vercel(),
});

Step 2: Create the Checkout Session Endpoint

Create src/pages/api/checkout.ts. This endpoint receives a price ID from the client and creates a Stripe Checkout Session:

import type { APIRoute } from 'astro';
import Stripe from 'stripe';

const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);

export const POST: APIRoute = async ({ request, url }) => {
  try {
    const { priceId } = await request.json();

    if (!priceId) {
      return new Response(
        JSON.stringify({ error: 'Price ID is required' }),
        { status: 400 }
      );
    }

    const session = await stripe.checkout.sessions.create({
      mode: 'payment',
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      success_url: `${url.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${url.origin}/checkout/cancel`,
    });

    return new Response(
      JSON.stringify({ url: session.url }),
      { status: 200 }
    );
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    return new Response(
      JSON.stringify({ error: message }),
      { status: 500 }
    );
  }
};

Key details:

  • The mode can be payment for one-time charges or subscription for recurring billing
  • {CHECKOUT_SESSION_ID} is a Stripe template variable that gets replaced with the actual session ID
  • Error handling returns structured JSON so the client can display meaningful messages

Step 3: Build the Purchase Button

Create a client-side component that triggers the checkout flow. This is one of those cases where you need actual JavaScript in the browser:

---
interface Props {
  priceId: string;
  label: string;
}

const { priceId, label } = Astro.props;
---

<button
  class="rounded-lg bg-primary px-6 py-3 font-semibold text-white
         transition-colors hover:bg-primary/90 disabled:opacity-50"
  data-price-id={priceId}
  data-checkout-button
>
  {label}
</button>

<script>
  document.querySelectorAll('[data-checkout-button]').forEach((button) => {
    button.addEventListener('click', async (e) => {
      const target = e.currentTarget as HTMLButtonElement;
      const priceId = target.dataset.priceId;

      target.disabled = true;
      target.textContent = 'Redirecting...';

      try {
        const response = await fetch('/api/checkout', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ priceId }),
        });

        const data = await response.json();

        if (data.url) {
          window.location.href = data.url;
        } else {
          throw new Error(data.error || 'Failed to create checkout session');
        }
      } catch (error) {
        target.disabled = false;
        target.textContent = 'Try Again';
        console.error('Checkout error:', error);
      }
    });
  });
</script>

Notice this uses vanilla JavaScript instead of a React component. For a single button interaction, there is no reason to ship a framework to the client. The <script> tag in an Astro component is bundled and deduplicated automatically.

Step 4: Handle Webhooks

Stripe sends webhook events when payment status changes. This is critical for fulfillment. Never rely solely on the client-side redirect to confirm payment, as users can close their browser or manipulate URLs.

Create src/pages/api/webhook.ts:

import type { APIRoute } from 'astro';
import Stripe from 'stripe';

const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
const webhookSecret = import.meta.env.STRIPE_WEBHOOK_SECRET;

export const POST: APIRoute = async ({ request }) => {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return new Response('Missing signature', { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error';
    return new Response(`Webhook signature verification failed: ${message}`, {
      status: 400,
    });
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      // Fulfill the order: send email, update database, grant access
      console.log(`Payment succeeded for session ${session.id}`);
      console.log(`Customer email: ${session.customer_details?.email}`);
      break;
    }
    case 'payment_intent.payment_failed': {
      const intent = event.data.object as Stripe.PaymentIntent;
      console.log(`Payment failed: ${intent.last_payment_error?.message}`);
      break;
    }
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 });
};

The webhook handler does three things:

  1. Reads the raw body as text, not JSON, because Stripe’s signature verification needs the raw bytes
  2. Verifies the signature to confirm the event actually came from Stripe
  3. Switches on event type to handle different payment outcomes

Step 5: Build Success and Cancel Pages

Create src/pages/checkout/success.astro:

---
import Layout from '../../layouts/Layout.astro';

const sessionId = Astro.url.searchParams.get('session_id');
---

<Layout title="Payment Successful">
  <section class="mx-auto max-w-2xl px-4 py-24 text-center">
    <div class="mb-6 text-6xl">&#10003;</div>
    <h1 class="mb-4 text-3xl font-bold">Payment Successful</h1>
    <p class="mb-8 text-lg text-muted-foreground">
      Thank you for your purchase. You will receive a confirmation email shortly.
    </p>
    <a
      href="/"
      class="inline-block rounded-lg bg-primary px-6 py-3 font-semibold text-white
             transition-colors hover:bg-primary/90"
    >
      Back to Home
    </a>
  </section>
</Layout>

Create a similar page at src/pages/checkout/cancel.astro for when users abandon the checkout flow.

Step 6: Set Up the Webhook in Stripe

For local development, use the Stripe CLI to forward webhooks:

stripe listen --forward-to localhost:3002/api/webhook

This prints a webhook signing secret. Use it as your STRIPE_WEBHOOK_SECRET during development.

For production, add the webhook endpoint in the Stripe Dashboard under Developers > Webhooks. Point it to https://yourdomain.com/api/webhook and select the events you want to receive.

Step 7: Create Products and Prices

You can create products in the Stripe Dashboard or via the API. For a template-based site, dashboard creation is usually simpler. Create a product, add a price, and copy the price ID (starts with price_). Use that ID in your checkout button:

---
import CheckoutButton from '../components/CheckoutButton.astro';
---

<CheckoutButton
  priceId="price_1ABC123def456"
  label="Buy Now - $49"
/>

Production Checklist

Before going live:

  • Switch to live API keys in your production environment variables
  • Add the production webhook URL in the Stripe Dashboard
  • Test the full flow with Stripe’s test card numbers (4242 4242 4242 4242)
  • Handle edge cases: duplicate webhook deliveries, expired sessions, refunds
  • Add error monitoring so you know when payments fail in production
  • Enable Stripe’s fraud prevention tools (Radar is included by default)

Subscriptions

For recurring payments, change the checkout session mode to subscription and use a recurring price ID. The webhook flow is the same, but you will also want to handle customer.subscription.updated and customer.subscription.deleted events to manage access.

The entire integration adds maybe 200 lines of code to your Astro site. No payment plugin, no third-party wrapper, no abstraction layer. Just the Stripe SDK, a couple of API routes, and some HTML pages.

Published 28 March 2025
PC

About Paul Chamberlain

Creator

Founder of Astro Starter, Paul combines 10+ years of web development experience with cutting-edge AI technologies to help Australian businesses dominate online. When he's not crafting high-converting websites, you'll find him exploring the Adelaide beaches or diving into the latest AI research.

Connect:

Want to work with Paul? Get a free AI-powered website audit:

Get Your Free Audit

Ready to Apply These Insights?

Get a free AI-powered audit of your website and see how we can help you implement these strategies.

Related Articles