// AntPay hosted checkout - everything your server needs. Node.js 18 or newer, no dependencies. // Keep the three values below on your server. Never put them in a browser or a mobile app. // [setup] const crypto = require('crypto'); const http = require('http'); const API_BASE = process.env.ANTPAY_API_BASE || 'https://api.antpay.network'; const API_KEY = process.env.ANTPAY_API_KEY; // your payment key const POSTBACK_SECRET = process.env.ANTPAY_POSTBACK_SECRET; // issued with your account, 64 characters const SIGNING_SECRET = process.env.ANTPAY_SIGNING_SECRET || ''; // only if request signing is on for your account // A reference you can always read back: letters, digits, dot, underscore, dash; 1 to 120 characters; no "..". const REFERENCE = /^(?!.*\.\.)[A-Za-z0-9._-]{1,120}$/; // Wait at least 80 seconds for an answer. The API can take up to about 75 s on a create; giving up earlier and // sending the create again while the first is still running is how one order ends up with two checkouts. const TIMEOUT_MS = 80_000; // [/setup] // [sign] // Request signing (only if it is switched on for your account). // canonical = "{timestamp}.{METHOD}.{path with query}.{body exactly as sent}" function signRequest(method, path, body = '', timestamp = Math.floor(Date.now() / 1000).toString()) { const canonical = `${timestamp}.${method.toUpperCase()}.${path}.${body}`; const signature = crypto.createHmac('sha256', SIGNING_SECRET) // the secret is used as the 64-character STRING .update(canonical, 'utf8') .digest('hex'); return { 'X-Timestamp': timestamp, 'X-Signature': signature }; } // [/sign] // [call] async function call(method, path, payload) { const body = payload === undefined ? '' : JSON.stringify(payload); // serialise ONCE; sign and send this string const headers = { 'X-Api-Key': API_KEY }; if (body) headers['Content-Type'] = 'application/json'; if (SIGNING_SECRET) Object.assign(headers, signRequest(method, path, body)); const res = await fetch(API_BASE + path, { method, headers, body: body || undefined, signal: AbortSignal.timeout(TIMEOUT_MS) }); const json = await res.json().catch(() => null); return { http: res.status, json, retryAfter: res.headers.get('retry-after') }; } // [/call] // [create] // Step 1: create the checkout on your server, store the checkoutId, redirect the customer. async function createCheckout(order) { if (!REFERENCE.test(order.id)) throw new Error('reference: use letters, digits, ".", "_" and "-" only (1 to 120 characters)'); const { http: status, json } = await call('POST', '/api/v1/checkouts', { reference: order.id, // your order id, unique per API key amount: order.total, // whole rupees, minimum 300 paymentMethod: 'qr', paymentType: 'onetime', returnUrl: `https://shop.example.com/orders/${encodeURIComponent(order.id)}`, }); if (status === 200 && json && json.success) { return { checkoutId: json.checkoutId, checkoutUrl: json.checkoutUrl, expiresAt: json.expiresAt }; } // 4xx: nothing was created (a duplicate reference already exists: look it up). // 5xx or no answer: the checkout may exist. Ask by reference before you retry. const error = new Error(`checkout not created (HTTP ${status})`); error.status = status; error.body = json; throw error; } // [/create] // [status] // Ask for the payment by YOUR reference. Use it after an unclear create, and as a fallback to the postback. // Returns null ONLY when the API itself says there is no such payment. Any other 404 means the address was refused // (a wrong path, or a reference with characters that cannot travel in a URL): that is an error, not "no payment". async function getByReference(reference) { const { http: status, json } = await call('GET', '/api/v1/transactions/' + encodeURIComponent(reference)); if (status === 200 && json && json.success) return json.transaction; // .status is pending | completed | failed if (status === 404 && json && json.error === 'Transaction not found') return null; throw new Error(`status call failed (HTTP ${status}${json && json.error ? ', ' + JSON.stringify(json.error) : ''})`); } async function getCheckout(checkoutId) { const { http: status, json } = await call('GET', '/api/v1/checkouts/' + encodeURIComponent(checkoutId)); if (status === 200 && json && json.success) return json.checkout; if (status === 404 && json && json.error === 'Checkout session not found') return null; throw new Error(`status call failed (HTTP ${status}${json && json.error ? ', ' + JSON.stringify(json.error) : ''})`); } // [/status] // [verify] // A postback is genuine when X-Mint-Signature equals "sha256=" + HMAC-SHA256(raw body, your postback secret). // rawBody must be the bytes exactly as received (a Buffer), not a re-encoded object. function verifyPostback(rawBody, signatureHeader, secret = POSTBACK_SECRET) { if (!secret || typeof signatureHeader !== 'string') return false; const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const got = Buffer.from(signatureHeader); const want = Buffer.from(expected); return got.length === want.length && crypto.timingSafeEqual(got, want); } // [/verify] // [postback] // Step 4: receive the postback. Verify, record once, answer 2xx within 10 seconds. // onResult(payload) is YOUR function: it must be safe to call twice with the same transactionId + status. const MAX_POSTBACK_BYTES = 1024 * 1024; // a postback is a few kB; refuse anything huge function postbackServer(onResult) { return http.createServer((req, res) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > MAX_POSTBACK_BYTES) { res.writeHead(413).end(); req.destroy(); return; } chunks.push(c); }); req.on('end', async () => { if (size > MAX_POSTBACK_BYTES) return; const raw = Buffer.concat(chunks); if (!verifyPostback(raw, req.headers['x-mint-signature'])) { res.writeHead(401).end(); // not from AntPay, or the body was changed return; } const p = JSON.parse(raw.toString('utf8')); try { // p.status is "completed" or "failed" (never "pending"). Find your order by p.checkoutId: the id you // stored when you created the checkout. Do not match on p.reference alone. await onResult(p); res.writeHead(200).end('ok'); } catch (e) { res.writeHead(500).end(); // we try again: 3 attempts in total, 5 s apart } }); }); } // [/postback] module.exports = { signRequest, call, createCheckout, getByReference, getCheckout, verifyPostback, postbackServer, REFERENCE };