Android SDK (Kotlin)
Integrate PayUs payments into your application. Authenticate with OAuth credentials, discover terminals, and process payments — all with built-in token management and error handling.
nz.payus:sdk aar with an optional drop-in Compose payment screen (nz.payus:sdk-ui). Note the two separate families: nz.payus:sdk[-ui] is this server-integration REST client; the on-device Intent SDK is com.payus:paycore[-compose] (see the Intent APIpage) — different namespaces, don't mix them. Discover which channels a terminal has enabled with client.capabilities(configId), and register a device with client.auth.activate(code) as an alternative to OAuth-app credentials. Download the sample app.Download SDK
Pre-built SDK package ready to drop into your project (~400 KB).
Prerequisites
- Ask your PayUs admin to create an OAuth App in the portal (Admin → OAuth Apps)
- Get your App ID and App Secret (the secret is shown once — save it)
- Note which scopes were granted (e.g.
payments:direct,terminals:read) - Note your Branch ID (visible in the admin portal under Users)
- Supported currencies: NZD (New Zealand Dollar) and AUD (Australian Dollar)
- (Optional) Ask for Sandbox Mode to test with simulated responses — no real money
Sandbox mode lets you test every scenario (success, decline, timeout, rate limit) using magic test amounts.
Installation
// Published to Maven Central — no repository config or token needed
// (mavenCentral() is already in most Android projects' settings.gradle.kts).
// build.gradle.kts (app module)
dependencies {
implementation("nz.payus:sdk:1.0.0") // the SDK (single .aar)
implementation("nz.payus:sdk-ui:1.0.0") // optional drop-in Compose payment screen
}Quick Start
// 1. Build the client (encrypted token storage; add a cert pin in production)
val client = OneMyPosMateClient.Builder("https://payus.co.nz", system = "pos")
.tokenStore(EncryptedSharedPrefsTokenStore(context))
.build()
// 2. Register this device once with its 6-digit activation code
client.auth.activate("123456")
// (or, for an integrator app: loginWithClientCredentials(appId, appSecret))
// 3. Discover which channels the backend enabled for this terminal
val caps = client.capabilities(configId = 123)
// 4. Take a non-card QR payment (typed — card/EFTPOS stays on the device SDK)
if (PaymentChannel.WECHAT in caps.usableChannels) {
val resp = client.payments.payNow(
channel = PaymentChannel.WECHAT,
amount = "10.00",
referenceId = "REF-${System.currentTimeMillis()}",
branchId = 17,
configId = 123,
)
// render resp.qrCode / resp.codeUrl, then poll until terminal status:
val details = client.query.getTransactionDetails("REF-…", 17, 123)
}Discover enabled channels
Channels are backend-controlled per terminal — ask, don't hardcode. A channel is usable only when it's enabled AND has a resolved gateway.
val caps = client.capabilities(configId = 123)
// caps.usableChannels ⊆ { WECHAT, ALIPAY, UNION_PAY, CENTRA_PAY }
// caps.qrDisplayMs — on-screen countdown windowRefund & cancel
client.payments.refund(RefundRequest(
referenceId = "REF-…", refundAmount = "10.00", refundReason = "customer",
branchId = 17, configId = 123))
client.payments.cancel(CancelRequest(
referenceId = "REF-…", branchId = 17, configId = 123))Optional drop-in Compose UI
Use our ready-made QR + countdown + result screen, or ignore it and build your own on client.payments.
SkyzerPay.init(client)
val launcher = registerForActivityResult(SkyzerPaymentContract()) { result ->
// PaymentResult.Success / Failed / Cancelled / Refunded
}
launcher.launch(SkyzerPaymentArgs(
amount = "10.00", channel = PaymentChannel.WECHAT,
branchId = 17, configId = 123, referenceId = "REF-…",
))Security: TLS 1.2+ with optional cert pinning, encrypted token store, no secret logging, and an optional AnalyticsSink for the payment funnel. Card/EFTPOS stays on the on-device SDK — see the Intent API. Download the sample app.
Line items (optional)
payNow (and cloud requestTerminal) accept an optional itemised basket via lineItems. Optional and additive — omit it and nothing changes. The server reconciles it and rejects a bad basket with HTTP 400 (ERR_LINE_ITEM_MISMATCH / ERR_INVALID_LINE_ITEMS). Full field + reconciliation reference: payNow and requestTerminal.
// payNow request body — lineItems is an optional array alongside the usual fields
{
"grandTotal": "11.00", // decimal string; includes the tip below
"referenceId": "ORD-42", // the refund key — always send one
"channel": "ALIPAY",
"branchId": 17, "configId": 123,
"tipAmount": "1.00", // decimal string; declared so the basket reconciles
"lineItems": [ // amounts in integer cents; sum(lineTotalCents) == merchandise (1100 - 100 = 1000)
{ "name": "Flat White", "quantity": "2", "unitPriceCents": 300, "lineTotalCents": 600, "sku": "FW" },
{ "name": "Muffin", "quantity": "1", "unitPriceCents": 400, "lineTotalCents": 400, "taxCents": 52 }
]
}Per line: name (≤255), quantity (decimal string, weight-capable), unitPriceCents / lineTotalCents(integer cents), optional taxCents / sku / category; max 200 lines. Reconciliation (strict, ±1c): sum(lineTotalCents) = grandTotal − tip − fee − donation + discount. Line amounts are integer cents on every surface; the modifier amounts (tipAmountetc.) are decimal strings on this REST path and integer cents on the Intent/LAN surfaces — don't mix them. Integrating on-device via the Intent/paycore SDK instead? It ships a typed PayUsLineItem with validation — see the Intent API page.
Authentication
The SDK handles OAuth token exchange, caching, and auto-refresh. You provide your appId and appSecret — the SDK does the rest.
val client = OneMyPosMateClient.Builder("https://payus.co.nz", system = "pos")
.tokenStore(EncryptedSharedPrefsTokenStore(context)) // encrypted at rest
.build()
// Option A — per-device terminal activation (recommended; no shared secret shipped)
client.auth.activate("123456") // 6-digit code from the PayUs admin
// Option B — OAuth integrator app (one credential for a fleet / server-to-server)
client.auth.loginWithClientCredentials("YOUR_APP_ID", "YOUR_APP_SECRET")
// Either way the SDK caches the JWT and auto-refreshes 60s before expiry.
// A single-flight lock prevents concurrent refreshes; revoke → re-authenticate.
val token = client.auth.currentToken()Token Lifecycle
Tokens expire after 7 days. The SDK auto-refreshes 60 seconds before expiry. If an admin revokes your OAuth app or changes your scopes, you need to re-authenticate.
// Tokens expire after 7 days — the SDK auto-refreshes
// If the admin revokes your OAuth app, new tokens are blocked immediately
// If scopes change, get a new token to pick up the new permissions
// Get the current token — currentToken() refreshes transparently when near expiry
val token = client.auth.currentToken()
// Handle scope errors
try {
client.payments.payNow(request)
} catch (e: OneMyPosMateException) {
if (e.errorCode == ErrorCode.ERR_FORBIDDEN) {
// Your OAuth app doesn't have the required scope
// Ask your admin to grant 'payments:direct'
}
}Error Handling
All SDK methods throw typed exceptions with structured error codes. The SDK auto-retries on ERR_UNAUTHORIZED (once) and ERR_SYSTEM_ERROR (3x).
try {
client.payments.payNow(request)
} catch (e: OneMyPosMateException) {
when (e.errorCode) {
ErrorCode.ERR_FORBIDDEN ->
// Missing OAuth scope — ask admin to grant it
ErrorCode.ERR_GATEWAY_NOT_FOUND ->
// Terminal has no gateway configured
ErrorCode.ERR_DUPLICATE_TRANSACTION ->
// referenceId already used — generate a unique one
ErrorCode.ERR_REFUND_EXCEEDED ->
// Refund amount exceeds remaining balance
ErrorCode.ERR_UNAUTHORIZED ->
// Token expired and auto-refresh failed
// Call loginWithClientCredentials() again
ErrorCode.ERR_RATE_LIMITED ->
// Too many requests — back off and retry
ErrorCode.ERR_SYSTEM_ERROR ->
// Server error — SDK retries 3x automatically
else -> // Unknown error
}
}Error Codes Reference
| Code | HTTP | Meaning | SDK Behavior |
|---|---|---|---|
| ERR_FORBIDDEN | 403 | Missing required OAuth scope | Throws immediately — check your granted scopes |
| ERR_GATEWAY_NOT_FOUND | 422 | Terminal has no gateway configured | Throws immediately — contact admin |
| ERR_DUPLICATE_TRANSACTION | 400 | referenceId already used | Throws immediately — use a unique referenceId |
| ERR_REFUND_EXCEEDED | 400 | Refund amount exceeds remaining balance | Throws immediately — check remaining balance |
| ERR_UNAUTHORIZED | 401 | Token expired or revoked | Auto-retries once with fresh token |
| ERR_RATE_LIMITED | 429 | Too many requests | Throws immediately — implement backoff |
| ERR_SYSTEM_ERROR | 500 | Server error | Auto-retries 3x with exponential backoff |
Required Scopes
Each SDK method requires a specific OAuth scope. If your app doesn't have the required scope, the server returns 403 ERR_FORBIDDEN. Ask your admin to update your app's scopes.
| SDK Method | Required Scope |
|---|---|
| client.payments.payNow() | payments:direct |
| client.payments.saveTransaction() | payments:direct |
| client.payments.refund() | refunds:write |
| client.payments.cancel() | refunds:write |
| client.query.getTransactionDetails() | transactions:read |
| client.triggers.listSiblings() | terminals:read |
| client.triggers.sendTrigger() | terminals:trigger |