Pay a bill
Once you have taken the customer’s money, report it with
POST /api/app/payment-processor/payment-details, scope unipay:bills.
The examples retry safely until the outcome is known.
CURRENCY=$(echo "$LOOKUP" | jq -r .bill.currency)RRN="EX$(date +%s%3N)" # your unique reference: reuse it on every retry of this payment
curl -sS --fail-with-body -X POST "$BASE_URL/api/app/payment-processor/payment-details" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"billNo\": \"$BILL_NO\", \"rrn\": \"$RRN\", \"paymentDate\": \"$(date +%Y-%m-%dT%H:%M:%S%:z)\", \"currency\": \"$CURRENCY\", \"amount\": $AMOUNT }"# → {"successful":true,"message":"Payment Saved","isNewRecord":true,...}// Payment.Rrn is your unique reference: reuse it on every retry of this payment.public static async Task<Result> PayBillAsync(UniPayClient client, Payment payment){ Exception? outcome = null; for (var attempt = 1; attempt <= 5; attempt++) { try { var result = await client.PostAsync<Result>("/api/app/payment-processor/payment-details", payment); if (!result.Successful) throw new InvalidOperationException(result.Message); // refused: retrying won't change the answer return result; // IsNewRecord is false when UniPay already had this RRN } catch (Exception e) when (e is TaskCanceledException || e is HttpRequestException { StatusCode: null or (HttpStatusCode)429 or >= HttpStatusCode.InternalServerError }) { outcome = e; // unknown whether it arrived: retrying with the same RRN is safe } await Task.Delay(TimeSpan.FromSeconds(Math.Min(Math.Pow(2, attempt), 30))); } throw new InvalidOperationException($"Payment {payment.Rrn} not confirmed", outcome);}static final Set<Integer> RETRYABLE = Set.of(429, 500, 502, 503, 504);
static JsonNode payBill(UniPayClient client, String billNo, BigDecimal amount, String currency, String rrn) throws IOException, InterruptedException { Map<String, Object> payment = new LinkedHashMap<>(); payment.put("billNo", billNo); payment.put("rrn", rrn); // your unique reference: reuse it on every retry of this payment payment.put("paymentDate", OffsetDateTime.now().toString()); payment.put("currency", currency); payment.put("amount", amount);
IOException outcome = null; for (int attempt = 1; attempt <= 5; attempt++) { try { JsonNode result = client.post("/api/app/payment-processor/payment-details", payment); if (!result.get("successful").asBoolean()) { throw new RefusedException(result.get("message").asText()); // retrying won't change the answer } return result; // isNewRecord is false when UniPay already had this rrn } catch (HttpStatusException e) { if (!RETRYABLE.contains(e.status)) throw e; outcome = e; } catch (HttpTimeoutException | java.net.ConnectException e) { outcome = e; // unknown whether it arrived: retrying with the same rrn is safe } Thread.sleep(Math.min(1000L << attempt, 30_000L)); } throw new IOException("Payment " + rrn + " not confirmed", outcome);}private val retryable = setOf(429, 500, 502, 503, 504)
fun payBill(client: UniPayClient, billNo: String, amount: BigDecimal, currency: String, rrn: String): JsonNode { val payment = mapOf( "billNo" to billNo, "rrn" to rrn, // your unique reference: reuse it on every retry of this payment "paymentDate" to OffsetDateTime.now().toString(), "currency" to currency, "amount" to amount, ) var outcome: Exception? = null for (attempt in 1..5) { try { val result = client.post("/api/app/payment-processor/payment-details", payment) if (!result["successful"].asBoolean()) { throw RefusedException(result["message"].asText()) // retrying won't change the answer } return result // isNewRecord is false when UniPay already had this rrn } catch (e: HttpStatusException) { if (e.status !in retryable) throw e outcome = e } catch (e: HttpTimeoutException) { outcome = e // unknown whether it arrived: retrying with the same rrn is safe } catch (e: ConnectException) { outcome = e } Thread.sleep(minOf(1000L shl attempt, 30_000L)) } throw IOException("Payment $rrn not confirmed", outcome)}def pay_bill(client: UniPayClient, bill_no: str, amount: Decimal, currency: str, rrn: str) -> dict: payment = { "billNo": bill_no, "rrn": rrn, # your unique reference: reuse it on every retry of this payment "paymentDate": datetime.now(timezone.utc).isoformat(), "currency": currency, "amount": float(amount), # 2-dp amounts round-trip exactly as JSON numbers } for attempt in range(1, 6): try: result = client.post("/api/app/payment-processor/payment-details", payment) except (requests.ConnectionError, requests.Timeout) as error: outcome = error # unknown whether it arrived: retrying with the same rrn is safe except requests.HTTPError as error: if error.response.status_code not in (429, 500, 502, 503, 504): raise outcome = error else: if not result["successful"]: raise RuntimeError(result["message"]) # refused: retrying won't change the answer return result # isNewRecord is false when UniPay already had this rrn time.sleep(min(2 ** attempt, 30)) raise RuntimeError(f"Payment {rrn} not confirmed: {outcome}")async function payBill(client: UniPayClient, billNo: string, amount: number, currency: string, rrn: string) { const payment = { billNo, rrn, // your unique reference: reuse it on every retry of this payment paymentDate: new Date().toISOString(), currency, amount, // keep money in minor units in your own system; send 2-dp amounts here }; let outcome: unknown; for (let attempt = 1; attempt <= 5; attempt++) { try { const result = await client.post<Result>('/api/app/payment-processor/payment-details', payment); if (!result.successful) throw new RefusedError(result.message); // retrying won't change the answer return result; // isNewRecord is false when UniPay already had this rrn } catch (error) { const retryable = !(error instanceof RefusedError) && (!(error instanceof HttpError) || [429, 500, 502, 503, 504].includes(error.status)); if (!retryable) throw error; outcome = error; // unknown whether it arrived: retrying with the same rrn is safe } await new Promise((resolve) => setTimeout(resolve, Math.min(2 ** attempt, 30) * 1000)); } throw new Error(`Payment ${rrn} not confirmed: ${outcome}`);}
class RefusedError extends Error {}type Payment struct { BillNo string `json:"billNo"` RRN string `json:"rrn"` // your unique reference: reuse it on every retry of this payment PaymentDate time.Time `json:"paymentDate"` Currency string `json:"currency"` Amount json.Number `json:"amount"` // e.g. "25.00", sent as a JSON number}
var errRefused = errors.New("refused")
func PayBill(c *UniPayClient, payment Payment) (*Result, error) { var lastErr error for attempt := 1; attempt <= 5; attempt++ { var result Result err := c.Post("/api/app/payment-processor/payment-details", payment, &result) if err == nil { if !result.Successful { // Refused: retrying won't change the answer. return nil, fmt.Errorf("%w: %s", errRefused, result.Message) } return &result, nil // IsNewRecord is false when UniPay already had this RRN } var httpErr *HTTPError if errors.As(err, &httpErr) && !retryable(httpErr.Status) { return nil, err } lastErr = err // unknown whether it arrived: retrying with the same RRN is safe time.Sleep(min(time.Duration(1<<attempt)*time.Second, 30*time.Second)) } return nil, fmt.Errorf("payment %s not confirmed: %w", payment.RRN, lastErr)}
func retryable(status int) bool { return status == 429 || status == 500 || status == 502 || status == 503 || status == 504}function payBill(UniPayClient $client, string $billNo, string $amount, string $currency, string $rrn): array{ $payment = [ 'billNo' => $billNo, 'rrn' => $rrn, // your unique reference: reuse it on every retry of this payment 'paymentDate' => (new DateTimeImmutable())->format(DATE_RFC3339), 'currency' => $currency, 'amount' => (float) $amount, // 2-dp amounts round-trip exactly as JSON numbers ]; $outcome = null; for ($attempt = 1; $attempt <= 5; $attempt++) { try { $result = $client->post('/api/app/payment-processor/payment-details', $payment); if (!$result['successful']) { throw new RefusedException($result['message']); // retrying won't change the answer } return $result; // isNewRecord is false when UniPay already had this rrn } catch (HttpStatusException $e) { if (!in_array($e->status, [429, 500, 502, 503, 504], true)) { throw $e; } $outcome = $e; } catch (NetworkException $e) { $outcome = $e; // unknown whether it arrived: retrying with the same rrn is safe } sleep(min(2 ** $attempt, 30)); } throw new RuntimeException("Payment $rrn not confirmed", 0, $outcome);}Request
Section titled “Request”{ "billNo": "4444INV0009", "rrn": "PX20260929000123", "paymentDate": "2026-09-29T10:15:00+02:00", "currency": "USD", "amount": 170.00}To pay against an account instead, replace billNo with serviceProvider and accountNumber, as in the
lookup. The payment lands on the same bill the lookup returned.
| Field | Required | Rules |
|---|---|---|
billNo |
Yes, or accountNumber |
The bill being paid, from the lookup. If you send both, the bill number wins. |
serviceProvider |
With accountNumber |
Provider number or short code. |
accountNumber |
Or billNo |
Up to 50 characters. |
rrn |
Yes | Your unique reference for this payment, up to 50 characters. See Idempotency. |
paymentDate |
Yes | When the customer paid. Include a UTC offset (+02:00 or Z) — a time without one is read as UTC. Not in the future and not more than 90 days ago, judged by the calendar day in Zimbabwe. |
currency |
Yes | Three-letter ISO 4217 code: the bill’s currency or one of its acceptedCurrencies. |
amount |
Yes | What the customer paid, greater than zero. |
Paying more than is owed, and paying after the due date, are both accepted — unless the service provider has chosen to refuse overpayments.
Response
Section titled “Response”{ "successful": true, "message": "Payment Saved", "isNewRecord": true }"isNewRecord": false with successful: true means UniPay already had this rrn — the payment is recorded; treat it as success. The
message is then Duplicate notification; already processed.
Refusals
Section titled “Refusals”A payment UniPay can’t accept answers HTTP 200 with "successful": false. Nothing is recorded: reverse the customer’s debit on your
side and show the reason.
message |
Cause |
|---|---|
Bill '4444INV0009' not found. Cannot process payment. |
No bill with that number. |
No bill found for that account. Cannot process payment. |
Account payment: the account has no bill. |
Currency 'EUR' is not accepted by this service provider. Accepted currencies: USD, ZWG |
Pay in an accepted currency. |
Invoice has expired |
The bill’s expiryDate has passed. |
Payment would exceed the remaining balance on this invoice |
Overpayment, where the provider refuses them. |
Payment date cannot be in the future |
Check your clock and the offset on paymentDate. |
Payment date cannot be more than 90 days in the past |
Too late to report through the API; contact support. |
Service provider is required when paying by account number |
Add serviceProvider. |
RRN 'X' was already used for a different payment |
You sent an rrn you’d already used, for a different bill, account, amount or currency. Give each payment its own rrn. |
Retrieval Reference Number is required · RRN must not exceed 50 characters |
Fix rrn. |
Payment amount must be greater than zero · Currency must be 3 characters · Payment date is required |
Fix the field. |
A request the API can’t parse at all gets HTTP 400 — see Errors and messages.
Paying exactly once
Section titled “Paying exactly once”This is the part to get right. If a call times out, or you get a 429 or 5xx, you don’t know whether the payment was recorded.
Send the same request again with the same rrn: if it was recorded you get isNewRecord: false, and nothing is recorded twice.
An rrn is yours alone: another processor using the same value doesn’t affect you. Never reuse one of your own for a
different payment — UniPay refuses it — and never reverse a customer’s debit because a report timed out. The whole policy is in
Idempotency and retries.
UniPay API v1 · build e1eb5c49 · Powered by Axis Solutions