API v1 · v2
Merchant Integration Guide

Accept payments across Zimbabwe with a single integration.

xPay Engine lets you take every major local payment method — EcoCash, OneMoney, InnBucks, Omari, Zimswitch and Visa or Mastercard — without ever touching card or wallet details yourself. Your backend creates a payment, the customer pays on a secure xPay-hosted page, and xPay sends the result back to your backend. This guide walks you through building that integration, step by step.

EcoCash OneMoney InnBucks Omari Visa / Mastercard Zimswitch
i

Base URLs below use the production host https://api.xpay.optimasysprod.com; sandbox / test environment URLs are in your dashboard portal. There are exactly three integration points: initiate, check, and the callback.

01Core concepts

TermMeaning
ApplicationYour merchant app on the platform, identified by an applicationCode. Its keys and settings live in your dashboard portal.
Integration KeyA per-application secret sent in a request header. Identifies and authorises your application on the payment endpoints.
Encryption KeyA per-application secret used to encrypt/decrypt the payment payload. Distinct from the integration key.
Reference numberThe xPay transaction reference returned by initiate. Use it to check the transaction and to match the callback.
resultUrlYour server-to-server callback URL. Must be HTTPS and publicly resolvable.
returnUrlThe browser URL the customer returns to after paying on the hosted page.
redirectUrlThe xPay-hosted payment page you send the customer to.
i

All payments are completed on the xPay-hosted payment page. You never collect card or wallet credentials yourself — you initiate, redirect the customer, and receive the result.

02How it works

The solution context, the message channels between environments, and the end-to-end payment sequence.

Solution context

MERCHANT ENVIRONMENT xPAY PLATFORM PAYMENT PROVIDERS Merchant Server your backend Customer Browser checkout / device xPay API / Engine orchestration Hosted Payment Page customer pays here Transaction store Provider APIs EcoCash · OneMoney Omari · InnBucks Visa · Mastercard ZimSwitch A E C B D
Environment boundaries & message channels
AInitiate & check — Merchant Server → xPay API over HTTPS REST. Request & response bodies are AES-GCM encrypted; the integration key travels in a request header.
BCustomer pays — the customer completes payment on the xPay-hosted page over HTTPS. xPay collects the method & credentials, not you.
CRedirect — your backend sends the customer's browser to redirectUrl (the hosted page).
DProvider dispatch — the xPay Engine calls the payment provider server-to-server over HTTPS and receives the provider's callback.
EResult callback — xPay POSTs the final result to your resultUrl over HTTPS (with the integration-key header). Asynchronous; you can also pull status over channel A.

Payment sequence

Merchant Server Customer Browser xPay Payment Provider 1 POST /v2/payments/initiate · encrypted 2 200 · { referenceNumber, redirectUrl } 3 redirect browser → redirectUrl 4 open hosted page · choose method · pay 5 process payment (server-to-server) 6 prompt: PIN / OTP / 3-D Secure 7 customer approves 8 provider callback (result) 9 POST resultUrl ← callback 10 GET /v2/payments/{ref}/status · reconcile 11 verdict (SUCCEEDED / EXPIRED / …) 12 redirect browser → returnUrl
End-to-end payment sequence

What you implement

  1. InitiatePOST /v2/payments/initiate, then read referenceNumber and redirectUrl from the response.
  2. Redirect — send the customer's browser to redirectUrl. They pay on the hosted page; xPay returns them to your returnUrl.
  3. Receive the callback — accept the POST to your resultUrl, respond 2xx, and update your order.
  4. Reconcile — pull GET /v2/payments/{ref}/status as the authoritative floor for any callback you miss.

Steps 1 and 3–4 are backend calls; step 2 is a browser redirect. You never handle payment credentials.

03Getting your credentials

Your two secrets are issued per application and are found in the customer dashboard portal — not through any API:

