Collect payments
UniPay doesn’t push payments to you. Your system pulls them from the payments feed
(GET /api/app/service-provider/payments) as often as suits you,
resuming from where it last stopped. Scopes: unipay:bills and unipay:payments.
# Omit afterId the first time; afterwards pass the id of the last payment you stored.curl -sS --fail-with-body "$BASE_URL/api/app/service-provider/payments${AFTER_ID:+?afterId=$AFTER_ID}" \ -H "Authorization: Bearer $TOKEN"# → [{"payments":[{"id":"01a0ee4f-…","rrn":"…","billNo":"…","amount":1.4,"currency":"ZWG",# "allocatedAmount":1.4,"allocatedCurrency":"ZWG",…}],"successful":true,…}, …]# An empty array [] means you're up to date.public record Payment( Guid Id, string Rrn, string BillNo, decimal Amount, string Currency, // what the customer tendered… decimal? AllocatedAmount, string? AllocatedCurrency); // …and what it settles (null when unattached)
public record PaymentEnvelope(Payment[] Payments);
public static async Task CollectPaymentsAsync(UniPayClient client){ var afterId = File.Exists(Checkpoint) ? File.ReadAllText(Checkpoint).Trim() : null; while (true) { var path = "/api/app/service-provider/payments" + (afterId is null ? "" : $"?afterId={afterId}"); var batch = await client.SendAsync<PaymentEnvelope[]>(HttpMethod.Get, path); if (batch.Length == 0) return; // caught up: call again later foreach (var payment in batch.SelectMany(e => e.Payments)) { RecordPayment(payment); // must be idempotent on payment.Id afterId = payment.Id.ToString(); File.WriteAllText(Checkpoint, afterId); // only after the payment is safely stored } }}
private static void RecordPayment(Payment p){ var settled = p.AllocatedAmount is { } allocated ? $"settles {allocated} {p.AllocatedCurrency}" : "unattached: resolve through your exceptions process"; Console.WriteLine($"{p.Rrn}: {p.Amount} {p.Currency} on {p.BillNo} ({settled})");}static void collectPayments(UniPayClient client) throws IOException, InterruptedException { String afterId = Files.exists(CHECKPOINT) ? Files.readString(CHECKPOINT).trim() : null; while (true) { String query = afterId == null ? "" : "?afterId=" + afterId; JsonNode batch = client.send("GET", "/api/app/service-provider/payments" + query, null); if (batch.isEmpty()) { return; // caught up: call again later } for (JsonNode envelope : batch) { for (JsonNode payment : envelope.get("payments")) { recordPayment(payment); // must be idempotent on payment id afterId = payment.get("id").asText(); Files.writeString(CHECKPOINT, afterId); // only after the payment is safely stored } } }}
static void recordPayment(JsonNode p) { String settled = p.get("allocatedAmount").isNull() ? "unattached: resolve through your exceptions process" : "settles " + p.get("allocatedAmount").decimalValue() + " " + p.get("allocatedCurrency").asText(); System.out.printf("%s: %s %s on %s (%s)%n", p.get("rrn").asText(), p.get("amount").decimalValue(), p.get("currency").asText(), p.get("billNo").asText(), settled);}fun collectPayments(client: UniPayClient) { var afterId = if (CHECKPOINT.exists()) CHECKPOINT.readText().trim() else null while (true) { val query = afterId?.let { "?afterId=$it" } ?: "" val batch = client.send("GET", "/api/app/service-provider/payments$query") if (batch.isEmpty) return // caught up: call again later for (envelope in batch) { for (payment in envelope["payments"]) { recordPayment(payment) // must be idempotent on payment id afterId = payment["id"].asText() CHECKPOINT.writeText(afterId) // only after the payment is safely stored } } }}
fun recordPayment(p: JsonNode) { val settled = if (p["allocatedAmount"].isNull) "unattached: resolve through your exceptions process" else "settles ${p["allocatedAmount"].decimalValue()} ${p["allocatedCurrency"].asText()}" println("${p["rrn"].asText()}: ${p["amount"].decimalValue()} ${p["currency"].asText()} on ${p["billNo"].asText()} ($settled)")}def collect_payments(client: UniPayClient) -> None: after_id = CHECKPOINT.read_text().strip() if CHECKPOINT.exists() else None while True: params = {"afterId": after_id} if after_id else {} batch = client.request("GET", "/api/app/service-provider/payments", params=params) if not batch: return # caught up: call again later for envelope in batch: for payment in envelope["payments"]: record_payment(payment) # must be idempotent on payment["id"] after_id = payment["id"] CHECKPOINT.write_text(after_id) # only after the payment is safely stored
def record_payment(payment: dict) -> None: settled = ( f"settles {payment['allocatedAmount']} {payment['allocatedCurrency']}" if payment["allocatedAmount"] is not None else "unattached: resolve through your exceptions process" ) print(f"{payment['rrn']}: {payment['amount']} {payment['currency']} on {payment['billNo']} ({settled})")async function collectPayments(client: UniPayClient) { let afterId = existsSync(CHECKPOINT) ? readFileSync(CHECKPOINT, 'utf8').trim() : ''; for (;;) { const query = afterId ? `?afterId=${afterId}` : ''; const batch = await client.request<{ payments: Payment[] }[]>('GET', `/api/app/service-provider/payments${query}`); if (batch.length === 0) return; // caught up: call again later for (const envelope of batch) { for (const payment of envelope.payments) { recordPayment(payment); // must be idempotent on payment.id afterId = payment.id; writeFileSync(CHECKPOINT, afterId); // only after the payment is safely stored } } }}
function recordPayment(payment: Payment) { const settled = payment.allocatedAmount !== null ? `settles ${payment.allocatedAmount} ${payment.allocatedCurrency}` : 'unattached: resolve through your exceptions process'; console.log(`${payment.rrn}: ${payment.amount} ${payment.currency} on ${payment.billNo} (${settled})`);}type Payment struct { ID string `json:"id"` RRN string `json:"rrn"` BillNo string `json:"billNo"` Amount json.Number `json:"amount"` // what the customer tendered… Currency string `json:"currency"` AllocatedAmount json.Number `json:"allocatedAmount"` // …and what it settles (empty when unattached) AllocatedCurrency string `json:"allocatedCurrency"`}
func CollectPayments(c *UniPayClient) error { afterID := "" if saved, err := os.ReadFile(checkpoint); err == nil { afterID = strings.TrimSpace(string(saved)) } for { path := "/api/app/service-provider/payments" if afterID != "" { path += "?afterId=" + url.QueryEscape(afterID) } var batch []struct { Payments []Payment `json:"payments"` } if err := c.Do(http.MethodGet, path, nil, &batch); err != nil { return err } if len(batch) == 0 { return nil // caught up: call again later } for _, envelope := range batch { for _, payment := range envelope.Payments { recordPayment(payment) // must be idempotent on payment.ID afterID = payment.ID // Only after the payment is safely stored. if err := os.WriteFile(checkpoint, []byte(afterID), 0o644); err != nil { return err } } } }}
func recordPayment(p Payment) { settled := "unattached: resolve through your exceptions process" if p.AllocatedAmount != "" { settled = fmt.Sprintf("settles %s %s", p.AllocatedAmount, p.AllocatedCurrency) } fmt.Printf("%s: %s %s on %s (%s)\n", p.RRN, p.Amount, p.Currency, p.BillNo, settled)}function collectPayments(UniPayClient $client): void{ $afterId = file_exists(CHECKPOINT) ? trim(file_get_contents(CHECKPOINT)) : null; while (true) { $query = $afterId === null ? '' : '?afterId=' . urlencode($afterId); $batch = $client->request('GET', '/api/app/service-provider/payments' . $query); if ($batch === []) { return; // caught up: call again later } foreach ($batch as $envelope) { foreach ($envelope['payments'] as $payment) { recordPayment($payment); // must be idempotent on $payment['id'] $afterId = $payment['id']; file_put_contents(CHECKPOINT, $afterId); // only after the payment is safely stored } } }}
function recordPayment(array $p): void{ $settled = $p['allocatedAmount'] === null ? 'unattached: resolve through your exceptions process' : "settles {$p['allocatedAmount']} {$p['allocatedCurrency']}"; printf("%s: %s %s on %s (%s)\n", $p['rrn'], $p['amount'], $p['currency'], $p['billNo'], $settled);}How the feed works
Section titled “How the feed works”flowchart LR
A[Load last stored id] --> B["GET /payments?afterId=id"]
B --> C{Empty?}
C -- Yes --> D[Caught up.<br/>Try again later.]
C -- No --> E[Store each payment,<br/>then its id]
E --> B
afterIdis theidof the last payment you stored. You get only payments after it, oldest first, up to 100 per call.- Leave
afterIdout on your first call to start from the beginning. - Keep calling until you get an empty array
[]: you’re caught up. Poll again later — every few minutes is plenty. - Store the payment, then the
id, in that order and ideally in one transaction. If you crash in between, you re-read that payment next time — so make storing it idempotent onid.
What you get
Section titled “What you get”The feed is a JSON array. Each element wraps one payment:
[ { "successful": true, "message": "Found", "payments": [ { "id": "01a0ee4f-b160-75ef-9036-c51fd40af1d1", "rrn": "PX20260929000123", "billNo": "4444INV0009", "uniqueNo": "INV0009", "accountNumber": "100-2345", "serviceProvider": "4444", "paymentDate": "2026-09-29T08:15:00Z", "amount": 170.00, "currency": "USD", "allocatedAmount": 170.00, "allocatedCurrency": "USD", "allocationRate": null } ] }]| Field | Meaning |
|---|---|
id |
UniPay’s ID for the payment. Use it as your checkpoint and your de-duplication key. |
rrn |
The paying processor’s reference. |
billNo, accountNumber |
What was paid. |
uniqueNo |
Your reference for the bill on its first payment; the rrn on later ones. |
paymentDate |
When the customer paid, in UTC. |
amount, currency |
What the customer tendered. |
allocatedAmount, allocatedCurrency |
What it settled against the bill, in the bill’s currency. |
allocationRate |
The exchange rate used, when the tender was converted. |
allocatedAmount and allocatedCurrency are null when the payment couldn’t be matched to a bill — for example, paid in a currency
the account has no bill in, with no exchange rate available. Record those as unallocated and resolve them through your exceptions
process; see Currencies.
Reversals
Section titled “Reversals”A payment can be reversed after you’ve collected it. Reversals have their own feed, read exactly the same way:
GET /api/app/service-provider/payment-reversals?afterId=….
[ { "id": "01a0f0c2-…", "paymentId": "01a0ee4f-b160-75ef-9036-c51fd40af1d1", "rrn": "PX20260929000123", "billNo": "4444INV0009", "reversedAt": "2026-09-30T09:00:00Z", "category": "…", "reason": "…" }]Match paymentId to the payment you stored and reverse it in your system. Keep a separate checkpoint for this feed. Only approved
reversals appear; Reversals explains how a payment comes to be reversed.
Processors’ day settlements
Section titled “Processors’ day settlements”When a processor transfers a day’s takings to you, it reports the transfer to UniPay. The processor settlements feed lists those reports — processor, settlement date, transfer reference, the account credited, gross, fee and net amounts, and the payments included — for reconciling your bank statement. Read it the same way, with its own checkpoint; it returns up to 50 per call.
UniPay API v1 · build e1eb5c49 · Powered by Axis Solutions