Take Payments
Charge money with Stripe Checkout: a hosted payment page, plus a webhook so you only unlock access after the payment actually clears.
Your product has users. Now it needs revenue. This guide takes a payment the safe way: Stripe hosts the actual card form (so you never touch card numbers), and a webhook tells your server when the money truly arrives — because the redirect back to your site is not proof of payment.
We use Astro for the app and Stripe Checkout for the money. By the end you can charge for a one-off purchase or start a subscription, and unlock access only after Stripe confirms it.
What you’ll have at the end
- A “Buy” button that sends the user to a Stripe-hosted checkout page.
- A server endpoint that creates the checkout session (your secret key stays server-side).
- A webhook that verifies Stripe’s signature and marks the order paid.
- A clear rule: grant access in the webhook, never on the success redirect.
Before you start
- Node 18+, a terminal.
- A Stripe account → https://dashboard.stripe.com. Stay in Test mode (toggle, top-right) the whole time; test cards spend nothing.
- The Stripe CLI for local webhooks → https://docs.stripe.com/stripe-cli. On macOS:
brew install stripe/stripe-cli/stripe. - ~1–2 hours. If you did Add Authentication, you already have the app shape.
Step 1 — Create the app and install Stripe
npm create astro@latest checkout
cd checkout
Pick Empty, TypeScript: Strict, install deps: yes.
The pages are static, but the checkout and webhook endpoints run on the server. Add an adapter and the Stripe SDK:
npx astro add node
npm install stripe
Step 2 — Add your keys
From the Stripe dashboard (Developers → API keys, Test mode), copy the Secret key
(sk_test_…). The webhook signing secret comes in Step 4. Create .env:
STRIPE_SECRET_KEY=sk_test_your_key
STRIPE_WEBHOOK_SECRET=whsec_filled_in_step_4
Both are real secrets. No
PUBLIC_prefix, never imported into client code, never logged. Confirm.envis gitignored.
Step 3 — Create a Checkout session
This endpoint builds a payment page on Stripe and hands back its URL. Create
src/pages/api/checkout.ts:
import type { APIRoute } from "astro";
import Stripe from "stripe";
export const prerender = false; // must run on the server
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
export const POST: APIRoute = async ({ request }) => {
const origin = new URL(request.url).origin;
try {
const session = await stripe.checkout.sessions.create({
mode: "payment", // one-off charge; use "subscription" for recurring
line_items: [
{
price_data: {
currency: "usd",
product_data: { name: "Pro plan — lifetime" },
unit_amount: 2900, // amount in CENTS ($29.00)
},
quantity: 1,
},
],
// Stripe sends the user here after; the session id lets us look up status.
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/?canceled=1`,
});
return new Response(JSON.stringify({ url: session.url }), {
headers: { "Content-Type": "application/json" },
});
} catch (e) {
console.error("checkout create failed:", e);
return new Response("Could not start checkout", { status: 500 });
}
};
Subscriptions: set
mode: "subscription"and use a real recurringpriceid from the dashboard (create a Product with a monthly price) instead of the inlineprice_dataabove. Everything else in this guide is the same.
Step 4 — The webhook (the part people skip, and regret)
The success redirect can be faked by anyone who guesses the URL, and it fires even if the
card is later declined. The webhook is your only trustworthy signal that money moved.
Create src/pages/api/stripe-webhook.ts:
import type { APIRoute } from "astro";
import Stripe from "stripe";
export const prerender = false;
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
export const POST: APIRoute = async ({ request }) => {
const sig = request.headers.get("stripe-signature");
const raw = await request.text(); // raw body — required for signature check
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw,
sig!,
import.meta.env.STRIPE_WEBHOOK_SECRET,
);
} catch (e) {
console.error("bad webhook signature:", e);
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
// THIS is where you grant access: mark the order paid, flip the user to "pro",
// send the receipt, etc. Keyed on session.id / session.customer_details?.email.
console.log("✅ paid:", session.id, session.customer_details?.email);
// e.g. await db.orders.markPaid(session.id)
}
return new Response("ok", { status: 200 }); // 200 tells Stripe "got it"
};
Why
request.text()and not.json()? Signature verification hashes the exact raw bytes. Parsing to JSON first changes them and every check fails. Read the raw text, verify, then trust the parsedevent.
Step 5 — A Buy button and a success page
Minimal src/pages/index.astro:
---
// static page; the buy click hits our server endpoint
---
<html lang="en">
<head><meta charset="utf-8" /><title>Buy</title></head>
<body style="font-family: system-ui; max-width: 32rem; margin: 4rem auto;">
<h1>Pro plan — $29</h1>
<button id="buy">Buy now</button>
<script>
document.getElementById("buy")!.addEventListener("click", async () => {
const res = await fetch("/api/checkout", { method: "POST" });
const { url } = await res.json();
window.location.href = url; // off to Stripe's hosted page
});
</script>
</body>
</html>
And src/pages/success.astro — friendly, but not where you unlock anything:
---
// The real "did they pay?" truth came through the webhook. This page just says thanks.
const sessionId = Astro.url.searchParams.get("session_id");
---
<html lang="en">
<body style="font-family: system-ui; max-width: 32rem; margin: 4rem auto;">
<h1>Thank you! 🎉</h1>
<p>Your payment is processing. Ref: <code>{sessionId}</code></p>
</body>
</html>
Step 6 — Test the whole loop locally
Point the Stripe CLI at your webhook, so real test events reach localhost:
stripe login
stripe listen --forward-to localhost:4321/api/stripe-webhook
It prints a whsec_… — paste that into .env as STRIPE_WEBHOOK_SECRET, then start
the app in another terminal:
npm run dev
Click Buy now, and on Stripe’s page use test card 4242 4242 4242 4242, any
future expiry, any CVC. You should land on /success, and your stripe listen
terminal should log checkout.session.completed while your server prints ✅ paid. That
second signal is the one that matters.
Step 7 — Deploy
Swap the local adapter for your host’s (e.g. Cloudflare):
npx astro add cloudflare
Then:
- In your host dashboard, set env vars
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRET. - In Stripe (Developers → Webhooks → Add endpoint), register
https://yourdomain.com/api/stripe-webhookfor thecheckout.session.completedevent. Stripe shows a newwhsec_…for this live endpoint — use that value in production (the CLI one was only for local). - When you’re ready for real money, flip Stripe out of Test mode and swap in the
live
sk_live_…key.
You now have
A real payment flow: a hosted checkout you don’t have to secure, and a signed webhook that is the single source of truth for “they paid.” Unlock access there and you can’t be spoofed by a guessed success URL.
Make it yours (next ideas)
- Tie it to a user: put the buy button behind login (see Add Authentication) and store the paid flag against that user in your database.
- Go recurring: switch to
mode: "subscription"and also handlecustomer.subscription.deletedto revoke access when someone cancels. - Receipts: Stripe can email them automatically (Settings → Emails), or send your own from the webhook.
Troubleshooting
- Webhook returns 400 “Invalid signature” → you parsed the body as JSON, or the
whsec_doesn’t match this endpoint. Userequest.text()and the secret Stripe shows for this listener/endpoint. - Payment succeeds but nothing unlocks → your grant logic is on the success page, not
the webhook. Move it into
checkout.session.completed. No signatures found matching…→ thestripe-signatureheader was stripped by a proxy, or you’re forwarding to the wrong path.- Amount looks 100× off →
unit_amountis in cents. $29 is2900.