CredentialWhereUsed for
Integration KeyDashboard portal → your applicationSent in a request header to authorise payment calls (§4).
Encryption KeyDashboard portal → your applicationEncrypts/decrypts the request & response payloads (§5).
applicationCodeDashboard portal → your applicationYour application identifier.
!

Keep both keys server-side only. Never ship them in a browser or mobile client. If a key is exposed, rotate it from the dashboard portal.

04Authenticating requests

Payment calls are authenticated by your integration key, sent in a request header. xPay reads it from the first of these headers present, in order:

keythen Authorizationthen Access Key
request header
key: <your-integration-key>

The key must belong to an active application with an enabled integration key; otherwise the request is rejected 403 Forbidden"Integration key for the application is not valid (Not active or disabled)".

i

The key resolves your application context server-side (application code, encryption key, settings). That is why the initiate payload carries no application code — it is derived from the key.

05Payload encryption

The initiate request body and xPay's responses are not plain JSON. They travel inside an envelope containing an encrypted string:

wire body
{ "payload": "<base64 ciphertext>" }

payload is the AES-encrypted JSON of the actual message, encrypted with your application's encryption key; xPay encrypts its responses back to you the same way, so you decrypt those too.

5.1 · The scheme — AES-GCM

xPay uses a single payload-encryption scheme: AES-GCM (authenticated, random IV). You encrypt your request payload with it, and xPay encrypts the response payload the same way — request and response, both directions, always AES-GCM.

5.2 · Reference implementation

CipherAES/GCM/NoPadding
Key256-bit, derived as SHA-256(encryptionKey) — apply the same derivation in your SDK
IVfresh random 12 bytes per message
Auth tag128-bit, appended by GCM
Wire formatBase64( iv[12] ‖ ciphertext ‖ authTag[16] )
python — cryptography
import base64, hashlib, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def encrypt(plaintext_json: str, encryption_key: str) -> str:
    key = hashlib.sha256(encryption_key.encode()).digest()          # 32 bytes
    iv  = os.urandom(12)
    ct  = AESGCM(key).encrypt(iv, plaintext_json.encode(), None)  # ciphertext ‖ tag
    return base64.b64encode(iv + ct).decode()                       # iv ‖ ciphertext ‖ tag

def decrypt(b64: str, encryption_key: str) -> str:
    key = hashlib.sha256(encryption_key.encode()).digest()
    raw = base64.b64decode(b64)
    iv, ct = raw[:12], raw[12:]
    return AESGCM(key).decrypt(iv, ct, None).decode()
node.js — crypto
const crypto = require("crypto");

function encrypt(plaintextJson, encryptionKey) {
  const key = crypto.createHash("sha256").update(encryptionKey, "utf8").digest();  // 32 bytes
  const iv  = crypto.randomBytes(12);
  const c   = crypto.createCipheriv("aes-256-gcm", key, iv);
  const ct  = Buffer.concat([c.update(plaintextJson, "utf8"), c.final()]);
  const tag = c.getAuthTag();                                    // 16 bytes
  return Buffer.concat([iv, ct, tag]).toString("base64"); // iv ‖ ciphertext ‖ tag
}

function decrypt(b64, encryptionKey) {
  const key = crypto.createHash("sha256").update(encryptionKey, "utf8").digest();
  const buf = Buffer.from(b64, "base64");
  const iv  = buf.subarray(0, 12);
  const tag = buf.subarray(buf.length - 16);
  const ct  = buf.subarray(12, buf.length - 16);
  const d   = crypto.createDecipheriv("aes-256-gcm", key, iv);
  d.setAuthTag(tag);
  return Buffer.concat([d.update(ct), d.final()]).toString("utf8");
}
java — javax.crypto
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;

public final class XPayCrypto {
    private static final SecureRandom RNG = new SecureRandom();

