Skip to main content

Build a Card Checkout

Early Access — Requires Activation

AUB Direct Card is available to partners on request, not by default. Your NextPay workspace needs the aub-direct-card-v1 feature activated before any card_online payment intent will work. Contact your NextPay account manager to request access. Once activated, this tutorial works against the standard API with no additional setup.

This tutorial walks you through building a complete AUB Direct Card checkout — from collecting card details in your frontend, through 3DS authentication, to payment confirmation and order fulfillment.

Time: ~45 minutes Prerequisites: Complete Your First Collection and Setup Webhooks. You need a working webhook endpoint before following this tutorial. You also need AUB Direct Card access activated on your workspace — contact your NextPay account manager to request it.


What you'll build

A card checkout that:

  1. Collects card details and browser context in the browser
  2. Submits them to your backend, which creates a NextAPI card payment intent
  3. Redirects the customer to the AUB 3DS authentication page
  4. Handles the return from 3DS and polls for settlement
  5. Processes the v2.payment_intent.succeeded webhook to fulfill the order
  6. Handles declines, 3DS failures, and expired challenges

Architecture

Card checkout involves more moving parts than QRPH because the customer's browser makes two navigations — one to the 3DS page and one back to your site:

Browser (checkout form)
│ POST /checkout/initiate (card details + browser info)
│ │
│ │ POST /v2/payment-intents (NextAPI)
│ │ ← { payment_instrument.actions[].client_instructions.redirect_url }
│ │
│ ← { challengeUrl, orderId }

Browser → redirect → AUB 3DS page
│ customer authenticates

│ (async, server-to-server)
│ AUB → NextAPI callback → NextAPI settles
│ NextAPI → POST /webhooks/nextapi (your server)

Browser → redirect → /checkout/return?order_id=...
│ your handler polls GET /v2/payment-intents/{id}

Browser ← confirmation or error page

Two things confirm the same payment: the browser's return to your redirect_url and the v2.payment_intent.succeeded webhook arriving at your server. Both can happen in either order — handle both idempotently.


PCI boundary

Card data (PAN, CVV) passes through your server on the way to NextAPI. It must never appear in logs, databases, error messages, or responses. Disable request body logging on the route that creates card payment intents.


Step 1: Collect browser information in the frontend

3DS requires browser context that only exists in the customer's browser. Collect it before the customer submits the checkout form and include it with the card details sent to your backend.

<!-- checkout.html -->
<form id="checkout-form">
<div class="card-fields">
<label>
Card Number
<input type="text" id="card-pan" inputmode="numeric" autocomplete="cc-number" />
</label>
<label>
Expiry (MM/YY)
<input type="text" id="card-expiry" autocomplete="cc-exp" />
</label>
<label>
CVV
<input type="text" id="card-cvv" autocomplete="cc-csc" />
</label>
</div>

<div class="billing-fields">
<input type="text" id="billing-street" placeholder="Street address" />
<input type="text" id="billing-city" placeholder="City" />
<input type="text" id="billing-postcode" placeholder="Postcode" />
</div>

<button type="submit">Pay PHP <span id="total"></span></button>
</form>

<script>
function collectBrowserInfo() {
return {
accept_header: '*/*',
language: navigator.language || 'en-US',
java_enabled: navigator.javaEnabled?.() ?? false,
javascript_enabled: true,
screen_color_depth: window.screen.colorDepth,
screen_height: window.screen.height,
screen_width: window.screen.width,
timezone: new Date().getTimezoneOffset(),
user_agent: navigator.userAgent,
challenge_window: 5,
// ip is populated server-side — leave null here
ip: null,
};
}

