All WordPress HTML Templates Forms & Webhooks AI & Tools
Forms & Webhooks

Handling Payments From a Static Site Safely

A practitioner’s guide to static site payments: Stripe Checkout from plain HTML, webhook fulfilment, idempotency, PCI DSS 4.0.1 scripts and where no-backend

A real working moment illustrating the theme of an article about static site payments. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

The worst payment bug we’ve inherited on a static site took about nine seconds to exploit. The pricing page carried the amount in a data-amount attribute, a serverless function took that number and created a Stripe PaymentIntent with it, and anyone who could open DevTools could buy a 2,400 euro training course for 1 euro. Nobody had tried it. That’s luck, not security.

Static site payments get framed as a hosting problem, as if the only question is “where does the code run”. It isn’t. The real questions are: which values the browser is allowed to decide, who confirms the order, and what happens on your checkout page when an third-party script you forgot about gets compromised. You can absolutely accept payments from a static site. You just can’t do it with zero server-side code, and anyone telling you otherwise is selling a tutorial.

Key Takeaways

  • The browser may send a product identifier (a SKU or a Stripe price ID looked up against a server-side allowlist). It must never send an amount, a currency, a discount or a tax rate.
  • Stripe deprecated the client-only Checkout integration, so redirectToCheckout with inline line items is no longer the path. Use a Payment Link for fixed-price items or a 30-line edge function to create a Checkout Session.
  • Fulfilment happens in a signature-verified webhook handler, not on your success page. Users close tabs, mobile Safari kills background redirects, and bank redirects can take 40 seconds to come back.
  • PCI DSS v4.0.1 requirements 6.4.3 and 11.6.1 stopped being “future-dated” on 31 March 2025. Script inventory and payment page change detection now matter even for small merchants, which is a strong argument for a dedicated checkout page with no analytics, chat or tag manager on it.
  • If you need subscriptions, customer self-service, licence keys or stock levels, you need persistent storage. That’s the real boundary of “no backend”, not the payment itself.

The one rule: the price never comes from the browser

Everything else in this article is detail. This part is the thing that gets people breached or defrauded.

Any value that affects what the customer is charged has to originate server-side: amounts, currencies, coupon percentages, tax rates, shipping costs, trial lengths. The client is allowed to say “the customer wants two of SKU ebook-advanced“, and even that quantity needs clamping. Someone will eventually send -3 and see what your accounting does with a negative line item.

The sneaky version of this mistake is passing a Stripe price ID from the client. It feels safe because price IDs are server-defined objects, so the amount isn’t literally in the browser. But every price in your account is now reachable. That internal price_ you made for a 1 euro test, or the 90 percent partner rate, is one fetch away. Keep a map in your function and reject anything not in it.

A real working moment that fits an article section about

Three ways to do static site payments, ranked

Payment Links. Create the product and price in the Stripe dashboard, get a URL, put it in an anchor. No code, no keys, no function. For a fixed-price digital product, a deposit, or an event ticket, this is usually the right answer. Developers skip it because it feels too easy. It supports quantity adjustment, promo codes, Stripe Tax and multiple currencies. It cannot do dynamic pricing or per-customer logic.

<!-- No keys, no JS, no XHR. The amount lives in Stripe. -->
<a class="button button-large"
   href="https://buy.stripe.com/aEU14n2Yk5gG7yw9AA">
  Buy the course — 249 EUR
</a>

Hosted Checkout created by a small function. One edge function, one webhook handler. This is what we ship most often. You get dynamic line items, metadata, promo code validation, address collection and tax, while card data never touches your domain.

A merchant of record. Paddle, Lemon Squeezy (Stripe-owned since 2024, still operating as an MoR), Polar. They become the seller, so they own VAT registration, OSS filings, US sales tax nexus and the invoice your customer in Brazil needs. You pay a higher percentage for that. If you’re a two-person studio selling a template globally, the fee is cheaper than an accountant who understands EU digital VAT. If you’re processing six figures a month in one country, it isn’t.

Embedding a card form on your own page with Payment Element is the fourth option. It converts a little better, looks a little better, and pulls your page into PCI scope in a way the other three don’t. Make that trade knowingly.

Wiring Stripe Checkout into plain HTML

Here’s the whole pattern for stripe checkout html on a static build, deployed as a Cloudflare Pages Function. Netlify, Vercel and Deno Deploy are the same shape with different handler signatures.

// functions/api/checkout.js
const PRICES = {
  "course-advanced": "price_1PqZYbG3kLm9Xx02aBcDeFgH",
  "ebook-bundle":    "price_1PqZa1G3kLm9Xx02QwErTyUi",
};

