Guides
Accept a payment
An end-to-end tutorial: create a checkout session on your backend, hand your customer off to LuniPay, verify the webhook, and fulfill the order.
This guide walks through the canonical LuniPay payment flow — the one most apps use. You will need a LuniPay account, a test API key, and a backend you can call from. We will use Node.js in the code samples, but the flow is identical in any language.
What we will build
- A
POST /create-checkoutendpoint on your server. - A browser redirect to the hosted LuniPay checkout page.
- A
POST /webhookendpoint that verifies signatures and fulfills orders.
1. Create a checkout session on your server
When a customer clicks "Pay", your backend calls LuniPay to create a checkout session, then returns the url to the browser. Never call this from the browser directly — your secret key must stay on the server.
Install the SDK first:
npm install lunipay// app/api/create-checkout/route.ts (Next.js App Router)
import { randomUUID } from 'node:crypto';
import LuniPay from 'lunipay';
import { NextResponse } from 'next/server';
const lunipay = new LuniPay(process.env.LUNIPAY_SECRET_KEY!);
export async function POST(req: Request) {
const { cartId } = await req.json();
const session = await lunipay.checkout.sessions.create(
{
amount: 5000,
currency: 'usd',
success_url: 'https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://example.com/cart',
metadata: { cart_id: cartId },
},
{ idempotencyKey: randomUUID() },
);
return NextResponse.json({ url: session.url });
}Stash the cart id in metadata
cart_id now means your fulfillment handler can look up the cart later without another API call.2. Redirect the customer
From your frontend, call your new endpoint and redirect to the URL it returns. LuniPay takes over from there — card form, 3DS, receipts, everything.
async function onCheckoutClick() {
const res = await fetch('/api/create-checkout', {
method: 'POST',
body: JSON.stringify({ cartId: currentCartId }),
});
const { url } = await res.json();
window.location.href = url;
}3. Register a webhook endpoint
In Settings → Developer, add a webhook endpoint pointing at https://yourdomain.com/webhook and subscribe to at least checkout.session.completed. LuniPay will show the signing secret exactly once — copy it into your backend environment as LUNIPAY_WEBHOOK_SECRET.
4. Verify the webhook and fulfill
LuniPay POSTs a signed event to your endpoint. Your server verifies the LuniPay-Signature header, parses the payload, and runs your fulfillment logic.
// app/api/webhook/route.ts
import { Webhook, WebhookSignatureError } from 'lunipay';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const body = await req.text(); // raw string — do NOT JSON.parse yet
const sig = req.headers.get('LuniPay-Signature');
let event;
try {
event = Webhook.constructEvent(
body,
sig,
process.env.LUNIPAY_WEBHOOK_SECRET!,
);
} catch (err) {
if (err instanceof WebhookSignatureError) {
return new NextResponse('invalid signature', { status: 400 });
}
throw err;
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as { id: string; metadata?: { cart_id?: string } };
await fulfillOrder(session.metadata?.cart_id, session.id);
}
return new NextResponse(null, { status: 200 });
}Read the raw body, once
5. Handle retries idempotently
LuniPay retries failed deliveries with exponential backoff over ~10.5 hours. Your handler must be idempotent — deduplicate by event.id so you do not ship the same order twice.
async function fulfillOrder(cartId: string, sessionId: string) {
// Upsert a fulfillment row keyed by session id. If it already exists,
// the INSERT is a no-op and we skip the ship call.
const { rowCount } = await db.query(
'INSERT INTO fulfillments (session_id, cart_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[sessionId, cartId],
);
if (rowCount === 0) return; // already processed
await shipOrder(cartId);
}Test it
- Trigger the flow from your test frontend.
- Pay with
4242 4242 4242 4242, any future expiry, any CVC. - Confirm your webhook handler received
checkout.session.completedand ran your fulfillment code. - Open the dashboard in test mode and check that the payment shows up with
SUCCEEDED.
Going live
Swap sk_test_… for sk_live_… in your environment, register a new webhook endpoint in live mode (it gets its own signing secret), update LUNIPAY_WEBHOOK_SECRET, and deploy. Nothing else changes.