xPay Engine Integration 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 /v1/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 /v1/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 the check response 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. 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:

MessageScheme
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

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 · AES-CBC (v1) — legacy

CipherAES/CBC/PKCS5Padding
Keythe 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.)
IVthe first 16 bytes of the encryption key
Wire formatBase64( 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.

python — cryptography
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()
node.js — crypto
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");
}
java — javax.crypto
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);
    }
}
c# — System.Security.Cryptography
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:

POST/opn/v1/encryption-utils/encrypt
request → response
// body
{ "payload": { "amount": 100.00, "currencyCode": "USD" }, "encryptionKey": "<your key>" }

// response — the encrypted string
"<base64 ciphertext>"
POST/opn/v1/encryption-utils/decrypt
request → response
// 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.

POST/v2/payments/initiate · alias /v2/transactions/initiate · AES-GCM
POST/v1/payments/initiate · alias /v1/transactions/initiate · AES-CBC (legacy)
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

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.

GET/v1/payments/{referenceNumber}/status

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.
GET/v1/payments/check-payment?ref={referenceNumber}

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.

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-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; 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.