export async function onRequestPost({ request, env }) {
  const { sku, quantity } = await request.json();

  const price = PRICES[sku];                 // allowlist: never accept a price id directly
  if (!price) return new Response("Unknown SKU", { status: 400 });

  // Clamp before it reaches Stripe. Negative and 9999 are both attacks.
  const qty = Math.min(Math.max(parseInt(quantity, 10) || 1, 1), 10);

  const body = new URLSearchParams({
    mode: "payment",
    "line_items[0][price]": price,
    "line_items[0][quantity]": String(qty),
    "automatic_tax[enabled]": "true",
    allowpromotioncodes: "true",
    successurl: "https://example.com/thanks?s={CHECKOUTSESSION_ID}",
    cancel_url: "https://example.com/pricing?checkout=cancelled",
  });

  const res = await fetch("https://api.stripe.com/v1/checkout/sessions", {
    method: "POST",
    headers: {
      Authorization: Bearer ${env.STRIPESECRETKEY},
      "Content-Type": "application/x-www-form-urlencoded",
      // Double-clicks and retried edge invocations stop creating duplicate sessions
      "Idempotency-Key": crypto.randomUUID(),
    },
    body,
  });

  if (!res.ok) return new Response("Upstream error", { status: 502 });
  const session = await res.json();
  return Response.json({ url: session.url });
}
// On the page. Disable the button first; the network is slower than people click.
document.querySelectorAll("[data-sku]").forEach((btn) => {
  btn.addEventListener("click", async () => {
    btn.disabled = true;
    btn.textContent = "Redirecting...";
    try {
      const r = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ sku: btn.dataset.sku, quantity: 1 }),
      });
      if (!r.ok) throw new Error(r.status);
      const { url } = await r.json();
      window.location.assign(url);
    } catch (e) {
      btn.disabled = false;
      btn.textContent = "Try again";
      // Show something human. A dead button is worse than an error message.
    }
  });
});

Note what isn’t here: no publishable key, no js.stripe.com on the pricing page, no card fields. You’re issuing a redirect to a Stripe-hosted page. That’s the cheapest possible position to be in, both for security review and for page weight. That matters if you’ve already done the work of eliminating render-blocking resources and don’t want a payments SDK undoing it.

Fulfilment belongs to the webhook, not the success page

The success page is a courtesy. Treat it as one.

We’ve seen orders go unfulfilled because a customer paid via iDEAL on a train, lost signal during the bank redirect, and never landed on /thanks. The money arrived. The licence email didn’t. If your only fulfilment trigger is the success_url, you are relying on a round trip that fails for a measurable slice of real traffic.

Verify the signature with the raw request body. Parsing the JSON first and re-stringifying it will break the HMAC. That’s the single most common webhook bug we debug. (If webhooks are new to you, we’ve written a plain explanation of webhooks versus APIs.)

// functions/api/stripe-webhook.js
import Stripe from "stripe";

export async function onRequestPost({ request, env }) {
  const stripe = new Stripe(env.STRIPESECRETKEY);
  const raw = await request.text();          // raw string, not parsed JSON
  let event;
  try {
    // constructEventAsync uses Web Crypto, required on edge runtimes
    event = await stripe.webhooks.constructEventAsync(
      raw,
      request.headers.get("stripe-signature"),
      env.STRIPEWEBHOOKSECRET
    );
  } catch {
    return new Response("Bad signature", { status: 400 });
  }

  if (event.type === "checkout.session.completed" ||
      event.type === "checkout.session.asyncpaymentsucceeded") {
    const s = event.data.object;
    if (s.payment_status === "paid") {
      await enqueueFulfilment(s.id, s.customer_details?.email);
    }
  }
  return new Response("ok", { status: 200 });  // ack fast
}

Three things that are not obvious. Handle asyncpaymentsucceeded as well as checkout.session.completed, because bank debits and some wallets complete later and the first event can arrive with payment_status: "unpaid". Make your handler idempotent on event.id or the session ID, because Stripe retries and will deliver the same event twice. And don’t generate the PDF, call the mail provider and hit your CRM inside the handler: ack in under a second and push the work onto a queue. A slow Mailgun night turns into missed deliveries and a retry storm.

What PCI DSS 4.0.1 now expects from your checkout page

The two requirements that changed the calculus for small merchants are 6.4.3 (inventory and authorise every script that loads on a payment page, and assure its integrity) and 11.6.1 (detect unauthorised changes to HTTP headers and payment page content). Both were future-dated and became mandatory on 31 March 2025.

Whether they apply to you depends on your integration. The answer is genuinely different for a full redirect to a Stripe-hosted page versus an iframe or Payment Element embedded in your own markup. The SAQ A wording has been revised more than once. Confirm your current eligibility with your acquirer rather than with a blog post, including this one.

What’s defensible regardless: put checkout on its own route with nothing else on it. No tag manager, no heatmap, no chat widget, no A/B testing snippet. Each of those is a third party that can inject a field into a page where customers type card numbers, and script injection on payment pages is the dominant skimming technique. Then lock the page down at the edge:

