Developers

This page is a ZenPay-styled copy of the Zenith Payments developer documentation. View the original docs

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 = () => {
				if (window.frameElement) {
					window.frameElement.style.pointerEvents = 'none';
				}
			};
			return result;
		}
	</script>
</body>

</html>
Need help with your integration?Talk to the Zenith Integrations Team.
Email integrationsTalk to us