Skip to main content

Build an E-Commerce Checkout

This tutorial walks you through building a complete QRPH checkout flow for an e-commerce store — from cart to payment confirmation to order fulfillment.

Time: ~30 minutes Prerequisites: Complete Your First Collection and Setup Webhooks first. You should have a working webhook endpoint before following this tutorial.


What you'll build

A checkout flow that:

  1. Creates a Payment Intent for the cart total
  2. Displays the QRPH code at checkout
  3. Handles the payment webhook to confirm the order
  4. Marks the order as paid and triggers fulfillment

Architecture

Customer adds to cart

POST /checkout/initiate (your backend)

POST /v2/payment-intents (NextAPI)

Display QR to customer

Customer scans & pays via bank app

NextAPI → POST /webhooks/nextapi (your backend)

Mark order paid → trigger fulfillment

Show confirmation to customer

Step 1: Create the checkout endpoint

Your backend creates a Payment Intent when the customer proceeds to checkout:

const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

// In-memory order store — use a database in production
const orders = new Map();

app.post('/checkout/initiate', async (req, res) => {
const { cartId, items, customerId } = req.body;

// Calculate total in centavos
const totalCentavos = items.reduce((sum, item) => sum + (item.priceCentavos * item.quantity), 0);

const orderId = `ORD-${Date.now()}`;

// Store order as pending
orders.set(orderId, {
id: orderId,
cartId,
customerId,
items,
totalCentavos,
status: 'pending_payment',
createdAt: new Date().toISOString(),
});

// Create Payment Intent
const response = await fetch('https://api.partners.nextpay.world/v2/payment-intents', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString('base64'),
'Content-Type': 'application/json',
'X-Idempotency-Key': `checkout-${orderId}-v1`,
},
body: JSON.stringify({
account_id: process.env.NEXTAPI_ACCOUNT_ID,
amount: totalCentavos,
description: `Order ${orderId}`,
external_id: orderId, // This comes back in the webhook
}),
});

if (!response.ok) {
const error = await response.json();
return res.status(500).json({ error: 'Payment setup failed', detail: error.message });
}

const paymentIntent = await response.json();

res.json({
orderId,
paymentIntentId: paymentIntent.id,
qrImageUrl: paymentIntent.qr_image_url,
totalCentavos,
expiresAt: paymentIntent.expires_at,
});
});

Step 2: Display checkout in your frontend

<!-- checkout.html -->
<div id="checkout-page">
<h2>Scan to Pay</h2>

<div class="order-summary">
<p>Order Total: <strong id="total"></strong></p>
<p>Order ID: <strong id="order-id"></strong></p>
</div>

<div class="qr-container">
<img id="qr-image" alt="Scan with GCash, Maya, or your bank app" />
<p>Payment expires in <span id="countdown">60:00</span></p>
</div>

<div id="status-message" class="hidden">
<!-- Updated when payment is confirmed -->
</div>
</div>

<script>
async function initCheckout(cartId, items, customerId) {
const response = await fetch('/checkout/initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartId, items, customerId }),
});
const checkout = await response.json();

document.getElementById('total').textContent =
`PHP ${(checkout.totalCentavos / 100).toFixed(2)}`;
document.getElementById('order-id').textContent = checkout.orderId;
document.getElementById('qr-image').src = checkout.qrImageUrl;

// Poll for payment status (or use WebSockets for real-time)
pollPaymentStatus(checkout.orderId, checkout.paymentIntentId);
}

async function pollPaymentStatus(orderId, paymentIntentId) {
const response = await fetch(`/checkout/status/${orderId}`);
const { status } = await response.json();

if (status === 'paid') {
document.getElementById('status-message').textContent = '✓ Payment confirmed!';
document.getElementById('status-message').classList.remove('hidden');
// Redirect to order confirmation page
setTimeout(() => { window.location.href = `/orders/${orderId}/confirmation`; }, 2000);
} else if (status === 'pending_payment') {
setTimeout(() => pollPaymentStatus(orderId, paymentIntentId), 3000);
}
}
</script>

Step 3: Handle the payment webhook

This is the critical piece — when NextAPI fires payment_intent.paid, mark the order as paid and trigger fulfillment:

// Register BEFORE express.json() for this route
app.post('/webhooks/nextapi', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-nextpay-signature'];

// Always verify first
const expected = crypto
.createHmac('sha256', process.env.NEXTAPI_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');

try {
if (!crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
return res.status(401).json({ error: 'Invalid signature' });
}
} catch {
return res.status(401).json({ error: 'Invalid signature' });
}

const event = JSON.parse(req.body.toString());

if (event.type === 'payment_intent.paid') {
const { external_id: orderId, amount, id: paymentIntentId } = event.data;

const order = orders.get(orderId);
if (!order) {
console.error(`Unknown order: ${orderId}`);
return res.status(200).send('OK'); // Still return 200 to prevent retries
}

if (order.status === 'paid') {
// Already processed — idempotent handling
return res.status(200).send('OK');
}

// Verify amount matches
if (amount !== order.totalCentavos) {
console.error(`Amount mismatch for ${orderId}: expected ${order.totalCentavos}, got ${amount}`);
// Log for review but don't fail the webhook
}

// Update order status
orders.set(orderId, {
...order,
status: 'paid',
paymentIntentId,
paidAt: new Date().toISOString(),
});

// Trigger fulfillment (async — don't block the webhook response)
triggerFulfillment(orderId).catch(err => {
console.error(`Fulfillment failed for ${orderId}:`, err);
});

console.log(`Order ${orderId} paid: PHP ${amount / 100}`);
}

// Always return 200 quickly
res.status(200).send('OK');
});

async function triggerFulfillment(orderId) {
// Your fulfillment logic here:
// - Send confirmation email
// - Update inventory
// - Notify warehouse
// - Create shipping label
console.log(`Fulfillment triggered for order ${orderId}`);
}

Step 4: Status polling endpoint

Your frontend polls this to know when the order is paid:

app.get('/checkout/status/:orderId', (req, res) => {
const order = orders.get(req.params.orderId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}

res.json({
orderId: order.id,
status: order.status,
paidAt: order.paidAt || null,
});
});

Step 5: Handle expired payments

If the customer doesn't pay within the expiry window (default 1 hour), the Payment Intent expires:

if (event.type === 'payment_intent.expired') {
const { external_id: orderId } = event.data;
const order = orders.get(orderId);

if (order && order.status === 'pending_payment') {
orders.set(orderId, { ...order, status: 'payment_expired' });
// Optionally: cancel the cart reservation, notify the customer
}
}

Your checkout frontend detects the expired status on its next poll and shows a "Payment expired — please try again" message, triggering a new checkout initiation.


Best practices

Idempotent webhook handling. Check if (order.status === 'paid') before processing — NextAPI may deliver the same webhook multiple times.

Return 200 immediately. Process webhooks asynchronously. If your handler takes more than a few seconds, NextAPI treats it as a failure and retries. Acknowledge receipt first, process after.

Don't rely solely on polling. Webhooks are faster and more reliable. Use polling as a fallback for customers who close the browser before the webhook fires.

Use external_id for correlation. Set external_id to your order ID when creating the Payment Intent — you get it back in every webhook and status check without maintaining a mapping table.


What's next?