# Netlify _headers (or the Cloudflare equivalent)
/checkout
  Content-Security-Policy: default-src 'none'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; connect-src https://api.stripe.com; style-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

Add integrity hashes to your own bundles. You can’t SRI-pin js.stripe.com/v3 because Stripe updates it continuously and pinning would break payments. That’s exactly why the requirement says “assure integrity” and not “use SRI everywhere”.

Tax, refunds and the parts that surface three months later

Turn on Stripe Tax before launch, not after your first cross-border sale. It calculates at checkout based on the customer’s location and your registered jurisdictions. It does not register you anywhere or file anything. That distinction is the whole reason merchant-of-record platforms exist.

Other things that arrive late. Refunds: decide now whether a partial refund revokes access, and build the webhook path for charge.refunded. Doing it manually in the dashboard means your database quietly disagrees with Stripe. Apple Pay and Google Pay come free on hosted Checkout; if you move to Express Checkout on your own domain you’ll need to serve /.well-known/apple-developer-merchantid-domain-association, which is a real file your static host has to stop rewriting. Receipts: Stripe can email them, but if you’re selling into the EU as the merchant of record, a receipt is not a compliant invoice.

Where “no backend” actually ends

One-off purchases of digital goods work well with two functions and no database. The moment you add any of the following, you own state:

  • Subscriptions with self-service. The Stripe billing portal solves the UI, but you need to map a logged-in user to a cus_ ID to open it. That’s a datastore and an auth system.
  • Licence keys or gated downloads. A static file behind a guessable URL is not gated. You need signed, expiring URLs issued after payment.
  • Real inventory. Checkout won’t stop two people buying the last ticket. You need a reservation with a TTL, which means Workers KV, D1, Upstash or similar.
  • Anything an accountant will ask about. Order history you can query without exporting CSVs from a dashboard.

Contact and lead forms are the opposite case: they genuinely don’t need a backend, and we route them through WebForms on static builds so there’s no server to patch. Payments are the exception, not the rule, and conflating the two is how people end up trusting the browser with an amount.

Last piece: design the unhappy paths. Card declined, 3D Secure abandoned, user hits back from Stripe, webhook arrives before the success page renders. Your cancel_url should land somewhere that explains what happened and keeps the cart, and your thanks page should cope with “payment still processing” as a legitimate state. We’ve written more on designing for failure states, and checkout is where sloppiness there costs actual revenue.

Frequently Asked Questions

Can I accept payments on a static site with no server-side code at all?

Yes, with Stripe Payment Links, PayPal buttons or a merchant-of-record checkout URL. You get a fixed price and a hosted page, and the trade is that you can’t do dynamic pricing, custom metadata or automated fulfilment. The moment you want to deliver something automatically after payment, you need a webhook handler, which means code running somewhere.

Is it safe to put my Stripe publishable key in client-side HTML?

Yes, that’s what it’s for: it can tokenise card details and confirm intents, but it can’t read customers, charges or list prices. The key you must never ship is the secret key (sklive), and restricted API keys are also server-side only. If a secret key ever reaches a public repo or a bundle, roll it in the dashboard immediately rather than deleting the commit.

Why did my webhook signature verification start failing?

Almost always because the body was parsed before verification. Stripe signs the exact bytes it sent, so frameworks that auto-parse JSON (Express with express.json(), some serverless adapters) destroy the signature. Read the raw body as text or a buffer on that route only. Also check you’re using the webhook signing secret for the right endpoint, since each one has its own.

Should I use Stripe directly or a merchant of record like Paddle?

Use an MoR if you sell digital products to consumers in many countries and don’t want to register for VAT or track US sales tax nexus. Use Stripe directly if you’re mostly domestic, selling B2B, or your volume makes the extra percentage expensive. Migrating later is painful for subscriptions, so decide before you have 500 active customers.

Do I need to be PCI compliant if Stripe handles the card form?

You still have obligations, they’re just lighter. Redirecting to a Stripe-hosted page keeps you in the smallest self-assessment bucket; embedding an iframe or Payment Element brings script controls on your page into scope under requirements 6.4.3 and 11.6.1. Keep your checkout page free of third-party scripts, set a strict CSP, and check the current SAQ A criteria with your acquirer.

If you’re shipping this week: start with a Payment Link, validate that people actually buy, then upgrade to a Checkout Session function when you need dynamic line items. Write the webhook handler before you write the success page, and make it idempotent from the first commit. The one thing not worth deferring is the allowlist on the server. That’s the line between a static site that takes payments and a static site that gives your product away for a euro.

accept payments static site no backend cloudflare pages function stripe checkout merchant of record vs stripe direct pci dss 4.0.1 requirement 6.4.3 payment page scripts static site payments stripe checkout html static site stripe payment link vs checkout session stripe webhook signature verification raw body