Skip to main content
Version: 0.0.1

Full Worked Example

Full Worked Example​

Every file below is the exact source that runs in the live sandbox — not a transcription of it. Open the sandbox to run this same code against the ZenPay sandbox environment and watch the callback arrive.

The split follows the trust model: the backend holds the credentials and signs; the browser only launches what it was handed.

Credentials

The sandbox reads its merchant credentials from WCDEMO_* environment variables — that prefix is specific to the demo. Use whatever names your own configuration uses; what matters is that they are read server-side and never reach the browser.

Backend — sign the request​

Mints the timestamp, merchantUniquePaymentId and SHA3-512 fingerprint, then returns a payload the browser can safely receive. username and password are used only to compute the hash and are never part of the response.

server/routes/authorise.v3.ts
/**
* @fileoverview Server-side authorisation route for Hosted Checkout (standalone / zero-SDK).
* Mints the timestamp, MUPID and SHA3-512 fingerprint directly via `\@noble/hashes` — no SDK package — and returns the signed payload `$.zpPayment(...)` needs to launch.
*/
import { sha3_512 } from '@noble/hashes/sha3.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { mintCallbackToken, mintStreamToken } from '../demo-tokens.ts';

/** Merchant signing credentials — sandbox demo only, never production. */
const apiKey: string = process.env.WCDEMO_API_KEY ?? 'Your-Key';
const username: string = process.env.WCDEMO_USERNAME ?? 'Your-Username';
const password: string = process.env.WCDEMO_PASSWORD ?? 'Your-Password';
const merchantCode: string = process.env.WCDEMO_MERCHANTCODE ?? 'Your-Merchant-Code';
const sid: string = process.env.WCDEMO_SESSION_ID ?? '';
const callbackApiUrl: string = `${process.env.WCDEMO_API_ORIGIN ?? 'https://api.zenithpayments.support'}/v1/docs/webpod/callbacks`;

const authoriseBodySchema = z.object({
paymentAmount: z.number().positive('payment amount must be greater than zero'),
customerName: z.string().trim().min(1),
customerEmail: z.email(),
customerReference: z.string().trim().min(1),
});

type AuthoriseCheckoutRequest = z.infer<typeof authoriseBodySchema>;

/** Generates a UTC timestamp in the ISO 8601 format required by ZenPay (`YYYY-MM-DDTHH:MM:SS`). */
const zpTimestamp = (): string => new Date().toISOString().slice(0, 19);

/** Generates a random, URL-safe Merchant Unique Payment ID (MUPID) — same algorithm as `@zp-hcp/shared`'s `createZpMupid()`, inlined here so this route stays SDK-free. */
const zpMupid = (): string => {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]+$/, '');
};

/**
* Converts a dollar amount into whole cents as a string for fingerprint hashing.
*
* Mode 0 only. The `mode` this feeds is the hardcoded `0` below, and `x 100` is
* the right answer only for that mode — modes 1 and 2 hash `"0"` regardless of
* the amount. `resolveZpHashAmount` in `@zp-hcp/shared` owns all four rules but
* cannot be imported here: this file is mounted into the Nodepod sandbox and run
* through its regex TypeScript stripper, so it stays SDK-free like `zpMupid`.
*/
const zpAmountToCents = (amountDollars: number): string => Math.round(amountDollars * 100).toString();

/**
* Calculates the SHA3-512 fingerprint hash for a payment request.
* Hashes seven pipe-separated fields in fixed order:
* `apiKey|username|password|mode|paymentAmountInCents|merchantUniquePaymentId|timestamp`.
* @param apiKey - Merchant API key.
* @param username - Merchant username.
* @param password - Merchant password.
* @param mode - Plugin mode (`0` for Make Payment).
* @param paymentAmount - Payment amount in dollars.
* @param merchantUniquePaymentId - Unique merchant transaction ID.
* @param timestamp - ISO UTC timestamp (`YYYY-MM-DDTHH:MM:SS`).
* @returns 128-character lowercase hexadecimal SHA3-512 digest.
*/
export function zpFingerprint(apiKey: string, username: string, password: string, mode: number, paymentAmount: number, merchantUniquePaymentId: string, timestamp: string): string {
const amountCents: string = zpAmountToCents(paymentAmount);
const source: string = [apiKey, username, password, String(mode), amountCents, merchantUniquePaymentId, timestamp].join('|');
return bytesToHex(sha3_512(utf8ToBytes(source)));
}

