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.
Prefer to inspect a complete app?
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.Checkout session id substitution
success_url contains {CHECKOUT_SESSION_ID}, LuniPay replaces that template with the real cs_id before redirecting the customer.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. For local development, use an HTTPS tunnel instead of registering alocalhost URL directly.
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.