Collector API

Collector API

This API is for payment collectors: mobile-money operators, prepaid vendors and payment aggregators that collect burial-society premiums from members on the society's behalf.

With it you can:

  • look a member up before you take their money: who they are, whether they can pay and what is due;
  • report each payment, with the charge you kept, the moment it is made.

The society's books record every payment straight away. The member gets a receipt for the full amount. The collector's account in the society's books receives the amount less your charge, and your charge is booked as a bank charge. So the balance of that account is always what you owe the society.

What the society gives you

The society creates you as a collector in BurialPro → Settings → Collectors and sends you three values:

Value Example Notes
API address https://<erp-host>/apiv4/burialpro/c/q9V3xT0sEXAMPLEtoken Every endpoint below is relative to this address.
API key bpk_1a2b3c4d5e6f7a8b9c0d Identifies you. Sent on every call.
Secret bps_0f1e2d3c4b5a6978… Signs your calls. It is shown to the society only once, so keep it on your server and never in an app or a web page.

The society can also switch your key to Test mode (every call is checked, nothing is recorded) and restrict calls to your server addresses (IP allow-list).

Conventions

  • HTTPS only. JSON in UTF-8.
  • Amounts are in the society's currency (Pula, BWP), as numbers with at most 2 decimals.
  • Times are ISO 8601. Include the offset, for example 2026-10-29T09:15:00+02:00. A time without an offset is read as the society's local time.

Signing a call

Every call carries three headers:

Header Value
X-Api-Key Your API key.
X-Timestamp The current Unix time in seconds. It must be within 300 seconds of the server clock.
X-Signature Lowercase hex HMAC-SHA256 of the string below, keyed with your secret.

The string to sign is four parts joined by a newline (\n):

<X-Timestamp>
<METHOD>
<request target: the path and query exactly as you send them>
<raw request body: the exact bytes you send; empty for GET>

Worked example. Use it to check your code before you call the server.

secret     bps_0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978
timestamp  1761724800
GET        /apiv4/burialpro/c/q9V3xT0sEXAMPLEtoken/members/lookup?omang=123456789
body       (empty)
signature  489e0b2f33bb237aff487f68842d8a4059ce7f306e9705799ce2772cb3cdf850
POST       /apiv4/burialpro/c/q9V3xT0sEXAMPLEtoken/payments
body       {"payments":[{"collector_ref":"OM-20261029-000123","omang":"123456789","amount":150.00,"charge":3.00,"paid_at":"2026-10-29T09:15:00+02:00","payer_phone":"+26771234567"}]}
signature  21fb798800d08487856ba5ffc80a09405a9ccc780e3fcb8f457a422313c5d321

Sign the body exactly as you send it. Serialise the JSON once, sign that string, and send that same string.

curl (bash)

BASE="https://<erp-host>/apiv4/burialpro/c/<token>"
KEY="bpk_..."; SECRET="bps_..."
PATH_Q="$(echo "$BASE" | sed -E 's#https?://[^/]+##')/payments"
BODY='{"payments":[{"collector_ref":"OM-20261029-000123","omang":"123456789","amount":150.00,"charge":3.00}]}'
TS=$(date +%s)
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" "POST" "$PATH_Q" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -s "$BASE/payments" -H "Content-Type: application/json" \
  -H "X-Api-Key: $KEY" -H "X-Timestamp: $TS" -H "X-Signature: $SIG" --data "$BODY"

Python

import hashlib, hmac, json, time, urllib.parse, requests

BASE, KEY, SECRET = "https://<erp-host>/apiv4/burialpro/c/<token>", "bpk_...", "bps_..."

def call(method, path, payload=None):
    url = BASE + path
    body = json.dumps(payload, separators=(",", ":")) if payload is not None else ""
    u = urllib.parse.urlsplit(url)
    target = u.path + ("?" + u.query if u.query else "")
    ts = str(int(time.time()))
    sig = hmac.new(SECRET.encode(), f"{ts}\n{method}\n{target}\n{body}".encode(), hashlib.sha256).hexdigest()
    headers = {"X-Api-Key": KEY, "X-Timestamp": ts, "X-Signature": sig, "Content-Type": "application/json"}
    return requests.request(method, url, data=body.encode() if body else None, headers=headers, timeout=60)

print(call("GET", "/members/lookup?omang=123456789").json())

Node.js

const crypto = require("crypto");
const BASE = "https://<erp-host>/apiv4/burialpro/c/<token>", KEY = "bpk_...", SECRET = "bps_...";

async function call(method, path, payload) {
  const url = new URL(BASE + path);
  const body = payload === undefined ? "" : JSON.stringify(payload);
  const ts = Math.floor(Date.now() / 1000).toString();
  const sig = crypto.createHmac("sha256", SECRET)
    .update(`${ts}\n${method}\n${url.pathname}${url.search}\n${body}`).digest("hex");
  const res = await fetch(url, { method, body: body || undefined, headers: {
    "X-Api-Key": KEY, "X-Timestamp": ts, "X-Signature": sig, "Content-Type": "application/json" } });
  return res.json();
}

