KhalasPay API Documentation

Topup API v2 — Merchant Integration Guide

API v1 has been retired (2026-09-06). Every /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.
One capability disappeared with it: 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.
Authentication

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.

HeaderDescription
X-Api-KeyYour merchant API Key (identity)
X-TimestampUnix seconds. Must be within 300s of our clock.
X-NonceRandom per request. Accepted once; a replay is rejected.
X-SignatureLowercase hex HMAC-SHA256 over the string below, keyed with your app secret.
String to sign

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}
Example
// 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.

Endpoints

All endpoints are POST and take a JSON object (send {} when there are no parameters).

POST /api/v2/products

Products and SKUs available to you, with your prices and each SKU's required account fields.

POST /api/v2/topup/submit

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.

POST /api/v2/topup/query

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.

POST /api/v2/balance

Your wallet: balance, frozen_balance, available_balance.

POST /api/v2/tools/verify-signature

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.

Order Status

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.

StatusDescription
processingAccepted, funds frozen, not yet settled. Keep waiting.
successDelivered; the amount has been deducted.
failedNot 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.

Callback Notification

When an order reaches a final state (success/failed), we send a POST request to your configured callback_url.

Callback Payload:
{
    "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).

Errors

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 StatusMeaning
200OK (or a duplicate of an order you already placed)
202Accepted — order created, funds frozen, fulfillment queued
400Bad request: account fields, routing, or an unsigned-query violation
401Authentication failed (bad key, bad signature, expired timestamp, reused nonce)
402Insufficient balance
403Merchant suspended or IP not whitelisted
404Order not found — or an endpoint that no longer exists (all of /api/v1/*)
409out_trade_no already used with different parameters
422Request validation failed, or the SKU does not exist / is off shelf
429Rate limit exceeded
500Our 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