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 accounthttps://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 .env is 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 recurring price id from the dashboard (create a Product with a monthly price) instead of the inline price_data above. 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 parsed event.


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:

  1. In your host dashboard, set env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
  2. In Stripe (Developers → Webhooks → Add endpoint), register https://yourdomain.com/api/stripe-webhook for the checkout.session.completed event. Stripe shows a new whsec_… for this live endpoint — use that value in production (the CLI one was only for local).
  3. 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 handle customer.subscription.deleted to 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. Use request.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… → the stripe-signature header was stripped by a proxy, or you’re forwarding to the wrong path.
  • Amount looks 100× offunit_amount is in cents. $29 is 2900.