/** Inputs for {@link buildZpV3AuthoriseRequest}. */
interface BuildAuthoriseInput {
baseUrl: string;
request: AuthoriseCheckoutRequest;
}

/** Payload returned to launch Hosted Checkout. */
interface ZpV3AuthorisePayload {
url: string;
apiKey: string;
fingerprint: string;
merchantCode: string;
timestamp: string;
merchantUniquePaymentId: string;
customerEmail: string;
redirectUrl: string;
callbackUrl: string;
mode: number;
paymentAmount: number;
customerName: string;
customerReference: string;
allowApplePayOneOffPayment: boolean;
allowGooglePayOneOffPayment: boolean;
allowSaveCardInformation: boolean;
allowUnionPayOneOffPayment: boolean;
allowAliPayPlusOneOffPayment: boolean;
allowBankAcOneOffPayment: boolean;
allowPayToOneOffPayment: boolean;
allowPayIdOneOffPayment: boolean;
allowLatitudePayOneOffPayment: boolean;
allowSlicePayOneOffPayment: boolean;
allowCardOneOffPayment: boolean;
allowCardTokenisePayment: boolean;
allowWeChatOneOffPayment: boolean;
}

/**
* Constructs the signed authorisation payload for a payment integration.
* @param input - Base URL and parsed checkout request fields.
* @returns Authorisation options object for Hosted Checkout.
*/
function buildZpV3AuthoriseRequest(input: BuildAuthoriseInput): ZpV3AuthorisePayload {
const { baseUrl, request } = input;
const { paymentAmount, customerEmail, customerName, customerReference } = request;
const timestamp: string = zpTimestamp();
const merchantUniquePaymentId: string = zpMupid();
const mode: number = 0;
const fingerprint: string = zpFingerprint(apiKey, username, password, mode, paymentAmount, merchantUniquePaymentId, timestamp);
const callbackToken: string = mintCallbackToken(paymentAmount, merchantUniquePaymentId, timestamp, 'webpod-v3');
const streamToken: string = mintStreamToken(paymentAmount, merchantUniquePaymentId, timestamp);

const payload: ZpV3AuthorisePayload = {
url: 'https://pay.sandbox.travelpay.com.au/online/v5',
apiKey,
fingerprint,
merchantCode,
timestamp,
merchantUniquePaymentId,
customerEmail,
redirectUrl: `${baseUrl}/webpod/result?t=${encodeURIComponent(streamToken)}&sid=${encodeURIComponent(sid)}`,
callbackUrl: `${callbackApiUrl}?t=${encodeURIComponent(callbackToken)}&sid=${encodeURIComponent(sid)}`,
mode,
paymentAmount,
customerName,
customerReference,
allowApplePayOneOffPayment: true,
allowGooglePayOneOffPayment: true,
allowSaveCardInformation: true,
allowUnionPayOneOffPayment: true,
allowAliPayPlusOneOffPayment: true,
allowPayToOneOffPayment: true,
allowBankAcOneOffPayment: false,
allowPayIdOneOffPayment: false,
allowLatitudePayOneOffPayment: false,
allowSlicePayOneOffPayment: false,
allowCardOneOffPayment: true,
allowCardTokenisePayment: true,
allowWeChatOneOffPayment: true,
};
return payload;
}

/** Hono routes for checkout authorise. */
export const authoriseRoutes: Hono = new Hono();

authoriseRoutes.post(
'/v1/checkout/authorise',
zValidator('json', authoriseBodySchema, (result, c) => {
if (!result.success) {
return c.json({ error: result.error.issues[0]?.message ?? 'invalid request' }, 400);
}
return undefined;
}),
async (c): Promise<Response> => {
try {
const request: AuthoriseCheckoutRequest = c.req.valid('json');
const baseUrl: string = process.env.WCDEMO_BASE_URL ?? new URL(c.req.url).origin;
const payload: ZpV3AuthorisePayload = buildZpV3AuthoriseRequest({ baseUrl, request });
return c.json(payload);
} catch (error: unknown) {
const message: string = error instanceof Error ? error.message : String(error);
return c.json({ error: message }, 400);
}
}
);