PHP

function bp_call($method, $path, $payload = null) {
    $base = 'https://<erp-host>/apiv4/burialpro/c/<token>'; $key = 'bpk_...'; $secret = 'bps_...';
    $url  = $base.$path;
    $body = $payload === null ? '' : json_encode($payload);
    $u    = parse_url($url);
    $target = $u['path'].(isset($u['query']) ? '?'.$u['query'] : '');
    $ts   = (string)time();
    $sig  = hash_hmac('sha256', "$ts\n$method\n$target\n$body", $secret);
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,
        CURLOPT_POSTFIELDS => $body === '' ? null : $body,
        CURLOPT_HTTPHEADER => ["X-Api-Key: $key", "X-Timestamp: $ts", "X-Signature: $sig", 'Content-Type: application/json']]);
    return json_decode(curl_exec($ch), true);
}

Java

// javax.crypto: signature = hex(HMAC-SHA256(secret, ts + "\n" + method + "\n" + target + "\n" + body))
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] h = mac.doFinal((ts + "\n" + method + "\n" + target + "\n" + body).getBytes(StandardCharsets.UTF_8));
String signature = HexFormat.of().formatHex(h);   // Java 17+

Endpoints

GET /ping

Checks your key, secret and clock. Call it first.

{
  "ok": true,
  "society": "Example Burial Society",
  "collector": "Orange Money",
  "mode": "live",
  "currency": "BWP",
  "server_time": "2026-10-29T09:15:02+02:00",
  "limits": { "payments_per_request": 200, "calls_per_minute": 600, "timestamp_skew_seconds": 300 }
}

mode is test while the society keeps your key in Test mode.

GET /members/lookup

Pass one of these:

  • omang: the member's national ID number;
  • member_no: the society's member number. URL-encode it, because it can contain /.
GET /members/lookup?omang=123456789
GET /members/lookup?member_no=BUR%2F2026%2F0042
{
  "member": {
    "member_no": "BUR/2026/0042",
    "omang": "123456789",
    "name": "Kagiso M.",
    "status": "Active",
    "can_pay": true,
    "block_reason": null,
    "monthly_premium": 150.00,
    "balance": -300.00,
    "amount_due": 300.00,
    "months_in_arrears": 2,
    "months_paid_ahead": 0,
    "max_payment": 750.00,
    "currency": "BWP"
  }
}
Field Meaning
name First name and initials only: enough for the payer to confirm it is them.
can_pay false for a deceased, terminated or rejected member, or a record the society has not finished. Don't take money when it is false; block_reason says why.
balance Paid minus billed to date. Negative means arrears, positive means paid ahead.
amount_due The arrears today (0 when up to date).
max_payment The most this member may pay now under the society's prepayment limit, or null when there is no limit. A larger payment is refused.

If no member matches: 404 member_not_found.

POST /payments

Report one or more payments: up to 200 per request. Send each payment as soon as it is made; a list is for catching up after an outage.

{
  "payments": [
    {
      "collector_ref": "OM-20261029-000123",
      "omang": "123456789",
      "amount": 150.00,
      "charge": 3.00,
      "paid_at": "2026-10-29T09:15:00+02:00",
      "payer_phone": "+26771234567",
      "narration": "USSD *145#"
    }
  ],
  "dry_run": false
}
Field Required Notes
collector_ref yes Your transaction ID: 1 to 64 letters, digits and . _ : -. It must be unique among your payments; it is how a resend is recognised.
omang or member_no one of them Which member paid.
amount yes What the member paid, greater than 0. The member is credited with all of it.
charge no (0) Your fee or commission on this payment, less than amount.
paid_at no (now) When the member paid.
payer_phone no The paying phone number, up to 30 characters.
narration no Free text, up to 120 characters.
dry_run no true runs every check and answers as live would, but records nothing.

The answer is 200 whenever the request itself was valid. Each payment gets its own result:

{
  "mode": "live",
  "dry_run": false,
  "results": [
    {
      "collector_ref": "OM-20261029-000123",
      "status": "posted",
      "receipt_no": "RCP/2026/01523",
      "member": { "member_no": "BUR/2026/0042", "omang": "123456789", "name": "Kagiso M." },
      "amount": 150.00,
      "charge": 3.00,
      "net": 147.00,
      "paid_at": "2026-10-29T09:15:00+02:00",
      "received_at": "2026-10-29T09:15:03+02:00",
      "error": null
    }
  ],
  "summary": { "received": 1, "posted": 1, "duplicate": 0, "validated": 0, "rejected": 0,
               "amount": 150.00, "charge": 3.00, "net": 147.00 }
}
status Meaning
posted Recorded. receipt_no is the member's receipt.
duplicate This collector_ref was recorded before. You get the original result and nothing new is recorded.
validated A dry run, or your key is in Test mode. Everything checked out and nothing was recorded.
rejected Not recorded. error.code and error.message say why (see below). Fix it and send it again, with the same collector_ref or a new one.