    public static String encrypt(String json, String encryptionKey) throws Exception {
        byte[] key = MessageDigest.getInstance("SHA-256")
                .digest(encryptionKey.getBytes(StandardCharsets.UTF_8));
        byte[] iv = new byte[12];
        RNG.nextBytes(iv);
        Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
        c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv));
        byte[] ct = c.doFinal(json.getBytes(StandardCharsets.UTF_8));   // ciphertext ‖ tag
        byte[] out = new byte[iv.length + ct.length];
        System.arraycopy(iv, 0, out, 0, iv.length);
        System.arraycopy(ct, 0, out, iv.length, ct.length);
        return Base64.getEncoder().encodeToString(out);              // iv ‖ ciphertext ‖ tag
    }

    public static String decrypt(String b64, String encryptionKey) throws Exception {
        byte[] key = MessageDigest.getInstance("SHA-256")
                .digest(encryptionKey.getBytes(StandardCharsets.UTF_8));
        byte[] all = Base64.getDecoder().decode(b64);
        byte[] iv  = Arrays.copyOfRange(all, 0, 12);
        byte[] ct  = Arrays.copyOfRange(all, 12, all.length);
        Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
        c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv));
        return new String(c.doFinal(ct), StandardCharsets.UTF_8);
    }
}
c# — System.Security.Cryptography
using System;
using System.Security.Cryptography;
using System.Text;

public static class XPayCrypto
{
    public static string Encrypt(string json, string encryptionKey)
    {
        byte[] key = SHA256.HashData(Encoding.UTF8.GetBytes(encryptionKey)); // 32 bytes
        byte[] iv  = RandomNumberGenerator.GetBytes(12);
        byte[] pt  = Encoding.UTF8.GetBytes(json);
        byte[] ct  = new byte[pt.Length];
        byte[] tag = new byte[16];
        using var gcm = new AesGcm(key, 16);
        gcm.Encrypt(iv, pt, ct, tag);
        byte[] outBytes = new byte[iv.Length + ct.Length + tag.Length];
        Buffer.BlockCopy(iv,  0, outBytes, 0, iv.Length);
        Buffer.BlockCopy(ct,  0, outBytes, iv.Length, ct.Length);
        Buffer.BlockCopy(tag, 0, outBytes, iv.Length + ct.Length, tag.Length);
        return Convert.ToBase64String(outBytes);                       // iv ‖ ciphertext ‖ tag
    }

    public static string Decrypt(string b64, string encryptionKey)
    {
        byte[] key = SHA256.HashData(Encoding.UTF8.GetBytes(encryptionKey));
        byte[] all = Convert.FromBase64String(b64);
        byte[] iv  = all[..12];
        byte[] tag = all[^16..];
        byte[] ct  = all[12..^16];
        byte[] pt  = new byte[ct.Length];
        using var gcm = new AesGcm(key, 16);
        gcm.Decrypt(iv, ct, tag, pt);
        return Encoding.UTF8.GetString(pt);
    }
}

5.3 · Verify your implementation

Before wiring the endpoints, confirm your crypto locally with a round-trip: decrypt(encrypt(json)) == json using the snippets above. If that holds, your key derivation, IV handling and Base64 framing are correct. To see it end-to-end without writing any crypto, use the interactive simulator in §11, which performs the full AES-GCM encrypt → initiate → decrypt round-trip against the sandbox in your browser.

06Integration endpoints

Three touch points: initiate a transaction, reconcile its status, and receive the callback. Initiate and check-payment require the integration-key header (§4); the status pull is unauthenticated — no header needed.

6.1 · Initiate a transaction

The request payload is AES-GCM (§5); xPay returns the response payload AES-GCM too.

POST/v2/payments/initiate · alias /v2/transactions/initiate
request
key: <integration-key>
Content-Type: application/json

{ "payload": "<AES-GCM encrypted InitiateTransactionRequest>" }

Decrypted payload — the initiate request:

FieldTypeNotes
amountnumberRequired. Transaction amount.
currencyCodestringRequired. e.g. USD, ZWG. Validated against active currencies.
reasonForPaymentstringRequired. Description shown to the customer.
resultUrlstringRequired. Your callback URL (HTTPS).
returnUrlstringRequired. Browser return URL after the hosted page.
merchantReferencestringOptional. Your own reference (echoed back in the callback).
customerEmailstringOptional. Customer email.
paymentMethodsstring[]Optional. Restrict the hosted page to these method codes (§7). Empty/omitted = all your active methods.

