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.
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
| Term | Meaning |
|---|---|
| Application | Your merchant app on the platform, identified by an applicationCode. Its keys and settings live in your dashboard portal. |
| Integration Key | A per-application secret sent in a request header. Identifies and authorises your application on the payment endpoints. |
| Encryption Key | A per-application secret used to encrypt/decrypt the payment payload. Distinct from the integration key. |
| Reference number | The xPay transaction reference returned by initiate. Use it to check the transaction and to match the callback. |
resultUrl | Your server-to-server callback URL. Must be HTTPS and publicly resolvable. |
returnUrl | The browser URL the customer returns to after paying on the hosted page. |
redirectUrl | The xPay-hosted payment page you send the customer to. |
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
redirectUrl (the hosted page).resultUrl over HTTPS (with the integration-key header). Asynchronous; you can also pull status over channel A.Payment sequence
What you implement
- Initiate —
POST /v2/payments/initiate, then readreferenceNumberandredirectUrlfrom the response. - Redirect — send the customer's browser to
redirectUrl. They pay on the hosted page; xPay returns them to yourreturnUrl. - Receive the callback — accept the POST to your
resultUrl, respond 2xx, and update your order. - Reconcile — pull
GET /v1/payments/{ref}/statusas 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:
| Credential | Where | Used for |
|---|---|---|
| Integration Key | Dashboard portal → your application | Sent in a request header to authorise payment calls (§4). |
| Encryption Key | Dashboard portal → your application | Encrypts/decrypts the request & response payloads (§5). |
applicationCode | Dashboard portal → your application | Your 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:
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)".
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 the check response are not plain JSON. They travel inside an envelope containing an encrypted string:
{ "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. The scheme for the request you send depends on
the endpoint version (below); responses are always AES-CBC (§5.3), whichever
version you call.
5.1 · Which scheme?
The scheme for the request payload you send is selected by the endpoint version. The response payload xPay returns (initiate and check-payment) is currently always AES-CBC, regardless of the version you call:
| Message | Scheme |
|---|---|
Request on /v2/… | AES-GCM — authenticated, random IV (§5.2) · recommended |
Request on /v1/… | AES-CBC — key-derived IV, no auth tag (§5.3) · legacy |
| Any response (initiate, check-payment) | AES-CBC (§5.3) — on both versions |
5.2 · AES-GCM (v2) — recommended
| Cipher | AES/GCM/NoPadding |
|---|---|
| Key | 256-bit, derived as SHA-256(encryptionKey) — apply the same derivation in your SDK |
| IV | fresh random 12 bytes per message |
| Auth tag | 128-bit, appended by GCM |
| Wire format | Base64( iv[12] ‖ ciphertext ‖ authTag[16] ) |
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()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");
}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);
}
}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 · AES-CBC (v1) — legacy
| Cipher | AES/CBC/PKCS5Padding |
|---|---|
| Key | the encryption key's raw UTF-8 bytes used directly as the AES key. Dashboard-issued keys are always 32 ASCII characters ⇒ AES-256. (AES requires a 16/24/32-byte key; don't substitute a differently-sized secret.) |
| IV | the first 16 bytes of the encryption key |
| Wire format | Base64( ciphertext ) |
AES-CBC here uses a key-derived (deterministic) IV — the same plaintext always produces the same ciphertext — and carries no integrity tag. It is supported for existing v1 integrations; prefer AES-GCM (v2) for new work.
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
def encrypt(plaintext_json: str, encryption_key: str) -> str:
key = encryption_key.encode("utf-8") # 32 bytes → AES-256
iv = key[:16]
padder = PKCS7(128).padder()
data = padder.update(plaintext_json.encode()) + padder.finalize()
enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
return base64.b64encode(enc.update(data) + enc.finalize()).decode()
def decrypt(b64: str, encryption_key: str) -> str:
key = encryption_key.encode("utf-8")
iv = key[:16]
dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
data = dec.update(base64.b64decode(b64)) + dec.finalize()
unp = PKCS7(128).unpadder()
return (unp.update(data) + unp.finalize()).decode()const crypto = require("crypto");
function encrypt(plaintextJson, encryptionKey) {
const key = Buffer.from(encryptionKey, "utf8"); // 32 bytes → AES-256
const iv = key.subarray(0, 16);
const c = crypto.createCipheriv("aes-256-cbc", key, iv);
return Buffer.concat([c.update(plaintextJson, "utf8"), c.final()]).toString("base64");
}
function decrypt(b64, encryptionKey) {
const key = Buffer.from(encryptionKey, "utf8");
const iv = key.subarray(0, 16);
const d = crypto.createDecipheriv("aes-256-cbc", key, iv);
return Buffer.concat([d.update(Buffer.from(b64, "base64")), d.final()]).toString("utf8");
}import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
public final class XPayCryptoCbc {
public static String encrypt(String json, String encryptionKey) throws Exception {
byte[] key = encryptionKey.getBytes(StandardCharsets.UTF_8); // 32 bytes → AES-256
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(key, 0, 16));
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), iv);
return Base64.getEncoder().encodeToString(c.doFinal(json.getBytes(StandardCharsets.UTF_8)));
}
public static String decrypt(String b64, String encryptionKey) throws Exception {
byte[] key = encryptionKey.getBytes(StandardCharsets.UTF_8);
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(key, 0, 16));
Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), iv);
return new String(c.doFinal(Base64.getDecoder().decode(b64)), StandardCharsets.UTF_8);
}
}using System;
using System.Security.Cryptography;
using System.Text;
public static class XPayCryptoCbc
{
public static string Encrypt(string json, string encryptionKey)
{
byte[] key = Encoding.UTF8.GetBytes(encryptionKey); // 32 bytes → AES-256
using var aes = Aes.Create();
aes.Key = key; aes.IV = key[..16];
aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
using var enc = aes.CreateEncryptor();
byte[] pt = Encoding.UTF8.GetBytes(json);
return Convert.ToBase64String(enc.TransformFinalBlock(pt, 0, pt.Length));
}
public static string Decrypt(string b64, string encryptionKey)
{
byte[] key = Encoding.UTF8.GetBytes(encryptionKey);
using var aes = Aes.Create();
aes.Key = key; aes.IV = key[..16];
aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
using var dec = aes.CreateDecryptor();
byte[] ct = Convert.FromBase64String(b64);
return Encoding.UTF8.GetString(dec.TransformFinalBlock(ct, 0, ct.Length));
}
}5.4 · Encryption testing helper (development only)
To sanity-check your key handling and the request/response envelope while building your SDK, xPay exposes a public round-trip utility. It operates on the v1 (AES-CBC) scheme:
// body
{ "payload": { "amount": 100.00, "currencyCode": "USD" }, "encryptionKey": "<your key>" }
// response — the encrypted string
"<base64 ciphertext>"// body
{ "encryptedData": "<base64 ciphertext>", "encryptionKey": "<your key>" }
// response — the decrypted plaintext JSON
"{ \"amount\": 100.00, \"currencyCode\": \"USD\" }"Development and sandbox only — never call this against the production host.
This utility accepts your encryption key over the wire; use it only against your
sandbox environment URL (from your dashboard portal), then remove it from your
flow — /opn/** is unauthenticated, so a call to production would land your live key
in server logs; if that ever happens, treat the key as compromised and rotate it from the
dashboard portal. It verifies the v1 (AES-CBC) scheme — for
v2 (AES-GCM) the authoritative check is a local round-trip:
decrypt(encrypt(json)) == json using the samples above.
06Integration endpoints
Three touch points: initiate a transaction, check a transaction, 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
Available on both versions — same request/response fields; only the payload encryption scheme differs (§5). Prefer v2.
key: <integration-key>
Content-Type: application/json
{ "payload": "<AES-GCM encrypted InitiateTransactionRequest>" }Decrypted payload — the initiate request:
| Field | Type | Notes |
|---|---|---|
amount | number | Required. Transaction amount. |
currencyCode | string | Required. e.g. USD, ZWG. Validated against active currencies. |
reasonForPayment | string | Required. Description shown to the customer. |
resultUrl | string | Required. Your callback URL (HTTPS). |
returnUrl | string | Required. Browser return URL after the hosted page. |
merchantReference | string | Optional. Your own reference (echoed back in the callback). |
customerEmail | string | Optional. Customer email. |
paymentMethods | string[] | Optional. Restrict the hosted page to these method codes (§7). Empty/omitted = all your active methods. |
Decrypted response (inside the returned payload):
| Field | Type | Notes |
|---|---|---|
referenceNumber | string | Use to check the transaction & match the callback. |
redirectUrl | string | Send the customer's browser here — the hosted payment page. |
checkUrl | string | Poll URL for this transaction. |
transactionId | string | Internal UID. |
status | TransactionStatus enum (§8) | Initial status (typically INITIATED). |
The returned payload is AES-CBC (§5.3) — decrypt it with the CBC parameters even when you initiated on /v2.
6.2 · Check a transaction
Available on v1 only. Two ways to check state — a lightweight verdict pull (recommended for reconciliation) and a full encrypted detail read.
Read-only verdict pull — the authoritative floor behind the callback. Unauthenticated — no header needed. Returns plain JSON:
| Field | Type | Notes |
|---|---|---|
referenceNumber | string | |
intentStatus | IntentStatus enum (§8) | High-level verdict. |
latestAttemptStatus | TransactionStatus enum (§8) | Underlying attempt status. |
latestVerdictSeq | number | null | Highest wins (ordering). null until the first verdict notification is dispatched. |
latestNotificationId | string | null | Matches the callback X-Notification-Id. null until the first verdict notification is dispatched. |
retryable | boolean | Whether the customer can re-attempt. |
terminal | boolean | Whether the transaction is final. |
Requires the integration-key header. Returns the full transaction detail as an encrypted
envelope ({ "payload": "…" }) — always AES-CBC (v1 scheme, §5.3),
regardless of your initiation version. Decrypt it with the CBC parameters from §5.3.
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.
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
- Verify the
Authorizationheader equals your integration key (use a constant-time comparison) before processing the body — reject anything without it with401. The value is the raw integration key with no scheme prefix — read it withrequest.getHeader("Authorization"), not a framework Bearer/Basic parser (which would strip or reject it). - Respond 2xx quickly. Any non-2xx or timeout is treated as a failure and retried.
- Dedup on
X-Notification-Id. Delivery is at-least-once; the same verdict may arrive more than once. - Order by
X-Verdict-Seq. The highest sequence is authoritative (handles a lateEXPIRED → SUCCEEDEDcorrection). - 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: ≈ 1m → 5m → 30m → 2h → 6h, over a 72-hour window.
- After 72h it is parked UNDELIVERED — reconcile via
/status. - Your
resultUrlmust be HTTPS and publicly resolvable. Non-HTTPS or internal-resolving URLs are permanently rejected by the SSRF guard.
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.
| Method | Code | Currencies |
|---|---|---|
| EcoCash | XPM101 | USD, ZWG |
| OneMoney | XPM102 | USD, ZWG |
| Visa | XPM104 | USD |
| Mastercard | XPM105 | USD |
| InnBucks | XPM106 | USD |
| Zimswitch (EFT) | XPM108 | USD, ZWG |
| Omari | XPM109 | USD, ZWG |
08Transaction & intent statuses
IntentStatus — high-level verdict (callback & /status)
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
| Status | Code | Group |
|---|---|---|
SUCCESS | 304 | success |
INITIATED | 301 | in flight |
PROCESSING | 302 | in flight |
PENDING | 303 | in flight |
FAILED | 300 | retryable |
INSUFFICIENT_FUNDS | 308 | retryable |
AUTHORIZATION_FAILED | 312 | retryable |
DECLINED | 311 | retryable |
TIME_OUT | 306 | retryable |
SERVICE_UNAVAILABLE | 313 | retryable |
ERROR | 310 | retryable |
CLOSED_PERIOD_ELAPSED | 307 | retryable |
CANCELLED | 309 | terminal |
TERMINATED | 305 | terminal |
REVERSED | 314 | terminal |
09Error handling
Errors return a JSON ErrorMessage:
{
"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"
}| HTTP | When |
|---|---|
| 400 | Invalid/undecryptable payload, missing required field, unknown currency, no active payment method in the request. |
| 403 | Missing/invalid/disabled integration key. |
| 404 | Unknown referenceNumber. |
| 422 | Requested payment method is unavailable for your application. |
| 500 | Payload 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
# 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-CBC, §5.3); 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/v1/payments/TXN-XXXX/status
# → { "intentStatus": "SUCCEEDED", "terminal": true, ... }On v1? The flow is identical — call
/v1/payments/initiate and encrypt the payload with AES-CBC (§5.3).
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;returnUrlhandles their return. - Callback receiver is HTTPS, returns 2xx, dedups on
X-Notification-Id, orders byX-Verdict-Seq. /statusreconciliation implemented as the source of truth.