A card payment has three moving parts, and the App Router gives each of them an obvious home. The mistakes worth avoiding are almost all about putting a part in the wrong place: the amount decided in the browser, the secret key inlined into the bundle, the order fulfilled from a response that may never arrive.

The shape of the flow

  1. Server creates a payment intent for an amount it computed itself, and returns only the client_secret.
  2. Browser collects the card in hosted fields and confirms the payment against that client_secret.
  3. Webhook receives the result out of band and is what actually causes fulfilment.

The third step is the one people leave out, and it is the one that makes the integration correct. The browser is not a reliable reporter of whether a payment happened — it can close mid-request.

1. Environment variables

# Server only. No NEXT_PUBLIC_ prefix, so it is never sent to the browser.
PAYMENTS_SECRET_KEY=sk_...
WEBHOOK_SIGNING_SECRET=whsec_...

# Safe in the browser by design — these two are needed to build the card form.
NEXT_PUBLIC_PAYMENTS_PUBLISHABLE_KEY=pk_...
NEXT_PUBLIC_PAYMENTS_ACCOUNT_ID=acct_...
.env.local

2. The server route that creates the intent

export async function POST(request) {
  const { orderId } = await request.json();

  // The amount comes from your own records, never from the request body.
  // A client-supplied total is a client-controlled price.
  const order = await db.orders.findById(orderId);
  if (!order) return Response.json({ error: "Unknown order" }, { status: 404 });

  const response = await fetch("https://api.sandbox-paymentsonline.io/v1/payment-intents", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "payments-api-key": process.env.PAYMENTS_SECRET_KEY,
      "payments-account": process.env.NEXT_PUBLIC_PAYMENTS_ACCOUNT_ID,
    },
    body: JSON.stringify({
      amount: order.totalCents,        // minor units: 2500 is $25.00
      currency: "usd",
      payment_method_types: ["card"],
      metadata: { order_id: order.id },  // makes the payment findable later
    }),
  });

  const intent = await response.json();
  if (!response.ok) {
    // message is a string OR an array of strings. Flatten both.
    const detail = [].concat(intent?.message ?? []).join("; ");
    return Response.json({ error: detail }, { status: 502 });
  }

  // Only the client_secret crosses to the browser.
  return Response.json({ clientSecret: intent.client_secret });
}
app/api/checkout/route.js

Two details in that snippet are easy to get wrong. The amount is in minor units — 2500 means $25.00, and sending 25 charges a quarter. And the error message field is either a string or an array of strings depending on whether the failure was a validation error, so flattening it with [].concat() saves a class of crash in your error path.

3. The client component that collects the card

Card details are collected in fields hosted by the processor and injected into your page. Your React code positions and styles the containers; it never sees the card number. That is what keeps your server out of PCI scope.

"use client";

import { useEffect, useRef, useState } from "react";

export default function PayForm({ orderId }) {
  const [status, setStatus] = useState("loading");
  const paymentsRef = useRef(null);
  const formRef = useRef(null);

  useEffect(() => {
    let cancelled = false;

    (async () => {
      await loadScript("https://js.paymentsonline.io/v2");
      if (cancelled) return;

      const payments = new window.Payments(
        process.env.NEXT_PUBLIC_PAYMENTS_PUBLISHABLE_KEY,
        process.env.NEXT_PUBLIC_PAYMENTS_ACCOUNT_ID,
        { sandbox: true },
      );

      // form(), not createForm().
      const form = await payments.form({ payment_method_type: "card" });
      form.createField("cardNumber").inject("#card-number");
      form.createField("cardExpiry").inject("#card-expiry");
      form.createField("cardCvv").inject("#card-cvv");
      await form.build();

      paymentsRef.current = payments;
      formRef.current = form;
      if (!cancelled) setStatus("ready");
    })();

    // Strict Mode mounts effects twice in development. Without a teardown the
    // second mount injects a second set of fields into the same containers.
    return () => {
      cancelled = true;
      formRef.current?.teardown?.();
    };
  }, []);

  async function handleSubmit(event) {
    event.preventDefault();
    setStatus("submitting");

    // Fetch the client_secret now, not on page load — see the note below.
    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ orderId }),
    });
    const { clientSecret, error } = await res.json();
    if (error) return setStatus("error");

    const payments = paymentsRef.current;

    // Tokenize on submit. An unattached payment method expires in 15 minutes.
    const paymentMethod = await payments.createPaymentMethod({
      type: "card",
      billing_details: { name: "Ada Lovelace", address: { zip: "80021", country: "US" } },
    });

    const result = await payments.confirmPayment(clientSecret, paymentMethod);

    // A 2xx does not mean the money moved. Read the status.
    setStatus(result?.status === "succeeded" ? "succeeded" : "declined");
  }

  return (
    <form onSubmit={handleSubmit}>
      <div id="card-number" />
      <div id="card-expiry" />
      <div id="card-cvv" />
      <button type="submit" disabled={status !== "ready"}>
        {status === "submitting" ? "Paying…" : "Pay"}
      </button>
    </form>
  );
}
app/checkout/PayForm.jsx

