# 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[​](#how-it-works "Direct link to How It Works")

<!-- -->

1. The browser asks **your backend** to start a payment.
2. Your backend mints a [POS auth token](https://docs.monei.com/monei-pay/app-integration/getting-started/.md#step-2-generate-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](https://docs.monei.com/apis/rest/payments-get/.md) before fulfilling.** See [Result Delivery](https://docs.monei.com/monei-pay/app-integration/getting-started/.md#result-delivery).

## Prerequisites[​](#prerequisites "Direct link to Prerequisites")

* [MONEI account](https://dashboard.monei.com/register) 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[​](#step-1-server-side-endpoint "Direct link to 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

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

  });

});
```

server.py

```
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

    })
```

server.php

```
<?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 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](https://docs.monei.com/monei-pay/app-integration/getting-started/.md#reconciliation-with-order_id).

## Step 2: Open the Deep Link from the Browser[​](#step-2-open-the-deep-link-from-the-browser "Direct link to 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.

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-quirks "Direct link to 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`[​](#step-3-handle-the-return-on-complete_url "Direct link to 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](https://docs.monei.com/apis/rest/payments-get/.md).

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](https://docs.monei.com/monei-pay/app-integration/getting-started/.md#result-query-parameters-on-complete_url-open).

## Step 4: Receive and Verify the Webhook[​](#step-4-receive-and-verify-the-webhook "Direct link to 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](https://docs.monei.com/guides/verify-signature/.md).

## Trust Model Recap[​](#trust-model-recap "Direct link to 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[​](#common-issues "Direct link to 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[​](#reference "Direct link to Reference")

* Full deep-link parameter list: [iOS Manual Integration](https://docs.monei.com/monei-pay/app-integration/ios/.md#url-parameters) (the URL is identical across platforms)
* Trust model and `complete_url` query params: [Result Delivery](https://docs.monei.com/monei-pay/app-integration/getting-started/.md#result-delivery)
* Webhook signature: [Verify signature](https://docs.monei.com/guides/verify-signature/.md)
* Server APIs used here: [Create POS Auth Token](https://docs.monei.com/apis/rest/pos-auth-token-create/.md), [Get Payment](https://docs.monei.com/apis/rest/payments-get/.md)
