Skip to main content

Web App Integration

Trigger NFC payments on the MONEI Pay app from a browser-based POS (a web ordering screen, a tablet POS, a back-office payment link). Your web page opens the MONEI Pay link; MONEI Pay handles the tap; the result comes back to your backend via a signed webhook and to the browser via a redirect.

No SDK to install — this is a pure HTTP + deep-link integration. Use it when your POS is HTML/JS and you don't ship a native mobile app of your own.

Use the Universal Link

Open https://pay.monei.com/accept-payment — a Universal Link on iOS and an App Link on Android. It launches MONEI Pay directly, with no "Open in MONEI Pay?" confirmation, on both platforms. The legacy monei-pay://accept-payment URL scheme accepts the same query parameters and still works, but it triggers the iOS confirmation sheet — prefer the HTTPS link. If MONEI Pay isn't installed, the HTTPS link opens the MONEI Pay web app sign-in instead of failing silently.

How It Works

  1. The browser asks your backend to start a payment.
  2. Your backend mints a POS auth token, builds the deep link with callback_url (your webhook) and complete_url (where Safari returns the user), returns it to the browser.
  3. The browser opens the deep link. The Universal Link launches MONEI Pay directly — no app-switch confirmation.
  4. The user taps their card on the device running MONEI Pay.
  5. MONEI fires a signed webhook to your callback_url — this is the authoritative result. Fulfill the order here.
  6. MONEI Pay opens complete_url in the original browser; your page reads the result query params, then polls your backend to confirm.

The two channels are independent on purpose: the webhook is the trusted record (HMAC MONEI-Signature), the redirect is a UX nicety that can be spoofed. Always confirm from the webhook or Get Payment before fulfilling. See Result Delivery.