Retries are safe. If a call times out or fails with a 5xx error, send the same payments again with the same collector_refs. Anything already recorded comes back as duplicate. If you use the same collector_ref for a different payment (another member or another amount), it is refused with collector_ref_reused.

What happens in the society's books for the example above:

  • a receipt RCP/2026/01523 for P150.00 to the member;
  • P147.00 into your collector account;
  • P3.00 as a bank charge.

The balance of your collector account is what you owe the society. Settle it as agreed with the society, for example by a daily transfer.

GET /payments/{collector_ref}

The current state of one payment you sent: the same shape as one entry of results, under "payment". 404 payment_not_found if it was never received.

GET /payments?from=&to=&status=&page=&per_page=

Reconciliation: everything you sent that the society received between from and to (dates YYYY-MM-DD, at most 93 days; both default to today).

  • status: posted, rejected or all (the default).
  • per_page: 1 to 500, default 100.
{
  "from": "2026-10-29", "to": "2026-10-29", "page": 1, "per_page": 100, "count": 412,
  "totals": { "posted": 409, "rejected": 3, "amount": 61350.00, "charge": 1227.00, "net": 60123.00 },
  "payments": [ { "collector_ref": "OM-20261029-000123", "status": "posted", "...": "..." } ]
}

totals.net is what you owe the society for the period.

Errors

A problem with the whole request returns an HTTP error with this body:

{ "error": { "code": "signature_invalid", "message": "The signature does not match. ..." } }
HTTP code What to do
400 invalid_json, invalid_request, too_many_payments Fix the request.
401 signature_missing, unknown_key, stale_timestamp, signature_invalid Check the headers, your secret and your server clock.
403 collector_inactive, ip_not_allowed Contact the society.
404 not_found The API address is wrong or the society replaced it. Ask the society for the current one.
404 member_not_found, payment_not_found No such member, or no such payment from you.
429 rate_limited Too many calls. Wait for the number of seconds in the Retry-After header.
500 server_error Nothing in progress was recorded. Send the same request again.
503 service_unavailable The service is off for this society. Contact the society.

A refused payment comes back inside results with status: "rejected":

error.code Meaning
invalid_request A field is missing or malformed: the message names it.
member_not_found No member with that Omang or member number.
member_blocked The member is deceased, terminated or rejected. No payment can be taken.
member_not_ready The society has not finished the member's record.
prepay_limit The payment would go beyond the society's prepayment limit: see max_payment in the lookup.
period_closed The society's books are closed for that date.
not_configured The society has not finished setting you up.
collector_ref_reused That collector_ref was already used for a different payment.
in_progress The same payment is being recorded by another call. Ask for its status in a moment.
not_posted The society's system could not record it. The message says why.

Recommended flow

  1. The member enters their Omang (USSD, app or agent). Call GET /members/lookup.
  2. Show the short name and the amount due. Stop if can_pay is false. Don't accept more than max_payment.
  3. Take the payment, then call POST /payments straight away with your own transaction ID as collector_ref.
  4. On a timeout or a 5xx error, retry with the same collector_ref until you get an answer.
  5. Each day, call GET /payments?from=…&to=…. Match it against your own records and settle totals.net with the society.

Testing and go-live

  1. Ask the society for a key in Test mode. Every call is checked exactly as live, and payments come back validated with nothing recorded.
  2. Run the worked signing example above, then GET /ping, a lookup and a few payments.
  3. When you are ready, the society switches your key to live. Nothing changes on your side.

You can also send "dry_run": true with a live key at any time.

Limits

Limit Value
Payments per request 200
Calls per minute per key 600
Timestamp drift 300 seconds
Reconciliation window 93 days per call, per_page up to 500

Machine-readable specification

The OpenAPI 3 specification is at https://<erp-host>/apiv4/burialpro/openapi.yaml. Import it into Postman, Insomnia or Swagger UI. Remember to add the signing step; the examples above show how.

Global Coverage

Multi-Country ERP — One Platform

Supports GST (India), VAT (UAE, South Africa, Egypt, Iraq, Botswana), and local compliance across all regions.

🇮🇳 India (GST)
🇦🇪 UAE (VAT)
🇸🇦 Saudi Arabia
🇶🇦 Qatar
🇧🇭 Bahrain
🇴🇲 Oman
🇰🇼 Kuwait
🇪🇬 Egypt
🇮🇶 Iraq
🇿🇦 South Africa
🇧🇼 Botswana
🇲🇾 Malaysia
🇸🇬 Singapore
🇦🇺 Australia
🇬🇧 United Kingdom
🇺🇸 USA
🌏 & More Regions