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
modecan bepaymentfor one-time charges orsubscriptionfor 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:
- Reads the raw body as text, not JSON, because Stripe’s signature verification needs the raw bytes
- Verifies the signature to confirm the event actually came from Stripe
- 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">✓</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.