Biller API
Extracted from
full-reference.md(verified against developers.paynow.co.zw, 21 Sep 2026). Members, payments CSV and signed payment webhooks.
16. Biller API Setup
Note: The Biller API serves offline billers — BillPay accepts payment on the biller’s behalf and then notifies the biller. Paynow prefers online biller integrations where the customer’s payment is processed directly by the biller. Online billers should contact Paynow support to discuss options.
16.1 Access
A user must be set up with a role of Biller Admin or Biller User. Contact the system admin to have an appropriate user created.
16.2 Authentication
Identical to the Vendor API — HTTP Basic on every request:
Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l
(Combine username:password, Base64-encode, prefix with Basic .)
16.3 Request formats
Bodies may be sent as form post, JSON or XML:
MyVariable=MyValue
{ "MyVariable": "MyValue" }
<MyVariable>MyValue</MyVariable>
17. Member API
17.1 Create member
POST /api/member/create
| Field | Type | Required |
|---|---|---|
MemberNumber |
String | Required |
FullName |
String | Required |
EmailAddress |
String | Optional |
MobileNo |
String | Optional |
PostalAddress |
String | Optional |
AccountDetails |
JSON string | Optional |
AccountDetails format:
{"Ship Name": "Jolly Roger", "Age": "4723"}
Returns 200 OK on success. All created members are logged in BillPay and viewable from the normal login.
17.2 Update member
POST /api/member/update
Same fields as Create Member.
Warning: All optional fields will be overwritten with empty values if not specified. Always send the complete record.
Returns 200 OK on success. All updates are logged in BillPay.
17.3 Delete member
POST /api/member/delete
| Field | Type | Required |
|---|---|---|
MemberNumber |
String | Required |
Warning: The member number of a deleted member cannot be re-used in future. Deleted members can be undeleted later if required.
Returns 200 OK on success.
17.4 Undelete member
POST /api/member/undelete
| Field | Type | Required |
|---|---|---|
MemberNumber |
String | Required |
Returns 200 OK on success.
17.5 List members
GET /api/member/list
| Field | Type |
|---|---|
Filters |
String (syntax not specified by Paynow) |
Page |
Integer (default 1) |
PerPage |
Integer (default 200) |
Returns a paginated member list.
17.6 View individual member
GET /api/member/single/<membernumber>
Returns the member’s details.
17.7 Bulk member upload
POST /api/member/uploadmembers
Content-Type: multipart/form-data
The file must be CSV with these mandatory columns in this order:
| Member Number | Full Name | Mobile | Postal Address | |
|---|---|---|---|---|
| 12345ABC | John | john@example.com | 0777123456 |
Additional detail columns may follow the mandatory ones (e.g. National Id, Place of Birth).
Response:
| Field | Type | Description |
|---|---|---|
ResponseCode |
Integer | 0 = Unspecified, 1 = Success |
Narrative |
String | e.g. "Inserted: 0, Updated: 1" |
Warnings |
List<String> | e.g. "Member 'a2001' has been overwritten" |
Errors |
List<String> | Errors, if any |
Note: If a member number already exists, that member’s details are updated. If it does not exist, a new member record is created (upsert behaviour).
17.8 Bulk member delete
POST /api/member/uploadmembersdelete
Content-Type: multipart/form-data
The CSV must contain only one column:
| Member Number |
|---|
| 12000 |
| 12001 |
Response:
| Field | Type | Description |
|---|---|---|
ResponseCode |
Integer | 0 = Unspecified, 1 = Success |
Narrative |
String | e.g. "Deleted: 1" |
Warnings |
List<String> | Warnings |
Errors |
List<String> | Errors, if any |
18. Download Payments (Biller)
Retrieve member payments as a CSV.
Request
GET /api/member/downloadpayments
| Field | Type | Required | Description |
|---|---|---|---|
From |
String dd-MMM-yyyy HH:mm:ss |
Required | Start date |
To |
String dd-MMM-yyyy HH:mm:ss |
Required | End date |
MemberNumber |
String | Optional | Filter to one member |
Response
A CSV file:
| Payment ID | BillPay Ref | Bank Ref | Paid Date | Member Number | Member Name | Product | Price | Department |
|---|---|---|---|---|---|---|---|---|
| 172 | BP000123 | 9796 | 15-Sep-2017 14:35:21 | 12345ABC | John Doe | Lunch | 150.00 | Canteen |
Note: All fields are populated except
Department, which may or may not be present depending on how products are set up in your biller.
19. Biller Payment Webhooks
A biller can be notified of payments by having data posted to a URL on their site. You can choose to receive payment data after every transaction or daily.
Contact the system admin to register your webhook URL. You will then be issued a secret key for verifying incoming data.
19.1 Payload
| Field | Type | Description |
|---|---|---|
Payments |
Array<Payment> | Payment data |
Hash |
String | Legacy hash — prefer the X-Signature header |
19.2 Payment fields
| Field | Type | Description |
|---|---|---|
PaymentId |
Integer | Unique payment id |
BillPayReference |
String | Unique reference containing the biller code |
BankReference |
String | Payment gateway reference |
PaidDate |
Date + Time | Payment date |
MemberNumber |
String | Member identifier |
MemberName |
String | Member name |
ProductCode |
String | Product code |
ProductPrice |
Decimal | Price paid |
ProductDepartment |
String | Product department (may be absent) |
19.3 Validating with HMAC-SHA256 (recommended)
The posted message includes an X-Signature HTTP request header — an HMAC-based method of authenticating the source of the message.
Verify that the X-Signature header value matches the HMAC-SHA256 of the raw message body, computed with your secret key and Base64-encoded.
C#
using System.Security.Cryptography;
using System.Text;
public static string ComputeHmacSHA256(string payload, string secret)
{
var keyBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(keyBytes);
var hash = hmac.ComputeHash(payloadBytes);
return Convert.ToBase64String(hash);
}
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public static String computeHmacSHA256(String payload, String secret) throws Exception {
SecretKeySpec secretKeySpec =
new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKeySpec);
byte[] hmacBytes = mac.doFinal(payload.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(hmacBytes);
}
PHP
function computeHmacSHA256($payload, $secret) {
$hash = hash_hmac('sha256', $payload, $secret, true);
return base64_encode($hash);
}
Python
import base64, hashlib, hmac
def compute_hmac_sha256(payload: str, secret: str) -> str:
digest = hmac.new(secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
Node.js
const crypto = require('crypto');
function computeHmacSha256(payload, secret) {
return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('base64');
}
Implementation note: Compute the HMAC over the raw request body bytes, before any JSON parsing or re-serialisation. Re-serialising will change whitespace and key order and break verification. Compare using a constant-time comparison function.
19.4 Validating with SHA256 hash (legacy)
Note: There is no need to do this if you have already validated using the HMAC-SHA256 method above.
The posted message includes a Hash field. To validate it:
- Concatenate the values of all fields in each payment in the message, in order.
- Append the secret key.
- SHA256 hash the resulting string and output as lowercase hexadecimal.
Warning: The order of fields when concatenating is critical. If the wrong order is used, the hash will not validate correctly.
Worked example
{
"Payments": [
{
"PaymentId": 172,
"BillPayReference": "FAKE-181211122304615",
"BankReference": "9796",
"PaidDate": "11-Dec-2018 12:24:51",
"MemberNumber": "T00001",
"MemberName": "John Doe",
"ProductCode": "LN",
"ProductPrice": 3.21,
"ProductDepartment": "Sales"
},
{
"PaymentId": 245,
"BillPayReference": "FAKE-18121112212345",
"BankReference": "",
"PaidDate": "11-Dec-2018 13:14:11",
"MemberNumber": "K00123",
"MemberName": "Abby Fijngold",
"ProductCode": "MP",
"ProductPrice": 30.00,
"ProductDepartment": "Sales"
}
],
"Hash": "660ad6a83bdd9993a2ef44e3b02098a6ce62763a145eccf1f669951bdd53ce40"
}
Step 1 — concatenate all field values from each payment:
172FAKE-181211122304615979611-Dec-2018 12:24:51T00001John DoeLN3.21Sales245FAKE-1812111221234511-Dec-2018 13:14:11K00123Abby FijngoldMP30.00Sales
Step 2 — append the secret key:
172FAKE-181211122304615979611-Dec-2018 12:24:51T00001John DoeLN3.21Sales245FAKE-1812111221234511-Dec-2018 13:14:11K00123Abby FijngoldMP30.00Sales415b654f-3544-4281-a91e-051e710bfb8d
Step 3 — SHA256, lowercase hex:
660ad6a83bdd9993a2ef44e3b02098a6ce62763a145eccf1f669951bdd53ce40
Formatting gotcha:
ProductPriceis formatted to two decimal places (30.00, not30).ProductDepartmentmay be absent — substitute an empty string in that case.BankReferencemay legitimately be an empty string.
Reference PHP implementation (legacy hash)
The snippet below is reproduced as published by Paynow, to show the field order.
Security warning: Do not use the official snippet as-is in production. It builds the SQL
INSERTby concatenating webhook values (SQL injection) and compares hashes with===(not timing-safe). Use the hardened version that follows it.
<?php
// Takes raw data from the request
$json = file_get_contents('php://input');
// Converts it into a PHP object
$data = json_decode($json);
$payments = new Payments;
$payments->addPayments($data);
Class Payments {
private $payments = array();
public function addPayments($data){
$hash = $data->Hash;
$plainText = '';
foreach($data->Payments as $payment) {
$paymentId = $payment->PaymentId;
$billpayRef = $payment->BillPayReference;
$bankReference = $payment->BankReference;
$paidDate = $payment->PaidDate;
$memberNumber = $payment->MemberNumber;
$memberName = $payment->MemberName;
$productCode = $payment->ProductCode;
$productPrice = number_format($payment->ProductPrice, 2, '.', '');
// department field may or may not be present
if(isset($payment->ProductDepartment)){
$productDepartment = $payment->ProductDepartment;
} else {
$productDepartment = '';
}
$plainText .= $paymentId.$billpayRef.$bankReference
.$paidDate.$memberNumber.$memberName
.$productCode.$productPrice.$productDepartment;
$sqlData[] = "('".$paymentId."', '".$billpayRef."', '"
.$bankReference."', '".$paidDate."', '".$memberNumber
."', '".$memberName."', '".$productCode."', '"
.$productPrice."', '".$productDepartment."')";
}
/* Read your Secret Key from config */
$verified = Hash::verify($plainText, SECRETKEY, $hash);
if ($verified) {
$query = "INSERT INTO tblPayment
(PaymentId, BillPayRef, BankRef, PaidDate,
MemberNumber, MemberName, ProductCode,
ProductPrice, ProductDepartment)
VALUES ".implode(',', $sqlData);
}
}
}
class Hash
{
public static function make($plainText, $secretKey) {
$string = $plainText.$secretKey;
$hash = hash("sha256", $string);
return strtolower($hash);
}
public static function verify($values, $key, $hash)
{
return self::make($values, $key) === $hash;
}
}
?>
Hardened version (recommendation) — same field order and hash, but with prepared statements and a constant-time comparison:
<?php
function verifyLegacyHash(object $data, string $secretKey): bool
{
$plainText = '';
foreach ($data->Payments as $p) {
$plainText .= $p->PaymentId
. $p->BillPayReference
. $p->BankReference
. $p->PaidDate
. $p->MemberNumber
. $p->MemberName
. $p->ProductCode
. number_format($p->ProductPrice, 2, '.', '')
. ($p->ProductDepartment ?? '');
}
$expected = strtolower(hash('sha256', $plainText . $secretKey));
return hash_equals($expected, strtolower((string)($data->Hash ?? '')));
}
$data = json_decode(file_get_contents('php://input'));
if (!$data || !verifyLegacyHash($data, SECRETKEY)) {
http_response_code(401);
exit;
}
$stmt = $pdo->prepare(
'INSERT INTO tblPayment
(PaymentId, BillPayRef, BankRef, PaidDate, MemberNumber,
MemberName, ProductCode, ProductPrice, ProductDepartment)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
// add an ON DUPLICATE KEY / ON CONFLICT clause keyed on PaymentId if you want idempotency
);
foreach ($data->Payments as $p) {
$stmt->execute([
$p->PaymentId, $p->BillPayReference, $p->BankReference, $p->PaidDate,
$p->MemberNumber, $p->MemberName, $p->ProductCode,
number_format($p->ProductPrice, 2, '.', ''), $p->ProductDepartment ?? null,
]);
}
http_response_code(200);
19.5 Webhook receiver checklist (billers) — recommendations
The official Biller API pages do not document the expected response code, a retry policy, or duplicate-delivery behaviour for payment webhooks. The following are defensive recommendations:
- Verify
X-Signaturebefore trusting or persisting any payload data. - Respond
200 OKquickly; do heavy processing asynchronously. - Treat
PaymentIdas an idempotency key, in case the same payment is ever delivered more than once. - Expect
ProductDepartmentto be missing on some records. - Never expose the secret key in client-side code, logs or source control.
This page is generated from paynow-paybill-skills/paynow-billpay/references/biller-api.md in the skill. Spotted something wrong? Open an issue.