Callbacks and Handling the Customer's Return
Handling the Customer's Return
Your return page should use this trust order:
- Verified callback state
- Local server-side payment attempt state
- Redirect parameters only as secondary UX context
| State | Show |
|---|---|
| Redirect says success, callback not yet verified | Payment is being confirmed |
| Callback verified success | Payment successful |
| Callback verified failed | Payment failed |
| Callback validation failed | Safe failure message; do not trust the result |
Do not rely on redirect alone to mark a payment as complete.
Callbacks: What They Are and Why They Matter
When a callbackUrl parameter is included in the plugin payload, Zenith Payments POSTs the transaction result to that URL after payment is processed.
Callbacks are the authoritative server-side confirmation path for Hosted Checkout. Redirect is for customer-facing UX; callback validation is what allows your backend to trust the result.
Unlike redirect, a callback:
- Does not depend on the browser completing the journey
- Is not controlled by the user
- Normally includes a
validationCodethat can be verified server-side (a callback without one is an unsigned notification)
Requirements:
- Transaction-specific: applies only to the transaction in which
callbackUrlwas supplied - Asynchronous: delivery happens in the background after Zenith finishes processing
- Integrator-controlled: you decide the destination via
callbackUrl - Not merchant-wide: does not notify on unrelated transactions (use Webhooks for broader patterns)
- HTTPS POST endpoint: must be a secure endpoint reachable from Zenith Payments
- Backend-owned: receive and validate on your backend, not your frontend
- Validation required: validate
validationCodebefore trusting the payload, and treat a POST with novalidationCodeas unverified
Callback vs Redirect
| Redirect | Callback | |
|---|---|---|
| Channel | Browser | Server-to-server |
| Timing | Synchronous with browser | Asynchronous |
| Reliability | Not guaranteed to complete | More reliable |
| Purpose | Customer-facing UX | Authoritative confirmation |
| Verified | No | Yes, when it carries a validationCode |
For merchant-wide, event-style notifications across broader activity (not transaction-specific), see Webhooks instead.
Callback Payload Reference
Every callback body is JSON with two top-level keys: response, the transaction result, and validationCode, the hash described in Validating the Callback. What is inside response depends on the mode:
| Mode | response shape |
|---|---|
| 0 (Make Payment), 2 (Custom Payment) | Payment result |
| 1 (Tokenise) | Tokenise result |
| 3 (Preauthorisation) | Preauthorisation result |
validationCode is a SHA3-512 hash, sent beside response, not inside it. A callback can arrive without it: see Unsigned notifications.
Payment result (modes 0 and 2)
The plugin's server package (@zp-hcp/server) requires only paymentReference. Read every other field defensively.
| Field | Type | Notes |
|---|---|---|
paymentReference | string | Unique payment reference assigned by Zenith |
customerName | string | Name of the customer |
customerReference | string | Reference provided by the merchant |
paymentStatus | number | 0 Pending · 1 Error · 3 Successful · 4 Failed · 5 Cancelled · 6 Suppressed · 7 InProgress |
paymentStatusString | string | Human-readable payment status |
baseAmount | number | Base transaction amount before fees |
fundsToMerchant | number | Net amount payable to the merchant |
accountOrCardNo | string | Masked account or card number |
paymentAccount | string | Payment account type such as Card or BankAccount |
processingDate | string | UTC ISO-8601 datetime |
settlementDate | string | UTC ISO-8601 datetime |
processorReference | string | Reference from the processor or gateway |
isPaymentSettledToMerchant | boolean | Whether the payment has been settled to the merchant |
failureCode | string | Failure code if unsuccessful, otherwise empty |
failureReason | string | Failure reason if unsuccessful, otherwise empty |
paymentCard | string | Card brand such as MasterCard or Visa |
merchantUniquePaymentId | string | Unique ID supplied by the merchant for the payment |
additionalReference | string | Same as the input parameter. Returned when supplied in the request payload |
merchantCode | string | Merchant identifier code |
transactionSource | number | Numeric identifier for the source channel |
transactionSourceString | string | Human-readable source channel |
customerFee | number | Fee charged to the customer |
processedAmount | number | Total processed amount including fees |
cardCategory | string | Category of card such as Domestic or International |
cardInformationSaved | boolean | Whether card information was stored for tokenisation |
token | string | Returned only if the payment was processed using the cardProxy input parameter. Same value as cardProxy |
cardTypeValue | number | Numeric value of the card or account type |
cardTypeString | string | Name of the card or account type, matching cardTypeValue, for example MasterCard or PayTo |
subCardTypeString | string | Sub-type of the card, as text |
payToStatus | string | PayTo status. PayTo accounts only |
sku1 | string | Stock keeping unit field 1 |
sku2 | string | Stock keeping unit field 2 |
additionalData | object | Authorisation details from the card acquirer: authCode, rrn and stan |
Example payload structure (illustrative values only, a subset of the fields above):
{
"response": {
"paymentReference": "95408",
"customerName": "Leah Adria",
"customerReference": "a12b1926-811f-411c-b10a-1b330cc50d3b",
"paymentStatus": 3,
"paymentStatusString": "Successful",
"baseAmount": 635.72,
"fundsToMerchant": 635.72,
"accountOrCardNo": "555555XXXXXX4444",
"paymentAccount": "Card",
"processingDate": "2025-08-27T21:48:59.447",
"settlementDate": "2025-08-29T00:00:00",
"processorReference": "ddb3a3436cbc7d77c241",
"isPaymentSettledToMerchant": false,
"failureCode": "",
"failureReason": "",
"paymentCard": "MasterCard",
"merchantUniquePaymentId": "35917c6c-ee6b-4e52-866b-bea51841e796",
"merchantCode": "1337",
"transactionSource": 36,
"transactionSourceString": "Public_Customer_OnlineOneOffPayment",
"customerFee": 63.57,
"processedAmount": 699.29,
"cardCategory": "International Cards",
"cardInformationSaved": false
},
"validationCode": "1dbe5ddcc72b8207e839da42308d0596cbbb72e5bf7074520ec885e3c615bb519848fce4a0c073a9a7e6544854338cab9c0c87bc8c82c54bb3706a91c21a7410"
}
Tokenise result (mode 1)
A Tokenise callback saves a payment method. It returns a token, not a payment result, so there is no paymentReference or paymentStatus. @zp-hcp/server requires only token.
| Field | Type | Notes |
|---|---|---|
token | string | The token for the saved payment method. Use it to process a payment later through the API or the payment plugin. It is also the reference in the validation hash |
merchantUniquePaymentId | string | Same as the input parameter |
cardType | string | Type of card or account chosen: Bank Account, PayID, PayTo or UnionPay in the captured examples |
cardTypeValue | number | Numeric value of cardType. In the captured examples: Bank Account 4, PayID 13, PayTo 16, UnionPay 21 |
cardTypeString | string | Name matching cardTypeValue |
paymentDetail | object | Fees and amounts, see below |
paymentDetail.paymentAmount | number | Same as the paymentAmount input parameter |
paymentDetail.customerFee | number | Customer fee for a payment of paymentAmount |
paymentDetail.merchantFee | number | Merchant fee for a payment of paymentAmount |
paymentDetail.processingAmount | number | The total that will be processed: paymentAmount plus customerFee |
isRestrictedCard | boolean | Whether the card is restricted |
doRedirect | boolean | true in every captured example. Its meaning is not documented |
accountName | string | Account name entered by the customer. Bank Account and PayTo only |
accountNumber | string | Obfuscated account number. Bank Account and PayTo only |
payId | string | The PayID. PayID only |
payIdName | string | Display name for the PayID. PayID only |
cardHolderName | string | Card holder name entered by the customer. Card payment methods only, for example UnionPay |
cardNumber | string | Obfuscated card number. Card payment methods only |
cardExpiry | string | Card expiry, MM/CCYY. Card payment methods only |
Examples of each payment type are in Callback Examples.
Preauthorisation result (mode 3)
In @zp-hcp/server, a Preauthorisation response is the Payment result above with these differences.
Left out: paymentReference, paymentStatus, paymentStatusString, fundsToMerchant, settlementDate, isPaymentSettledToMerchant, processedAmount, token, cardInformationSaved, payToStatus, sku1, sku2 and additionalData.
Added: the fields below. The package requires only preauthReference.
| Field | Type | Notes |
|---|---|---|
preauthReference | string | Preauthorisation reference. It is the reference in the validation hash |
preauthStatus | number | Same codes as paymentStatus: 0 Pending · 1 Error · 3 Successful · 4 Failed · 5 Cancelled · 6 Suppressed · 7 InProgress |
preauthStatusString | string | Human-readable preauthorisation status |
preauthAmount | number | Base amount plus customer fee |
preauthExpiryAt | string | When the preauthorisation expires. The format is not documented |
Validating the Callback
The validationCode is a SHA3-512 hash of 7 pipe-separated fields, in exact order:
apiKey|userName|password|mode|paymentAmount|merchantUniquePaymentId|reference
- The first 6 fields are the same values used in the original fingerprint hash
- Only the last field changes: from
timestamp(fingerprint) to the returned transactionreference(validation code) referencedepends on mode: for Mode 1 (Tokenise), this is theTokenoutput parameter
Reference field and amount format by mode
| Mode | Reference field | Amount in hash |
|---|---|---|
| 0 (Make Payment) | paymentReference | whole cents |
| 1 (Tokenise) | token | "0" when the launch amount is 0, 0.00 or blank, otherwise whole cents |
| 2 (Custom Payment) | paymentReference | "0" (literal zero) |
| 3 (Preauthorisation) | preauthReference | whole cents |
Mode 3 note: Preauthorisation callbacks carry
preauthStatusandpreauthReferencein place ofpaymentStatusandpaymentReference.
Steps to validate:
- Receive the callback payload
- Extract
merchantUniquePaymentId, the returned reference, andvalidationCode - Rebuild the input string in the exact order above
- Generate SHA3-512 server-side
- Compare your computed value against the received
validationCode
If they match, the callback is authentic, so update payment state. If they don't match, reject the callback, do not trust the result, and log for investigation.
Recovering launch context
To verify a callback you need the original mode, paymentAmount, and merchantUniquePaymentId from the launch. The callback does not say which mode it belongs to, so keep the mode with your launch record rather than guessing it from the body. The hash uses the paymentAmount you launched with. merchantUniquePaymentId is in the callback body (Tokenise included), but the plugin's server package treats it as optional, so do not rely on it alone to find the launch.
The stateless approach is a signed callback token: embed a short-lived signed token in the callbackUrl as a query parameter (e.g. ?t=…). The token carries the launch context and is verified by your server before the validationCode check runs. This avoids any session store or database lookup:
callbackUrl: https://your-server/callbacks/zenith?t=<signed-token>
Unsigned notifications
A POST to your callbackUrl can arrive with no validationCode. It is a notification only: it cannot be verified, so do not mark the payment complete on it. Answer 200 and wait for the signed POST. Both carry merchantUniquePaymentId, which pairs them.
What the sandbox sent when this was tested (September and October 2026, so check it again before relying on it):
- Modes 0, 1 and 2 each sent one signed POST. Mode 0 sometimes sent an unsigned POST before the signed one.
- Mode 3 sent an unsigned POST first. On one test the signed POST followed about 1.4 seconds later. On two later tests only the unsigned POST arrived, so those Mode 3 callbacks could not be verified.
- In one Mode 3 unsigned POST,
preauthStatuswas the text"Successful"instead of the number3. Do not assume the type.
Backend handling checklist
- Accept POST requests over HTTPS
- Answer
200to every POST, signed or not, and fulfil only on one that verifies - Validate
validationCodebefore trusting any field - Process callbacks idempotently
- Update local payment state safely
- Log failures and mismatches clearly
Do not trust callback data without validation. Do not rely on redirect as final truth. Do not expose credentials or validation logic to the browser.
Timing and idempotency
Callbacks can arrive before redirect completes, after redirect completes, without redirect completing, or more than once.
- Treat callbacks as idempotent
- Use
merchantUniquePaymentIdto prevent double-processing - Show a pending / confirming state if redirect returns before callback validation completes
Callback Examples
These are callbacks captured from sandbox payments, grouped by mode. Each entry is one payment's callback as the API Server stored it. A Mode 1 (Tokenise) callback carries a different response from the payment callback above: it returns the token for the saved payment method, not a payment result. A Mode 3 (Pre-Auth) callback returns pre-authorisation fields. Pick a mode filter or search, then select an entry to see its payload.
Callback reference
{
"response": {
"paymentReference": "250905",
"customerName": "Jaina Proudmoore",
"customerReference": "CUST-523YU4BE",
"paymentStatus": 3,
"paymentStatusString": "Successful",
"baseAmount": 214.18,
"fundsToMerchant": 214.18,
"accountOrCardNo": "625094XXXXXX0016",
"paymentAccount": "Card",
"processingDate": "2026-11-03T20:33:18.187",
"settlementDate": "2026-11-05T00:00:00",
"processorReference": "1456-P-BVFV6A4PZBSBIMWE",
"isPaymentSettledToMerchant": false,
"failureCode": "",
"failureReason": "",
"paymentCard": "UnionPay",
"merchantUniquePaymentId": "OOLrdYj3SNVXY9uQBfM4Vw",
"merchantCode": "1337",
"transactionSource": 36,
"transactionSourceString": "Public_Customer_OnlineOneOffPayment",
"customerFee": 0,
"processedAmount": 214.18,
"cardCategory": "International Cards",
"cardInformationSaved": false,
"additionalReference": "ADD-QQ8KDNW3",
"merchantFee": 0,
"payToStatus": "NotApplicable",
"cardTypeValue": 21,
"cardTypeString": "UnionPay",
"subCardTypeString": "",
"settlementType": "Gross",
"settlementFrequencyString": "Monthly",
"settlementFrequencyValue": 2
},
"validationCode": "5287cdab1c3a6e7c7e62a7cc5aed33354a05f601979d7c92e105da7b95cc6bcf3af3ed73859ac15e6e1ea229074a5f8c5a565fc89ac55e3a5f76c048acf85d86"
}