Skip to main content

Payment Integration Examples

This page is a vendor-neutral reference for integrating a merchant backend with the Payment Gateway. It shows how to create payment orders and monitor payment results through the merchant API (/payorders/v1/, authenticated with a merchant key + app_id).

These snippets are intentionally generic — substitute your own project's public gateway host(s) and credentials. They do not assume any specific payment provider, terminal, or customer flow.

Design

A payment monitor long-polls the merchant endpoint wait_app_payment and reacts to status changes. The design below keeps the request path non-blocking — no client-side throttle, locks, or sleeps inside request(). A blocking sender would just become a place where requests pile up and go stale, adding latency to the whole system.

  • Keep one long poll open — start the next request immediately after HTTP 200 or 202. The server holds an idle request for up to about 50 seconds, so use a client timeout of at least 60 seconds.
  • Send other requests independently — do not serialize order creation and lookups behind the long-poll request; they define the real rate-limit load.
  • On a burst, retry — don't buffer — if you hit the limit (HTTP 429), surface it and let the caller (e.g. the browser) retry. The public gateway applies a short fixed-window limit per credential/IP, so short peaks recover quickly; buffering in a lock/sleep queue only accumulates stale requests.
  • Fail over between hosts only on network / 5xx errors — if your deployment exposes more than one public gateway endpoint, list them for failover. With merchant-key auth each request is stateless, so any endpoint works; a sticky index avoids bouncing. Do not fail over on 429 — the limit is per key, so another endpoint won't help.
  • Persist a cursor — store the last processed idUpdate and poll incrementally via cursor.
  • Classify transaction states by bitmask, not equality.
  • Be idempotent — the same idTransaction is delivered again on every status change.

Move the cursor only after the batch is handled successfully, so a crash re-processes instead of skipping.

Completion check

get_app_payments and wait_app_payment return the raw TransactStateEnum bitmask in status. Classify by bits, not equality (see Transaction States):

OutcomeRule
canceled / returnedstatus & Canceled (32) is set
successstatus & Finished (128) is set
pendingneither bit is set

txType carries the TransactTypeEnum value (e.g. PaymentOrder). Which types you receive depends on the terminals and flows configured for your app — see the Transactions and Orders admin for the full value-to-name catalog. The examples below dispatch on txType so you can branch per flow without re-classifying.

Python

Requires httpx (pip install httpx). Set GATEWAY_PKEY and GATEWAY_APP_ID env vars and point HOSTS at your project's public gateway domain(s).

import json, logging, os, sys, time
from pathlib import Path
import httpx

# One or more public gateway endpoints for your project. List several only if your
# deployment actually exposes multiple endpoints; a single-host deployment lists one.
HOSTS = ["https://gateway.example.com"]
PKEY = os.environ["GATEWAY_PKEY"]
APP_ID = os.environ["GATEWAY_APP_ID"]

RETRY_DELAY = 5.0 # delay only after an error
HTTP_TIMEOUT = 60.0 # long poll may be held for about 50 seconds
CURSOR_FILE = Path("cursor.json")

FINISHED = 1 << 7 # 128 — funds credited (TransactStateEnum.Finished)
CANCELED = 1 << 5 # 32 — funds returned (TransactStateEnum.Canceled)

PAYMENT_ORDER = 24 # TransactTypeEnum.PaymentOrder — order-based gateway payment

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("monitor")


class RateLimited(Exception):
"""HTTP 429 — back off and retry later; do not fail over."""


class Client:
"""Merchant-key client with sticky failover. request() never blocks: failover happens only on
network / 5xx errors, 429 is raised so the caller decides when to retry."""
def __init__(self, hosts, pkey):
self.hosts = hosts
self.idx = 0
self.http = httpx.Client(headers={"pkey": pkey}, timeout=HTTP_TIMEOUT)

def request(self, method, path, *, params=None, json_body=None):
start, last_err = self.idx, None
for i in range(len(self.hosts)):
idx = (start + i) % len(self.hosts)
host = self.hosts[idx]
try:
r = self.http.request(method, f"{host}{path}", params=params, json=json_body)
except httpx.TransportError as e: # network only -> try next host
last_err = f"{host}: {e}"
continue
if r.status_code in (502, 503, 504):
last_err = f"{host}: HTTP {r.status_code}"
continue
if r.status_code == 429: # per-key limit: another host won't help
raise RateLimited("rate limited — retry later")
body = r.json()
if body.get("error"):
err = body["error"]
raise RuntimeError(f"api error code={err.get('code')} msg={err.get('message')!r}")
self.idx = idx # stick to the host that worked
return body
raise RuntimeError(f"all hosts failed: {last_err}")


def classify(status: int) -> str:
if status & CANCELED:
return "canceled"
if status & FINISHED:
return "success"
return "pending"


def handle_payments(payments):
# Must be idempotent by idTransaction — a payment is re-delivered on every status change.
for p in payments:
result = classify(int(p["status"]))
tx_type = int(p.get("txType", 0))
log.info("payment id=%s txType=%s status=%s -> %s amount=%s %s partner=%s",
p["idTransaction"], tx_type, p["status"], result,
p["amount"], p["currency"], p.get("partnerInfo"))
if result != "success":
continue
if tx_type == PAYMENT_ORDER:
pass # match by order code / group, credit the order in your system ...
else:
pass # other configured credit types: reconcile by partnerInfo + amount + datetime ...


def load_cursor() -> int:
if not CURSOR_FILE.exists():
sys.exit(f"cursor file {CURSOR_FILE} not found (set it manually first)")
return int(json.loads(CURSOR_FILE.read_text())["cursor"])


