商户 API 对接手册 v1.3

商户 API 对接手册

适用对象:接入本平台代收(payin)与代付(payout)能力的商户技术团队。

版本 v1.3 · 2026-09-10 · 商户接入说明

开通时单独提供

接入前请联系技术支持获取环境地址、app_idapi_keyapi_secret,并确认通知地址与 IP 白名单。支持的代收支付方式及代付银行说明见第 7 节;各支付方式对应的下单编码在开通时提供。

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 节的签名规范鉴权。查询接口是订单最终状态的权威来源;异步通知可能重复、延迟或耗尽重试,收到通知后仍建议查单核对。

1.1 快速开始:第一笔成功的收款

按顺序做完这五步,你就完成了一次完整的收款闭环。每步都标了「做不通时看哪里」。

步骤做什么做不通时
1 拿凭据联系技术支持获取环境地址、app_idapi_keyapi_secret,确认出口 IP 已加入白名单,并给出你的通知地址请求返回 20003 多半是出口 IP 不在白名单内
2 验签名先用第 2.5 节的测试向量自检你的实现,不要直接打接口试三组向量哪组不过,问题就在哪一段;第三组不过必是查询串规范化
3 取下单编码用开通时提供的支付服务清单里的 channel_code。它按「支付方式 × 收付方向 × 币种」区分,见第 7 节10006 表示该编码未对你开通;10007 表示已开通但当前受理不了本笔金额
4 创建订单POST /api/v1/payin/create,见第 5.1 节。拿 provider_pay_urlprovider_qr_code 引导付款人code=0 只表示订单已受理,不表示付款成功
5 拿结果接收异步通知(第 9 节)GET /api/v1/payin/{payin_order_no} 查单核对只收通知不查单,会漏掉通知耗尽重试的那些单

第 2 步不要跳过。签名是接入里唯一无法靠试错收敛的部分——先用测试向量把它确认下来,后面每一个错误码才指向真正的问题,而不是「可能又是签名」。

开始写代码前先记住三件事,它们是对接事故最集中的地方: code=0 不等于付款成功,订单结果一律看 status 收不到响应时用同一个 merchant_request_id 重发,换新 ID 等于下第二笔; status=7(PENDING_VERIFY)既不是成功也不是失败,不要据此重新下单,出款侧尤其会造成双重打款。

02 签名与鉴权

2.1 请求头

每个请求必须携带以下 5 个 Header:

Header说明
X-App-Id平台分配的应用 ID
X-Api-Key平台分配的 API Key
X-TimestampUnix 秒级时间戳,与服务器时间差不得超过 300 秒
X-Nonce随机字符串,单次使用,短期内重复会被拒绝
X-Sign按 2.2 计算的签名,小写十六进制

2.2 签名算法

算法:HMAC-SHA256,密钥为 api_secret,结果取小写 hex。待签名串固定为 8 段,以换行符 \n 连接。某段为空时该段留空,但分隔符不可省略

1METHOD
2PATH
3QUERY
4APP_ID
5API_KEY
6TIMESTAMP
7NONCE
8SHA256_HEX(RAW_BODY)

规范化规则:

  1. METHOD 大写(如 POSTGET)。
  2. PATH实际请求路径,含路径参数的真实值(如 /api/v1/payin/PO20260829000123),不是路由模板。
  3. QUERY 为规范化后的查询串:参数按键升序、同键多值按值升序,键和值分别进行 URL 编码,空格编码为 +,再以 k=v& 连接;无查询参数时为空串。实际请求 URL 应使用同一规范化查询串。
  4. 最后一段是原始请求体字节的 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_demoapi_key=key_demoapi_secret=secret_demoX-Timestamp=1789000000X-Nonce=nonce_demo。签名原文中的 \n 是真实换行符。

A 无请求体、无查询参数
规范化查询串 QUERY
(空)
请求体 RAW_BODY
(空)
SHA256_HEX(RAW_BODY)
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
签名原文(8 段)
GET
/api/v1/balance

