Apple Pay on the Web — sample merchant site
A runnable merchant integration for Apple Pay on your own website, matching section 3.3.1 of the integration guide. Four files, no dependencies, Node 18+.
Web differs from in-app Apple Pay by one step: before the sheet will release a payment token, your page must call completeMerchantValidation() with a session obtained from Apple over mutual TLS. Until that happens onpaymentauthorized never fires and there is nothing to charge — if your sheet opens and closes with no token, that is almost always why. TruePay makes that call for you with your Merchant Identity certificate; the payment itself then goes to the same wallet-pay endpoint an iOS app uses.
Apple Pay will not run on localhost. It needs HTTPS on a real domain that is registered and verified with Apple against your own merchant identifier, opened in Safari. The README says exactly what to set up.
⬇ Download the whole sample (.zip) or the native iOS sample
README.md Download
# Apple Pay on the Web — TruePay sample
A complete, runnable merchant integration for Apple Pay on your own website, matching
**§3.3.1** of the TruePay API Integration Guide.
Four files, no dependencies, Node 18+.
```
server.js your backend — the two endpoints that matter
public/index.html the checkout page
public/checkout.js the Apple Pay JS: onvalidatemerchant + onpaymentauthorized
config.js your API key, your domain — server-side only
```
## The one thing that makes web different from in-app
Apple Pay in a browser needs a step in-app Apple Pay does not: **merchant validation**.
Before the sheet will release a payment token, your page must call
`completeMerchantValidation()` with a session obtained from Apple over mutual TLS.
Until that happens `onpaymentauthorized` never fires and there is no token to send.
**If your sheet opens and then closes with nothing, this is almost always why.**
TruePay makes that call for you, with your Apple merchant identifier and your Merchant
Identity certificate. Neither leaves TruePay, and nothing about them appears in this
sample. The payment itself then goes to the *same* endpoint an iOS app uses.
```
browser your backend TruePay Apple
─────── ──────────── ─────── ─────
onvalidatemerchant ──────> /apple-pay/validate ──────> validate-merchant ──> mTLS
completeMerchantValidation <───────── Apple's session, verbatim ──────────────┘
onpaymentauthorized ──────> /apple-pay/pay ──────> wallet-pay ──> decrypt + charge
completePayment <───────── SUCCESSFUL / FAILED ─────────────────────┘
```
## Before you start
You need three things from Apple and TruePay. The sample cannot substitute for any of them.
**1. Your own Apple Pay merchant identifier, with your domain registered and verified.**
In your Apple Developer account, register the exact host serving the checkout page and
host the verification file Apple gives you at
`https://<your-domain>/.well-known/apple-developer-merchantid-domain-association`.
Drop Apple's file into this sample's `.well-known/` folder and `server.js` will serve it.
Apple enforces this. TruePay does not check domains and cannot register one for you.
**2. A Merchant Identity certificate**, set up in the merchant portal under
**Apple Pay → Merchant Identity**: generate the CSR, enroll it with Apple, upload the
`.cer`. TruePay holds the key and makes the call — it is never downloaded.
Without it the validate endpoint returns `NO_IDENTITY_CERT`.
**3. A Payment Processing certificate.** Reuse the one your iOS app uses, or enroll a
separate one for the web and add it in the portal under *Additional Payment Processing
certificates*. TruePay matches each payment to the right certificate by the token's
public key hash, so both keep working at once.
## Running it
```bash
cp .env.example .env # then fill it in
node server.js
```
`APPLE_PAY_DOMAIN` must be the fully-qualified host serving the page — no scheme, no
path, no port — and it must match the address bar **exactly**. `www.example.om` and
`example.om` are different domains to Apple.
### localhost will not work
Apple Pay JS refuses to run on an insecure origin, and unlike service workers there is no
localhost exemption. To try this you need the page served over **HTTPS on the real
registered domain**, opened in **Safari**, on a device with a card in Wallet.
A tunnel (ngrok, Cloudflare Tunnel) works only if you register *that* hostname with Apple
and point `APPLE_PAY_DOMAIN` at it.
## What the sample does deliberately
**The amount lives on the server.** A page that posts its own amount can be edited in the
console, and the customer pays 0.001 for the laptop. `server.js` keeps a small cart and
charges its own figure; the page only displays it.
**Your API key never reaches the browser.** That is the only reason both endpoints exist.
Anyone with your key can charge your account.
**The `domain` sent to TruePay is server configuration, not a request field.** Letting the
page choose it would be both useless — Apple only issues a session for a domain registered
to your merchant identifier — and unsafe.
**`validationURL` is pinned to `apple.com` before being forwarded.** TruePay checks this
too, but a server that will POST wherever a browser tells it to is worth not building.
**The outcome is confirmed server-to-server.** After `wallet-pay`, the sample calls
`POST /api/v1/transactions/inquiry`. The browser's word is not proof that money moved.
**The whole token object is sent**, exactly as `event.payment.token` gives it — not just
`paymentData`, not a JSON string of part of it.
## When it does not work
| What you see | What it usually is |
|---|---|
| No Apple Pay button at all | Not Safari, no card in Wallet, or not HTTPS. The page says which. |
| The sheet opens, then closes with no token | Merchant validation failed. Check your server log and the response from step 1. |
| `NO_IDENTITY_CERT` | The Merchant Identity certificate is not set up in the merchant portal. |
| `NO_MERCHANT_ID` | Your Apple merchant identifier is not set in the merchant portal. |
| Apple returns a non-2xx to validation | Nearly always the domain: not registered against *your* merchant identifier, or `APPLE_PAY_DOMAIN` does not match the address bar. |
| `DECRYPT_FAILED` on the payment | The token was encrypted to a Payment Processing certificate TruePay does not hold — usually a separate web certificate that was never added. The error carries the token's public key hash; compare it with the hashes under *Active certificates* in the portal. |
| `MERCHANT_CAPABILITY_DISABLED` | Apple Pay is not enabled on your account. Ask OMPAY. |
## Don't want to build this?
TruePay's bank-hosted pay page already has Apple Pay in the browser, on TruePay's own
domain — you redirect and TruePay handles validation and the sheet. No certificates, no
merchant identifier, no domain registration. Ask OMPAY to enable bank-hosted checkout.
## Going live
Point `TRUEPAY_BASE_URL` at the production API, use your production API key, and register
your production domain with Apple. Everything else is the same.
server.js Download
'use strict';
/**
* Apple Pay on the Web — the merchant side.
*
* This is the backend half of the flow in §3.3.1 of the TruePay API Integration Guide.
* Apple Pay in a browser needs ONE step that in-app Apple Pay does not: before the sheet
* will release a payment token, the page must call completeMerchantValidation() with a
* session obtained from Apple over mutual TLS. Until that happens onpaymentauthorized
* never fires and there is nothing to charge — if your sheet opens and closes with no
* token, that is almost always why.
*
* TruePay performs that mutual-TLS call for you, using your Apple merchant identifier
* and your Merchant Identity certificate. Neither leaves TruePay, and nothing about them
* is in this sample.
*
* TWO ENDPOINTS, and both exist for the same reason: your API key must never reach the
* browser. Anyone with it can charge your account.
*
* POST /apple-pay/validate page's validationURL -> TruePay -> Apple's session
* POST /apple-pay/pay page's payment token -> TruePay -> a charged payment
*
* Zero dependencies — Node 18+ only, so you can read the whole thing.
*/
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const { config, missing } = require('./config');
// ── the "cart" ───────────────────────────────────────────────────────────────
// The amount lives on the SERVER. A sample that lets the page post its own amount
// teaches a hole: the customer edits it in the console and pays 0.001 for the laptop.
// Your real version looks this up from the order the customer is checking out.
const CART = {
reference: () => `WEB-${Date.now()}`,
lines: [
{ label: 'Omani dates, 1kg', amount: 8.500 },
{ label: 'Delivery', amount: 1.500 },
],
total() { return round3(this.lines.reduce((t, l) => t + l.amount, 0)); },
};
const round3 = (n) => Math.round((Number(n) + Number.EPSILON) * 1000) / 1000;
// ── talking to TruePay ───────────────────────────────────────────────────────
/** POST JSON to the TruePay API with this merchant's credentials. Server-side only. */
function truepay(pathname, body) {
const url = new URL(pathname, config.baseUrl);
const payload = JSON.stringify(body);
const agent = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
const req = agent.request({
method: 'POST',
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'OMPAY-API-Key': config.apiKey,
'OMPAY-API-Secret': config.apiSecret,
},
timeout: 25000,
}, (res) => {
let data = '';
res.on('data', (c) => { data += c; });
res.on('end', () => {
let parsed = null;
try { parsed = JSON.parse(data); } catch (_) { /* non-JSON, keep the text */ }
resolve({ status: res.statusCode, body: parsed, text: data });
});
});
req.on('error', (e) => resolve({ status: 0, body: null, text: e.message }));
req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: null, text: 'timeout' }); });
req.write(payload);
req.end();
});
}
// ── plumbing ─────────────────────────────────────────────────────────────────
function json(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
function readBody(req, limit = 256 * 1024) {
return new Promise((resolve, reject) => {
let data = ''; let size = 0;
req.on('data', (c) => {
size += c.length;
// An Apple Pay token is a few KB. Anything much larger is not one.
if (size > limit) { req.destroy(); reject(new Error('body too large')); return; }
data += c;
});
req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
req.on('error', reject);
});
}
const STATIC = {
'/': ['public/index.html', 'text/html; charset=utf-8'],
'/index.html': ['public/index.html', 'text/html; charset=utf-8'],
'/checkout.js': ['public/checkout.js', 'text/javascript; charset=utf-8'],
};
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (req.method === 'GET' && STATIC[url.pathname]) {
const [file, type] = STATIC[url.pathname];
return fs.readFile(path.join(__dirname, file), (err, buf) => {
if (err) { res.writeHead(404); return res.end('Not found'); }
res.writeHead(200, { 'Content-Type': type });
return res.end(buf);
});
}
// Apple fetches this to verify you control the domain. Serve the file Apple gave you,
// byte for byte, as text/plain, over HTTPS, at exactly this path. If you already proxy
// /.well-known elsewhere, serve it there instead — it does not have to come from here.
if (req.method === 'GET' && url.pathname === '/.well-known/apple-developer-merchantid-domain-association') {
return fs.readFile(path.join(__dirname, '.well-known', 'apple-developer-merchantid-domain-association'), (err, buf) => {
if (err) { res.writeHead(404); return res.end('Not found'); }
res.writeHead(200, { 'Content-Type': 'text/plain' });
return res.end(buf);
});
}
// What the page needs to draw the sheet. Deliberately no credentials and no domain —
// the page has no use for either, and the amount is the server's to state.
if (req.method === 'GET' && url.pathname === '/checkout/config') {
return json(res, 200, {
displayName: config.displayName,
countryCode: config.countryCode,
currency: config.currency,
lines: CART.lines,
total: CART.total(),
ready: missing().length === 0,
missing: missing(),
});
}
// ── STEP 1 — merchant validation ──────────────────────────────────────────
// Safari hands the page a one-time validationURL. The page cannot call it: the call
// needs a client certificate. It comes here, we ask TruePay, and Apple's session goes
// back to the page untouched.
if (req.method === 'POST' && url.pathname === '/apple-pay/validate') {
let body;
try { body = await readBody(req); } catch (_) { return json(res, 400, { error: 'BAD_REQUEST' }); }
const validationURL = String(body.validationURL || '');
// Pin it to Apple before forwarding. TruePay checks this too, but a server that will
// POST wherever a browser tells it to is a hole worth not having in the first place.
let u;
try { u = new URL(validationURL); } catch (_) { return json(res, 400, { error: 'BAD_VALIDATION_URL' }); }
if (u.protocol !== 'https:' || !/(^|\.)apple\.com$/i.test(u.hostname)) {
return json(res, 400, { error: 'BAD_VALIDATION_URL' });
}
// `domain` is OUR configuration, never the browser's. It must be the host serving the
// page, and Apple only issues a session for one registered against our merchant
// identifier — so letting the page choose it would be both useless and unsafe.
const r = await truepay('/api/v1/transactions/apple-pay/validate-merchant', {
validationURL,
domain: config.domain,
display_name: config.displayName,
});
if (r.status === 200 && r.body) {
// Apple's merchant session, verbatim. Do not reshape it, do not cache it — it is
// opaque and short-lived, and completeMerchantValidation wants it exactly as given.
return json(res, 200, r.body);
}
console.error('[apple-pay] merchant validation failed', r.status, r.body || r.text);
return json(res, 502, {
error: (r.body && r.body.error) || 'VALIDATION_FAILED',
message: (r.body && r.body.message) || 'Could not validate with Apple.',
});
}
// ── STEP 2 — the payment ──────────────────────────────────────────────────
// Identical to what an iOS app's backend does. The token object goes to the SAME
// endpoint, and TruePay decrypts it with your Payment Processing certificate.
if (req.method === 'POST' && url.pathname === '/apple-pay/pay') {
let body;
try { body = await readBody(req); } catch (_) { return json(res, 400, { error: 'BAD_REQUEST' }); }
const token = body.token;
if (!token || typeof token !== 'object' || !token.paymentData) {
// The whole event.payment.token object — not just paymentData, not a JSON string
// of part of it.
return json(res, 400, { error: 'BAD_TOKEN', message: 'Send the whole event.payment.token object.' });
}
const reference = CART.reference();
const r = await truepay('/api/v1/transactions/wallet-pay', {
amount: CART.total(), // the SERVER's amount, never the page's
currency: config.currency,
reference_number: reference,
description: 'Apple Pay web sample',
wallet: { type: 'APPLE_PAY', payment_token: token },
});
const data = (r.body && r.body.data) || {};
const ok = r.status >= 200 && r.status < 300 && data.status === 'SUCCESSFUL';
if (!ok) {
console.error('[apple-pay] wallet-pay did not succeed', r.status, r.body || r.text);
}
// Confirm server-to-server before you treat an order as paid (§3.9). The browser's
// word is not proof of anything — and on a redirect flow it never even reaches you.
let confirmed = data.status || null;
if (data.transaction_id) {
const q = await truepay('/api/v1/transactions/inquiry', { transaction_id: data.transaction_id });
const qd = (q.body && q.body.data) || {};
if (qd.status) confirmed = qd.status;
}
return json(res, ok ? 200 : 402, {
success: confirmed === 'SUCCESSFUL',
status: confirmed,
reference_number: data.reference_number || reference,
transaction_id: data.transaction_id || null,
// Show the customer your own wording. Never surface a raw acquirer message.
message: confirmed === 'SUCCESSFUL' ? 'Payment received.' : 'The payment was not completed.',
});
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
});
server.listen(config.port, () => {
const gaps = missing();
console.log(`Apple Pay web sample listening on http://localhost:${config.port}`);
if (gaps.length) {
console.log('');
console.log(` NOT READY — set ${gaps.join(', ')} in .env (copy .env.example).`);
}
console.log('');
console.log(' Apple Pay will not appear unless ALL of these are true:');
console.log(' - the page is served over HTTPS, on a real domain, in Safari');
console.log(` - that domain is exactly ${config.domain}, registered + verified with Apple`);
console.log(' - the device has a card in Wallet');
console.log(' localhost will not work. See README.md.');
});
config.js Download
'use strict';
/**
* Configuration for the Apple Pay on the Web sample.
*
* Everything secret lives HERE, on the server. Nothing in this file is ever sent to the
* browser — the checkout page gets only an amount and a currency. Your API key in page
* source would let anyone charge your account.
*
* Copy .env.example to .env and fill it in, or set these in the environment.
*/
const fs = require('fs');
const path = require('path');
(function loadEnv() {
const envPath = path.join(__dirname, '.env');
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
if (!m) continue;
if (process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
})();
const config = {
// TruePay's API. Sandbox while you build; switch to the production host to go live.
baseUrl: process.env.TRUEPAY_BASE_URL || 'https://api.sandbox.truepay.ompay.om',
// Your merchant API key and secret, from the merchant portal. Server-side only.
apiKey: process.env.TRUEPAY_API_KEY || '',
apiSecret: process.env.TRUEPAY_API_SECRET || '',
// The fully-qualified host serving your checkout page — no scheme, no path, no port.
// This is what TruePay sends to Apple as initiativeContext, and Apple will only issue
// a session for a domain already registered and verified against YOUR Apple merchant
// identifier. It must match the address bar exactly: if customers reach you at
// www.example.om, "example.om" here is rejected.
domain: process.env.APPLE_PAY_DOMAIN || 'shop.example.om',
// What the Apple Pay sheet shows as the payee. Cosmetic.
displayName: process.env.APPLE_PAY_DISPLAY_NAME || 'Example Shop',
// Apple Pay JS needs a country and the currency of the charge. TruePay settles in OMR.
countryCode: process.env.APPLE_PAY_COUNTRY || 'OM',
currency: process.env.CURRENCY || 'OMR',
port: parseInt(process.env.PORT, 10) || 4300,
};
function missing() {
const out = [];
if (!config.apiKey) out.push('TRUEPAY_API_KEY');
if (!config.apiSecret) out.push('TRUEPAY_API_SECRET');
if (!config.domain || config.domain === 'shop.example.om') out.push('APPLE_PAY_DOMAIN');
return out;
}
module.exports = { config, missing };
public/index.html Download
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Checkout — Apple Pay sample</title>
<style>
:root {
--ink: #0b2545; --muted: #5b6b7c; --line: #e6ebf2; --bg: #eef2f7;
--ok-bg: #f0f7f2; --ok-line: #cfe7d8; --ok-ink: #15803d;
--err-bg: #fdf2f2; --err-line: #f6d4d4; --err-ink: #b42318;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 24px; background: var(--bg); color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.card {
background: #fff; border: 1px solid var(--line); border-radius: 16px;
max-width: 420px; width: 100%; padding: 28px 30px 26px;
box-shadow: 0 10px 30px rgba(11, 37, 69, .08);
}
h1 { font-size: 19px; margin: 0 0 4px; }
.sub { color: var(--muted); font-size: 13px; margin: 0 0 20px; }
ul { list-style: none; margin: 0 0 4px; padding: 0; }
li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; font-size: 14px; border-bottom: 1px solid var(--line); }
li span:last-child { font-variant-numeric: tabular-nums; }
.total { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 0 20px; font-weight: 700; }
.total .amt { font-size: 22px; font-variant-numeric: tabular-nums; }
.cur { font-size: 13px; color: var(--muted); font-weight: 600; margin-left: 4px; }
/* Apple require their own button styling. -apple-pay-button is the supported way and
degrades to nothing on browsers that do not know it — which is why the button stays
hidden until the script has confirmed Apple Pay is actually available. */
apple-pay-button {
--apple-pay-button-width: 100%;
--apple-pay-button-height: 44px;
--apple-pay-button-border-radius: 9px;
display: block; width: 100%;
}
.fallback-button {
-webkit-appearance: -apple-pay-button;
-apple-pay-button-type: buy;
-apple-pay-button-style: black;
width: 100%; height: 44px; border-radius: 9px; border: 0; cursor: pointer;
}
.status { margin-top: 16px; border-radius: 10px; padding: 11px 14px; font-size: 13px; line-height: 20px; }
.status.ok { background: var(--ok-bg); border: 1px solid var(--ok-line); color: var(--ok-ink); }
.status.error { background: var(--err-bg); border: 1px solid var(--err-line); color: var(--err-ink); }
.status.muted { background: #f6f8fa; border: 1px solid var(--line); color: var(--muted); }
.note { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; line-height: 18px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
</style>
</head>
<body>
<div class="card">
<h1>Your order</h1>
<p class="sub">Apple Pay on the web — TruePay sample</p>
<ul id="items"></ul>
<div class="total"><span>Total</span><span><span class="amt" id="total">0.000</span><span class="cur">OMR</span></span></div>
<!-- Hidden until the script confirms Apple Pay is usable here. Showing a dead Apple
Pay button is worse than showing none: the customer taps it and nothing happens. -->
<button id="apple-pay-button" class="fallback-button" hidden aria-label="Buy with Apple Pay"></button>
<div id="status" class="status muted" hidden></div>
<p class="note">
The amount is decided by the server, not this page. The payment token goes to your own
backend, which calls <code>POST /api/v1/transactions/wallet-pay</code> — your API key
never reaches the browser.
</p>
</div>
<script src="/checkout.js"></script>
</body>
</html>
public/checkout.js Download
'use strict';
/**
* Apple Pay on the Web — the browser half.
*
* The two handlers below are the whole integration. Everything else here is plumbing to
* make the sample readable.
*
* onvalidatemerchant -> your backend -> TruePay -> Apple -> completeMerchantValidation
* onpaymentauthorized -> your backend -> TruePay -> completePayment
*
* The first one is what in-app Apple Pay does not have, and skipping it is why a sheet
* opens and then closes with no token.
*
* Nothing secret is here, and nothing secret can be: this file is public. The amount is
* read from the server and the server charges its own figure — a page that posts its own
* amount can be edited in the console.
*/
(function () {
const $ = (id) => document.getElementById(id);
const statusEl = () => $('status');
function say(kind, text) {
const el = statusEl();
el.className = `status ${kind}`;
el.textContent = text;
el.hidden = false;
}
/**
* Apple Pay is available only when ALL of this holds, and the reasons are worth
* distinguishing because each has a different fix.
*/
function availability() {
if (!window.ApplePaySession) {
return { ok: false, why: 'This browser does not support Apple Pay. Open the page in Safari on an Apple device.' };
}
if (!ApplePaySession.supportsVersion(3)) {
return { ok: false, why: 'This device has an Apple Pay version older than 3.' };
}
if (!ApplePaySession.canMakePayments()) {
return { ok: false, why: 'Apple Pay is not set up on this device — add a card to Wallet.' };
}
if (location.protocol !== 'https:') {
// Apple Pay JS refuses to run on an insecure origin, and localhost is not an
// exception the way it is for service workers.
return { ok: false, why: 'Apple Pay needs HTTPS on a real domain. localhost will not work.' };
}
return { ok: true };
}
async function post(path, body) {
const r = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await r.json().catch(() => null);
if (!r.ok) {
const err = new Error((data && (data.message || data.error)) || `Request failed (${r.status})`);
err.payload = data;
throw err;
}
return data;
}
function startPayment(cfg) {
const request = {
countryCode: cfg.countryCode,
currencyCode: cfg.currency,
merchantCapabilities: ['supports3DS'], // required; 3DS is the wallet's own attestation
supportedNetworks: ['visa', 'masterCard'],
total: { label: cfg.displayName, amount: cfg.total.toFixed(3), type: 'final' },
lineItems: cfg.lines.map((l) => ({ label: l.label, amount: l.amount.toFixed(3) })),
};
const session = new ApplePaySession(3, request);
// ── STEP 1 — merchant validation ───────────────────────────────────────
// Safari gives us a one-time validationURL. It cannot be called from here: the call
// needs a client certificate. Hand it to your own backend and pass the answer back
// UNCHANGED — it is opaque and short-lived.
session.onvalidatemerchant = async (event) => {
try {
const merchantSession = await post('/apple-pay/validate', { validationURL: event.validationURL });
session.completeMerchantValidation(merchantSession);
} catch (e) {
// Abort rather than leave the sheet spinning. Without a session the sheet can
// never produce a token, so there is nothing to wait for.
console.error('[apple-pay] merchant validation failed', e, e.payload);
say('error', `Could not start Apple Pay: ${e.message}`);
session.abort();
}
};
// ── STEP 2 — the payment ───────────────────────────────────────────────
// event.payment.token is the same PKPaymentToken an iOS app produces, and it goes to
// the same TruePay endpoint. Send the WHOLE object.
session.onpaymentauthorized = async (event) => {
try {
const result = await post('/apple-pay/pay', { token: event.payment.token });
// Tell the sheet before you tell the customer — it is waiting on this, and a slow
// page here leaves them looking at a spinner on their own device.
session.completePayment(result.success ? ApplePaySession.STATUS_SUCCESS : ApplePaySession.STATUS_FAILURE);
if (result.success) {
say('ok', `Paid. Reference ${result.reference_number}.`);
} else {
say('error', result.message || 'The payment was not completed.');
}
} catch (e) {
session.completePayment(ApplePaySession.STATUS_FAILURE);
console.error('[apple-pay] payment failed', e, e.payload);
say('error', e.message);
}
};
session.oncancel = () => { say('muted', 'Payment cancelled.'); };
// Must be called from the user gesture that created the session, which is why this
// whole function runs inside the button's click handler.
session.begin();
}
async function init() {
let cfg;
try {
cfg = await (await fetch('/checkout/config')).json();
} catch (e) {
say('error', 'Could not load the checkout.');
return;
}
$('total').textContent = cfg.total.toFixed(3);
$('items').innerHTML = cfg.lines
.map((l) => `<li><span>${l.label}</span><span>${l.amount.toFixed(3)}</span></li>`)
.join('');
if (!cfg.ready) {
say('error', `The server is not configured yet — set ${cfg.missing.join(', ')} in .env.`);
return;
}
const avail = availability();
if (!avail.ok) {
say('muted', avail.why);
return;
}
const btn = $('apple-pay-button');
btn.hidden = false;
btn.addEventListener('click', () => {
statusEl().hidden = true;
startPayment(cfg);
});
}
document.addEventListener('DOMContentLoaded', init);
})();
.env.example Download
# TruePay API. Sandbox while you build.
TRUEPAY_BASE_URL=https://api.sandbox.truepay.ompay.om
TRUEPAY_API_KEY=your-merchant-api-key
TRUEPAY_API_SECRET=your-merchant-api-secret
# The fully-qualified host serving your checkout page. No scheme, no path, no port.
# Must be registered and verified with Apple against YOUR merchant identifier, and must
# match the address bar exactly (www.example.om is a different domain from example.om).
APPLE_PAY_DOMAIN=shop.example.om
# Shown on the Apple Pay sheet.
APPLE_PAY_DISPLAY_NAME=Example Shop
APPLE_PAY_COUNTRY=OM
CURRENCY=OMR
PORT=4300
.well-known/README.txt Download
Put Apple's domain-verification file here, named exactly:
apple-developer-merchantid-domain-association
(no extension). You get it from your Apple Developer account when you register the
domain against your Apple Pay merchant identifier. server.js serves it at
/.well-known/apple-developer-merchantid-domain-association as text/plain.
Serve it byte for byte — do not reformat it, and do not add a trailing newline that
was not in Apple's file.
If your infrastructure already handles /.well-known, serve it there instead. It does
not have to come from this app; it only has to be reachable over HTTPS at that exact
path on the domain you registered.
package.json Download
{
"name": "truepay-apple-pay-web-demo",
"version": "1.0.0",
"description": "Apple Pay on the Web — a runnable TruePay merchant integration (API Integration Guide §3.3.1).",
"private": true,
"engines": { "node": ">=18" },
"scripts": { "start": "node server.js" }
}