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:// deep 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 + URL-scheme integration. Use it when your POS is HTML/JS and you don't ship a native mobile app of your own.

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. iOS / Android prompts the user to switch to MONEI Pay.
  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: `monei-pay://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. On iOS Safari prompts to switch apps.
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.

Safari quirks

  • Safari shows a confirmation sheet ("Open in MONEI Pay?") the first time a domain triggers monei-pay://. The user must tap Open.
  • If MONEI Pay is not installed, Safari silently stays on your page. You'll see no error event. Detect it with a fallback timer:
window.location.href = deepLink;

setTimeout(() => {
// Page is still visible → user dismissed the sheet or MONEI Pay isn't installed
if (!document.hidden) {
alert('MONEI Pay does not appear to be installed on this device.');
}
}, 2000);
  • Embedded <iframe> cannot navigate the top-level location to a custom scheme. If your POS runs inside an iframe, open the deep 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
Browser shows "Cannot Open Page"MONEI Pay not installed on this deviceInstall 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