app_demo
key_demo
1789000000
nonce_demo
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
预期 X-Sign
21b0a67b99490232913d9bf2e1206593ab79285441465d925c26f3ff50c62912
B 有请求体
规范化查询串 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
C 同键多值与空格
规范化查询串 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
codeHTTP含义商户侧处理
10000500系统错误按第 4 节核对原订单结果;持续出现请提供 trace_id 联系平台
10001400参数错误修正参数后重发;重试不会改变结果
10002404资源不存在确认平台订单号及所用商户凭证后重查
10003409重复/冲突按第 4 节核对原请求与原订单;不要直接更换 merchant_request_id 重复下单
10004503服务暂不可用退避后重试;创建订单须遵守第 4 节的幂等规则
10005400币种小数位不受支持amount 小数位不得超过该币种允许的精度,无需补足末尾零;不做四舍五入
10006400支付服务未开通核对 channel_code 是否为本商户已开通的下单编码;如需开通相应支付服务,请联系技术支持,不要原样连续重试
10007400支付服务暂时无法受理所选支付服务已开通,但当前无法受理本笔金额。可稍后按第 4 节的幂等规则重试;如需改用其他已开通的支付方式,请先确认原请求与订单结果,不要重复付款。持续出现时请联系技术支持
20001401未鉴权/签名验证失败检查 app_id 与签名算法
20003403访问被拒绝可能是请求出口 IP 不在商户 API 白名单内;确认出口 IP 与已开通权限,必要时联系平台
20004403账户被冻结/商户不可用联系平台
20005403账户被禁用联系平台
20006429触发限流退避后重试

3.2 订单失败码 fail_code

上表的 code 说的是这次接口调用怎么了;fail_code 说的是这笔订单最后怎么了——请求本身成功(HTTP 200、code=0),只是订单以失败告终。两者受众相同但语义正交,不要混用。

订单进入 FAILfail_code 才有值。请按它决定要不要重试fail_reasonstatus_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-TimestampX-NonceX-Sign。相同创建请求重发返回原订单,不会产生第二笔;
    • 超时或网络中断不代表创建失败:已有平台订单号时先查单;尚未取得单号时按上述规则重发原请求。原订单结果未知时,不得更换幂等键重复下单
    • 同一 merchant_request_id 修改了金额、下单编码、收款人等任一关键要素 → 返回 10003 冲突,不会创建新订单,此时应改用查单接口获取原订单结果;
    • 不要复用 merchant_request_id 来换金额/换下单编码/换收款人。只有确认原订单已失败或关闭,且确需重新下单,或创建一笔独立的新业务订单时,才使用新的 merchant_request_id

05 代收 Payin

5.1 创建订单 POST /api/v1/payin/create