Decrypted response (inside the returned payload):

FieldTypeNotes
referenceNumberstringUse to check the transaction & match the callback.
redirectUrlstringSend the customer's browser here — the hosted payment page.
checkUrlstringPoll URL for this transaction.
transactionIdstringInternal UID.
statusTransactionStatus enum (§8)Initial status (typically INITIATED).
i

Decrypt the returned payload with AES-GCM (§5) — the same scheme you used for the request.

6.2 · Reconcile a transaction

GET/v2/payments/{referenceNumber}/status
i

This response is plain JSON, never encrypted — unlike the full-detail pull below, there is nothing to decrypt. (Also served on /v1/payments/{ref}/status for existing integrations.)

Read-only verdict pull — the authoritative floor behind the callback. Unauthenticated — no header needed. Returns plain JSON:

FieldTypeNotes
referenceNumberstring
intentStatusIntentStatus enum (§8)High-level verdict.
latestAttemptStatusTransactionStatus enum (§8)Underlying attempt status.
latestVerdictSeqnumber | nullHighest wins (ordering). null until the first verdict notification is dispatched.
latestNotificationIdstring | nullMatches the callback X-Notification-Id. null until the first verdict notification is dispatched.
retryablebooleanWhether the customer can re-attempt.
terminalbooleanWhether the transaction is final.

Full-detail pull. When you need the complete transaction record — not just the verdict — pull:

GET/v2/payments/check-payment?ref={referenceNumber} · alias /v2/transactions/check-payment

Requires the integration-key header (§4). Returns the full transaction detail as an AES-GCM encrypted envelope ({ "payload": "…" }); decrypt it with the same GCM parameters from §5. If the transaction isn't yet terminal, this also triggers a fresh status check with the provider (unlike /status, which is read-only). Use it when a callback was missed and you need the settled amounts and fees — amountPaid, serviceFee, totalTransactionAmount, merchantAmount, provider references — rather than only the verdict.

6.3 · Receive the callback (DMN)

When the transaction reaches a verdict, xPay POSTs a notification to your resultUrl. The callback is a single mechanism, not version-scoped — the same delivery serves both v1 and v2 initiations.

CALLBACKPOST <your resultUrl>
xPay → your backend
POST <your resultUrl>
Content-Type: application/json
Authorization: <your integration key>
X-Notification-Id: <stable idempotency key>
X-Intent-Status: SUCCEEDED | EXPIRED | CANCELLED
X-Verdict-Seq: <monotonic integer>

{ …transaction result… }

Body fields include: referenceNumber, transactionId, applicationCode, amount, amountPaid, serviceFee, totalTransactionAmount, merchantAmount, currencyCode, email, reasonForPayment, merchantReference, transactionStatus, statusCode, statusDescription, pollUrl, returnUrl, date, time. The callback body is plain JSON (not encrypted).

Your receiver must

  1. Verify the Authorization header equals your integration key (use a constant-time comparison) before processing the body — reject anything without it with 401. The value is the raw integration key with no scheme prefix — read it with request.getHeader("Authorization"), not a framework Bearer/Basic parser (which would strip or reject it).
  2. Respond 2xx quickly. Any non-2xx or timeout is treated as a failure and retried.
  3. Dedup on X-Notification-Id. Delivery is at-least-once; the same verdict may arrive more than once.
  4. Order by X-Verdict-Seq. The highest sequence is authoritative (handles a late EXPIRED → SUCCEEDED correction).
  5. Verify independently — call /status (§6.2) before releasing goods. Treat the push as a trigger, the pull as the source of truth.

Delivery guarantees

  • Exponential backoff on failure: ≈ 1m5m30m2h6h, over a 72-hour window.
  • After 72h it is parked UNDELIVERED — reconcile via /status.
  • Your resultUrl must be HTTPS and publicly resolvable. Non-HTTPS or internal-resolving URLs are permanently rejected by the SSRF guard.