document.getElementById('checkout-form').addEventListener('submit', async (e) => {
e.preventDefault();

const [expiryMonth, expiryYear] = document.getElementById('card-expiry').value.split('/');

const payload = {
orderId: window.checkoutOrderId, // set by your page when it loads
card: {
pan: document.getElementById('card-pan').value.replace(/\s/g, ''),
expiration_month: expiryMonth.trim(),
expiration_year: '20' + expiryYear.trim(),
security_code: document.getElementById('card-cvv').value,
},
billing: {
street1: document.getElementById('billing-street').value,
city: document.getElementById('billing-city').value,
postcode: document.getElementById('billing-postcode').value,
},
browser_info: collectBrowserInfo(),
};

const response = await fetch('/checkout/initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

const result = await response.json();

if (result.challengeUrl) {
// Save order ID in sessionStorage before leaving the page
sessionStorage.setItem('pendingOrderId', payload.orderId);
window.location.href = result.challengeUrl;
} else {
showError('Could not start card payment. Please try again.');
}
});
</script>

Step 2: Create the payment intent

Your backend receives the card data and browser context, then forwards them to NextAPI. The ip field comes from the incoming request — never trust the client to provide it.

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

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

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

app.post('/checkout/initiate', async (req, res) => {
const { orderId, card, billing, browser_info } = req.body;

const order = orders.get(orderId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}

// Populate IP server-side — never trust the client's value
browser_info.ip = req.ip;

const idempotencyKey = `card-checkout-${orderId}-v1`;

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': idempotencyKey,
'X-Feature-Flag': 'aub-direct-card-v1',
},
body: JSON.stringify({
account_id: process.env.NEXTAPI_ACCOUNT_ID,
external_id: orderId,
amount: order.totalCentavos,
currency: 'PHP/2',
expires_in_seconds: 300,
intent_created_at: new Date().toISOString(),
payment_instrument_options: {
method_type: 'card_online',
method_provider: 'ph_aub',
authorization_mode: 'sale_3ds',
redirect_url: `${process.env.BASE_URL}/checkout/return?order_id=${orderId}`,
card,
customer_information: {
first_name: order.customer.firstName,
last_name: order.customer.lastName,
phone: order.customer.phone,
browser_information: browser_info,
},
billing_information: {
...billing,
state: 'PH-00', // ISO 3166-2 subdivision — derive from billing input if you collect it
country: 'PH',
},
},
}),
});

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();
const instrument = paymentIntent.payment_instrument;

// Extract the 3DS challenge URL from the card.challenge action
const challengeAction = instrument?.actions?.find(
(a) => a.action_kind === 'card.challenge' && a.status === 'requires_action'
);

if (!challengeAction?.client_instructions?.redirect_url) {
return res.status(500).json({ error: 'No 3DS challenge returned from NextAPI' });
}

// Store the instrument ID for sandbox simulation and the intent ID for polling
orders.set(orderId, {
...order,
paymentIntentId: paymentIntent.id,
paymentInstrumentId: instrument.id,
status: 'awaiting_3ds',
});

res.json({
orderId,
challengeUrl: challengeAction.client_instructions.redirect_url,
});
});

Step 3: Handle the return from 3DS

After the customer completes (or abandons) the 3DS challenge, AUB redirects the browser to your redirect_url. Poll the payment intent to get the current status and route the customer to the right page.

app.get('/checkout/return', async (req, res) => {
const orderId = req.query.order_id;
const order = orders.get(orderId);

if (!order || !order.paymentIntentId) {
return res.redirect('/checkout/error?reason=not_found');
}

// If already marked paid by the webhook, skip polling
if (order.status === 'paid') {
return res.redirect(`/orders/${orderId}/confirmation`);
}

// Poll for up to 10 seconds — settlement typically completes within a few seconds
let intent;
for (let attempt = 0; attempt < 5; attempt++) {
const response = await fetch(
`https://api.partners.nextpay.world/v2/payment-intents/${order.paymentIntentId}`,
{
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString('base64'),
},
}
);
intent = await response.json();

if (intent.status === 'succeeded' || intent.status === 'failed') break;
if (attempt < 4) await new Promise((r) => setTimeout(r, 2000));
}

if (intent.status === 'succeeded') {
// Mark paid here too — the webhook may not have arrived yet
if (order.status !== 'paid') {
orders.set(orderId, { ...order, status: 'paid', paidAt: new Date().toISOString() });
triggerFulfillment(orderId).catch(console.error);
}
return res.redirect(`/orders/${orderId}/confirmation`);
}

if (intent.status === 'failed') {
orders.set(orderId, { ...order, status: 'failed' });
return res.redirect(`/checkout?order_id=${orderId}&error=card_declined`);
}

// Still processing — show an intermediate page with auto-refresh
return res.redirect(`/checkout/processing?order_id=${orderId}`);
});