字段类型必填说明
merchant_order_nostring(≤64)商户订单号
merchant_request_idstring(≤64)幂等键,见第 4 节
amountstring(≤32)主单位金额字符串,如 "100.00"
currencystring币种代码
channel_codestring(2–16)开通时提供的下单编码;按已开通的支付服务填写
product_namestring(≤128)商品名
notify_urlstring(≤512)本单通知地址;为空则用商户默认收款通知地址
return_urlstring(≤512)付款后用户跳转地址;页面跳转不能作为支付成功凭据
payer_phonestring(≤32)付款人手机号;巴基斯坦为 03 开头 11 位,部分支付方式必填
payer_id_nostring(≤64)付款人证件号;巴基斯坦为 13 位 CNIC,直接扣款方式必填
payer_namestring(≤128)付款人姓名
payer_emailstring(≤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_urlprovider_qr_code 引导付款人支付,也可使用 url 打开收银台。

status = 5(FAIL):支付失败,查看 fail_codefail_reason。如需再次付款,请先确认原订单结果,再使用新的 merchant_request_id 创建订单。

status = 13(INIT/PROCESSING):订单仍在处理中,请通过查单获取后续状态,或引导付款人访问 url 收银台。不要将此状态视为付款失败。

code=0 只表示本次接口请求处理成功,不表示付款成功。请根据 status 判断订单结果;失败原因见 fail_codefail_reason

5.2 查询订单 GET /api/v1/payin/{payin_order_no}

响应 data 字段:payin_order_nomerchant_nomerchant_order_nomerchant_request_idstatusamountcurrencychannel_codepay_methodchannel_order_noprovider_pay_urlprovider_qr_code(仅可继续支付状态返回)、notify_urlreturn_urlexpire_timesuccess_timefail_codefail_reasoncreated_at。时间字段为 RFC3339 格式。

5.3 收款订单状态

创建和查询接口的 status 为下列数字值。未收到通知或请求超时不代表支付失败。

状态含义商户处理
1INIT订单已创建等待处理;通过查单获取后续状态
2PENDING等待付款使用返回的支付链接或二维码引导付款
3PROCESSING支付处理中等待通知或查单,不要重复创建订单
4SUCCESS支付成功核对订单号、金额和币种后更新商户订单
5FAIL支付失败查看失败原因;如需再次付款,确认原单结果后创建新订单
6CLOSED订单已关闭或过期停止使用原支付链接;如需付款请创建新订单
7PENDING_VERIFY支付结果待确认保持待处理,等待通知或查单;不要标记成功或失败

06 代付 Payout

6.1 创建订单 POST /api/v1/payout/create

字段类型必填说明
merchant_order_nostring(≤64)商户订单号
merchant_request_idstring(≤64)幂等键
amountstring(≤32)主单位金额字符串
currencystring币种代码
channel_codestring(2–16)开通时提供的下单编码;按已开通的支付服务填写
receiver_namestring(≤128)收款人姓名
receiver_accountstring(≤128)收款账号
receiver_bank_codestring(≤64)收款银行代码;需填写时,使用开通时提供的支持银行清单中的代码,见第 7.2 节
receiver_emailstring(≤128)收款人邮箱,做格式校验
receiver_id_nostring(≤64)收款人证件号;巴基斯坦钱包代付为 13 位 CNIC,钱包代付时必填
receiver_phonestring(≤32)收款人手机号;巴基斯坦为 03 开头 11 位,银行代付必填
receiver_ibanstring(≤64)收款人 IBAN;巴基斯坦为 PK 开头 24 位,银行代付必填
receiver_bank_namestring(≤128)收款银行名称;按支持银行清单填写,部分银行代付必填

响应 data

{ "payout_order_no": "PT…", "status": 1 }
余额变化

订单创建成功后,可用余额中的 订单金额 + 手续费 转为冻结余额。出款失败(5)后相应金额恢复为可用余额;出款成功(4)后从冻结余额扣除。余额不足时无法创建订单。

6.2 查询订单 GET /api/v1/payout/{payout_order_no}

响应 data 字段:payout_order_nomerchant_order_noamountcurrencystatusstatus_textfail_codefail_reasonreceiver_account脱敏)、success_timecreated_at

6.3 出款订单状态

创建和查询接口的 status 为下列数字值。仅凭超时或暂未收到通知,不能判断出款失败。

状态含义商户处理
1INIT订单已创建等待出款结果,不要重复提交
3PROCESSING出款处理中等待通知或查单,不要重复提交
4SUCCESS出款成功核对订单号、金额和币种后更新商户订单
5FAIL出款失败查看失败原因;如需重试,确认原单结果后使用新的请求 ID
6CLOSED订单已关闭如需重新出款,先确认原订单结果
7PENDING_VERIFY出款结果待确认保持待处理;资金仍冻结,不要按失败重新出款
重复出款风险