i

Always implement the /status pull as your reconciliation floor. Never rely on the callback alone.

07Payment methods

The customer chooses a method on the hosted page. Use the paymentMethods array in initiate to restrict which of your active methods are offered — pass the codes below. Only methods active for your application appear.

MethodCodeCurrencies
EcoCashXPM101USD, ZWG
OneMoneyXPM102USD, ZWG
VisaXPM104USD
MastercardXPM105USD
InnBucksXPM106USD
Zimswitch (EFT)XPM108USD, ZWG
OmariXPM109USD, ZWG

08Transaction & intent statuses

IntentStatus — high-level verdict (callback & /status)

REQUIRES_PAYMENT PROCESSING REQUIRES_ACTION SUCCEEDED CANCELLED EXPIRED

Map to your order state: SUCCEEDED ⇒ fulfil; EXPIRED / CANCELLED ⇒ abandon; REQUIRES_ACTION ⇒ the customer still has a pending step on the hosted page; PROCESSING / REQUIRES_PAYMENT ⇒ keep polling. The callback fires only on the verdicts SUCCEEDED, EXPIRED, CANCELLED.

TransactionStatus — fine-grained; statusCode carries the numeric code

StatusCodeGroup
SUCCESS304success
INITIATED301in flight
PROCESSING302in flight
PENDING303in flight
FAILED300retryable
INSUFFICIENT_FUNDS308retryable
AUTHORIZATION_FAILED312retryable
DECLINED311retryable
TIME_OUT306retryable
SERVICE_UNAVAILABLE313retryable
ERROR310retryable
CLOSED_PERIOD_ELAPSED307retryable
CANCELLED309terminal
TERMINATED305terminal
REVERSED314terminal

09Error handling

Errors return a JSON ErrorMessage:

error body
{
  "timestamp":   "07-14-2026 12:34:567",
  "message":     "Integration key for the application is not valid (Not active or disabled)",
  "path":        "uri=/v2/payments/initiate",
  "exception":   "…AccessDeniedException",
  "status":      "403",
  "description": "Unauthorized Access"
}
HTTPWhen
400Invalid/undecryptable payload, missing required field, unknown currency, no active payment method in the request.
403Missing/invalid/disabled integration key.
404Unknown referenceNumber.
422Requested payment method is unavailable for your application.
500Payload decryption failure (wrong key / tampered ciphertext) and other server errors.

Decryption fails closed — a tampered or wrong-key payload is rejected rather than partially processed.

10Quick start

bash
# 1. Encrypt this JSON with your encryption key (AES-GCM, §5) into "<CIPHERTEXT>":
#    { "amount": 100.00, "currencyCode": "USD", "merchantReference": "ORDER-1001",
#      "reasonForPayment": "Order 1001", "customerEmail": "buyer@example.com",
#      "resultUrl": "https://merchant.example.com/xpay/callback",
#      "returnUrl": "https://merchant.example.com/checkout/return" }

# 2. Initiate
curl -X POST https://api.xpay.optimasysprod.com/v2/payments/initiate \
  -H "key: $XPAY_INTEGRATION_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "payload": "<CIPHERTEXT>" }'
# → { "payload": "<ENCRYPTED { referenceNumber, redirectUrl, ... }>" }

# 3. Decrypt the response (AES-GCM, §5); redirect the customer's browser to redirectUrl.
# 4. Receive the callback at resultUrl: dedup on X-Notification-Id, respond 2xx.

# 5. Reconcile
curl https://api.xpay.optimasysprod.com/v2/payments/TXN-XXXX/status
# → { "intentStatus": "SUCCEEDED", "terminal": true, ... }

