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
- The browser asks your backend to start a payment.
- Your backend mints a POS auth token, builds the deep link with
callback_url(your webhook) andcomplete_url(where Safari returns the user), returns it to the browser. - The browser opens the deep link. iOS / Android prompts the user to switch to MONEI Pay.
- The user taps their card on the device running MONEI Pay.
- MONEI fires a signed webhook to your
callback_url— this is the authoritative result. Fulfill the order here. - MONEI Pay opens
complete_urlin 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_urlwebhook - An HTTPS endpoint you control (required for
callback_url— local IPs andhttp://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.
- Node.js
- Python
- PHP
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
});
});
from flask import Flask, request, jsonify
from urllib.parse import urlencode
import Monei
import uuid
app = Flask(__name__)
monei = Monei.MoneiClient(api_key="YOUR_API_KEY")
@app.post("/create-payment")
def create_payment():
body = request.get_json()
token_result = monei.pos_auth_token.create()
payment_id = str(uuid.uuid4())
db.payments.insert(id=payment_id, order_id=body["orderId"], status="PENDING")
params = urlencode({
"amount": body["amount"],
"auth_token": token_result.token,
"order_id": body["orderId"],
"callback_url": "https://merchant.com/webhooks/monei",
"complete_url": f"https://merchant.com/checkout/done?paymentId={payment_id}"
})
return jsonify({
"deepLink": f"monei-pay://accept-payment?{params}",
"paymentId": payment_id
})
<?php
require_once 'vendor/autoload.php';
use Monei\MoneiClient;
$monei = new MoneiClient('YOUR_API_KEY');
$body = json_decode(file_get_contents('php://input'), true);
$tokenResult = $monei->posAuthToken->create([]);
$paymentId = bin2hex(random_bytes(16));
// $db->payments->insert([...]) — persist for webhook + status lookup
$params = http_build_query([
'amount' => $body['amount'],
'auth_token' => $tokenResult->getToken(),
'order_id' => $body['orderId'],
'callback_url' => 'https://merchant.com/webhooks/monei',
'complete_url' => "https://merchant.com/checkout/done?paymentId={$paymentId}"
]);
echo json_encode([
'deepLink' => "monei-pay://accept-payment?{$params}",
'paymentId' => $paymentId
]);
?>
order_id for reconciliationPass 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.
Step 2: Open the Deep Link from the Browser
When the cashier clicks Charge in your web POS, fetch the deep link and navigate to it.
<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>
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 viawindow.top.location.href = ...(requires same-origin orallow-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.
<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 = ``;
}
// 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.
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
| Channel | Direction | Trust | Use for |
|---|---|---|---|
callback_url | Server → server | Trusted | Order fulfillment, settlement, business logic |
complete_url | Browser redirect | NOT trusted | UX 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
| Symptom | Cause | Fix |
|---|---|---|
| Safari does nothing on click | Navigation not inside a user gesture, or popup blocker | Trigger from a click / pointer event; avoid setTimeout / async-then-navigate |
| Browser shows "Cannot Open Page" | MONEI Pay not installed on this device | Install MONEI Pay from the App Store / Play Store on the device that's tapping the card |
| Webhook never fires | callback_url is http://, a private IP, or > 2048 chars | Use a public https:// URL. MONEI's DNS-pinned client also blocks RFC1918 / loopback / IMDS |
Webhook fires but my order_id is missing | Cashier hit an old version of MONEI Pay (pre-2.7.3) that didn't parse order_id | Make sure the device runs the latest MONEI Pay from the store |
| Two browser tabs both jump to MONEI Pay | Both opened the deep link in the same window group | Use one POS tab; deep link should be tied to a single in-flight payment |
complete_url opens but lost session | Safari opens it in a fresh tab without your cookies | Carry state in the URL (paymentId query) and confirm via your backend instead of relying on session |
User refreshes complete_url page | Idempotency on your side | Server-side /payment-status/{id} should be safe to call repeatedly |
Reference
- Full deep-link parameter list: iOS Manual Integration (the URL is identical across platforms)
- Trust model and
complete_urlquery params: Result Delivery - Webhook signature: Verify signature
- Server APIs used here: Create POS Auth Token, Get Payment