Backend — verify the callback​

Recomputes the SHA3-512 ValidationCode from the callback body and compares it against the one ZenPay sent. Never update order or payment status until this returns a match.

server/routes/callback.v3.ts
/**
* @fileoverview Server-side callback verification route for Hosted Checkout (standalone / zero-SDK).
* Parses the callback with Zod and recomputes the SHA3-512 `ValidationCode` directly — no SDK package — by hashing `apiKey|username|password|mode|paymentAmountInCents|merchantUniquePaymentId|reference` (`reference` is `response.token`). Never update payment status until the recomputed hash matches the callback's.
*/
import { sha3_512 } from '@noble/hashes/sha3.js';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import type { Context } from 'hono';
import { z } from 'zod';

/** Merchant credentials used for callback signature verification. */
const apiKey: string = process.env.WCDEMO_API_KEY ?? 'Your-Key';
const username: string = process.env.WCDEMO_USERNAME ?? 'Your-Username';
const password: string = process.env.WCDEMO_PASSWORD ?? 'Your-Password';

const callbackByMupid = new Map<string, unknown>();

const callbackBodySchema = z.object({
merchantUniquePaymentId: z.string(),
paymentAmount: z.number(),
response: z.object({
responseCode: z.string().optional(),
responseText: z.string().optional(),
paymentStatus: z.number().optional(),
paymentReference: z.string().optional(),
token: z.string().optional(),
validationCode: z.string().optional(),
}),
});

type CallbackBody = z.infer<typeof callbackBodySchema>;

/**
* Converts a dollar amount into whole cents as a string for ValidationCode hashing.
*
* Mode 0 only. The `mode` this feeds is the hardcoded `0` below, and `x 100` is
* the right answer only for that mode — modes 1 and 2 hash `"0"` regardless of
* the amount. `resolveZpHashAmount` in `@zp-hcp/shared` owns all four rules but
* cannot be imported here: this file is mounted into the Nodepod sandbox and run
* through its regex TypeScript stripper, so it stays SDK-free like `zpMupid`.
*/
const zpAmountToCents = (amountDollars: number): string => Math.round(amountDollars * 100).toString();

/** Inputs for {@link verifyValidationCode}. */
interface VerifyValidationCodeInput {
apiKey: string;
username: string;
password: string;
mode: number;
body: CallbackBody;
}

/**
* Recomputes the SHA3-512 ValidationCode hash and verifies it against the callback payload.
* @param input - Merchant credentials, plugin mode, and the parsed callback payload.
* @returns True if the computed SHA3-512 hash matches `body.response.validationCode`.
*/
function verifyValidationCode(input: VerifyValidationCodeInput): boolean {
const { apiKey, username, password, mode, body } = input;
const { validationCode, token } = body.response;
if (!validationCode) {
return false;
}
const amountCents: string = zpAmountToCents(body.paymentAmount);
const reference: string = token ?? '';
const source: string = [apiKey, username, password, String(mode), amountCents, body.merchantUniquePaymentId, reference].join('|');
const computedHash: string = bytesToHex(sha3_512(utf8ToBytes(source)));
return computedHash.toLowerCase() === validationCode.toLowerCase();
}

/** Payment status 0 indicates a successful payment in ZenPay Hosted Checkout. */
function isPaymentSuccessful(status: number | undefined): boolean {
return status === 0;
}

/** Hono routes for ZenPay callback verification. */
export const callbackRoutes: Hono = new Hono();

callbackRoutes.post(
'/v1/callback/zenith',
zValidator('json', callbackBodySchema, (result, c) => {
if (!result.success) {
return c.json({ error: 'malformed payment callback' }, 400);
}
return undefined;
}),
async (c): Promise<Response> => {
try {
const body: CallbackBody = c.req.valid('json');
const mode: number = 0;
const isValid: boolean = verifyValidationCode({ apiKey, username, password, mode, body });
const successful: boolean = isValid && isPaymentSuccessful(body.response.paymentStatus);

const result = {
received: true,
valid: isValid,
successful,
paymentReference: body.response.paymentReference,
};

callbackByMupid.set(body.merchantUniquePaymentId, result);
return c.json(result);
} catch (error: unknown) {
const message: string = error instanceof Error ? error.message : String(error);
return c.json({ error: message }, 400);
}
}
);