Checklist before go-live

  • Encryption verified with a local round-trip (decrypt(encrypt(x)) == x); testing helper removed from production paths.
  • Integration key & encryption key read from config (dashboard portal), never hard-coded or client-side.
  • Integration-key header sent on every payment call.
  • Customer is redirected to redirectUrl; returnUrl handles their return.
  • Callback receiver is HTTPS, returns 2xx, dedups on X-Notification-Id, orders by X-Verdict-Seq.
  • /status reconciliation implemented as the source of truth.

11Sandbox testing

The sandbox is a fully isolated test environment. Every payment rail is simulated — no real EcoCash/OneMoney/card call is made, no funds move, and no real wallet or PIN is needed. You run the exact same initiate → hosted page → callback → status flow you will use in production.

SandboxProduction
API base URLhttps://api.xpay.sandbox.optimasysdev.comhttps://api.xpay.optimasysprod.com
Hosted payment pagehttps://payments.sandbox.xpay.optimasysdev.comyour production payment page
Providerssimulated (outcome chosen by test data)real

Two ways in. Just try it — use the sandbox base URL with the ready-made test merchant the sandbox seeds automatically (every method enabled); get its applicationCode, integration key and encryption key from your dashboard. Integrate — point your integration at the sandbox base URL with your own sandbox credentials and build the full flow before switching the base URL to production.

11.1 · Choosing the outcome (test data)

On the sandbox the result is decided by what you send, resolved in this order — explicit override → test card → test wallet number → magic amount → SUCCESS.

Magic amount — works on every rail (including Zimswitch/PayPal that take no card or phone); the cents decide the result, so you pick the scenario at initiate time:

Amount ends inResult
.00 (or anything unlisted)SUCCESS
.51DECLINED
.10INSUFFICIENT_FUNDS
.05TIME_OUT
.99FAILED

Test cards (Visa XPM104 / Mastercard XPM105) — any future expiry, any CVV, any name:

Card numberResult
4111 1111 1111 1111SUCCESS (Visa)
5555 5555 5555 4444SUCCESS (Mastercard)
4000 0000 0000 0002DECLINED
4000 0000 0000 9995INSUFFICIENT_FUNDS
4000 0000 0000 0069TIME_OUT
4000 0000 0000 0119FAILED
any other valid-format cardfalls through to the magic amount (so .51 still declines); otherwise SUCCESS

Test wallet numbers (EcoCash/OneMoney/Omari/InnBucks/Telecash) — by the last 4 digits:

Phone ends inResult
0002DECLINED
0009INSUFFICIENT_FUNDS
0006TIME_OUT
0001FAILED
any other (e.g. 263 77 000 0000)SUCCESS

The prefix must be valid for the rail (the provider validates it): OneMoney 071…, Telecash 073…, EcoCash / Omari / InnBucks 077…. The last 4 digits pick the outcome — e.g. OneMoney decline = 263 71 000 0002. (The simulator fills the right prefix automatically when you switch method.)

Explicit override (any rail; highest priority) — send sandboxOutcome in the payment fields: SUCCESS DECLINE INSUFFICIENT_FUNDS TIMEOUT FAIL.

Callbacks on failure. DECLINED / INSUFFICIENT_FUNDS / TIME_OUT / FAILED are retryable — the customer can retry and the intent stays open, so no callback fires yet (the callback fires only on the terminal verdicts SUCCEEDED / EXPIRED / CANCELLED — §8). A successful payment fires the callback immediately; to see a terminal failure callback, let the transaction expire or cancel it.

11.2 · Try it inline

Run a real simulated payment against the sandbox right here — pick a method, enter test data (§11.1) and a scenario, and your browser drives the full encrypt → initiate → make-payment → status flow (AES-GCM, v2) using the public sandbox test merchant. No real money, no login.

🧪 Sandbox — test only. This runs on an isolated environment (separate server, database and namespace) with public throwaway credentials. Every payment is simulated — no real money moves and nothing here touches production.

Prefer raw calls? It's the same initiate → make-payment → status as §10 — the simulator just runs it against https://api.xpay.sandbox.optimasysdev.com with the public test merchant.