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/01523for 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,rejectedor 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
- The member enters their Omang (USSD, app or agent). Call
GET /members/lookup. - Show the short name and the amount due. Stop if
can_payisfalse. Don't accept more thanmax_payment. - Take the payment, then call
POST /paymentsstraight away with your own transaction ID ascollector_ref. - On a timeout or a 5xx error, retry with the same
collector_refuntil you get an answer. - Each day, call
GET /payments?from=…&to=…. Match it against your own records and settletotals.netwith the society.
Testing and go-live
- Ask the society for a key in Test mode. Every call is checked exactly as live, and payments come back
validatedwith nothing recorded. - Run the worked signing example above, then
GET /ping, a lookup and a few payments. - 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.