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:
- Creates a Payment Intent for the cart total
- Displays the QRPH code at checkout
- Handles the payment webhook to confirm the order
- 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?
- Build a SaaS Platform with Wallets — multi-merchant checkout with commission splits
- Handle Payout Failures — for when you need to refund
- Reconcile Transactions — end-of-day reconciliation