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.

On a PayUs terminal? You can also launch the PayUs app directly via the Intent API — PayUs owns the UI and handles card/EFTPOS as well as QR. This REST SDK is for server-mediated QR payments and your own checkout UI.
Android extras. The Android SDK ships as a single 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).

Download .zip

Prerequisites

  1. Ask your PayUs admin to create an OAuth App in the portal (Admin → OAuth Apps)
  2. Get your App ID and App Secret (the secret is shown once — save it)
  3. Note which scopes were granted (e.g. payments:direct, terminals:read)
  4. Note your Branch ID (visible in the admin portal under Users)
  5. Supported currencies: NZD (New Zealand Dollar) and AUD (Australian Dollar)
  6. (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 window

Refund & 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

CodeHTTPMeaningSDK Behavior
ERR_FORBIDDEN403Missing required OAuth scopeThrows immediately — check your granted scopes
ERR_GATEWAY_NOT_FOUND422Terminal has no gateway configuredThrows immediately — contact admin
ERR_DUPLICATE_TRANSACTION400referenceId already usedThrows immediately — use a unique referenceId
ERR_REFUND_EXCEEDED400Refund amount exceeds remaining balanceThrows immediately — check remaining balance
ERR_UNAUTHORIZED401Token expired or revokedAuto-retries once with fresh token
ERR_RATE_LIMITED429Too many requestsThrows immediately — implement backoff
ERR_SYSTEM_ERROR500Server errorAuto-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 MethodRequired 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