Topup API v2 — Merchant Integration Guide
/api/v1/* endpoint now returns 404 — the routes no longer
exist. There is no grace period and no compatibility shim: v1 had no active merchant traffic
when it was removed. Use /api/v2, described below.
POST /api/v1/topup/check-account
(pre-order account validation). v2 has never had that endpoint. Submit the order directly —
an invalid account is rejected during fulfillment and the frozen amount is released automatically.
The supports_account_check field in the product list still tells you whether the
upstream behind a SKU has a validation capability at all.
Every v2 request is a POST with a JSON body and must be signed.
An X-Api-Key on its own is not enough — it identifies you, it does not
authenticate you.
| Header | Description |
|---|---|
X-Api-Key | Your merchant API Key (identity) |
X-Timestamp | Unix seconds. Must be within 300s of our clock. |
X-Nonce | Random per request. Accepted once; a replay is rejected. |
X-Signature | Lowercase hex HMAC-SHA256 over the string below, keyed with your app secret. |
Four lines joined with \n. The last line is the raw request body,
byte for byte as sent — do not re-encode it.
{timestamp}
{nonce}
{path} e.g. /api/v2/topup/submit
{raw request body}
// PHP
$path = '/api/v2/topup/submit';
$raw = json_encode($payload, JSON_UNESCAPED_SLASHES);
$ts = (string) time();
$nonce = bin2hex(random_bytes(8));
$sig = hash_hmac('sha256', "$ts\n$nonce\n$path\n$raw", $appSecret);
// Python
import hmac, hashlib, json, os, time
raw = json.dumps(payload, separators=(',', ':'))
ts, nonce = str(int(time.time())), os.urandom(8).hex()
sig = hmac.new(app_secret.encode(),
f"{ts}\n{nonce}\n{path}\n{raw}".encode(),
hashlib.sha256).hexdigest()
A query string on a v2 URL is refused with 400 QUERY_NOT_ALLOWED:
query parameters are not covered by the signature, so allowing them would let an
attacker inject business parameters into an otherwise valid request.
All endpoints are POST and take a JSON object (send {} when there are no parameters).
Products and SKUs available to you, with your prices and each SKU's required account fields.
Place an order. Asynchronous: a successful call returns
202 with code ACCEPTED — that means accepted and funds
frozen, not delivered. The final state arrives by callback, or you can poll
topup/query.
Idempotent on out_trade_no: replaying the same order with the same target
returns the existing order (DUPLICATE); reusing it with a
different target is refused with 409 OUT_TRADE_NO_CONFLICT.
Never retry a failed submit under a new out_trade_no — that is how
an account gets topped up twice.
Order status by order_no or out_trade_no. Reads our own records only; it never queries the upstream, so polling it is cheap and safe.
Your wallet: balance, frozen_balance, available_balance.
Signature self-check. Returns the canonical string-to-sign we computed for your request, line by line, so you can diff it against what your client signed. It never returns the expected signature value.
v2 exposes three states only. Internal intermediate states are all reported as processing, so you never have to follow our state machine as it evolves.
| Status | Description |
|---|---|
processing | Accepted, funds frozen, not yet settled. Keep waiting. |
success | Delivered; the amount has been deducted. |
failed | Not delivered; the frozen amount has been released. fail_code says why. |
An order can stay processing for a while when the upstream result is genuinely
unknown. We never release funds on an unknown result — those orders go to a human
queue and settle by reconciliation. Treat processing as “wait”, never as failure.
When an order reaches a final state (success/failed), we send a POST request to your configured callback_url.
{
"order_no": "KP20250101120000xxxx",
"out_trade_no": "YOUR_UNIQUE_ORDER_ID",
"status": "success",
"amount": "52.00",
"account_id": "0512345678",
"fail_reason": null,
"completed_at": "2025-01-01T12:00:30+00:00"
}
You must respond with HTTP 2XX and body being exactly success or ok, or a JSON object like {"success": true} to acknowledge.
Retry schedule: 30s, 2min, 10min, 30min, 2h (max 5 attempts).
Every failure carries a machine-readable code alongside the HTTP status.
Branch on code, not on the message text — messages may be
reworded, codes are contract.
{"success": false, "code": "MISSING_REQUIRED_FIELD", "message": "...", "field": "server_id"}
| HTTP Status | Meaning |
|---|---|
| 200 | OK (or a duplicate of an order you already placed) |
| 202 | Accepted — order created, funds frozen, fulfillment queued |
| 400 | Bad request: account fields, routing, or an unsigned-query violation |
| 401 | Authentication failed (bad key, bad signature, expired timestamp, reused nonce) |
| 402 | Insufficient balance |
| 403 | Merchant suspended or IP not whitelisted |
| 404 | Order not found — or an endpoint that no longer exists (all of /api/v1/*) |
| 409 | out_trade_no already used with different parameters |
| 422 | Request validation failed, or the SKU does not exist / is off shelf |
| 429 | Rate limit exceeded |
| 500 | Our fault. Safe to retry the same request unchanged. |
The full per-code table (and what to do about each one) is in the integration document
we send you: API_Documentation_EN.md / API_Documentation_ZH.md.
KhalasPay Topup API v2