callbackRoutes.get('/v1/callback/zenith/status', (c: Context): Response => {
const mupid: string = c.req.query('mupid') ?? '';
return c.json(callbackByMupid.get(mupid) ?? null);
});

Frontend — v3, CDN / no build step​

The jQuery + Bootstrap build, loaded from the CDN with no bundler. Calls the authorise endpoint above, then hands the signed payload straight to $.zpPayment(...).

index.html
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Checkout</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"
referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdn.travelpay.com.au/css/zenpay.payment.css" />
<style>
html,
body {
margin: 0;
padding: 0;
background: transparent !important;
background-color: transparent !important;
}

.modal-dailog-payment .float-end {
float: none !important;
margin-left: auto
}
</style>
</head>

<body>
<!-- 1. jQuery + Bootstrap (cdnjs) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"
integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/js/bootstrap.min.js"
integrity="sha384-G/EV+4j2dNv+tEPo3++6LCgdCROaejBqfUeNjuKAiuXbjrxilcCdDz6ZAVfHWe1Y" crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<!-- 2. ZenPay v3 plugin from the TravelPay CDN -->
<script src="https://cdn.travelpay.com.au/js/zenpay.payment.bs5.js"></script>
<script>
/** Gets the server-signed Authorise payload. */
async function authoriseCheckout(opts) {
const res = await fetch((opts.apiBase || '') + '/v1/checkout/authorise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentAmount: opts.paymentAmount,
customerName: opts.customerName,
customerEmail: opts.customerEmail,
customerReference: opts.customerReference,
}),
});
if (!res.ok)
throw new Error(
((await res.json().catch(() => null)) || {}).error || 'authorise request failed: ' + res.status
);
return res.json();
}

/** Pay entry — authorise, then open. Fields from Authorise are listed explicitly so the sample shows every param the plugin receives */
async function launchZpPaymentV3(opts) {
console.log('[webpod3] launching HCP v3', JSON.parse(JSON.stringify({ formPayload: opts })));
const signed = await authoriseCheckout(opts);
const options = {
url: signed.url,
apiKey: signed.apiKey,
fingerprint: signed.fingerprint,
merchantCode: signed.merchantCode,
timestamp: signed.timestamp,
merchantUniquePaymentId: signed.merchantUniquePaymentId,
customerEmail: signed.customerEmail,
redirectUrl: signed.redirectUrl,
callbackUrl: signed.callbackUrl,
mode: signed.mode,
paymentAmount: signed.paymentAmount,
customerName: signed.customerName,
customerReference: signed.customerReference,
allowApplePayOneOffPayment: signed.allowApplePayOneOffPayment,
allowGooglePayOneOffPayment: signed.allowGooglePayOneOffPayment,
allowSaveCardInformation: signed.allowSaveCardInformation,
allowUnionPayOneOffPayment: signed.allowUnionPayOneOffPayment,
allowAliPayPlusOneOffPayment: signed.allowAliPayPlusOneOffPayment,
allowBankAcOneOffPayment: signed.allowBankAcOneOffPayment,
allowPayToOneOffPayment: signed.allowPayToOneOffPayment,
allowPayIdOneOffPayment: signed.allowPayIdOneOffPayment,
allowLatitudePayOneOffPayment: signed.allowLatitudePayOneOffPayment,
allowSlicePayOneOffPayment: signed.allowSlicePayOneOffPayment,
allowCardOneOffPayment: signed.allowCardOneOffPayment,
allowCardTokenisePayment: signed.allowCardTokenisePayment,
allowWeChatOneOffPayment: signed.allowWeChatOneOffPayment,
onPluginClose: 'onClose',
};
console.log('[webpod3] zpPayment.init (v3)', JSON.parse(JSON.stringify({
mode: 'modal',
activate: 'immediate (pre-launch)',
config: options,
})));
const payment = $.zpPayment(options);
const result = payment.init();
payment.options.onPluginClose = () => {
console.log('[webpod3] onPluginClose', options);
if (window.frameElement) {
window.frameElement.style.pointerEvents = 'none';
}
};
return result;
}
</script>
</body>

</html>