Step 4: Handle the settlement webhook

NextAPI fires v2.payment_intent.succeeded once AUB confirms settlement. This is the authoritative confirmation path — the return URL handler is a UX fallback for customers who get the result before the webhook arrives.

// 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'];

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());

// Card settlement — note: event.event and event.payload (not event.type / event.data)
if (event.event === 'v2.payment_intent.succeeded') {
const { external_id: orderId, id: paymentIntentId, amount } = event.payload;

const order = orders.get(orderId);
if (!order) {
// Unknown order — return 200 to prevent retries
return res.status(200).send('OK');
}

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

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

triggerFulfillment(orderId).catch(console.error);
}

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

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

Step 5: Handle failures

Three failure scenarios each require a different response:

Card declined or 3DS failed — the payment intent reaches failed status. Detected via the return URL handler's poll (Step 3) or a failed webhook event. Show the customer a "card declined" message and offer a retry with a new payment intent.

Customer abandons 3DS — the customer closes the browser or navigates away before completing authentication. The payment intent stays at pending until its expires_in_seconds window closes, then moves to expired. Detected when Step 3's poll times out without a terminal status.

Expired payment intent — same end state as abandonment. When the customer returns to your checkout page (or is redirected from /checkout/processing), create a new payment intent from scratch.

// Processing page — shown while status is still pending
// Polls your own status endpoint until the intent resolves
app.get('/checkout/processing', async (req, res) => {
const orderId = req.query.order_id;
res.send(`
<html>
<head>
<meta http-equiv="refresh" content="3;url=/checkout/return?order_id=${orderId}" />
</head>
<body>
<p>Processing your payment, please wait...</p>
</body>
</html>
`);
});

// Status endpoint — used by your processing page
app.get('/checkout/status/:orderId', (req, res) => {
const order = orders.get(req.params.orderId);
if (!order) return res.status(404).json({ error: 'Not found' });
res.json({ orderId: order.id, status: order.status });
});

Step 6: Test in sandbox

Sandbox card payments use a different simulation endpoint from QRPH. Use the paymentInstrumentId saved in Step 2:

// Simulate card payment settlement (sandbox only)
const simulateCardPayment = async (paymentInstrumentId) => {
const response = await fetch(
'https://api.partners.nextpay.world/v2/payment-simulations/payment-instrument',
{
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString('base64'),
'Content-Type': 'application/json',
},
body: JSON.stringify({ payment_instrument_id: paymentInstrumentId }),
}
);

const result = await response.json();
// result.result === 'payment_processed' on success
return result;
};

After simulation, your webhook endpoint should receive v2.payment_intent.succeeded and the payment intent status should move to succeeded.

Test card number for sandbox: 4111111111111111 (Visa test card, any future expiry, any CVV).


Best practices

Never log card fields. Disable request body logging on POST /checkout/initiate — the raw card data passes through this route. Log only non-sensitive fields like orderId and the resulting paymentIntentId.

Idempotency on both paths. Both the return URL handler and the webhook handler can trigger fulfillment. The if (order.status === 'paid') return 200 check in each handler ensures fulfillment runs exactly once regardless of which arrives first.

Webhook is authoritative. The return URL handler's poll is a convenience for customers who expect immediate feedback. It's not guaranteed — the settlement callback may arrive after the browser poll times out. Your fulfillment logic must work even if the return URL handler only shows "processing."

HTTPS for redirect_url. AUB rejects non-HTTPS return URLs. In local development, use a tunnel (ngrok, Cloudflare Tunnel) and set BASE_URL accordingly.

Don't reuse payment intents. If the customer abandons checkout and returns, create a new payment intent with a new idempotency key. A required_action or expired intent cannot be reused.

Return 200 from webhooks immediately. If your webhook handler takes more than a few seconds, NextAPI retries. Acknowledge receipt in the HTTP response, then process asynchronously.


What's next