PENDING_VERIFY(7)表示出款结果尚未确认,不代表出款失败。此时使用新的 merchant_request_id 重新下单可能造成重复出款。请保留原订单并等待通知或查单;长时间未更新时,携带订单号联系技术支持。

07 支付方式与支持银行

7.1 代收支付方式

代收可接入以下支付方式;本商户实际可用的方式、币种和下单编码,以开通时确认的清单为准。

国家/地区币种支付方式
巴基斯坦PKRJazzCash
巴基斯坦PKREasypaisa
巴基斯坦PKRQR 扫码支付

商户选择已开通的支付方式,使用对应的下单编码创建订单,再通过返回的支付链接或二维码引导付款人完成支付。查询响应的 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 异步通知

平台通过异步通知告知商户订单的成功或失败结果(SUCCESSFAIL):

  • 代收:优先使用下单时的 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_typePAYINPAYOUTstatusSUCCESSFAIL
  • 通知携带与商户请求完全相同规范的 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 对接最佳实践

  1. 以查单为准:收到通知后可查单核对;未收到通知时也应主动查询订单状态。
  2. 区分重试与新订单:重试同一笔创建请求时保留原 merchant_request_id 和业务参数;新订单使用新请求号。通知按第 9.3 节处理,避免重复业务操作或遗漏不同结果。
  3. 区分状态格式:创建与查单响应的 status 使用数值枚举;通知的 status 使用字符串 SUCCESSFAIL。失败原因按 fail_code 判断;messagestatus_textfail_reason 仅用于展示。
  4. 结果待确认时不要重复下单:收付款订单状态为 7PENDING_VERIFY)时,继续查单;长时间未更新请提供平台订单号联系平台。重新创建代付订单可能导致重复付款。
  5. 按错误码处理:10000/10004/20006 可逐步延长间隔后重试;创建请求须保留原请求号和业务参数。10001/10005/10006/10007 按第 3 节处理,勿原样连续重试。
  6. 保护好 api_secret:仅存服务端;怀疑泄露立即联系平台轮换。

11 联调与上线检查

商户服务平台带签名创建订单创建响应(请读取订单状态)异步通知(可能重复或延迟)HTTP 200 · success带签名查询订单当前订单状态与金额

上图为交互示例。创建响应不保证订单已经成功或失败;请读取返回状态。查单无需等待通知,收到不同结果的通知时也应查单核对。

上线前请完成以下检查:

  1. 使用平台提供的测试凭据完成一次代收和一次代付。
  2. 使用原请求号和相同业务参数重发创建请求,每次生成新的时间戳、随机数与签名,确认不会产生第二笔订单。
  3. 接收并验签通知,确认修改报文内容后验签失败;通知地址带查询参数时,一并验证参数参与签名。
  4. 故意让首次通知应答失败,确认同一结果的重复通知不会重复产生业务操作;在商户侧测试不同结果、不同到达顺序时,均会查单核对。
  5. 验证未收到通知时仍能主动查单,并核对每笔订单的状态、金额和币种。
  6. 验收通过后,由平台单独开通生产凭据、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 1000610007 有什么区别?

  • 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_REJECTEDPAYMENT_TIMEOUT 可以用新的 merchant_request_id 创建新订单;PENDING_MANUAL_REVIEW 不要重试

12.9 返回 10005「币种小数位不受支持」?

amount 的小数位超过了该币种允许的精度。平台不做四舍五入——金额是资金事实,替商户猜一个值是不可接受的。末尾零不需要补齐,"100""100.00" 等价。

12.10 时间戳报错,或偶发 20001?

X-Timestamp 与服务器时间相差不得超过 300 秒。偶发失败通常是服务器时间漂移,做 NTP 同步即可。

另外 X-Nonce 单次使用,短期内重复会被拒绝:每次发送(包括重试)都要用新的时间戳和随机值,并重新计算签名。

商户 API 对接手册 v1.3 · 2026-09-10 · 接入过程中如有疑问,请联系技术支持。