商户 API 对接手册
更新:2026-09-10
01 接口总览
| 方法 | 路径 | 用途 |
|---|---|---|
POST | /api/v1/payin/create | 创建收款订单 |
GET | /api/v1/payin/{payin_order_no} | 查询收款订单 |
POST | /api/v1/payout/create | 创建出款订单 |
GET | /api/v1/payout/{payout_order_no} | 查询出款订单 |
GET | /api/v1/balance | 查询商户余额 |
所有接口按第 2 节签名鉴权。环境地址、app_id、api_key、api_secret 与下单编码 channel_code 在开通时提供,需同时提供通知地址与出口 IP。
02 签名与鉴权
2.1 请求头
每个请求必须携带以下 5 个 Header:
| Header | 说明 |
|---|---|
X-App-Id | 平台分配的应用 ID |
X-Api-Key | 平台分配的 API Key |
X-Timestamp | Unix 秒级时间戳,与服务器时间差不得超过 300 秒 |
X-Nonce | 随机字符串,单次使用,短期内重复会被拒绝 |
X-Sign | 按 2.2 计算的签名,小写十六进制 |
2.2 签名算法
算法:HMAC-SHA256,密钥为 api_secret,结果取小写 hex。待签名串固定为 8 段,以换行符 \n 连接。某段为空时该段留空,但分隔符不可省略:
规范化规则:
METHOD大写(如POST、GET)。PATH为实际请求路径,含路径参数的真实值(如/api/v1/payin/PO20260829000123),不是路由模板。QUERY为规范化后的查询串:参数按键升序、同键多值按值升序,键和值分别进行 URL 编码,空格编码为+,再以k=v用&连接;无查询参数时为空串。实际请求 URL 应使用同一规范化查询串。- 最后一段是原始请求体字节的 SHA-256 小写 hex;GET 等无 body 请求对空字节串取哈希,即
e3b0c442…7852b855。不要先反序列化再重新序列化 body,必须对实际发送的字节计算。
2.3 多语言签名示例
按语言展开并复制示例。使用前请将示例域名和三项凭据替换为开通时提供的值,密钥只保存在服务端。查询参数传入未编码的字符串;JSON 只序列化一次,并将同一份 UTF-8 字节用于签名与发送。
Python 3.10+ · requests
需安装 requests。无请求体时使用默认的 b""。
import hashlib, hmac, time, uuid, requests
from urllib.parse import urlencode
APP_ID = "your_app_id"
API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
BASE = "https://api.example.com"
def canonical_query(params: dict) -> str:
if not params:
return ""
pairs = []
for k in sorted(params):
vs = params[k] if isinstance(params[k], list) else [params[k]]
for v in sorted(map(str, vs)):
pairs.append((str(k), v))
# 保留同键多值,并将空格编码为 +;签名和请求 URL 共用此结果。
return urlencode(pairs)
def sign(method: str, path: str, query: dict, body: bytes, ts: str, nonce: str) -> str:
body_sha = hashlib.sha256(body).hexdigest()
source = "\n".join([
method.upper(), path, canonical_query(query),
APP_ID, API_KEY, ts, nonce, body_sha,
])
return hmac.new(API_SECRET.encode(), source.encode(), hashlib.sha256).hexdigest()
def request(method: str, path: str, query: dict | None = None, body: bytes = b""):
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
headers = {
"X-App-Id": APP_ID,
"X-Api-Key": API_KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Sign": sign(method, path, query or {}, body, ts, nonce),
"Content-Type": "application/json",
}
query_string = canonical_query(query or {})
url = BASE + path + (("?" + query_string) if query_string else "")
return requests.request(method, url, headers=headers, data=body, timeout=10, allow_redirects=False)
Go · 标准库
保存为 merchant_sign.go,按项目调整包名。查询参数使用 url.Values,无请求体时传 nil。调用 Request 后须检查错误,并关闭返回的 response.Body。
package merchantapi
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const (
appID = "your_app_id"
apiKey = "your_api_key"
apiSecret = "your_api_secret"
baseURL = "https://api.example.com"
)
var client = &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
func CanonicalQuery(params url.Values) string {
normalized := make(url.Values, len(params))
for key, values := range params {
normalized[key] = append([]string(nil), values...)
sort.Strings(normalized[key])
}
return normalized.Encode()
}
func Sign(method, path string, query url.Values, body []byte, ts, nonce string) string {
bodyHash := sha256.Sum256(body)
source := strings.Join([]string{
strings.ToUpper(method), path, CanonicalQuery(query),
appID, apiKey, ts, nonce, hex.EncodeToString(bodyHash[:]),
}, "\n")
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(source))
return hex.EncodeToString(mac.Sum(nil))
}
func Request(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) {
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
return nil, err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := hex.EncodeToString(random)
queryString := CanonicalQuery(query)
requestURL := baseURL + path
if queryString != "" {
requestURL += "?" + queryString
}
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("X-App-Id", appID)
req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Sign", Sign(method, path, query, body, ts, nonce))
req.Header.Set("Content-Type", "application/json")
return client.Do(req)
}
Java 11+ · 标准库
保存为 MerchantApiSigner.java。查询参数使用 Map<String, List<String>>;JSON 字符串通过 getBytes(StandardCharsets.UTF_8) 转成字节,无请求体时传 new byte[0]。
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.StringJoiner;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class MerchantApiSigner {
private static final String APP_ID = "your_app_id";
private static final String API_KEY = "your_api_key";
private static final String API_SECRET = "your_api_secret";
private static final String BASE = "https://api.example.com";
private static final Duration TIMEOUT = Duration.ofSeconds(10);
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
private static final Comparator<String> UTF8_ORDER = (left, right) ->
Arrays.compareUnsigned(left.getBytes(StandardCharsets.UTF_8),
right.getBytes(StandardCharsets.UTF_8));
public static String canonicalQuery(Map<String, List<String>> query) {
if (query == null || query.isEmpty()) {
return "";
}
List<String> keys = new ArrayList<>(query.keySet());
keys.sort(UTF8_ORDER);
StringJoiner result = new StringJoiner("&");
for (String key : keys) {
List<String> values = new ArrayList<>(query.get(key));
values.sort(UTF8_ORDER);
for (String value : values) {
result.add(queryEncode(key) + "=" + queryEncode(value));
}
}
return result.toString();
}
private static String queryEncode(String value) {
// Spaces become +, literal + becomes %2B, ~ stays literal, and * becomes %2A.
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("%7E", "~").replace("*", "%2A");
}
public static String sha256Hex(byte[] body) throws GeneralSecurityException {
return hex(MessageDigest.getInstance("SHA-256")
.digest(body == null ? new byte[0] : body));
}
private static String hex(byte[] bytes) {
char[] digits = "0123456789abcdef".toCharArray();
char[] result = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int value = bytes[i] & 0xff;
result[i * 2] = digits[value >>> 4];
result[i * 2 + 1] = digits[value & 15];
}
return new String(result);
}
public static String sign(String method, String path, Map<String, List<String>> query,
byte[] body, String timestamp, String nonce)
throws GeneralSecurityException {
return signCanonical(method, path, canonicalQuery(query), body, timestamp, nonce);
}
private static String signCanonical(String method, String path, String queryString,
byte[] body, String timestamp, String nonce)
throws GeneralSecurityException {
String source = String.join("\n", method.toUpperCase(Locale.ROOT), path,
queryString, APP_ID, API_KEY, timestamp, nonce, sha256Hex(body));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(API_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex(mac.doFinal(source.getBytes(StandardCharsets.UTF_8)));
}
public static HttpResponse<byte[]> request(String method, String path,
Map<String, List<String>> query, byte[] body)
throws GeneralSecurityException, IOException, InterruptedException {
String timestamp = Long.toString(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString().replace("-", "");
byte[] rawBody = body == null ? new byte[0] : body;
String queryString = canonicalQuery(query);
String url = BASE + path + (queryString.isEmpty() ? "" : "?" + queryString);
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(TIMEOUT)
.header("X-App-Id", APP_ID)
.header("X-Api-Key", API_KEY)
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Sign", signCanonical(method, path, queryString, rawBody, timestamp, nonce))
.header("Content-Type", "application/json")
.method(method.toUpperCase(Locale.ROOT), HttpRequest.BodyPublishers.ofByteArray(rawBody))
.build();
// Do not retry order creation automatically; follow the idempotency rules.
return CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
}
2.4 注意事项
- 使用本商户的接入凭证调用接口,无需在请求体额外传入商户号。
- 每次发送请求(包括重试)均须使用新的时间戳和随机值,并重新计算签名。遇到服务暂不可用时请退避重试;创建订单的重试规则见第 4 节。
2.5 签名测试向量
用固定的凭据和时间戳给出三组向量,接入时先用它们自检:把同样的输入喂给你的实现,逐段比对签名原文,再比最终签名。这样能在联调之前定位差异,不必靠 20001 反复试。
三组用的凭据固定为 app_id=app_demo、api_key=key_demo、api_secret=secret_demo、X-Timestamp=1789000000、X-Nonce=nonce_demo。签名原文中的 \n 是真实换行符。
- 规范化查询串 QUERY
(空)
- 请求体 RAW_BODY
(空)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 签名原文(8 段)
GET /api/v1/balance app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 预期 X-Sign
21b0a67b99490232913d9bf2e1206593ab79285441465d925c26f3ff50c62912
- 规范化查询串 QUERY
(空)
- 请求体 RAW_BODY
{"merchant_order_no":"PM1","merchant_request_id":"RQ1","amount":"100.00","currency":"PKR","channel_code":"PC0001"}
- SHA256_HEX(RAW_BODY)
90782d98368489cb98ab0e48d79d1babdabc34f1a0a8e268787eafd1512eae97
- 签名原文(8 段)
POST /api/v1/payin/create app_demo key_demo 1789000000 nonce_demo 90782d98368489cb98ab0e48d79d1babdabc34f1a0a8e268787eafd1512eae97
- 预期 X-Sign
f891a6cf99ca74caf42e009e2b64488baecdbbf897a713344d85aa4298762178
- 规范化查询串 QUERY
probe=A&probe=a+b&x=1
- 请求体 RAW_BODY
(空)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 签名原文(8 段)
GET /api/v1/balance probe=A&probe=a+b&x=1 app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- 预期 X-Sign
0dce9b3f6782a672447c55becb932a90be345f3309c50e39d7c8d32346d8fea2
第三组是最容易出错的一组:空格必须编码为 + 而不是 %20,同键多值按值升序,且 !'()* 必须转义——JavaScript 的 encodeURIComponent 默认不转这几个字符,直接拿来用会验签失败。若前两组通过而第三组不过,问题一定在查询串规范化。
03 统一响应与错误码
所有接口使用以下统一响应格式:
{
"code": 0,
"message": "成功",
"data": { },
"trace_id": "…",
"timestamp": 1756450000
}
code = 0仅表示本次请求处理成功,不表示支付成功;订单结果须根据data.status判断。请求失败时data省略。- 请按
code分支处理,不要解析message文案,文案会本地化、会调整。 - 联系平台排障时请提供
trace_id。
| code | HTTP | 含义 | 商户侧处理 |
|---|---|---|---|
| 10000 | 500 | 系统错误 | 按第 4 节核对原订单结果;持续出现请提供 trace_id 联系平台 |
| 10001 | 400 | 参数错误 | 修正参数后重发;重试不会改变结果 |
| 10002 | 404 | 资源不存在 | 确认平台订单号及所用商户凭证后重查 |
| 10003 | 409 | 重复/冲突 | 按第 4 节核对原请求与原订单;不要直接更换 merchant_request_id 重复下单 |
| 10004 | 503 | 服务暂不可用 | 退避后重试;创建订单须遵守第 4 节的幂等规则 |
| 10005 | 400 | 币种小数位不受支持 | amount 小数位不得超过该币种允许的精度,无需补足末尾零;不做四舍五入 |
| 10006 | 400 | 支付服务未开通 | 核对 channel_code 是否为本商户已开通的下单编码;如需开通相应支付服务,请联系技术支持,不要原样连续重试 |
| 10007 | 400 | 支付服务暂时无法受理 | 所选支付服务已开通,但当前无法受理本笔金额。可稍后按第 4 节的幂等规则重试;如需改用其他已开通的支付方式,请先确认原请求与订单结果,不要重复付款。持续出现时请联系技术支持 |
| 20001 | 401 | 未鉴权/签名验证失败 | 检查 app_id 与签名算法 |
| 20003 | 403 | 访问被拒绝 | 可能是请求出口 IP 不在商户 API 白名单内;确认出口 IP 与已开通权限,必要时联系平台 |
| 20004 | 403 | 账户被冻结/商户不可用 | 联系平台 |
| 20005 | 403 | 账户被禁用 | 联系平台 |
| 20006 | 429 | 触发限流 | 退避后重试 |
3.2 订单失败码 fail_code
上表的 code 说的是这次接口调用怎么了;fail_code 说的是这笔订单最后怎么了——请求本身成功(HTTP 200、code=0),只是订单以失败告终。两者受众相同但语义正交,不要混用。
订单进入 FAIL 时 fail_code 才有值。请按它决定要不要重试;fail_reason 与 status_text 同样只供展示。
fail_code | 含义 | 能否重试 |
|---|---|---|
CHANNEL_REJECTED | 渠道明确拒绝交易 | 可以,用新的 merchant_request_id 创建新订单 |
PAYMENT_TIMEOUT | 支付窗口内未完成付款 | 可以,同上 |
PENDING_MANUAL_REVIEW | 结果不明,平台已挂起核查 | 不要重试。见下方警告 |
CHANNEL_UNAVAILABLE | 暂无可用支付服务 | 重试通常无用,请联系技术支持 |
ORDER_CLOSED | 订单已关闭或取消 | 如仍需交易,创建新订单 |
UNSPECIFIED | 未归类的失败 | 先按第 4 节核对原订单结果,再决定 |
PENDING_MANUAL_REVIEW 的含义与其余几个相反:不要重试。结果不明意味着上游那边可能已经扣款或已经出账,此时重发新单的后果是双重付款。请等待平台的后续通知,或按第 4 节走查单确认,不要自动重试。
取值集合是对外契约的一部分,只增不改——按它写的分支不会因为平台升级而失效。收到未在上表列出的值时,按 UNSPECIFIED 处理并联系技术支持。
04 金额、币种与幂等
- 订单金额与余额使用主单位十进制字符串。提交金额的小数位不得超过币种允许的精度,无需补足末尾零;例如精度为 2 时,
"100"、"100.0"与"100.00"均可。超出精度或格式非法会被拒绝,不做四舍五入。各币种允许的精度在接入时确认。 - 创建订单时填写开通清单提供的
channel_code(下单编码),并使用其对应的业务方向和币种;不要将支付方式名称或银行名称直接作为该字段的值。 - 创建幂等键为
merchant_request_id(商户维度唯一):- 重试时保留原请求体与原
merchant_request_id,但重新生成X-Timestamp、X-Nonce和X-Sign。相同创建请求重发返回原订单,不会产生第二笔; - 超时或网络中断不代表创建失败:已有平台订单号时先查单;尚未取得单号时按上述规则重发原请求。原订单结果未知时,不得更换幂等键重复下单;
- 同一
merchant_request_id修改了金额、下单编码、收款人等任一关键要素 → 返回10003冲突,不会创建新订单,此时应改用查单接口获取原订单结果; - 不要复用
merchant_request_id来换金额/换下单编码/换收款人。只有确认原订单已失败或关闭,且确需重新下单,或创建一笔独立的新业务订单时,才使用新的merchant_request_id。
- 重试时保留原请求体与原
05 代收 Payin
5.1 创建订单 POST /api/v1/payin/create
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
merchant_order_no | string(≤64) | 是 | 商户订单号 |
merchant_request_id | string(≤64) | 是 | 幂等键,见第 4 节 |
amount | string(≤32) | 是 | 主单位金额字符串,如 "100.00" |
currency | string | 是 | 币种代码 |
channel_code | string(2–16) | 是 | 开通时提供的下单编码;按已开通的支付服务填写 |
product_name | string(≤128) | 否 | 商品名 |
notify_url | string(≤512) | 否 | 本单通知地址;为空则用商户默认收款通知地址 |
return_url | string(≤512) | 否 | 付款后用户跳转地址;页面跳转不能作为支付成功凭据 |
payer_phone | string(≤32) | 否 | 付款人手机号;巴基斯坦为 03 开头 11 位,部分支付方式必填 |
payer_id_no | string(≤64) | 否 | 付款人证件号;巴基斯坦为 13 位 CNIC,直接扣款方式必填 |
payer_name | string(≤128) | 否 | 付款人姓名 |
payer_email | string(≤128) | 否 | 付款人邮箱,做格式校验 |
响应 data:
{
"url": "https://cashier.example.com/order/PO202609100001",
"payin_order_no": "PO202609100001",
"merchant_order_no": "M202609100001",
"amount": "100.00",
"currency": "PKR",
"channel_code": "assigned_code",
"pay_method": "JAZZCASH",
"status": 2,
"channel_order_no": null,
"provider_pay_url": "https://pay.example.com/PO202609100001",
"provider_qr_code": null,
"fail_code": "",
"fail_reason": null
}
以上地址和编号仅为示例。url 是收银台地址;provider_pay_url 是支付链接;provider_qr_code 是用于生成支付二维码的内容;channel_order_no 是支付参考编号。这三个字段可能为 null,请以实际响应为准。
status = 2(PENDING):等待付款,使用返回的 provider_pay_url 或 provider_qr_code 引导付款人支付,也可使用 url 打开收银台。
status = 5(FAIL):支付失败,查看 fail_code 和 fail_reason。如需再次付款,请先确认原订单结果,再使用新的 merchant_request_id 创建订单。
status = 1 或 3(INIT/PROCESSING):订单仍在处理中,请通过查单获取后续状态,或引导付款人访问 url 收银台。不要将此状态视为付款失败。
code=0 只表示本次接口请求处理成功,不表示付款成功。请根据 status 判断订单结果;失败原因见 fail_code 和 fail_reason。
5.2 查询订单 GET /api/v1/payin/{payin_order_no}
响应 data 字段:payin_order_no、merchant_no、merchant_order_no、merchant_request_id、status、amount、currency、channel_code、pay_method、channel_order_no、provider_pay_url、provider_qr_code(仅可继续支付状态返回)、notify_url、return_url、expire_time、success_time、fail_code、fail_reason、created_at。时间字段为 RFC3339 格式。
5.3 收款订单状态
创建和查询接口的 status 为下列数字值。未收到通知或请求超时不代表支付失败。
| 值 | 状态 | 含义 | 商户处理 |
|---|---|---|---|
| 1 | INIT | 订单已创建 | 等待处理;通过查单获取后续状态 |
| 2 | PENDING | 等待付款 | 使用返回的支付链接或二维码引导付款 |
| 3 | PROCESSING | 支付处理中 | 等待通知或查单,不要重复创建订单 |
| 4 | SUCCESS | 支付成功 | 核对订单号、金额和币种后更新商户订单 |
| 5 | FAIL | 支付失败 | 查看失败原因;如需再次付款,确认原单结果后创建新订单 |
| 6 | CLOSED | 订单已关闭或过期 | 停止使用原支付链接;如需付款请创建新订单 |
| 7 | PENDING_VERIFY | 支付结果待确认 | 保持待处理,等待通知或查单;不要标记成功或失败 |
06 代付 Payout
6.1 创建订单 POST /api/v1/payout/create
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
merchant_order_no | string(≤64) | 是 | 商户订单号 |
merchant_request_id | string(≤64) | 是 | 幂等键 |
amount | string(≤32) | 是 | 主单位金额字符串 |
currency | string | 是 | 币种代码 |
channel_code | string(2–16) | 是 | 开通时提供的下单编码;按已开通的支付服务填写 |
receiver_name | string(≤128) | 是 | 收款人姓名 |
receiver_account | string(≤128) | 是 | 收款账号 |
receiver_bank_code | string(≤64) | 否 | 收款银行代码;需填写时,使用开通时提供的支持银行清单中的代码,见第 7.2 节 |
receiver_email | string(≤128) | 否 | 收款人邮箱,做格式校验 |
receiver_id_no | string(≤64) | 否 | 收款人证件号;巴基斯坦钱包代付为 13 位 CNIC,钱包代付时必填 |
receiver_phone | string(≤32) | 否 | 收款人手机号;巴基斯坦为 03 开头 11 位,银行代付必填 |
receiver_iban | string(≤64) | 否 | 收款人 IBAN;巴基斯坦为 PK 开头 24 位,银行代付必填 |
receiver_bank_name | string(≤128) | 否 | 收款银行名称;按支持银行清单填写,部分银行代付必填 |
响应 data:
{ "payout_order_no": "PT…", "status": 1 }
订单创建成功后,可用余额中的 订单金额 + 手续费 转为冻结余额。出款失败(5)后相应金额恢复为可用余额;出款成功(4)后从冻结余额扣除。余额不足时无法创建订单。
6.2 查询订单 GET /api/v1/payout/{payout_order_no}
响应 data 字段:payout_order_no、merchant_order_no、amount、currency、status、status_text、fail_code、fail_reason、receiver_account(脱敏)、success_time、created_at。
6.3 出款订单状态
创建和查询接口的 status 为下列数字值。仅凭超时或暂未收到通知,不能判断出款失败。
| 值 | 状态 | 含义 | 商户处理 |
|---|---|---|---|
| 1 | INIT | 订单已创建 | 等待出款结果,不要重复提交 |
| 3 | PROCESSING | 出款处理中 | 等待通知或查单,不要重复提交 |
| 4 | SUCCESS | 出款成功 | 核对订单号、金额和币种后更新商户订单 |
| 5 | FAIL | 出款失败 | 查看失败原因;如需重试,确认原单结果后使用新的请求 ID |
| 6 | CLOSED | 订单已关闭 | 如需重新出款,先确认原订单结果 |
| 7 | PENDING_VERIFY | 出款结果待确认 | 保持待处理;资金仍冻结,不要按失败重新出款 |
PENDING_VERIFY(7)表示出款结果尚未确认,不代表出款失败。此时使用新的 merchant_request_id 重新下单可能造成重复出款。请保留原订单并等待通知或查单;长时间未更新时,携带订单号联系技术支持。
07 支付方式与支持银行
7.1 代收支付方式
代收可接入以下支付方式;本商户实际可用的方式、币种和下单编码,以开通时确认的清单为准。
| 国家/地区 | 币种 | 支付方式 |
|---|---|---|
| 巴基斯坦 | PKR | JazzCash |
| 巴基斯坦 | PKR | Easypaisa |
| 巴基斯坦 | PKR | QR 扫码支付 |
商户选择已开通的支付方式,使用对应的下单编码创建订单,再通过返回的支付链接或二维码引导付款人完成支付。查询响应的 pay_method 表示支付方式,不是创建订单的请求参数。
7.2 代付支持银行
银行代付请使用本商户已开通的支持银行清单。该清单在接入时由技术支持提供,包含国家/地区、币种、银行名称、需填写的银行代码及收款信息要求;请勿自行推测银行代码。
receiver_bank_code:需要银行代码时,按支持银行清单原样填写。receiver_bank_name:需要银行名称时,使用清单中的名称。channel_code:使用已开通的代付下单编码;银行名称或银行代码不能替代此字段。
未取得清单或无法确认收款银行时,请先联系技术支持确认后再提交出款。钱包代付不使用银行清单,按相应钱包的收款信息要求填写。
08 余额查询 GET /api/v1/balance
无请求参数,返回当前商户各币种余额。响应 data:
{
"as_of": "2026-08-29T12:00:00Z",
"balances": [
{
"currency": "PKR",
"available_balance": "12345.00",
"frozen_balance": "100.00",
"total_balance": "12445.00"
}
]
}
金额均为主单位字符串。总余额 = 可用 + 冻结。
09 异步通知
平台通过异步通知告知商户订单的成功或失败结果(SUCCESS/FAIL):
- 代收:优先使用下单时的
notify_url,为空则用商户默认收款通知地址; - 代付:使用商户默认出款通知地址;
- 地址未配置则跳过通知,请依赖查单。
9.1 通知报文
POST,JSON body:
{
"biz_type": "PAYIN",
"order_no": "PO…",
"merchant_order_no": "…",
"merchant_no": "…",
"amount": "100.00",
"currency": "PKR",
"status": "SUCCESS",
"fail_code": "",
"fail_reason": "",
"finished_at": "2026-08-29T12:00:00Z",
"notified_at": "2026-08-29T12:00:05Z"
}
biz_type:PAYIN或PAYOUT;status:SUCCESS或FAIL。- 通知携带与商户请求完全相同规范的 5 个签名 Header(第 2 节),对通知目标 URL 的实际路径和查询参数签名。商户可复用自己的签名代码验签,必须验签后再处理。
9.2 应答约定
验签通过,并完成处理或可靠保存待处理通知后,请在 5 秒内返回:HTTP 200,响应体为纯文本 success(去除首尾空白后严格等于)。其他响应或超时会触发重试。通知地址须直接接收 POST 请求,不应返回重定向。
9.3 重试与幂等
- 通知可能重复或延迟。按
biz_type + order_no找到订单;同一订单、相同结果的通知重复到达时,不得重复产生业务操作,完成处理或可靠保存后返回success。 - 同一订单后续通知的结果可能与之前不同。不要仅因订单号已处理就忽略新结果:请先查单核对当前状态,再更新本地订单,避免较早的通知覆盖较新的状态。
- 投递失败最多重试 3 次(总计 4 次),间隔约 15 秒 / 1 分钟 / 5 分钟;超过 30 分钟未成功则停止。
- 通知未送达不表示订单失败。请以查单接口返回的当前状态为准;未收到通知或结果仍待确认时,应主动查单,逐步延长查询间隔,不要重新创建订单。
9.4 商户侧验签示例(Python / Flask)
以下示例复用第 2.3 节 Python 版的 sign 函数。handle_notification 由商户实现:核对订单、金额和币种,按第 9.3 节处理重复或不同结果,并在返回前完成处理或可靠保存通知。
import hmac, json, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/notify")
def on_notify():
body = request.get_data() # 原始字节,勿先解析再序列化
ts = request.headers.get("X-Timestamp", "")
nonce = request.headers.get("X-Nonce", "")
got = request.headers.get("X-Sign", "")
query = request.args.to_dict(flat=False) # 保留同名参数的全部值
want = sign("POST", request.path, query, body, ts, nonce)
if not hmac.compare_digest(got, want):
return "invalid sign", 401
try:
timestamp = int(ts)
except ValueError:
return "invalid timestamp", 401
if abs(time.time() - timestamp) > 300:
return "expired", 401
data = json.loads(body)
handle_notification(data)
return "success", 200
10 对接最佳实践
- 以查单为准:收到通知后可查单核对;未收到通知时也应主动查询订单状态。
- 区分重试与新订单:重试同一笔创建请求时保留原
merchant_request_id和业务参数;新订单使用新请求号。通知按第 9.3 节处理,避免重复业务操作或遗漏不同结果。 - 区分状态格式:创建与查单响应的
status使用数值枚举;通知的status使用字符串SUCCESS/FAIL。失败原因按fail_code判断;message、status_text、fail_reason仅用于展示。 - 结果待确认时不要重复下单:收付款订单状态为
7(PENDING_VERIFY)时,继续查单;长时间未更新请提供平台订单号联系平台。重新创建代付订单可能导致重复付款。 - 按错误码处理:10000/10004/20006 可逐步延长间隔后重试;创建请求须保留原请求号和业务参数。10001/10005/10006/10007 按第 3 节处理,勿原样连续重试。
- 保护好
api_secret:仅存服务端;怀疑泄露立即联系平台轮换。
11 联调与上线检查
上图为交互示例。创建响应不保证订单已经成功或失败;请读取返回状态。查单无需等待通知,收到不同结果的通知时也应查单核对。
上线前请完成以下检查:
- 使用平台提供的测试凭据完成一次代收和一次代付。
- 使用原请求号和相同业务参数重发创建请求,每次生成新的时间戳、随机数与签名,确认不会产生第二笔订单。
- 接收并验签通知,确认修改报文内容后验签失败;通知地址带查询参数时,一并验证参数参与签名。
- 故意让首次通知应答失败,确认同一结果的重复通知不会重复产生业务操作;在商户侧测试不同结果、不同到达顺序时,均会查单核对。
- 验证未收到通知时仍能主动查单,并核对每笔订单的状态、金额和币种。
- 验收通过后,由平台单独开通生产凭据、IP 白名单、支付方式、支持银行及币种;测试凭据不得复用。
12 常见问题
12.1 返回 20001「签名验证失败」,怎么查?
不要靠改代码试。先用第 2.5 节的三组测试向量自检,哪组不过,问题就在哪一段:
- A 组不过:8 段的顺序或分隔符错了。段与段之间是
\n,某段为空时留空但分隔符不能省。 - B 组不过:body 哈希算错了。必须对实际发出去的字节取 SHA-256,不能先反序列化再重新序列化——键顺序或空格差一个字符,哈希就不同。
- C 组不过:查询串规范化不一致。这是最常见的一类,见下一条。
三组都过而线上仍失败,那就不是算法问题:检查 api_secret 是否用错环境的、PATH 是否用了路由模板(/api/v1/payin/{no})而不是真实路径。
12.2 查询串怎么规范化才和平台一致?
三条规则,缺一不可:
- 参数按键升序,同键多值按值升序;
- 键和值分别编码,空格编码为
+,不是%20; !'()*必须转义。
最后一条是 JavaScript 接入方踩得最多的:encodeURIComponent 默认不转这五个字符,直接拿来用就会失败。
请求实际发出的 URL 必须使用同一个规范化结果——用规范串签名、却发另一个顺序的 URL,一样验不过。
12.3 返回 20003「访问被拒绝」,签名没问题啊?
多半不是签名问题,是出口 IP 不在白名单内。这一层在签名校验之前,被拦时拿到的可能是 HTTP 403 而不是平台的 JSON 信封。
确认你的服务器实际出口 IP(不是内网地址),交给技术支持加白。经过 NAT、代理或多可用区部署时,出口 IP 可能不止一个,要全部提供。
12.4 10006 和 10007 有什么区别?
10006:这个channel_code没对你开通。重发不会改变结果,联系技术支持开通。10007:已开通,只是当前受理不了本笔——通常是金额超出该支付方式的可受理范围。可稍后按第 4 节的幂等规则重试,或改用其他已开通的支付方式。
12.5 没收到通知怎么办?能靠通知终结订单吗?
不能。通知投递失败最多重试 3 次(共 4 次),间隔约 15 秒 / 1 分钟 / 5 分钟,超过 30 分钟停止。网络、证书、白名单任一环节出问题,订单就永远等不到通知。
正确做法是通知 + 查单:以查单接口返回的状态为准,未收到通知时主动查,逐步延长间隔。排查方向依次是——回调地址是否公网可达、HTTPS 证书是否有效、是否已把我方回调 IP 加入你方白名单、你的应答是否符合第 9.2 节的约定。
12.6 收不到响应(超时/断连),该重发还是查单?
用同一个 merchant_request_id 重发。它是幂等键:同一个 ID 重发返回原单,不会产生第二笔。
绝对不要换新 ID 重发——换 ID 等于下第二笔单。收款侧是重复收款,出款侧是双重打款,钱已经出去了。
12.7 订单卡在 status=7(PENDING_VERIFY)怎么办?
等。它既不是成功也不是失败,表示结果尚未确定。出款侧此时资金仍处于冻结状态。
按失败重新下单是这份文档里最贵的一个错误:结果不明意味着上游那边可能已经出账,重下就是双付。请等待终态通知或继续查单。
12.8 订单失败了,能直接重试吗?
看 fail_code,见第 3.2 节。CHANNEL_REJECTED 和 PAYMENT_TIMEOUT 可以用新的 merchant_request_id 创建新订单;PENDING_MANUAL_REVIEW 不要重试。
12.9 返回 10005「币种小数位不受支持」?
amount 的小数位超过了该币种允许的精度。平台不做四舍五入——金额是资金事实,替商户猜一个值是不可接受的。末尾零不需要补齐,"100" 与 "100.00" 等价。
12.10 时间戳报错,或偶发 20001?
X-Timestamp 与服务器时间相差不得超过 300 秒。偶发失败通常是服务器时间漂移,做 NTP 同步即可。
另外 X-Nonce 单次使用,短期内重复会被拒绝:每次发送(包括重试)都要用新的时间戳和随机值,并重新计算签名。
Merchant API Handbook
Updated 2026-09-10
01 Endpoints
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/payin/create | Create a collection order |
GET | /api/v1/payin/{payin_order_no} | Query a collection order |
POST | /api/v1/payout/create | Create a disbursement order |
GET | /api/v1/payout/{payout_order_no} | Query a disbursement order |
GET | /api/v1/balance | Query merchant balances |
Every endpoint is signed as described in section 2. The host, app_id, api_key, api_secret and your channel_code values are issued at onboarding; provide your notification URL and egress IP at the same time.
02 Signing and authentication
2.1 Request headers
Every request must carry these five headers:
| Header | Description |
|---|---|
X-App-Id | Application ID issued by the platform |
X-Api-Key | API key issued by the platform |
X-Timestamp | Unix timestamp in seconds, within 300 seconds of server time |
X-Nonce | Random string, single use; a repeat within the replay window is rejected |
X-Sign | Signature from 2.2, lowercase hexadecimal |
2.2 Signature algorithm
HMAC-SHA256 keyed with api_secret, output as lowercase hex. The string to sign is always eight segments joined by newline \n. An empty segment stays empty, but the separator is never omitted:
Canonicalization rules:
METHODis uppercase, for examplePOSTorGET.PATHis the actual request path with real path parameter values, for example/api/v1/payin/PO20260829000123, not the route template.QUERYis the canonicalized query string: parameters sorted by key ascending, repeated keys sorted by value ascending, keys and values URL-encoded separately with spaces encoded as+, joined ask=vwith&. Empty when there are no query parameters. Use the same canonical query string in the request URL.- The last segment is the lowercase SHA-256 hex of the raw request body bytes. Requests without a body, such as GET, hash the empty byte string, giving
e3b0c442…7852b855. Never deserialize and re-serialize the body first; hash exactly the bytes you send.
2.3 Signing examples
Expand the example for your language. Replace the example host and three credentials with the values issued at onboarding; keep the secret server-side. Pass query parameters as unencoded strings. Serialize JSON once and use the same UTF-8 bytes for both signing and sending.
Python 3.10+ · requests
Requires requests. For a request without a body, use the default b"".
import hashlib, hmac, time, uuid, requests
from urllib.parse import urlencode
APP_ID = "your_app_id"
API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
BASE = "https://api.example.com"
def canonical_query(params: dict) -> str:
if not params:
return ""
pairs = []
for k in sorted(params):
vs = params[k] if isinstance(params[k], list) else [params[k]]
for v in sorted(map(str, vs)):
pairs.append((str(k), v))
# Preserve repeated keys and encode spaces as +; reuse for signing and the URL.
return urlencode(pairs)
def sign(method: str, path: str, query: dict, body: bytes, ts: str, nonce: str) -> str:
body_sha = hashlib.sha256(body).hexdigest()
source = "\n".join([
method.upper(), path, canonical_query(query),
APP_ID, API_KEY, ts, nonce, body_sha,
])
return hmac.new(API_SECRET.encode(), source.encode(), hashlib.sha256).hexdigest()
def request(method: str, path: str, query: dict | None = None, body: bytes = b""):
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
headers = {
"X-App-Id": APP_ID,
"X-Api-Key": API_KEY,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Sign": sign(method, path, query or {}, body, ts, nonce),
"Content-Type": "application/json",
}
query_string = canonical_query(query or {})
url = BASE + path + (("?" + query_string) if query_string else "")
return requests.request(method, url, headers=headers, data=body, timeout=10, allow_redirects=False)
Go · standard library
Save as merchant_sign.go and adjust the package name for your project. Use url.Values for query parameters and nil for an empty body. Check the error from Request and close the returned response.Body.
package merchantapi
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const (
appID = "your_app_id"
apiKey = "your_api_key"
apiSecret = "your_api_secret"
baseURL = "https://api.example.com"
)
var client = &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
func CanonicalQuery(params url.Values) string {
normalized := make(url.Values, len(params))
for key, values := range params {
normalized[key] = append([]string(nil), values...)
sort.Strings(normalized[key])
}
return normalized.Encode()
}
func Sign(method, path string, query url.Values, body []byte, ts, nonce string) string {
bodyHash := sha256.Sum256(body)
source := strings.Join([]string{
strings.ToUpper(method), path, CanonicalQuery(query),
appID, apiKey, ts, nonce, hex.EncodeToString(bodyHash[:]),
}, "\n")
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(source))
return hex.EncodeToString(mac.Sum(nil))
}
func Request(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) {
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
return nil, err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := hex.EncodeToString(random)
queryString := CanonicalQuery(query)
requestURL := baseURL + path
if queryString != "" {
requestURL += "?" + queryString
}
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("X-App-Id", appID)
req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Sign", Sign(method, path, query, body, ts, nonce))
req.Header.Set("Content-Type", "application/json")
return client.Do(req)
}
Java 11+ · standard library
Save as MerchantApiSigner.java. Use Map<String, List<String>> for query parameters. Convert JSON to bytes with getBytes(StandardCharsets.UTF_8), or pass new byte[0] for an empty body.
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.StringJoiner;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class MerchantApiSigner {
private static final String APP_ID = "your_app_id";
private static final String API_KEY = "your_api_key";
private static final String API_SECRET = "your_api_secret";
private static final String BASE = "https://api.example.com";
private static final Duration TIMEOUT = Duration.ofSeconds(10);
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
private static final Comparator<String> UTF8_ORDER = (left, right) ->
Arrays.compareUnsigned(left.getBytes(StandardCharsets.UTF_8),
right.getBytes(StandardCharsets.UTF_8));
public static String canonicalQuery(Map<String, List<String>> query) {
if (query == null || query.isEmpty()) {
return "";
}
List<String> keys = new ArrayList<>(query.keySet());
keys.sort(UTF8_ORDER);
StringJoiner result = new StringJoiner("&");
for (String key : keys) {
List<String> values = new ArrayList<>(query.get(key));
values.sort(UTF8_ORDER);
for (String value : values) {
result.add(queryEncode(key) + "=" + queryEncode(value));
}
}
return result.toString();
}
private static String queryEncode(String value) {
// Spaces become +, literal + becomes %2B, ~ stays literal, and * becomes %2A.
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("%7E", "~").replace("*", "%2A");
}
public static String sha256Hex(byte[] body) throws GeneralSecurityException {
return hex(MessageDigest.getInstance("SHA-256")
.digest(body == null ? new byte[0] : body));
}
private static String hex(byte[] bytes) {
char[] digits = "0123456789abcdef".toCharArray();
char[] result = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int value = bytes[i] & 0xff;
result[i * 2] = digits[value >>> 4];
result[i * 2 + 1] = digits[value & 15];
}
return new String(result);
}
public static String sign(String method, String path, Map<String, List<String>> query,
byte[] body, String timestamp, String nonce)
throws GeneralSecurityException {
return signCanonical(method, path, canonicalQuery(query), body, timestamp, nonce);
}
private static String signCanonical(String method, String path, String queryString,
byte[] body, String timestamp, String nonce)
throws GeneralSecurityException {
String source = String.join("\n", method.toUpperCase(Locale.ROOT), path,
queryString, APP_ID, API_KEY, timestamp, nonce, sha256Hex(body));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(API_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex(mac.doFinal(source.getBytes(StandardCharsets.UTF_8)));
}
public static HttpResponse<byte[]> request(String method, String path,
Map<String, List<String>> query, byte[] body)
throws GeneralSecurityException, IOException, InterruptedException {
String timestamp = Long.toString(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString().replace("-", "");
byte[] rawBody = body == null ? new byte[0] : body;
String queryString = canonicalQuery(query);
String url = BASE + path + (queryString.isEmpty() ? "" : "?" + queryString);
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(TIMEOUT)
.header("X-App-Id", APP_ID)
.header("X-Api-Key", API_KEY)
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Sign", signCanonical(method, path, queryString, rawBody, timestamp, nonce))
.header("Content-Type", "application/json")
.method(method.toUpperCase(Locale.ROOT), HttpRequest.BodyPublishers.ofByteArray(rawBody))
.build();
// Do not retry order creation automatically; follow the idempotency rules.
return CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
}
2.4 Notes
- Call the API with your own merchant credentials; no additional merchant number is needed in the request body.
- For every request, including retries, generate a fresh timestamp and nonce and recalculate the signature. Back off when the service is temporarily unavailable; follow section 4 when retrying order creation.
2.5 Signature test vectors
Three vectors with fixed credentials and timestamp. Self-check with these before integration: feed the same inputs to your implementation, compare the signing string segment by segment, then the final signature. It locates the difference before you ever hit 20001 in testing.
All three use app_id=app_demo, api_key=key_demo, api_secret=secret_demo, X-Timestamp=1789000000, X-Nonce=nonce_demo. The \n in the signing string is a real newline.
- Canonical QUERY
(empty)
- RAW_BODY
(empty)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Signing string (8 segments)
GET /api/v1/balance app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Expected X-Sign
21b0a67b99490232913d9bf2e1206593ab79285441465d925c26f3ff50c62912
- Canonical QUERY
(empty)
- RAW_BODY
{"merchant_order_no":"PM1","merchant_request_id":"RQ1","amount":"100.00","currency":"PKR","channel_code":"PC0001"}
- SHA256_HEX(RAW_BODY)
90782d98368489cb98ab0e48d79d1babdabc34f1a0a8e268787eafd1512eae97
- Signing string (8 segments)
POST /api/v1/payin/create app_demo key_demo 1789000000 nonce_demo 90782d98368489cb98ab0e48d79d1babdabc34f1a0a8e268787eafd1512eae97
- Expected X-Sign
f891a6cf99ca74caf42e009e2b64488baecdbbf897a713344d85aa4298762178
- Canonical QUERY
probe=A&probe=a+b&x=1
- RAW_BODY
(empty)
- SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Signing string (8 segments)
GET /api/v1/balance probe=A&probe=a+b&x=1 app_demo key_demo 1789000000 nonce_demo e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
- Expected X-Sign
0dce9b3f6782a672447c55becb932a90be345f3309c50e39d7c8d32346d8fea2
The third vector is where implementations most often diverge: a space must encode as +, not %20, repeated keys sort by value, and !'()* must be escaped — JavaScript's encodeURIComponent leaves those alone, so using it directly fails verification. If the first two pass and the third does not, the problem is in query canonicalisation.
03 Response format and error codes
Every endpoint uses the following response format:
{
"code": 0,
"message": "成功",
"data": { },
"trace_id": "…",
"timestamp": 1756450000
}
code = 0means only that this request was processed successfully, not that payment succeeded. Determine the order outcome fromdata.status.datais omitted when the request fails.- Branch on
code, never parsemessage: the text is localized and subject to change. - Quote
trace_idwhen contacting the platform about a request.
| code | HTTP | Meaning | What to do |
|---|---|---|---|
| 10000 | 500 | System error | Check the original order outcome as described in section 4; quote trace_id when contacting the platform if the error persists |
| 10001 | 400 | Invalid parameter | Fix the parameters and resend; retrying will not change the result |
| 10002 | 404 | Not found | Check the platform order number and merchant credentials, then query again |
| 10003 | 409 | Duplicate or conflict | Check the original request and order as described in section 4; do not simply change merchant_request_id to submit a duplicate order |
| 10004 | 503 | Service temporarily unavailable | Back off and retry; follow the idempotency rules in section 4 for order creation |
| 10005 | 400 | Unsupported currency scale | amount must not exceed the currency's allowed decimal precision; trailing zeros are optional and amounts are never rounded |
| 10006 | 400 | Payment service not enabled | Check that channel_code is an order code enabled for your merchant account. Contact technical support to enable the payment service instead of repeatedly resending the request |
| 10007 | 400 | Payment service temporarily unable to accept the request | The selected payment service is enabled but cannot accept this amount right now. Retry later following the idempotency rules in section 4. Before switching to another enabled payment method, confirm the original request and order result to avoid a duplicate payment. Contact technical support if the issue persists |
| 20001 | 401 | Unauthenticated or bad signature | Check app_id and the signing algorithm |
| 20003 | 403 | Access denied | Your outbound IP may be missing from the merchant API allowlist; confirm your outbound IP and enabled permissions, and contact the platform if needed |
| 20004 | 403 | Account frozen or merchant unavailable | Contact the platform |
| 20005 | 403 | Account disabled | Contact the platform |
| 20006 | 429 | Rate limited | Back off and retry |
3.2 Order failure codes fail_code
The code above tells you what happened to this API call; fail_code tells you what happened to the order — the call itself succeeded (HTTP 200, code=0), the order just ended in failure. Same audience, orthogonal meanings; do not mix them.
fail_code is set only when an order reaches FAIL. Decide whether to retry from this field. fail_reason and status_text are likewise display-only.
fail_code | Meaning | Retry? |
|---|---|---|
CHANNEL_REJECTED | The payment service declined the transaction | Yes — create a new order with a new merchant_request_id |
PAYMENT_TIMEOUT | The payer did not complete payment in time | Yes, as above |
PENDING_MANUAL_REVIEW | Outcome unknown; the platform has suspended it for review | Do not retry. See the warning below |
CHANNEL_UNAVAILABLE | No payment service can take it right now | Retrying rarely helps; contact technical support |
ORDER_CLOSED | The order was closed or cancelled | Create a new order if you still need the transaction |
UNSPECIFIED | Unclassified failure | Confirm the original order per section 4 first |
PENDING_MANUAL_REVIEW means the opposite of the others: do not retry. An unknown outcome means the upstream may already have taken or sent the money, so submitting a new order risks paying twice. Wait for the platform's follow-up notification, or confirm via query per section 4. Never retry automatically.
The set of values is part of the public contract and is append-only — branches you write against it will not break on a platform upgrade. If you receive a value not listed above, treat it as UNSPECIFIED and contact technical support.
04 Amounts, currencies and idempotency
- Order amounts and balances use major-unit decimal strings. Submitted amounts must have no more than the currency's allowed decimal places; trailing zeros are optional. For a precision of 2,
"100","100.0"and"100.00"are all valid. Excess decimal places and malformed values are rejected; amounts are never rounded. Confirm the allowed precision for each currency during integration. - When creating an order, use the
channel_code(order code) supplied at onboarding for the relevant payment direction and currency. Do not put a payment method name or bank name in this field. - The creation idempotency key is
merchant_request_id, unique per merchant:- When retrying, preserve the original request body and
merchant_request_id, but generate freshX-Timestamp,X-NonceandX-Signheaders. Repeating the same creation request returns the original order, not a second order. - A timeout or interrupted connection does not mean creation failed. Query first if you have the platform order number; otherwise resend the original request using the rules above. While the original outcome is unknown, never change the idempotency key to submit a duplicate order.
- Reusing the same
merchant_request_idwith a changed amount, order code, recipient or any other key element returns10003and does not create a new order. Use the query endpoint to retrieve the original order instead. - Never reuse a
merchant_request_idto change an amount, order code or recipient. Use a new key only when the original order is confirmed failed or closed and a replacement is needed, or when creating a separate, independent business order.
- When retrying, preserve the original request body and
05 Payin
5.1 Create POST /api/v1/payin/create
| Field | Type | Required | Description |
|---|---|---|---|
merchant_order_no | string(≤64) | yes | Your order number |
merchant_request_id | string(≤64) | yes | Idempotency key, see section 4 |
amount | string(≤32) | yes | Major-unit amount string, e.g. "100.00" |
currency | string | yes | Currency code |
channel_code | string(2–16) | yes | Order code provided at onboarding for the enabled payment service |
product_name | string(≤128) | no | Product name |
notify_url | string(≤512) | no | Notification URL for this order; falls back to your default payin URL |
return_url | string(≤512) | no | Redirect URL after payment; a browser redirect is not proof of successful payment |
payer_phone | string(≤32) | no | Payer phone; 11 digits starting with 03 in Pakistan, required by some payment methods |
payer_id_no | string(≤64) | no | Payer ID number; a 13-digit CNIC in Pakistan, required for direct-debit payments |
payer_name | string(≤128) | no | Payer name |
payer_email | string(≤128) | no | Payer email, format validated |
Response data:
{
"url": "https://cashier.example.com/order/PO202609100001",
"payin_order_no": "PO202609100001",
"merchant_order_no": "M202609100001",
"amount": "100.00",
"currency": "PKR",
"channel_code": "assigned_code",
"pay_method": "JAZZCASH",
"status": 2,
"channel_order_no": null,
"provider_pay_url": "https://pay.example.com/PO202609100001",
"provider_qr_code": null,
"fail_code": "",
"fail_reason": null
}
The URLs and identifiers above are examples. url is the cashier URL; provider_pay_url is a payment link; provider_qr_code contains the data for a payment QR code; channel_order_no is a payment reference. The latter three fields may be null; use the values returned in the actual response.
status = 2 (PENDING): awaiting payment. Use the returned provider_pay_url or provider_qr_code to guide the payer, or open the cashier at url.
status = 5 (FAIL): payment failed. Check fail_code and fail_reason. If another payment is needed, confirm the original order result before creating an order with a new merchant_request_id.
status = 1 or 3 (INIT / PROCESSING): the order is still being processed. Query for updates or direct the payer to the cashier url. Do not treat this as a failed payment.
code=0 means the API request was processed successfully, not that payment succeeded. Check status for the order result and fail_code / fail_reason for failure details.
5.2 Query GET /api/v1/payin/{payin_order_no}
Response data fields: payin_order_no, merchant_no, merchant_order_no, merchant_request_id, status, amount, currency, channel_code, pay_method, channel_order_no, provider_pay_url, provider_qr_code (returned only while the order is still payable), notify_url, return_url, expire_time, success_time, fail_code, fail_reason, created_at. Timestamps are RFC3339.
5.3 Payin order states
Create and query responses use the numeric status values below. A missing notification or request timeout does not mean the payment failed.
| Value | State | Meaning | Merchant action |
|---|---|---|---|
| 1 | INIT | Order created | Wait for processing and query for updates |
| 2 | PENDING | Awaiting payment | Direct the payer to the returned payment link or QR code |
| 3 | PROCESSING | Payment in progress | Wait for a notification or query; do not create a duplicate |
| 4 | SUCCESS | Payment successful | Verify the order number, amount and currency before updating your order |
| 5 | FAIL | Payment failed | Check the failure reason and confirm the original result before creating another order |
| 6 | CLOSED | Order closed or expired | Stop using the original payment link; create a new order if payment is still needed |
| 7 | PENDING_VERIFY | Payment result not yet confirmed | Keep the order pending and wait or query; do not mark it as successful or failed |
06 Payout
6.1 Create POST /api/v1/payout/create
| Field | Type | Required | Description |
|---|---|---|---|
merchant_order_no | string(≤64) | yes | Your order number |
merchant_request_id | string(≤64) | yes | Idempotency key |
amount | string(≤32) | yes | Major-unit amount string |
currency | string | yes | Currency code |
channel_code | string(2–16) | yes | Order code provided at onboarding for the enabled payment service |
receiver_name | string(≤128) | yes | Recipient name |
receiver_account | string(≤128) | yes | Recipient account |
receiver_bank_code | string(≤64) | no | Recipient bank code; when required, use the code in the supported bank list supplied at onboarding (section 7.2) |
receiver_email | string(≤128) | no | Recipient email, format validated |
receiver_id_no | string(≤64) | no | Recipient ID number; a 13-digit CNIC in Pakistan, required for wallet payouts |
receiver_phone | string(≤32) | no | Recipient phone; 11 digits starting with 03 in Pakistan, required for bank payouts |
receiver_iban | string(≤64) | no | Recipient IBAN; 24 characters starting with PK in Pakistan, required for bank payouts |
receiver_bank_name | string(≤128) | no | Recipient bank name from the supported bank list; required for some bank payouts |
Response data:
{ "payout_order_no": "PT…", "status": 1 }
After an order is created, order amount + fee moves from your available balance to your frozen balance. A failed payout (5) restores that amount to your available balance; a successful payout (4) deducts it from your frozen balance. An order cannot be created with insufficient funds.
6.2 Query GET /api/v1/payout/{payout_order_no}
Response data fields: payout_order_no, merchant_order_no, amount, currency, status, status_text, fail_code, fail_reason, receiver_account (masked), success_time, created_at.
6.3 Payout order states
Create and query responses use the numeric status values below. A timeout or missing notification alone does not mean a payout failed.
| Value | State | Meaning | Merchant action |
|---|---|---|---|
| 1 | INIT | Order created | Wait for the payout result; do not submit a duplicate |
| 3 | PROCESSING | Payout in progress | Wait for a notification or query; do not submit a duplicate |
| 4 | SUCCESS | Payout successful | Verify the order number, amount and currency before updating your order |
| 5 | FAIL | Payout failed | Check the failure reason and confirm the original result before retrying with a new request ID |
| 6 | CLOSED | Order closed | Confirm the original result before creating another payout |
| 7 | PENDING_VERIFY | Payout result not yet confirmed | Keep the order pending; funds remain frozen. Do not retry as a failed payout |
PENDING_VERIFY (7) means the payout result is not yet confirmed, not that it failed. Submitting an order with a new merchant_request_id can cause a duplicate payout. Keep the original order and wait for a notification or query. If its status remains unchanged for an extended period, contact technical support with the order number.
07 Payment methods and supported banks
7.1 Payin methods
The following payin methods are available for integration. Your enabled methods, currencies and order codes are confirmed at onboarding.
| Country / region | Currency | Payment method |
|---|---|---|
| Pakistan | PKR | JazzCash |
| Pakistan | PKR | Easypaisa |
| Pakistan | PKR | QR payment |
Choose an enabled payment method and create an order with its assigned order code, then direct the payer to the returned payment link or QR code. The pay_method field in a query response describes the payment method; it is not a create request parameter.
7.2 Supported payout banks
For bank payouts, use the supported bank list enabled for your merchant account. Technical support supplies this list at onboarding, including the country / region, currency, bank name, required bank code and recipient information requirements. Do not guess bank codes.
receiver_bank_code: when a bank code is required, use the exact value from the supported bank list.receiver_bank_name: when a bank name is required, use the name from that list.channel_code: use your enabled payout order code. A bank name or bank code cannot replace this field.
If you have not received the list or cannot confirm the recipient bank, contact technical support before submitting a payout. Wallet payouts do not use the bank list; follow the recipient information requirements for the selected wallet.
08 Balance GET /api/v1/balance
No request parameters. Returns balances by currency for your merchant account. Response data:
{
"as_of": "2026-08-29T12:00:00Z",
"balances": [
{
"currency": "PKR",
"available_balance": "12345.00",
"frozen_balance": "100.00",
"total_balance": "12445.00"
}
]
}
All amounts are major-unit strings. Total = available + frozen.
09 Asynchronous notifications
The platform sends asynchronous notifications with the order's success or failure result (SUCCESS or FAIL):
- Payin: the
notify_urlgiven at creation, falling back to your default payin notification URL. - Payout: your default payout notification URL.
- If no URL is configured the notification is skipped; rely on the query endpoint.
9.1 Notification payload
POST with a JSON body:
{
"biz_type": "PAYIN",
"order_no": "PO…",
"merchant_order_no": "…",
"merchant_no": "…",
"amount": "100.00",
"currency": "PKR",
"status": "SUCCESS",
"fail_code": "",
"fail_reason": "",
"finished_at": "2026-08-29T12:00:00Z",
"notified_at": "2026-08-29T12:00:05Z"
}
biz_typeisPAYINorPAYOUT;statusisSUCCESSorFAIL.- Notifications carry the same five signature headers under exactly the same rules as merchant requests (section 2), signed over the notification URL's actual path and query. Reuse your own signing code to verify, and always verify before acting.
9.2 Acknowledgement
After verifying the signature and either completing processing or durably saving the notification for processing, respond within 5 seconds with HTTP 200 and the plain-text body success, matched exactly after trimming whitespace. Other responses or a timeout trigger retries. Your notification URL must accept the POST directly without redirecting it.
9.3 Retries and idempotency
- Notifications may repeat or arrive late. Locate the order using
biz_type + order_no. Repeated notifications with the same result must not repeat your business operations. Returnsuccessafter processing or durably saving the notification. - A later notification for the same order may carry a different result. Do not ignore it just because you have processed that order number before. Query the current order state before updating your records, so an older notification cannot overwrite a newer state.
- A failed delivery is retried up to 3 times (4 attempts in total) at roughly 15 seconds, 1 minute and 5 minutes, and stops after 30 minutes.
- A missing notification does not mean the order failed. Use the current state returned by the query endpoint. If no notification arrives or the result is still unconfirmed, query the order with increasing intervals; do not create a replacement order.
9.4 Verification example (Python / Flask)
This example reuses sign from the Python example in section 2.3. Implement handle_notification in your application to check the order, amount and currency, handle repeated or different results as described in section 9.3, and complete processing or durably save the notification before returning.
import hmac, json, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/notify")
def on_notify():
body = request.get_data() # raw bytes; never parse then re-serialize
ts = request.headers.get("X-Timestamp", "")
nonce = request.headers.get("X-Nonce", "")
got = request.headers.get("X-Sign", "")
query = request.args.to_dict(flat=False) # preserve all values for each key
want = sign("POST", request.path, query, body, ts, nonce)
if not hmac.compare_digest(got, want):
return "invalid sign", 401
try:
timestamp = int(ts)
except ValueError:
return "invalid timestamp", 401
if abs(time.time() - timestamp) > 300:
return "expired", 401
data = json.loads(body)
handle_notification(data)
return "success", 200
10 Integration practices
- Confirm the state by querying. Query after a notification to check the result, and query proactively if no notification arrives.
- Distinguish retries from new orders. Retry a create request with its original
merchant_request_idand business parameters; use a new request ID for a new order. Follow section 9.3 to avoid repeated business operations without missing a different result. - Use the correct status format. Create and query responses use numeric
statusvalues; notifications use the stringsSUCCESSorFAIL. Usefail_codefor failure handling;message,status_textandfail_reasonare for display only. - Do not create another order while the result is unconfirmed. For status
7(PENDING_VERIFY), continue querying. If it remains unchanged, contact the platform with the platform order number. Creating another payout can result in a duplicate payment. - Handle errors by code. Retry 10000, 10004 or 20006 with increasing intervals, preserving the original request ID and business parameters for create requests. For 10001, 10005, 10006 or 10007, follow section 3 instead of repeatedly resending the unchanged request.
- Protect
api_secret. Keep it server-side only, and contact the platform to rotate it the moment you suspect exposure.
11 Integration testing and launch checklist
This diagram illustrates the interactions. A create response does not guarantee that the order has succeeded or failed; read its status. You can query without waiting for a notification, and should query when a notification carries a different result.
Complete these checks before going live:
- Use the platform's test credentials to complete one payin and one payout.
- Resend a create request with its original request ID and business parameters, generating a fresh timestamp, nonce and signature each time. Confirm that no second order appears.
- Receive and verify a notification, and confirm that modifying its body invalidates the signature. If your notification URL has query parameters, include them in your verification tests.
- Deliberately fail the first acknowledgement and confirm that a repeated result does not repeat business operations. Test different results and arrival orders in your application, confirming that you query the order to check its current state.
- Verify that you can query proactively when no notification arrives, and check each order's status, amount and currency.
- After acceptance the platform issues production credentials, IP allowlist, payment methods, supported banks and currencies separately. Test credentials are never reused.
12 FAQ
12.1 I get 20001 “signature verification failed” — how do I debug it?
Do not debug by trial and error. Run the three test vectors in section 2.5 first; whichever fails points at the segment:
- A fails: the order of the 8 segments or the separators. Segments are joined with
\n; an empty segment stays empty but the separator is never omitted. - B fails: the body hash. SHA-256 must be taken over the bytes you actually send. Never deserialise and re-serialise — one different key order or space changes the hash.
- C fails: query canonicalisation. The most common class; see the next question.
If all three pass but live calls still fail, it is not the algorithm: check that api_secret belongs to the right environment, and that PATH is the real path, not the route template (/api/v1/payin/{no}).
12.2 How exactly is the query string canonicalised?
Three rules, all required:
- Sort parameters by key ascending; repeated keys by value ascending.
- Encode keys and values separately, with space as
+, not%20. !'()*must be escaped.
That last rule catches most JavaScript integrations: encodeURIComponent leaves those five characters alone, so using it directly fails.
The URL you actually send must use the same canonical string — signing one ordering and sending another fails just the same.
12.3 I get 20003 “access denied” but my signature is fine
It is usually not the signature: your egress IP is not allowlisted. That check runs before signature verification, so you may receive a plain HTTP 403 rather than the platform's JSON envelope.
Confirm your server's actual egress IP (not a private address) and send it to technical support. Behind NAT, a proxy, or a multi-AZ deployment there may be several — provide them all.
12.4 What is the difference between 10006 and 10007?
10006: thatchannel_codeis not opened for you. Resending changes nothing; ask technical support to open it.10007: it is opened, but cannot take this particular order right now — usually the amount falls outside what that payment method accepts. Retry later under the idempotency rules in section 4, or use another opened payment method.
12.5 I did not receive a notification. Can I close orders on notifications alone?
No. Delivery is retried at most 3 times (4 attempts total) at roughly 15s / 1min / 5min, and stops after 30 minutes. A problem with the network, the certificate or an allowlist means that order never gets its notification.
Use notification plus query: the query endpoint is authoritative, and you should poll on a widening interval when no notification arrives. Check, in order: is your callback URL reachable from the public internet, is the HTTPS certificate valid, have you allowlisted our callback IPs, and does your response follow section 9.2.
12.6 The request timed out. Should I resend or query?
Resend with the same merchant_request_id. It is the idempotency key: resending returns the original order rather than creating a second one.
Never resend with a new ID — that is a second order. On payin it collects twice; on payout it pays out twice, and the money is already gone.
12.7 An order is stuck at status=7 (PENDING_VERIFY)
Wait. It is neither success nor failure — the outcome is not yet settled. On payouts the funds remain frozen.
Re-submitting as if it failed is the most expensive mistake in this document: an unknown outcome means the upstream may already have paid out, so a new order pays twice. Wait for the terminal notification or keep querying.
12.8 An order failed. Can I just retry?
Read fail_code (section 3.2). CHANNEL_REJECTED and PAYMENT_TIMEOUT can be retried as a new order with a new merchant_request_id. PENDING_MANUAL_REVIEW must not be retried.
12.9 I get 10005 “currency scale not supported”
amount has more decimal places than the currency allows. The platform does not round — an amount is a financial fact, and guessing one on your behalf is not acceptable. Trailing zeros are not required: "100" and "100.00" are equivalent.
12.10 Timestamp errors, or intermittent 20001
X-Timestamp must be within 300 seconds of server time. Intermittent failures are usually clock drift — sync with NTP.
Also, X-Nonce is single-use and a short-term repeat is rejected: every send, retries included, needs a fresh timestamp and nonce, and a recomputed signature.