Prerequisites

  • MONEI account and API key
  • A device running MONEI Pay (the merchant's iPhone / Android — the device that physically taps the card)
  • A backend that can mint POS auth tokens and host the callback_url webhook
  • An HTTPS endpoint you control (required for callback_url — local IPs and http:// are blocked)

Step 1: Server-Side Endpoint

Your backend exposes a small endpoint that the browser calls to start a payment. It generates the auth token, persists a payment record, and returns the deep link.

server.js
import express from 'express';
import {Monei} from '@monei-js/node-sdk';
import {randomUUID} from 'crypto';

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

const monei = new Monei('YOUR_API_KEY');

app.post('/create-payment', async (req, res) => {
const {amount, orderId} = req.body;
const {token} = await monei.posAuthToken.create({});

// Store a row so the webhook handler and /payment-status can find it
const paymentId = randomUUID();
await db.payments.insert({id: paymentId, orderId, status: 'PENDING'});

const params = new URLSearchParams({
amount: String(amount),
auth_token: token,
order_id: orderId,
callback_url: `https://merchant.com/webhooks/monei`,
complete_url: `https://merchant.com/checkout/done?paymentId=${paymentId}`
});

res.json({
deepLink: `https://pay.monei.com/accept-payment?${params.toString()}`,
paymentId
});
});
order_id for reconciliation

Pass your own order_id to surface it as the payment's orderId in the signed callback_url webhook. Skips the extra DB lookup when correlating against your POS order. If omitted, MONEI generates one. See Reconciliation.

When the cashier clicks Charge in your web POS, fetch the deep link and navigate to it.

checkout.html
<button id="charge">Charge €15.00</button>

<script>
document.getElementById('charge').addEventListener('click', async () => {
const res = await fetch('/create-payment', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({amount: 1500, orderId: 'order_42'})
});
const {deepLink, paymentId} = await res.json();

// Persist locally so the return page can find the right payment
sessionStorage.setItem('pendingPaymentId', paymentId);

// Hand off to MONEI Pay. The Universal Link opens the app directly — no prompt.
window.location.href = deepLink;
});
</script>
Trigger from a user gesture

Safari and Chrome only honor app-scheme navigation when it happens inside a real click handler (or pointerdown / touchend). Don't open the deep link from setTimeout, network callbacks, or page-load — the browser will block it as a popup.

Browser behavior

  • The Universal Link opens MONEI Pay directly — no "Open in MONEI Pay?" confirmation sheet on iOS, and no app-chooser on Android.
  • If MONEI Pay is not installed (or runs a version that predates Universal Link support), the link opens the MONEI Pay web app sign-in at pay.monei.com instead of failing silently. The cashier can install the app from there.
  • The legacy monei-pay://accept-payment scheme accepts the same parameters but, on iOS, Safari shows a confirmation sheet the first time a domain triggers it — another reason to prefer the HTTPS link.
  • Embedded <iframe> cannot navigate the top-level location to MONEI Pay. If your POS runs inside an iframe, open the link via window.top.location.href = ... (requires same-origin or allow-top-navigation).

Step 3: Handle the Return on complete_url

After the cashier completes (or cancels) the tap, MONEI Pay opens complete_url in the same browser. Treat its query parameters as a hint only — confirm via the webhook or Get Payment.

checkout/done.html
<script>
const params = new URLSearchParams(location.search);
const paymentId = params.get('paymentId');

if (params.get('success') === 'true') {
// Optimistically show "processing"; confirm from your backend below
document.body.innerHTML = '<p>Confirming payment…</p>';
} else {
document.body.innerHTML = `<p>Payment failed: ${params.get('error')}</p>`;
}

// Poll your backend — the webhook is the source of truth
async function poll() {
const res = await fetch(`/payment-status/${paymentId}`);
const {status} = await res.json();
if (status === 'COMPLETED') {
document.body.innerHTML = '<p>Paid ✓</p>';
} else if (status === 'FAILED') {
document.body.innerHTML = '<p>Payment failed</p>';
} else {
setTimeout(poll, 1000);
}
}
if (params.get('success') === 'true') poll();
</script>

complete_url query parameters are documented in Result Delivery.

Step 4: Receive and Verify the Webhook

The callback_url you set in Step 1 receives a signed POST when the payment finishes. Verify the MONEI-Signature HMAC before trusting the payload.

webhook-handler.js
import {Monei} from '@monei-js/node-sdk';
const monei = new Monei('YOUR_API_KEY');

app.post('/webhooks/monei', express.raw({type: '*/*'}), async (req, res) => {
const signature = req.headers['monei-signature'];
let payment;
try {
payment = monei.verifySignature(req.body.toString('utf8'), signature);
} catch {
return res.status(401).send('Invalid signature');
}

// payment.orderId is what you sent as order_id in the deep link
await db.payments.update(
{orderId: payment.orderId},
{status: payment.status, monei_id: payment.id}
);
res.status(200).send('OK');
});

Full signature-verification guide: Verify signature.

Trust Model Recap

ChannelDirectionTrustUse for
callback_urlServer → serverTrustedOrder fulfillment, settlement, business logic
complete_urlBrowser redirectNOT trustedUX feedback only — confirm via the webhook before

Never fulfill an order based on complete_url query parameters alone. The cashier can craft any URL.

Common Issues

SymptomCauseFix
Safari does nothing on clickNavigation not inside a user gesture, or popup blockerTrigger from a click / pointer event; avoid setTimeout / async-then-navigate
Link opens the MONEI Pay web sign-in, not the appMONEI Pay not installed (or a pre-Universal-Link version) on this deviceInstall or update MONEI Pay from the App Store / Play Store on the device that's tapping the card
Webhook never firescallback_url is http://, a private IP, or > 2048 charsUse a public https:// URL. MONEI's DNS-pinned client also blocks RFC1918 / loopback / IMDS
Webhook fires but my order_id is missingCashier hit an old version of MONEI Pay (pre-2.7.3) that didn't parse order_idMake sure the device runs the latest MONEI Pay from the store
Two browser tabs both jump to MONEI PayBoth opened the deep link in the same window groupUse one POS tab; deep link should be tied to a single in-flight payment
complete_url opens but lost sessionSafari opens it in a fresh tab without your cookiesCarry state in the URL (paymentId query) and confirm via your backend instead of relying on session
User refreshes complete_url pageIdempotency on your sideServer-side /payment-status/{id} should be safe to call repeatedly

Reference