'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.'); });