The cleanup function matters more in Next than elsewhere. React Strict Mode mounts effects twice in development, so without teardown() you get two sets of card fields injected into the same three containers, and the second set is the one that receives typing while the first is the one your code holds a reference to.

4. The webhook that decides it happened

Everything above is the happy path through a browser you do not control. The webhook is the delivery that arrives regardless, retries if your server was down, and is therefore the only sound place to grant access or ship goods.

import crypto from "node:crypto";

export async function POST(request) {
  const raw = await request.text();            // raw bytes, before parsing
  const header = request.headers.get("payments-signature") ?? "";

  if (!verify(raw, header, process.env.WEBHOOK_SIGNING_SECRET)) {
    return new Response("bad signature", { status: 400 });
  }

  const event = JSON.parse(raw);

  // Retries mean this can arrive more than once. Key on the event id.
  const fresh = await db.processedEvents.claim(event.id);
  if (!fresh) return new Response("ok", { status: 200 });

  if (event.type === "payment_intent.succeeded") {
    await fulfilOrder(event.data.metadata.order_id);
  }

  // Acknowledge fast. Slow work here is recorded as a failed delivery.
  return new Response("ok", { status: 200 });
}
app/api/webhooks/route.js — abridged

The metadata.order_id set back in step 2 is what lets this handler know which order to fulfil. Without it the webhook arrives describing a payment you cannot connect to anything, and you are left matching on amounts and timestamps.

The signature verification is elided here — it is fifteen lines and each one matters, so it has its own article: webhook signature verification, correctly.

Testing it

To testDo this
An approvalCard 4111 1111 1111 1111, any future expiry, any CVV other than 333 or 666
Insufficient fundsSet the amount to $77.01 — declines are triggered by amount, not by card
An expired cardSet the amount to $77.05
A decline that is not a declineAvoid large trigger amounts like $7,777.01; they can exceed a sandbox account processing limit and fail for an unrelated reason
Webhook retriesReturn a 500 from your handler once and watch the redelivery — up to 8 attempts over roughly 60 hours
Sandbox behaviour worth knowing before you debug

Going to production

  • Swap the sandbox host for https://api.paymentsonline.io and drop { sandbox: true } from the SDK constructor.
  • Use the live publishable and secret keys, and the live webhook signing secret — they are different values, and a sandbox secret against live traffic fails every signature check.
  • Make sure the webhook URL is reachable from outside your VPC, and that it is not behind authentication that rejects the processor.
  • Add the retry guard from idempotency keys explained around the intent creation, because this API has no idempotency-key header.

GOOD QUESTIONS

Frequently asked

Where does the secret key go in a Next.js app?+

Server-side only, in an environment variable without the NEXT_PUBLIC_ prefix, and read only inside a route handler or server component. Anything prefixed NEXT_PUBLIC_ is inlined into the browser bundle at build time. The publishable key is the one the client gets, and it is designed to be public.

Why does the amount have to be decided on the server?+

Because anything the client sends can be changed. If the browser posts the amount, a user can post a different one. The server should look up the cart or order, compute the total itself, and create the payment intent from that — the client only ever learns the resulting client_secret.

Should I use a Server Action or a route handler?+

Either works for creating the payment intent. A route handler is the better default here because the client SDK needs the client_secret returned to it as data, which a plain fetch to a route handler expresses directly. Webhooks must be route handlers regardless, since they receive requests from outside your app.

Do I need to mark the payment component use client?+

Yes. The card fields mount into the DOM and the SDK runs in the browser, so the component that builds the form must be a client component. Keep it small and leave the surrounding page as a server component.

Can I trust the confirmPayment result to fulfil the order?+

No. Use it to update the interface, not to grant anything. The browser can close, the network can drop, and a user can walk away between the charge and the response. The webhook is the only delivery you can rely on, so fulfilment belongs there.

How do I test declines?+

Card 4111 1111 1111 1111 with any future expiry works for approvals. In the sandbox, declines are triggered by the amount rather than the card: $77.01 gives insufficient funds and $77.05 gives an expired card. Avoid large trigger amounts, which can exceed a provisioned sandbox account processing limit and be rejected for an unrelated reason.