def save_cursor(cursor: int):
tmp = CURSOR_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps({"cursor": cursor}))
tmp.replace(CURSOR_FILE) # atomic


def wait_once(client: Client, cursor: int) -> int:
response = client.request(
"GET", "/payorders/v1/wait_app_payment",
params={"app_id": APP_ID, "cursor": cursor},
)
payments = response.get("result") or []
handle_payments(payments) # raises -> cursor not advanced
new_cursor = int(response.get("cursor") or cursor)
if new_cursor > cursor:
save_cursor(new_cursor)
log.info("cursor %s -> %s", cursor, new_cursor)
return new_cursor
return cursor


def main():
client = Client(HOSTS, PKEY)
cursor = load_cursor()
log.info("start cursor=%s", cursor)
while True:
try:
cursor = wait_once(client, cursor)
continue # HTTP 200/202 -> open the next long poll
except RateLimited:
log.warning("rate limited — retry after delay")
except Exception as e:
log.error("wait failed: %s — retry after delay", e)
time.sleep(RETRY_DELAY)


if __name__ == "__main__":
main()

Creating an order from the same client

Call this directly from your request handler — it runs in parallel with the poll loop and is the main contributor to rate-limit load. On RateLimited (429) just let the user retry the action. The response carries the authoritative redirectUrl for the hosted payment page — send the payer there rather than building the link yourself.

def put_payment_order(client, amount, currency, partner_info, tag, external_id=0):
response = client.request(
"POST", "/payorders/v1/put_payment_order",
params={"app_id": APP_ID},
json_body={
"amount": amount, "currency": currency,
"partnerInfo": partner_info, "tag": tag, "externalId": external_id,
},
)
return response["result"] # { "txId": "...", "code": "...", "redirectUrl": "..." }

Node.js

Node 18+ (built-in fetch). Set GATEWAY_PKEY and GATEWAY_APP_ID env vars and point HOSTS at your project's public gateway domain(s).

import fs from "node:fs";

// One or more public gateway endpoints for your project.
const HOSTS = ["https://gateway.example.com"];
const PKEY = process.env.GATEWAY_PKEY;
const APP_ID = process.env.GATEWAY_APP_ID;

const RETRY_DELAY = 5000; // delay only after an error
const HTTP_TIMEOUT = 60000; // long poll may be held for about 50 seconds
const CURSOR_FILE = "cursor.json";

const FINISHED = 1 << 7; // 128 — TransactStateEnum.Finished
const CANCELED = 1 << 5; // 32 — TransactStateEnum.Canceled

const PAYMENT_ORDER = 24; // TransactTypeEnum.PaymentOrder — order-based gateway payment

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

class RateLimited extends Error {} // HTTP 429 — back off and retry later

// request() never blocks: failover happens only on network / 5xx errors; 429 is thrown so the
// caller decides when to retry. Stick to the host that worked.
let hostIdx = 0;
async function request(method, path, { params, body } = {}) {
let lastErr;
for (let i = 0; i < HOSTS.length; i++) {
const idx = (hostIdx + i) % HOSTS.length;
const host = HOSTS[idx];
const url = new URL(host + path);
if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
let r;
try {
r = await fetch(url, {
method,
headers: { pkey: PKEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(HTTP_TIMEOUT),
});
} catch (e) { // network only -> try next host
lastErr = `${host}: ${e.message}`;
continue;
}
if ([502, 503, 504].includes(r.status)) {
lastErr = `${host}: HTTP ${r.status}`;
continue;
}
if (r.status === 429) throw new RateLimited("rate limited — retry later");
const json = await r.json();
if (json.error) throw new Error(`api error code=${json.error.code} msg=${json.error.message}`);
hostIdx = idx; // stick to the host that worked
return json;
}
throw new Error(`all hosts failed: ${lastErr}`);
}

function classify(status) {
if (status & CANCELED) return "canceled";
if (status & FINISHED) return "success";
return "pending";
}

function handlePayments(payments) {
// Idempotent by idTransaction — re-delivered on every status change.
for (const p of payments) {
const result = classify(Number(p.status));
const txType = Number(p.txType);
console.log(`payment id=${p.idTransaction} txType=${txType} status=${p.status} -> ${result} ` +
`amount=${p.amount} ${p.currency} partner=${p.partnerInfo}`);
if (result !== "success") continue;
if (txType === PAYMENT_ORDER) {
// match by order code / group, credit the order ...
} else {
// other configured credit types: reconcile by partnerInfo + amount + datetime ...
}
}
}

const loadCursor = () => String(JSON.parse(fs.readFileSync(CURSOR_FILE, "utf8")).cursor);
function saveCursor(cursor) {
fs.writeFileSync(CURSOR_FILE + ".tmp", JSON.stringify({ cursor }));
fs.renameSync(CURSOR_FILE + ".tmp", CURSOR_FILE); // atomic
}

async function waitOnce(cursor) {
const response = await request("GET", "/payorders/v1/wait_app_payment", {
params: { app_id: APP_ID, cursor },
});
const payments = response.result || [];
handlePayments(payments); // throws -> cursor not advanced
const next = String(response.cursor || cursor);
if (BigInt(next) > BigInt(cursor)) {
saveCursor(next);
console.log(`cursor ${cursor} -> ${next}`);
return next;
}
return cursor;
}

async function main() {
let cursor = loadCursor();
console.log(`start cursor=${cursor}`);
for (;;) {
try {
cursor = await waitOnce(cursor);
continue; // HTTP 200/202 -> open the next long poll
} catch (e) {
if (e instanceof RateLimited) console.warn("rate limited — retry after delay");
else console.error(`wait failed: ${e.message} — retry after delay`);
}
await sleep(RETRY_DELAY);
}
}

main();

See Also