Under the hood
<script src="https://js.monei.com/v3/monei.js"></script>
<script type="module">
// orderForm, setPayable, showError and hideExpressCheckout are your page's own UI;
// orderForm() returns {address, optionId, customer} from the checkout form.
const accountId = '94be6db7-babe-4271-b7fd-a4cd8e0e0da9';
const currency = 'EUR';
const sessionId = crypto.randomUUID().replaceAll('-', ''); // one per customer
const cart = 'ethiopia-guji:1,stoneware-cup:2,filter-papers:3'; // productId:quantity pairs
const amount = 5820; // goods only, in cents; shipping is added later
// Card and wallet both end here: the server prices the order and opens the
// payment, and confirming in the browser keeps 3D Secure in a popup.
const pay = async (order, paymentToken) => {
const response = await fetch('/api/payment', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({sessionId, cart, ...order})
});
const data = await response.json();
if (!response.ok) return showError(data.error);
const result = await monei.confirmPayment({paymentId: data.id, paymentToken});
if (result.nextAction?.mustRedirect) return location.assign(result.nextAction.redirectUrl);
location.assign(`/receipt?id=${data.id}`);
};
const card = monei.CardInput({
accountId, amount, currency, sessionId,
onChange: ({isTouched, error}) => showError(isTouched ? error : null)
});
card.render('#card-input');
document.querySelector('#pay').addEventListener('click', async () => {
const {token, error} = await card.submit();
if (error) return showError(error);
await pay(orderForm(), token);
});
// The wallet collects the address, so shipping is priced in its callbacks.
// Throwing marks the address as unserviceable.
let walletOption = null;
monei.PaymentRequest({
accountId, amount, currency, sessionId,
requestShipping: true,
requestBilling: true,
// The options the sheet opens with, before it knows the address.
shippingOptions: [{"id":"standard","label":"Standard (3–5 days)","amount":499},{"id":"express","label":"Express (24 h)","amount":999}],
onShippingAddressChange: async (address) => {
walletOption = null;
const response = await fetch('/api/shipping-rates', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({cart, address, wallet: true})
});
if (!response.ok) throw new Error('unserviceable');
const rates = await response.json();
walletOption = rates.shippingOptions[0].id;
return {shippingOptions: rates.shippingOptions, amount: rates.amount};
},
onShippingOptionChange: async (option) => {
walletOption = option.id;
return {amount: amount + option.amount};
},
onSubmit: async ({error, token, shippingDetails, billingDetails, shippingOption, finalAmount}) => {
if (error || !token) return showError(error ?? 'The wallet returned no payment method.');
await pay(
{
address: shippingDetails?.address,
billing: billingDetails,
optionId: shippingOption?.id ?? walletOption,
walletAmount: finalAmount // checked against the server's total, never charged
},
token
);
},
onLoad: (isSupported) => {
if (!isSupported) hideExpressCheckout();
}
}).render('#payment-request');
</script>
import express from 'express';
import {Monei} from '@monei-js/node-sdk';
const app = express();
const monei = new Monei(process.env.MONEI_API_KEY);
const origin = process.env.PUBLIC_URL; // e.g. https://shop.example
// cartTotal, zoneFor, isValidPostcode and newOrderId are your store's own.
// cartTotal must refuse a cart with any line it does not recognise.
// Rates per zone. No rates means the address cannot be shipped to.
const RATES = {
"canary": [
{
"id": "canary-standard",
"label": "Standard (5–8 days)",
"amount": 1499
},
{
"id": "canary-express",
"label": "Express (48 h)",
"amount": 2499
}
],
"peninsula": [
{
"id": "standard",
"label": "Standard (3–5 days)",
"amount": 499
},
{
"id": "express",
"label": "Express (24 h)",
"amount": 999
},
{
"id": "pickup",
"label": "Collect in store",
"amount": 0,
"type": "PICKUP"
}
],
"row": [
{
"id": "international",
"label": "International (7–14 days)",
"amount": 1999
}
]
};
// Lists the options for an address. The total is for display only.
app.post('/api/shipping-rates', express.json(), (req, res) => {
const {cart, address, wallet} = req.body;
// A wallet sheet is choosing where to ship, so collect-in-store is left out.
const options = (RATES[zoneFor(address)] ?? []).filter((r) => !wallet || r.type !== 'PICKUP');
if (!options.length) return res.status(422).json({error: 'unserviceable'});
res.json({shippingOptions: options, amount: cartTotal(cart) + options[0].amount});
});
app.post('/api/payment', express.json(), async (req, res) => {
const {sessionId, cart, address, billing, optionId, walletAmount} = req.body;
if (!address?.country) return res.status(400).json({error: 'Missing shipping address'});
if (!isValidPostcode(address)) return res.status(400).json({error: 'Invalid postcode'});
// Never trust an amount from the client. Price the goods from your catalogue
// and shipping from the zone of the address the order ships to.
const rate = (RATES[zoneFor(address)] ?? []).find((r) => r.id === optionId);
if (!rate) return res.status(400).json({error: 'Unknown shipping option'});
const amount = cartTotal(cart) + rate.amount;
// A wallet sheet shows its own total. Refuse a payment it priced differently.
if (walletAmount !== undefined && walletAmount !== amount) {
return res.status(422).json({error: 'Amount mismatch'});
}
let payment;
try {
// No paymentToken: the browser confirms this payment with monei.confirmPayment.
payment = await monei.payments.create({
amount,
currency: 'EUR',
orderId: newOrderId(),
sessionId,
shippingDetails: {address},
billingDetails: billing?.address ? {name: billing.name, address: billing.address} : {address},
completeUrl: `${origin}/receipt`,
cancelUrl: `${origin}/cancelled`,
callbackUrl: `${origin}/api/callback`
});
} catch (error) {
return res.status(502).json({error: error.message});
}
const {id, status, nextAction} = payment;
res.json({id, status, nextAction});
});
// The signature covers the raw bytes, so this route must not parse the body first.
app.post('/api/callback', express.raw({type: 'application/json'}), (req, res) => {
let payment;
try {
payment = monei.verifySignature(req.body.toString(), req.get('MONEI-Signature'));
} catch {
return res.sendStatus(401);
}
// Fulfil the order here on SUCCEEDED, once per payment id: callbacks can repeat.
res.sendStatus(200);
});
app.listen(3000);
Nothing yet. Interact with the checkout.
Illustrative — the running demo adds error handling and validation these extracts leave out. See the full integration guide.