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 Email 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)

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:

  1. Concatenate the values of all fields in each payment in the message, in order.
  2. Append the secret key.
  3. 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: ProductPrice is formatted to two decimal places (30.00, not 30). ProductDepartment may be absent — substitute an empty string in that case. BankReference may 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 INSERT by 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-Signature before trusting or persisting any payload data.
  • Respond 200 OK quickly; do heavy processing asynchronously.
  • Treat PaymentId as an idempotency key, in case the same payment is ever delivered more than once.
  • Expect ProductDepartment to 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.


Back to top

MIT licensed. Written and maintained by John Mugabe under 67even. Independent and community-maintained - not affiliated with or endorsed by Paynow Zimbabwe.