Hosted Payment Pages
The V3 Hosted Payment Page is a payment form hosted entirely by ArtsPay and Fat Zebra, embedded in your checkout as an <iframe>. The card number is entered and held entirely inside that iframe. Your server only ever handles a signed request URL and a signed response, never the PAN, which keeps most of your systems out of PCI DSS scope, under the simpler SAQ A self-assessment.
Pick a frontend and backend below and download a working example, already wired up and tested against the ArtsPay sandbox, to run alongside the steps.
Download example code, grab one of our pre-built hosted payment pages in HTML, React, Vue or Next.js, with Node.js, Python, PHP or Ruby as thier back-ends, select below or explore the ArtsPay Github.
Step 1: Build the signed request URL
There are two base URLs for interacting with the Gateway, depending on the environment required. In the ArtsPay GitHub and downloadable code, we will only reference the Sandbox environment.
| Environment | Base URL |
|---|---|
| Sandbox | https://paynow.pmnts-sandbox.io/v3 |
| Live | https://paynow.pmnts.io/v3 |
The request URL has the shape:
https://paynow.pmnts.io/v3/[username]/[reference]/[currency]/[amount]/[hash]| Param | Description |
|---|---|
username | Your ArtsPay merchant username |
reference | Your invoice/order reference |
currency | 3-letter ISO-4217 code, e.g. AUD |
amount | Decimal amount, e.g. 100.25 |
hash | HMAC-MD5 signature (see below) |
Hash calculation: concatenate reference:amount:currency, plus :hide_card_holder and/or :return_path if you're using those options, then sign with your shared secret.
const crypto = require('crypto')function buildVerificationHash(sharedSecret, { reference, amount, currency, hideCardHolder, returnPath }) { let parts = [reference, amount, currency] if (hideCardHolder) parts.push('true') if (returnPath) parts.push(returnPath) return crypto.createHmac('md5', sharedSecret).update(parts.join(':')).digest('hex')}const hash = buildVerificationHash(process.env.FZ_SHARED_SECRET, { reference: 'INV1121', amount: '100.25', currency: 'AUD',})Step 2: Embed the iframe
This guide uses the iframe + postMessage pattern: the frontend fetches the checkout URL from your backend, embeds it, and listens for a message event instead of navigating away or polling. The result then gets forwarded to your backend for verification (Step 3) before you trust it, the shared secret never reaches the browser.
<iframe id="payment-frame" title="ArtsPay Checkout" style="width:100%; height:600px; border:0;"></iframe><script> // Must match the environment used to build the checkout URL (sandbox // shown here; swap to https://paynow.pmnts.io for live). var PAYMENT_HOST = 'https://paynow.pmnts-sandbox.io'; // 1. Ask your backend for a signed checkout URL (see Step 1), then embed it. fetch('/api/checkout-url?amount=10.25') .then(function (res) { return res.json(); }) .then(function (data) { document.getElementById('payment-frame').src = data.url; }); // 2. Adapted from Fat Zebra's documented IFRAME/postMessage listener. window.addEventListener('message', function (event) { if (event.origin !== PAYMENT_HOST) return; var payload = event.data; if (typeof payload === 'string') { // Older browsers deliver a query-string style payload instead of an object. var pairs = payload.split('&'); payload = {}; for (var i = 0; i < pairs.length; i++) { var kv = pairs[i].split('='); payload[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || ''); } } if (!payload || typeof payload !== 'object' || !('message' in payload)) return; // payload.message can also be 'transaction.cancelled' if the customer backs out. if (payload.message !== 'transaction.complete') return; // 3. The response is untrusted until your backend confirms the signature -- // the shared secret needed to check it never reaches this page. fetch('/api/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload.data), }) .then(function (res) { return res.json(); }) .then(function (result) { console.log(result.verified ? 'Payment verified' : 'INVALID, do not trust this response'); }); });</script>There are commonly used display options (all appended as query params) that can be used to display your Hosted Payment to closer resemble your own integration; show_email, button_text, css / css_signature and more, all of which are unpacked in the documentation.
Step 3: Verify and handle the response
Fat Zebra returns the result either on-screen, via return_path redirect, or via postMessage (this guide's pattern, see Step 2). Always verify the response hash before trusting it — this runs in the POST /api/verify endpoint your Step 2 code calls, with the fields forwarded from the postMessage payload: { r, successful, amount, currency, id, token, v }.
Purchase response: sign response_code:successful:amount:currency:id:token and compare to v.
function verifyPurchaseResponse(sharedSecret, { responseCode, successful, amount, currency, id, token, verification }) { const expected = crypto .createHmac('md5', sharedSecret) .update(`${responseCode}:${successful}:${amount}:${currency}:${id}:${token}`) .digest('hex') return expected === verification}const verified = verifyPurchaseResponse(sharedSecret, { responseCode: r, successful, amount, currency, id, token, verification: v,})if (!verified) { // tampered or invalid, do not mark the order as paid}Tokenize-only response: sign response_code:token instead.
Test your integration
Use the standard test card numbers against the sandbox base URL. The behaviour of the hosted page in sandbox matches the API: the same cards trigger the same approve/decline outcomes.
| Card Number | Scenario | Testing |
|---|---|---|
4005 5500 0000 0001 | The card payment succeeds. | Fill out the payment form using the credit card number with any expiration and CVC. |
4557 0123 4567 8902 | The card is marked as declined with a declined code. | Fill out the payment form using the credit card number with any expiration and CVC. |
4000 0000 0000 1091 | The card payment requires liability shift 3DS/SCA authentication. | Fill out the payment form using the credit card number with any expiration and CVC. |
FAQs
What is a Hosted Payment Page
The V3 Hosted Payment Page is a payment form hosted entirely by ArtsPay/Fat Zebra. You can use it three ways:
- Standard redirect: send the customer's browser to the hosted URL
- IFrame embed: embed the same page inside an <iframe> on your own checkout
- Accounting platform page: for Xero or Saasu invoice payments
This guide covers the iframe method, the most common choice for a checkout page.
Why does it matter?
The card number is entered and held entirely inside the iframe. Your server only ever handles a signed request URL and a signed response, never the PAN. That keeps most of your systems out of PCI DSS scope, under the simpler SAQ A self-assessment.
Where is my username and secret?
Contact ArtsPay support to confirm the shared secret used for hashing, it's separate from your API token.
What's the difference between this and tokenize-only mode?
Setting tokenize_only=true stores the card for later use without charging it, which is useful for save-a-card flows. See the Tokenisation guide for charging a stored token afterwards.
Can I add Apple Pay or Google Pay to the iframe?
Apple Pay is added by following the Apple Pay guide and enabling it on your account first. The button then appears inside the hosted page automatically. Google Pay just needs activating in Payment Methods in the Merchant Dashboard.
Can I add 3D Secure to this integration?
Not to the plain iframe/URL integration described here. 3DS2 on a hosted page requires switching to the fatzebra.js-driven integration; see the 3D Secure guide.
How do I restrict accepted card schemes?
Use the cards parameter, e.g. cards=VISA,AMEX to allow only those, or cards=!AMEX,JCB to block them. Remember it must be included in the verification hash if present.
Can I style the hosted page to match my checkout?
Yes, via css (an HTTPS URL to your stylesheet) and css_signature (an HMAC-MD5 of that URL), plus logo_url for your logo. The CSS is scrubbed of unsafe rules and cached for 5 minutes.