All posts
stripenextjspayments

Stripe One-Time Payments in Next.js: A Safe Pattern

Build Stripe one-time payments in Next.js with trusted prices, signed webhooks, idempotent fulfillment, tax options, and post-checkout recovery.

Reliable Stripe one-time payments in Next.js need more than a Checkout redirect. The browser starts the purchase, but your server must control the amount, verify Stripe's webhook signature, fulfill the order idempotently, and recover when the customer returns before the webhook reaches your database.

This guide is for developers selling source code, downloads, licenses, courses, or any product that grants permanent access after one payment.

The safe payment flow in seven steps

  1. The authenticated customer selects a product ID.
  2. The server loads the product and price from its own database.
  3. The server creates a pending purchase record.
  4. The server creates a Stripe Checkout Session in payment mode.
  5. Stripe collects payment on its hosted page.
  6. A signed webhook marks the purchase paid and grants access.
  7. The success page reconciles the session if it arrives before the webhook.

This design treats the database as the entitlement source of truth and Stripe as the payment source of truth. The client is trusted for neither.

Table of contents

Create Checkout on the server

Never create the final amount from a browser-provided number. Send a stable product identifier to a Server Action or Route Handler, then load the name, currency, and price from trusted storage.

const product = await getPublishedProduct(productId);
if (!product) return { error: "Product unavailable" };
 
const purchase = await createPendingPurchase(user.id, product);
 
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  customer_email: user.email,
  line_items: [
    {
      quantity: 1,
      price_data: {
        currency: "usd",
        unit_amount: product.priceCents,
        product_data: { name: product.name },
      },
    },
  ],
  metadata: {
    purchaseId: purchase.id,
    userId: user.id,
    productId: product.id,
  },
  success_url: `${appUrl}/downloads?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${appUrl}/pricing`,
});

Stripe documents the available Checkout Session fields in its Checkout API reference. Keep secret-key calls in server-only modules and return only the hosted Checkout URL to the client.

Use trusted product data

There are two sound ways to represent a price.

Pre-created Stripe Price IDs

Create Products and Prices in Stripe and store each price_... identifier in configuration or your database. This works especially well for recurring plans because Stripe owns intervals, currencies, and billing behavior.

Inline price data

Create price_data for each Checkout Session using a server-side product record. This is useful for a small one-time catalog because your own database remains the commercial source of truth and repricing does not require another environment variable.

Both patterns are safe when the server selects the amount. Neither is safe when the browser sends 9900 and the server forwards it without checking.

Create the pending purchase before calling Stripe so its ID can travel in metadata and return in the webhook. Snapshot the amount and currency in that row; product prices change, but the amount a customer paid is historical data.

Verify and deduplicate webhooks

Your webhook endpoint must read the raw request body and verify the Stripe-Signature header with the endpoint's webhook secret. Do not treat an event as genuine merely because its JSON looks like a Stripe event. Stripe's webhook documentation covers signature verification, retries, event ordering, and endpoint configuration.

Then make fulfillment idempotent. A small event-claim table works well:

const claimed = await claimStripeEvent(event.id, event.type);
if (!claimed) return; // already processed
 
if (event.type === "checkout.session.completed") {
  const session = event.data.object;
  if (session.payment_status !== "paid") return;
  await fulfillPurchase(session.metadata.purchaseId);
}

Add a second guard on the purchase transition itself:

UPDATE purchases
SET status = 'paid', paid_at = NOW()
WHERE id = $1 AND status = 'pending_payment'
RETURNING *;

The webhook-event guard protects against repeated deliveries. The conditional update protects against two different fulfillment paths racing each other. Side effects such as email and license generation should run only when that update returns a row.

Handle the redirect-webhook race

Stripe can redirect the customer to your success URL before your webhook finishes. If the page reads the database immediately, a legitimate buyer may briefly see “no purchases.” That is confusing at exactly the moment trust matters most.

Include {CHECKOUT_SESSION_ID} in the success URL. On the authenticated success page, retrieve that session from Stripe and verify all of the following:

  • The session is in payment mode.
  • Its payment status is paid.
  • Its metadata references an existing pending purchase.
  • The purchase belongs to the signed-in user.
  • The stored Checkout Session ID matches.

If those checks pass, call the same idempotent fulfillment function used by the webhook. Do not duplicate the state transition and email logic in the page.

Stripe still recommends webhooks as the reliable fulfillment mechanism because a customer is not guaranteed to visit your landing page. The Checkout fulfillment guide describes both the landing-page optimization and the required webhook path.

Plan tax, refunds, and delayed payments

Tax

Decide before launch whether displayed prices include tax, where you must collect it, and whether Stripe Tax or another merchant-of-record arrangement owns calculation and remittance. Attach an appropriate product tax code and test customer locations relevant to your market. Tax obligations depend on your business and customers, so get qualified advice rather than copying another application's setting.

Refunds

Your database should support a refunded state even if refunds are initially reviewed manually. Handling Stripe's refund-related events lets access and support records stay aligned with the actual payment. Define whether a refund revokes future downloads and state the policy clearly before purchase.

Delayed payment methods

Some methods complete asynchronously. In those flows, checkout.session.completed may arrive before the payment is marked paid. Either restrict Checkout to immediate methods for V1 or also handle the relevant asynchronous success and failure events. Never grant access solely because Checkout was completed.

Test the failure paths

A green happy-path payment is only the beginning. Before switching to live mode, verify this matrix:

ScenarioExpected result
Customer cancels CheckoutPurchase remains pending and grants no access
Webhook is delivered twiceOne entitlement and one confirmation email
Success redirect arrives firstPage reconciles and grants access once
Invalid webhook signatureRequest rejected with no database mutation
Price changed in the browserServer ignores it and charges trusted price
Payment is unpaidNo entitlement
Existing owner clicks buy againCheckout is blocked
Download requested by another userRequest denied

Use Stripe test mode and the Stripe CLI during development, then perform one low-value live purchase with a real customer account after deployment. Confirm the charge, webhook, database row, email, download, refund, and support trail from end to end.

Frequently asked questions

Do I need to create a Stripe Product for every one-time purchase?

No. Stripe Checkout accepts inline price_data, allowing the server to create product and price details for a session. Pre-created Products and Prices are still useful for catalog management and reporting. Choose one source of truth and never accept the final amount from the browser.

Which Stripe webhook event grants access?

For immediate one-time payments, many integrations process checkout.session.completed only when payment_status is paid. If you enable delayed payment methods, also design for asynchronous success and failure events. Your exact event set should match the payment methods enabled in Checkout.

Why do I need idempotency if Stripe sends each event once?

Webhook delivery is retry-based and duplicate processing can also happen inside your infrastructure. An event table plus a conditional purchase-state transition makes repeated or racing calls harmless. This prevents duplicate access grants, emails, and downstream jobs.

Should the success page fulfill the purchase?

It may reconcile a verified paid session to improve the customer experience, but it must call the same idempotent function as the webhook. The webhook remains required because customers can pay successfully without ever loading the success page.

Build the entitlement, not just the checkout

The durable part of Stripe one-time payments in Next.js is the entitlement created after verified payment. Keep prices server-controlled, verify every webhook, make fulfillment idempotent, and design explicitly for races and retries.

Shipkitt includes this complete purchase-to-download path. Explore the Next.js starter, compare lifetime access, or start with the broader SaaS production launch checklist.