Quick start
This round-trips one payment on the test environment: get a token, look up a bill, pay it. It’s the whole payment processor integration in one program. Service providers: see Publish bills and Collect payments instead.
-
Get test credentials from your Axis Solutions contact: a
client_idandclient_secretforhttps://test.unipay.co.zw. For a bill to pay, use one from the sandbox in Test data —SBXOWEUSDalways owes. -
Set them in your environment:
Terminal window export UNIPAY_BASE_URL=https://test.unipay.co.zwexport UNIPAY_CLIENT_ID=your-client-idexport UNIPAY_CLIENT_SECRET=your-client-secret -
Run the example for your language with the bill number and an amount. It prints what the bill’s holder owes, pays, and prints the result:
Sandbox Owing USD owes 999875.0000 USD (Sandbox: owing in USD)EX1790704100495: Payment Saved (new record: True)
The program
Section titled “The program”Each tab is a complete program; its first lines say how to run it. The same code is split step by step through the rest of the docs.
#!/usr/bin/env bash# UniPay payment processor example: get a token, look up a bill, pay it.# bash + curl + jq.## export UNIPAY_BASE_URL=https://test.unipay.co.zw# export UNIPAY_CLIENT_ID=... UNIPAY_CLIENT_SECRET=...# ./payment_processor.sh <billNo> <amount>set -euo pipefailBASE_URL="${UNIPAY_BASE_URL:-https://test.unipay.co.zw}"BILL_NO="$1"AMOUNT="$2"
# Tokens last 30 minutes: reuse one for every call in that window.TOKEN=$(curl -sS --fail-with-body -X POST "$BASE_URL/connect/token" \ --data-urlencode "grant_type=client_credentials" \ --data-urlencode "client_id=$UNIPAY_CLIENT_ID" \ --data-urlencode "client_secret=$UNIPAY_CLIENT_SECRET" \ --data-urlencode "scope=unipay:bills" | jq -r .access_token)
LOOKUP=$(curl -sS --fail-with-body -X POST "$BASE_URL/api/app/payment-processor/lookup" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"billNo\": \"$BILL_NO\"}")
echo "$LOOKUP" | jq '{successful, message, name: .bill.name, currency: .bill.currency, payableNow, acceptedCurrencies}'
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,...}#:property PublishAot=false// UniPay payment processor example: get a token, look up a bill, pay it.// .NET 10 single-file app, no packages. On .NET 8/9, paste into a console project and drop the// #:property line above.//// set UNIPAY_BASE_URL=https://test.unipay.co.zw// set UNIPAY_CLIENT_ID=... & set UNIPAY_CLIENT_SECRET=...// dotnet run PaymentProcessor.cs <billNo> [amount]using System.Net;using System.Net.Http.Json;
var baseUrl = Environment.GetEnvironmentVariable("UNIPAY_BASE_URL") ?? "https://test.unipay.co.zw";using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) };var client = new UniPayClient(http, Environment.GetEnvironmentVariable("UNIPAY_CLIENT_ID")!, Environment.GetEnvironmentVariable("UNIPAY_CLIENT_SECRET")!);
var found = await Examples.LookUpBillAsync(client, args[0]);var bill = found.Bill!;Console.WriteLine($"{bill.Name} owes {found.PayableNow} {bill.Currency} ({bill.Description})");
var amount = args.Length > 1 ? decimal.Parse(args[1]) : found.PayableNow!.Value;var rrn = $"EX{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";var result = await Examples.PayBillAsync(client, new Payment(bill.BillNo, rrn, DateTimeOffset.Now, bill.Currency, amount));Console.WriteLine($"{rrn}: {result.Message} (new record: {result.IsNewRecord})");
/// <summary>Calls UniPay with a cached access token, renewed 60 seconds before it expires.</summary>public sealed class UniPayClient(HttpClient http, string clientId, string clientSecret, string scope = "unipay:bills"){ private readonly SemaphoreSlim _gate = new(1, 1); private string? _token; private DateTimeOffset _renewAt;
private async Task<string> AccessTokenAsync(CancellationToken ct) { await _gate.WaitAsync(ct); try { if (_token is null || DateTimeOffset.UtcNow >= _renewAt) { using var response = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string> { ["grant_type"] = "client_credentials", ["client_id"] = clientId, ["client_secret"] = clientSecret, ["scope"] = scope, }), ct); response.EnsureSuccessStatusCode(); var token = (await response.Content.ReadFromJsonAsync<TokenResponse>(ct))!; _token = token.access_token; _renewAt = DateTimeOffset.UtcNow.AddSeconds(token.expires_in - 60); } return _token; } finally { _gate.Release(); } }
public async Task<T> PostAsync<T>(string path, object body, CancellationToken ct = default) { for (var attempt = 0; ; attempt++) { using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) }; request.Headers.Authorization = new("Bearer", await AccessTokenAsync(ct)); using var response = await http.SendAsync(request, ct); if (response.StatusCode == HttpStatusCode.Unauthorized && attempt == 0) { _token = null; // revoked or expired early: get a new token, retry once continue; } response.EnsureSuccessStatusCode(); // throws HttpRequestException with StatusCode return (await response.Content.ReadFromJsonAsync<T>(ct))!; } }
private sealed record TokenResponse(string access_token, int expires_in);}
public record Result(bool Successful, string Message, bool IsNewRecord);
public record Bill(string BillNo, string Name, string Description, string Currency);
public record LookupResult(bool Successful, string Message, Bill? Bill, decimal? PayableNow, string[]? AcceptedCurrencies);
public record Payment(string BillNo, string Rrn, DateTimeOffset PaymentDate, string Currency, decimal Amount);
public static class Examples{ public static async Task<LookupResult> LookUpBillAsync(UniPayClient client, string billNo) { var result = await client.PostAsync<LookupResult>("/api/app/payment-processor/lookup", new { billNo }); if (!result.Successful) throw new InvalidOperationException(result.Message); // e.g. "Bill '4444INV0009' not found" return result; }
// 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); }}// UniPay payment processor example: get a token, look up a bill, pay it.// Java 17+, java.net.http + Jackson (com.fasterxml.jackson.core:jackson-databind).//// export UNIPAY_BASE_URL=https://test.unipay.co.zw// export UNIPAY_CLIENT_ID=... UNIPAY_CLIENT_SECRET=...// java -cp "jackson-databind.jar:jackson-core.jar:jackson-annotations.jar" PaymentProcessor.java <billNo> [amount]
import com.fasterxml.jackson.databind.DeserializationFeature;import com.fasterxml.jackson.databind.JsonNode;import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;import java.math.BigDecimal;import java.net.URI;import java.net.URLEncoder;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.net.http.HttpTimeoutException;import java.nio.charset.StandardCharsets;import java.time.Duration;import java.time.Instant;import java.time.OffsetDateTime;import java.util.LinkedHashMap;import java.util.Map;import java.util.Set;import java.util.stream.Collectors;
public class PaymentProcessor {
static final String BASE_URL = System.getenv().getOrDefault("UNIPAY_BASE_URL", "https://test.unipay.co.zw"); static final ObjectMapper JSON = new ObjectMapper() .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); // keep amounts exact
/** Calls UniPay with a cached access token, renewed 60 seconds before it expires. */ static final class UniPayClient { private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); private final String credentials; private String token; private Instant renewAt = Instant.MIN;
UniPayClient(String clientId, String clientSecret, String scope) { credentials = form(Map.of( "grant_type", "client_credentials", "client_id", clientId, "client_secret", clientSecret, "scope", scope)); }
private synchronized String accessToken() throws IOException, InterruptedException { if (token == null || Instant.now().isAfter(renewAt)) { HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/connect/token")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/x-www-form-urlencoded") .POST(HttpRequest.BodyPublishers.ofString(credentials)) .build(); HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new IOException("Token request failed: HTTP " + response.statusCode()); } JsonNode body = JSON.readTree(response.body()); token = body.get("access_token").asText(); renewAt = Instant.now().plusSeconds(body.get("expires_in").asLong() - 60); } return token; }
JsonNode post(String path, Object body) throws IOException, InterruptedException { String json = JSON.writeValueAsString(body); for (int attempt = 0; ; attempt++) { HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + path)) .timeout(Duration.ofSeconds(30)) .header("Authorization", "Bearer " + accessToken()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 401 && attempt == 0) { synchronized (this) { token = null; // revoked or expired early: get a new token, retry once } continue; } if (response.statusCode() != 200) { throw new HttpStatusException(response.statusCode()); } return JSON.readTree(response.body()); } }
private static String form(Map<String, String> fields) { return fields.entrySet().stream() .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) .collect(Collectors.joining("&")); } }
static final class HttpStatusException extends IOException { final int status;
HttpStatusException(int status) { super("HTTP " + status); this.status = status; } }
static final class RefusedException extends RuntimeException { RefusedException(String message) { super(message); } }
static JsonNode lookUpBill(UniPayClient client, String billNo) throws IOException, InterruptedException { JsonNode result = client.post("/api/app/payment-processor/lookup", Map.of("billNo", billNo)); if (!result.get("successful").asBoolean()) { throw new RefusedException(result.get("message").asText()); // e.g. "Bill '4444INV0009' not found" } return result; }
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); }
public static void main(String[] args) throws Exception { UniPayClient client = new UniPayClient( System.getenv("UNIPAY_CLIENT_ID"), System.getenv("UNIPAY_CLIENT_SECRET"), "unipay:bills");
JsonNode found = lookUpBill(client, args[0]); JsonNode bill = found.get("bill"); System.out.printf("%s owes %s %s (%s)%n", bill.get("name").asText(), found.get("payableNow").decimalValue(), bill.get("currency").asText(), bill.get("description").asText());
BigDecimal amount = args.length > 1 ? new BigDecimal(args[1]) : found.get("payableNow").decimalValue(); String rrn = "EX" + System.currentTimeMillis(); JsonNode result = payBill(client, bill.get("billNo").asText(), amount, bill.get("currency").asText(), rrn); System.out.printf("%s: %s (new record: %s)%n", rrn, result.get("message").asText(), result.get("isNewRecord").asBoolean()); }}// UniPay payment processor example: get a token, look up a bill, pay it.// Kotlin 1.9+ on JVM 17+, java.net.http + Jackson (com.fasterxml.jackson.core:jackson-databind).//// export UNIPAY_BASE_URL=https://test.unipay.co.zw// export UNIPAY_CLIENT_ID=... UNIPAY_CLIENT_SECRET=...// kotlinc PaymentProcessor.kt -cp jackson-databind.jar -include-runtime -d example.jar// java -cp "example.jar:jackson-databind.jar:jackson-core.jar:jackson-annotations.jar" PaymentProcessorKt <billNo> [amount]
import com.fasterxml.jackson.databind.DeserializationFeatureimport com.fasterxml.jackson.databind.JsonNodeimport com.fasterxml.jackson.databind.ObjectMapperimport java.io.IOExceptionimport java.math.BigDecimalimport java.net.ConnectExceptionimport java.net.URIimport java.net.URLEncoderimport java.net.http.HttpClientimport java.net.http.HttpRequestimport java.net.http.HttpResponseimport java.net.http.HttpTimeoutExceptionimport java.time.Durationimport java.time.Instantimport java.time.OffsetDateTime
val BASE_URL: String = System.getenv("UNIPAY_BASE_URL") ?: "https://test.unipay.co.zw"val JSON: ObjectMapper = ObjectMapper().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) // keep amounts exact
/** Calls UniPay with a cached access token, renewed 60 seconds before it expires. */class UniPayClient(clientId: String, clientSecret: String, scope: String = "unipay:bills") { private val http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build() private val credentials = mapOf( "grant_type" to "client_credentials", "client_id" to clientId, "client_secret" to clientSecret, "scope" to scope, ).entries.joinToString("&") { (k, v) -> "$k=${URLEncoder.encode(v, Charsets.UTF_8)}" } private var token: String? = null private var renewAt: Instant = Instant.MIN
@Synchronized private fun accessToken(): String { val current = token if (current != null && Instant.now().isBefore(renewAt)) return current val request = HttpRequest.newBuilder(URI.create("$BASE_URL/connect/token")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/x-www-form-urlencoded") .POST(HttpRequest.BodyPublishers.ofString(credentials)) .build() val response = http.send(request, HttpResponse.BodyHandlers.ofString()) if (response.statusCode() != 200) throw IOException("Token request failed: HTTP ${response.statusCode()}") val body = JSON.readTree(response.body()) renewAt = Instant.now().plusSeconds(body["expires_in"].asLong() - 60) return body["access_token"].asText().also { token = it } }
fun post(path: String, body: Any): JsonNode { val json = JSON.writeValueAsString(body) for (attempt in 0..1) { val request = HttpRequest.newBuilder(URI.create("$BASE_URL$path")) .timeout(Duration.ofSeconds(30)) .header("Authorization", "Bearer ${accessToken()}") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build() val response = http.send(request, HttpResponse.BodyHandlers.ofString()) if (response.statusCode() == 401 && attempt == 0) { synchronized(this) { token = null } // revoked or expired early: get a new token, retry once continue } if (response.statusCode() != 200) throw HttpStatusException(response.statusCode()) return JSON.readTree(response.body()) } throw HttpStatusException(401) }}
class HttpStatusException(val status: Int) : IOException("HTTP $status")
class RefusedException(message: String) : RuntimeException(message)
fun lookUpBill(client: UniPayClient, billNo: String): JsonNode { val result = client.post("/api/app/payment-processor/lookup", mapOf("billNo" to billNo)) if (!result["successful"].asBoolean()) { throw RefusedException(result["message"].asText()) // e.g. "Bill '4444INV0009' not found" } return result}
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)}
fun main(args: Array<String>) { val client = UniPayClient(System.getenv("UNIPAY_CLIENT_ID"), System.getenv("UNIPAY_CLIENT_SECRET"))
val found = lookUpBill(client, args[0]) val bill = found["bill"] println("${bill["name"].asText()} owes ${found["payableNow"].decimalValue()} ${bill["currency"].asText()} (${bill["description"].asText()})")
val amount = args.getOrNull(1)?.let(::BigDecimal) ?: found["payableNow"].decimalValue() val rrn = "EX${System.currentTimeMillis()}" val result = payBill(client, bill["billNo"].asText(), amount, bill["currency"].asText(), rrn) println("$rrn: ${result["message"].asText()} (new record: ${result["isNewRecord"].asBoolean()})")}"""UniPay payment processor example: get a token, look up a bill, pay it.
pip install requests export UNIPAY_BASE_URL=https://test.unipay.co.zw export UNIPAY_CLIENT_ID=... UNIPAY_CLIENT_SECRET=... python payment_processor.py <billNo> [amount]"""import osimport sysimport threadingimport timefrom datetime import datetime, timezonefrom decimal import Decimal
import requests
BASE_URL = os.environ.get("UNIPAY_BASE_URL", "https://test.unipay.co.zw")TIMEOUT = (10, 30) # connect, read (seconds)
class UniPayClient: """Calls UniPay with a cached access token, renewed 60 seconds before it expires."""
def __init__(self, client_id: str, client_secret: str, scope: str = "unipay:bills"): self._credentials = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": scope, } self._token = None self._renew_at = 0.0 self._lock = threading.Lock()
def _access_token(self) -> str: with self._lock: if self._token is None or time.monotonic() >= self._renew_at: response = requests.post(f"{BASE_URL}/connect/token", data=self._credentials, timeout=TIMEOUT) response.raise_for_status() body = response.json() self._token = body["access_token"] self._renew_at = time.monotonic() + body["expires_in"] - 60 return self._token
def post(self, path: str, body: dict) -> dict: for attempt in range(2): response = requests.post( f"{BASE_URL}{path}", json=body, headers={"Authorization": f"Bearer {self._access_token()}"}, timeout=TIMEOUT, ) if response.status_code == 401 and attempt == 0: self._token = None # revoked or expired early: get a new token, retry once continue response.raise_for_status() return response.json()
def look_up_bill(client: UniPayClient, bill_no: str) -> dict: result = client.post("/api/app/payment-processor/lookup", {"billNo": bill_no}) if not result["successful"]: raise RuntimeError(result["message"]) # e.g. "Bill '4444INV0009' not found" return result
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}")
def main() -> None: bill_no = sys.argv[1] client = UniPayClient(os.environ["UNIPAY_CLIENT_ID"], os.environ["UNIPAY_CLIENT_SECRET"])
found = look_up_bill(client, bill_no) bill = found["bill"] print(f"{bill['name']} owes {found['payableNow']} {bill['currency']} ({bill['description']})")
amount = Decimal(sys.argv[2]) if len(sys.argv) > 2 else Decimal(str(found["payableNow"])) rrn = f"EX{int(time.time() * 1000)}" result = pay_bill(client, bill["billNo"], amount, bill["currency"], rrn) print(f"{rrn}: {result['message']} (new record: {result['isNewRecord']})")
if __name__ == "__main__": main()// UniPay payment processor example: get a token, look up a bill, pay it.// Node.js 23.6+ runs this directly (22.6+ with --experimental-strip-types). No dependencies.//// export UNIPAY_BASE_URL=https://test.unipay.co.zw// export UNIPAY_CLIENT_ID=... UNIPAY_CLIENT_SECRET=...// node payment_processor.ts <billNo> [amount]
const BASE_URL = process.env.UNIPAY_BASE_URL ?? 'https://test.unipay.co.zw';
class UniPayClient { private readonly credentials: URLSearchParams; private token: string | null = null; private renewAt = 0; private pending: Promise<string> | null = null;
constructor(clientId: string, clientSecret: string, scope = 'unipay:bills') { this.credentials = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope, }); }
/** A cached access token, renewed 60 seconds before it expires. */ private async accessToken(): Promise<string> { if (this.token && Date.now() < this.renewAt) return this.token; // One request for concurrent callers. this.pending ??= (async () => { const response = await fetch(`${BASE_URL}/connect/token`, { method: 'POST', body: this.credentials, signal: AbortSignal.timeout(30_000), }); if (!response.ok) throw new Error(`Token request failed: HTTP ${response.status}`); const body = (await response.json()) as { access_token: string; expires_in: number }; this.token = body.access_token; this.renewAt = Date.now() + (body.expires_in - 60) * 1000; return this.token; })().finally(() => (this.pending = null)); return this.pending; }
async post<T>(path: string, body: unknown): Promise<T> { for (let attempt = 0; ; attempt++) { const response = await fetch(`${BASE_URL}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${await this.accessToken()}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); if (response.status === 401 && attempt === 0) { this.token = null; // revoked or expired early: get a new token, retry once continue; } if (!response.ok) throw new HttpError(response.status); return (await response.json()) as T; } }}
class HttpError extends Error { readonly status: number; constructor(status: number) { super(`HTTP ${status}`); this.status = status; }}
interface Result { successful: boolean; message: string; isNewRecord: boolean;}
interface LookupResult extends Result { bill: { billNo: string; name: string; description: string; currency: string } | null; payableNow: number | null; acceptedCurrencies: string[] | null;}
async function lookUpBill(client: UniPayClient, billNo: string): Promise<LookupResult> { const result = await client.post<LookupResult>('/api/app/payment-processor/lookup', { billNo }); if (!result.successful) throw new Error(result.message); // e.g. "Bill '4444INV0009' not found" return result;}
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 {}
const [billNo, amountArg] = process.argv.slice(2);const client = new UniPayClient(process.env.UNIPAY_CLIENT_ID!, process.env.UNIPAY_CLIENT_SECRET!);
const found = await lookUpBill(client, billNo);const bill = found.bill!;console.log(`${bill.name} owes ${found.payableNow} ${bill.currency} (${bill.description})`);
const amount = amountArg ? Number(amountArg) : found.payableNow!;const rrn = `EX${Date.now()}`;const result = await payBill(client, bill.billNo, amount, bill.currency, rrn);console.log(`${rrn}: ${result.message} (new record: ${result.isNewRecord})`);// UniPay payment processor example: get a token, look up a bill, pay it.// Go 1.21+, standard library only.//// export UNIPAY_BASE_URL=https://test.unipay.co.zw// export UNIPAY_CLIENT_ID=... UNIPAY_CLIENT_SECRET=...// go run payment_processor.go <billNo> [amount]package main
import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "strings" "sync" "time")
var baseURL = envOr("UNIPAY_BASE_URL", "https://test.unipay.co.zw")
type UniPayClient struct { http *http.Client credentials url.Values mu sync.Mutex token string renewAt time.Time}
func NewUniPayClient(clientID, clientSecret, scope string) *UniPayClient { return &UniPayClient{ http: &http.Client{Timeout: 30 * time.Second}, credentials: url.Values{ "grant_type": {"client_credentials"}, "client_id": {clientID}, "client_secret": {clientSecret}, "scope": {scope}, }, }}
// accessToken returns a cached token, renewed 60 seconds before it expires.func (c *UniPayClient) accessToken() (string, error) { c.mu.Lock() defer c.mu.Unlock() if c.token != "" && time.Now().Before(c.renewAt) { return c.token, nil } resp, err := c.http.PostForm(baseURL+"/connect/token", c.credentials) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("token request failed: HTTP %d", resp.StatusCode) } var body struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "", err } c.token = body.AccessToken c.renewAt = time.Now().Add(time.Duration(body.ExpiresIn-60) * time.Second) return c.token, nil}
type HTTPError struct{ Status int }
func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d", e.Status) }
// Post sends body as JSON and decodes the JSON response into out.func (c *UniPayClient) Post(path string, body, out any) error { payload, err := json.Marshal(body) if err != nil { return err } for attempt := 0; ; attempt++ { token, err := c.accessToken() if err != nil { return err } req, _ := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return err } if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { resp.Body.Close() c.mu.Lock() c.token = "" // revoked or expired early: get a new token, retry once c.mu.Unlock() continue } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return &HTTPError{resp.StatusCode} } decoder := json.NewDecoder(resp.Body) decoder.UseNumber() // keep amounts exact return decoder.Decode(out) }}
type Result struct { Successful bool `json:"successful"` Message string `json:"message"` IsNewRecord bool `json:"isNewRecord"`}
type LookupResult struct { Result Bill *struct { BillNo string `json:"billNo"` Name string `json:"name"` Description string `json:"description"` Currency string `json:"currency"` } `json:"bill"` PayableNow json.Number `json:"payableNow"` AcceptedCurrencies []string `json:"acceptedCurrencies"`}
func LookUpBill(c *UniPayClient, billNo string) (*LookupResult, error) { var result LookupResult err := c.Post("/api/app/payment-processor/lookup", map[string]string{"billNo": billNo}, &result) if err != nil { return nil, err } if !result.Successful { return nil, errors.New(result.Message) // e.g. "Bill '4444INV0009' not found" } return &result, nil}
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}
func main() { billNo := os.Args[1] client := NewUniPayClient(os.Getenv("UNIPAY_CLIENT_ID"), os.Getenv("UNIPAY_CLIENT_SECRET"), "unipay:bills")
found, err := LookUpBill(client, billNo) if err != nil { fail(err) } bill := found.Bill fmt.Printf("%s owes %s %s (%s)\n", bill.Name, found.PayableNow, bill.Currency, bill.Description)
amount := found.PayableNow if len(os.Args) > 2 { amount = json.Number(os.Args[2]) } rrn := fmt.Sprintf("EX%d", time.Now().UnixMilli()) result, err := PayBill(client, Payment{ BillNo: bill.BillNo, RRN: rrn, PaymentDate: time.Now(), Currency: bill.Currency, Amount: amount, }) if err != nil { fail(err) } fmt.Printf("%s: %s (new record: %t)\n", rrn, result.Message, result.IsNewRecord)}
func envOr(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback}
func fail(err error) { fmt.Fprintln(os.Stderr, err) os.Exit(1)}<?php// UniPay payment processor example: get a token, look up a bill, pay it.// PHP 8.1+ with the curl and json extensions, no packages.//// export UNIPAY_BASE_URL=https://test.unipay.co.zw// export UNIPAY_CLIENT_ID=... UNIPAY_CLIENT_SECRET=...// php payment_processor.php <billNo> [amount]
declare(strict_types=1);
const TIMEOUT_SECONDS = 30;$baseUrl = getenv('UNIPAY_BASE_URL') ?: 'https://test.unipay.co.zw';
/** Calls UniPay with a cached access token, renewed 60 seconds before it expires. */final class UniPayClient{ private ?string $token = null; private int $renewAt = 0;
public function __construct( private readonly string $baseUrl, private readonly string $clientId, private readonly string $clientSecret, private readonly string $scope = 'unipay:bills', ) { }
private function accessToken(): string { if ($this->token === null || time() >= $this->renewAt) { [$status, $body] = $this->send('/connect/token', http_build_query([ 'grant_type' => 'client_credentials', 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, 'scope' => $this->scope, ]), ['Content-Type: application/x-www-form-urlencoded']); if ($status !== 200) { throw new RuntimeException("Token request failed: HTTP $status"); } $this->token = $body['access_token']; $this->renewAt = time() + $body['expires_in'] - 60; } return $this->token; }
public function post(string $path, array $body): array { $json = json_encode($body, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); for ($attempt = 0; ; $attempt++) { [$status, $result] = $this->send($path, $json, [ 'Authorization: Bearer ' . $this->accessToken(), 'Content-Type: application/json', ]); if ($status === 401 && $attempt === 0) { $this->token = null; // revoked or expired early: get a new token, retry once continue; } if ($status !== 200) { throw new HttpStatusException($status); } return $result; } }
/** @return array{int, array} */ private function send(string $path, string $payload, array $headers): array { $curl = curl_init($this->baseUrl . $path); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => TIMEOUT_SECONDS, ]); $response = curl_exec($curl); if ($response === false) { $error = curl_error($curl); curl_close($curl); throw new NetworkException($error); } $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); return [$status, $response === '' ? [] : json_decode($response, true, 512, JSON_THROW_ON_ERROR)]; }}
final class HttpStatusException extends RuntimeException{ public function __construct(public readonly int $status) { parent::__construct("HTTP $status"); }}
final class NetworkException extends RuntimeException{}
final class RefusedException extends RuntimeException{}
function lookUpBill(UniPayClient $client, string $billNo): array{ $result = $client->post('/api/app/payment-processor/lookup', ['billNo' => $billNo]); if (!$result['successful']) { throw new RefusedException($result['message']); // e.g. "Bill '4444INV0009' not found" } return $result;}
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);}
$client = new UniPayClient($baseUrl, getenv('UNIPAY_CLIENT_ID'), getenv('UNIPAY_CLIENT_SECRET'));
$found = lookUpBill($client, $argv[1]);$bill = $found['bill'];printf("%s owes %s %s (%s)\n", $bill['name'], $found['payableNow'], $bill['currency'], $bill['description']);
$amount = $argv[2] ?? (string) $found['payableNow'];$rrn = 'EX' . (int) (microtime(true) * 1000);$result = payBill($client, $bill['billNo'], $amount, $bill['currency'], $rrn);printf("%s: %s (new record: %s)\n", $rrn, $result['message'], $result['isNewRecord'] ? 'true' : 'false');What it does
Section titled “What it does”- Caches the access token and renews it a minute before it expires — see Authentication.
- Looks the bill up with
POST /lookupand reads what’s owed — see Look up a bill. - Reports the payment with
POST /payment-detailsunder a reference (rrn) it generates, and retries with the same reference if the outcome is unknown — see Pay a bill and Idempotency and retries.
These examples are run against a UniPay instance before each docs release.
UniPay API v1 · build e1eb5c49 · Powered by Axis Solutions