Skip to content

Accept a payment with Next.js

Flint hosts the checkout page and signs the webhook that says an order is paid, so your app needs two server routes: one that sends the buyer to checkout and one that grants access when the payment lands. You'll build both in a Next.js App Router project, with an Express tab for the same flow, using the Node SDK. No account yet? Sign up and you'll have a sandbox to run it in.

Before you start#

  • A sandbox API key that starts with flint_test_. Accept your first payment walks through creating one.
  • npm install @flintpay/node. SDKs lists the Node.js and TypeScript requirements.
  • A webhook endpoint subscribed to order.paid, and its ID (whep_...).

Set three environment variables on the server:

Shell
FLINT_API_KEY=flint_test_...
FLINT_WEBHOOK_ENDPOINT_ID=whep_...
APP_URL=http://localhost:3000

The SDK sends money as integer strings, so a $25.00 price is amount: "2500" and a quantity is quantity: "1". See Money & currency.

Step 1: create the client#

Both frameworks share one client. Constructing it makes no network calls, so a module-level instance is fine.

TypeScript
// lib/flint.ts
import { Client } from "@flintpay/node";

export const flint = new Client({ apiKey: process.env.FLINT_API_KEY! });

Step 2: create the order and send the buyer to checkout#

The route creates an order for the cart, opens a checkout session for it, and redirects to the session's url. Each request carries an idempotency key built from the cart, so a double-click or a retry returns the same order and session instead of creating a second one. See Idempotency.

TypeScript
// app/api/checkout/route.ts
import { SdkError } from "@flintpay/node";
import { flint } from "@/lib/flint";

export async function POST(req: Request) {
  const cartId = String((await req.formData()).get("cart_id"));

  try {
    const order = await flint.orders.create(
      {
        line_items: [
          {
            type: "custom",
            name: "Design consultation",
            quantity: "1",
            unit_price_money: { amount: "2500", currency: "USD" },
            fulfillment: { requirement: "none" },
          },
        ],
        metadata: { cart_id: cartId },
      },
      { idempotencyKey: `order-${cartId}` },
    );

    const session = await flint.checkoutSessions.create(
      {
        type: "order",
        order_id: order.order_id,
        redirects: {
          success_url: `${process.env.APP_URL}/thanks`,
          cancel_url: `${process.env.APP_URL}/cart`,
        },
      },
      { idempotencyKey: `checkout-${cartId}` },
    );

    if (!session.url) throw new Error("Expected a hosted checkout session");
    return Response.redirect(session.url, 303);
  } catch (error) {
    if (error instanceof SdkError) {
      console.error("Flint request failed", error.code, error.message);
      return new Response("Checkout is unavailable. Try again.", { status: 502 });
    }
    throw error;
  }
}

To sell from your catalog, replace the custom line with { type: "variant", variant_id: "var_...", quantity: "1" }. Checkout sessions covers buyer fields, expiration, and appearance.

The metadata rides on the order and comes back in the webhook, which is how the webhook knows whose cart was paid. If the cart changes, change the key too: the same key with different parameters returns 409 IDEMPOTENCY_KEY_REUSED.

Step 3: handle SDK errors#

A failed call throws SdkError, and error.code says what went wrong. The routes above log it and show the buyer a plain message; branch on error.code, never on the message text. The SDK already retries only when a retry can't repeat an action. If a call still fails, Error handling explains which errors to send again with the same key and which to fix first.

Step 4: grant access on order.paid#

Never grant access from the redirect to success_url: a buyer can close the tab before it loads, or open the URL without paying. Flint sends order.paid after the payment succeeds, and it retries until you answer 2xx, so the same event can arrive more than once. Record each webhook_event_id once and grant access in the same step. This in-memory version shows the shape; in production, use a table with a unique key on the event ID and write both in one transaction.

TypeScript
// lib/access.ts
const seenEvents = new Set<string>();
const paidCarts = new Set<string>();

/** Returns false when this event was already handled. */
export function grantAccessOnce(eventId: string, cartId: string): boolean {
  if (seenEvents.has(eventId)) return false;
  seenEvents.add(eventId);
  paidCarts.add(cartId);
  return true;
}

The webhook route must hand the SDK the exact bytes Flint sent, because the signature covers them. Parsing the body as JSON first breaks verification. In Next.js, read it with await req.arrayBuffer(); in Express, use express.raw({ type: "application/json" }) on that route only. The SDK verifies the JWT, and endpointIds comes from your configuration, never from the request. See Verify the signature.

TypeScript
// app/api/webhooks/flint/route.ts
import { SdkError } from "@flintpay/node";
import { grantAccessOnce } from "@/lib/access";
import { flint } from "@/lib/flint";

export async function POST(req: Request) {
  let verified;
  try {
    verified = await flint.verifyWebhookAsync(await req.arrayBuffer(), req.headers, {
      endpointIds: [process.env.FLINT_WEBHOOK_ENDPOINT_ID!],
    });
  } catch (error) {
    if (error instanceof SdkError && error.code === "webhook_jwks_unavailable") {
      return new Response(null, { status: 503 }); // Flint's keys didn't load: ask Flint to retry.
    }
    return new Response(null, { status: 400 });
  }

  if (verified.known && verified.event.event_type === "order.paid" && !verified.event.test) {
    const order = verified.event.data?.object;
    const cartId = order?.metadata?.cart_id;
    if (cartId) grantAccessOnce(verified.event.webhook_event_id, cartId);
  }

  return new Response(null, { status: 200 }); // Acknowledge events you don't handle too.
}

Test events carry test: true and example IDs that don't exist in your account, so the handler skips them. Before an irreversible side effect such as shipping goods, read the order back with flint.orders.get(order.order_id) and check payment_status is paid, as Deduplicate and process asynchronously describes.

Step 5: test it in your sandbox#

Expose your server over HTTPS with a tunnel and register the tunnel URL as the endpoint's url. This verifier checks JWT deliveries from a registered endpoint, so flint listen, which signs with HMAC, can't exercise it.

  1. Post a cart to your checkout route, for example curl -i -d cart_id=cart-1 http://localhost:3000/api/checkout (/checkout on Express). The 303 response points at Flint's hosted checkout.
  2. Open that URL and pay with this test card, any future expiry date, and any CVC: Testing lists cards that decline or ask for 3D Secure.
  3. Watch your server receive order.paid and grant access. Resend the delivery from Developers > Webhooks in the dashboard: the second one is acknowledged and grantAccessOnce returns false.
  4. To check verification without a payment, send a test event as Send a test event shows, or from the same dashboard page. Your route should answer 200 without granting anything.

Next steps#

Was this helpful?