接入文档

PayHub 是统一收款网关:你只对接一套 API,即可通过我们后端聚合的多个上游支付渠道收款。全程无需引入 SDK、无需自建收银台——三次 HTTP 交互完成一笔收款。

更新 2026-08-07所有接口行为均经实跑验证时间口径 北京时间

0开始之前

你需要准备两样东西:一个能对外发起 HTTPS 请求的服务端,和一个能被公网访问的回调接口。除此之外不需要任何 SDK 或依赖。

接口地址

用途地址
API 根地址https://pay.payhub.asia
商户后台https://pay.payhub.asia/merchant/login
本文档https://pay.payhub.asia/docs

四个接口,就这些

接口做什么什么时候调
POST /api/v1/pay/create下单用户点「去支付」时
POST /api/v1/pay/query查单回调没到、或用户催单时
POST /api/v1/refund/create退款你的系统决定退款时
POST /api/v1/refund/query查退款确认退款进度时

请求用 Content-Type: application/x-www-form-urlencoded。也接受扁平 JSON,但所有值必须是字符串"amount":"12900" 而不是 "amount":12900)。

1完整流程

一笔收款经过的站点,按实际发生顺序:

运营方 · 一次性
给你的部门开户
你拿到商户号、签名密钥、商户后台账号
你的服务端
调 pay/create 下单
带上你自己的订单号和金额,拿回 pay_url
你的前端
把用户浏览器跳到 pay_url
PayHub 按路由把用户送到对应渠道的支付页
用户
在渠道页完成支付
扫码或输卡号,由上游渠道承接,你不接触任何卡密信息
PayHub → 你的服务端
推送签名回调到 notify_url
你验签、改单、应答 success这一步是发货依据
你的服务端 · 兜底
调 pay/query 主动查单
回调没到时用。以查单结果为准

发货以什么为准

只认验签通过的回调,或主动查单返回的 success。用户浏览器跳回 return_url 只是界面跳转,地址栏可以被人手动敲出来,绝不能作为发货依据。

2一 · 开户

联系运营方开通商户。你需要提供这几项:

提供什么用途以后能改吗
部门名称内部识别用能,找运营方
支付页显示名用户在支付页看到的收款方名字能,商户后台自己改
回调地址接收支付结果的接口,必须公网可访问能,商户后台自己改
服务器出口 IP 选填配了之后只有这些 IP 能调 API能,找运营方

开户完成后你会拿到三样东西:

拿到什么长什么样怎么用
商户号 merchant_noM1003每次请求都要带上
签名密钥 secretsk_live_538fe2……算签名用。只显示一次
商户后台账号部门名 / 初始密码登录看自己的订单、账单、改配置

密钥只显示一次

开户页面上的 secret 关掉就再也看不到了。请当场存进你自己的密钥管理系统,不要截图发群里。丢了只能找运营方重置——重置后旧密钥立即失效,所有在途请求会开始报签名错误。

IP 白名单怎么生效

白名单留空就是不限制任何来源。一旦填了,就只有名单内的 IP 能调用,其他一律拒绝并返回 2002。支持单个 IP 和 CIDR 网段两种写法,填单个 IP 时会自动按 /32 处理。建议生产环境配上——多一道防线,密钥万一泄露也调不动。

3二 · 签名

四个接口用同一套签名规则。回调验签也是这套规则,只是方向相反。一共五步:

取出除 sign 外的全部参数
包括 timestamp,一个都不能漏
剔掉空值
值为空字符串的参数不参与签名
按参数名 ASCII 升序排列
注意是按参数排,不是按值
拼成 k1=v1&k2=v2…
值原样拼接,不要做 URL 编码
算 HMAC-SHA256,放进 sign
用你的 secret 做密钥,输出 hex 小写

▸ 一个真实的待签名串(下单请求)

amount=12900&currency=USD&merchant_no=M1003&merchant_order_no=G1-20260807-001
&notify_url=https://your-service.example.com/payhub/notify&pay_method=mock
&subject=账户充值&timestamp=1786073088

# 上面的换行只是为了排版好看
# 实际是一整行,中间没有空格、没有换行
# sign = HMAC_SHA256(这一整行, secret) 取 hex 小写

签名实现

▸ Python

import hmac, hashlib

def sign(params: dict, secret: str) -> str:
    raw = "&".join(
        f"{k}={params[k]}"
        for k in sorted(params)
        if k != "sign" and params[k] != ""
    )
    return hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest()

▸ Go

func Sign(params map[string]string, secret string) string {
    keys := make([]string, 0, len(params))
    for k, v := range params {
        if k == "sign" || v == "" { continue }
        keys = append(keys, k)
    }
    sort.Strings(keys)
    parts := make([]string, 0, len(keys))
    for _, k := range keys {
        parts = append(parts, k+"="+params[k])
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(strings.Join(parts, "&")))
    return hex.EncodeToString(mac.Sum(nil))
}

▸ PHP

function payhub_sign(array $params, string $secret): string {
    unset($params['sign']);
    $params = array_filter($params, fn($v) => $v !== '' && $v !== null);
    ksort($params, SORT_STRING);          // 必须按字符串序,不是默认序
    $parts = [];
    foreach ($params as $k => $v) { $parts[] = $k . '=' . $v; }
    return hash_hmac('sha256', implode('&', $parts), $secret);
}

4三 · 下单

POST https://pay.payhub.asia/api/v1/pay/create

参数必填说明
merchant_no必填你的商户号
merchant_order_no必填你系统里的订单号,不超过 64 字符,同一商户下唯一。这是幂等键
amount必填金额,最小货币单位的正整数。$129.00 要传 12900,不是 129 也不是 129.00
currency必填目前只能传 USD,传其他值一律报 1003
pay_method必填支付方式,取值见下表
timestamp必填Unix 秒
sign必填签名
subject选填订单标题,会显示在支付页上
notify_url选填本笔单的回调地址。不传就用商户后台配的默认值
return_url选填用户支付完跳回你站点的地址
client_ip选填下单用户的 IP,用于风控
extra选填你的透传字段,回调时会原样带回

pay_method 取哪个值

取值渠道用户怎么付现在能用吗
mock模拟渠道联调专用,支付结果自己点可用
rub_qrNuwaPay俄罗斯 SBP 扫码可用
rub_cardNuwaPay俄罗斯银行卡可用
card_intlNuwaPay国际信用卡可用
usdtActyveUSDT 转账未开通

币种和支付方式是两回事

currency 恒传 USD,那是你的记账币种。用户实际掏卢布还是别的,由 pay_method 选中的渠道自己处理,汇率也是渠道换。所以 currency=USDpay_method=rub_qr 是完全正常的组合,不要因为看着别扭就去改 currency。

返回什么

{
  "code": 0,
  "msg": "ok",
  "data": {
    "platform_no": "P260807000005",    // PayHub 侧单号,查单退款都用它
    "pay_url":     "https://pay.payhub.asia/pay/P260807000005",
    "expire_at":   "2026-08-07 11:54:48"   // 北京时间,下单后 30 分钟
  }
}

请把 platform_no 存进你的订单表,后续查单、退款、对账都要用它。

5四 · 跳转支付

拿到 pay_url 之后,把用户浏览器整页跳转过去就行,不要用 iframe 嵌(部分渠道禁止被嵌套)。这个地址永远是 PayHub 自己的地址,用户访问时由 PayHub 决定后续走向:

订单当时的情况用户看到什么
mock 渠道PayHub 的模拟收银页,上面有「支付成功 / 支付失败」两个按钮
真实渠道302 跳转到上游渠道的支付页(扫码页或卡号输入页)
已经付过了PayHub 的状态页,不会让用户重复付款
已超时关闭过期提示页,需要你重新下单

为什么不直接给你上游地址

这一层中转是刻意设计的:你永远只对接一个地址,平台换渠道、加渠道,你的代码一行都不用改。如果你确实需要上游的原始地址(比如想自己渲染二维码),调 pay/querychannel_pay_url 字段。

6五 · 接收回调

支付和退款的结果,由 PayHub 用 POST 推到你的 notify_url,body 是 JSON。这是整个对接里最容易出错的一步,下面每一条都请仔细看。

你会收到什么

{
  "platform_no":       "P260807000005",
  "merchant_order_no": "G1-20260807-001",
  "status":            "success",
  "amount":            "12900",
  "paid_amount":       "12900",
  "currency":          "USD",
  "pay_method":        "mock",
  "paid_at":           "2026-08-07 11:24:48",   // 北京时间,注意含空格
  "extra":             null,                    // 下单时没传就是 null
  "timestamp":         "1786073092",
  "sign":              "a3f1……"
}

验签的四条规则

规则具体怎么做
① 取哪些字段body 里全部非 null 的字段(除 sign)。不要按固定字段列表写死——协议以后会加字段,写死了将来某天会突然全部验签失败
② 值怎么取字符串直接用;非字符串取它的紧凑 JSON文本(分隔符不带空格)
③ 怎么拼串按 key 字典序,k=v 之间用 & 连接,值原样写入、不做 URL 编码
④ 怎么比对用你的 secret 重算 HMAC-SHA256,和 sign 比。请用常数时间比较函数,不要直接用 ==

验签失败?九成是这个原因

paid_at 的值是 2026-08-07 11:24:48中间那个空格要原样参与签名。如果你顺手用了 urlencode 拼串,空格会变成 %20+,签名必然对不上。下面是一个实测验签通过的串,可以拿去逐字符对照:

amount=12900&currency=USD&merchant_order_no=G1-20260807-001&paid_amount=12900
&paid_at=2026-08-07 11:24:48&pay_method=mock&platform_no=P260807000005
&status=success&timestamp=1786073092

# 注意两处:
#   1. paid_at 里的空格是原样的,没被编码
#   2. extra 是 null,整个字段没有参与拼串

你的接口要怎么应答

必须应答纯文本 success

HTTP 状态码小于 300,并且响应体去掉首尾空白后正好等于 success 这 7 个小写字母。

返回 JSON、返回 {"code":0}、返回大写 SUCCESS、返回 success\n</br>——统统算失败,PayHub 会一直重推。

没收到 success 时,按 15秒 → 1分钟 → 5分钟 → 30分钟 → 2小时 → 6小时 → 24小时 的间隔重试,一共 7 次,之后标记为已耗尽不再推送。

▸ 回调接收端完整实现(Python)

import hmac, hashlib, json

def verify(body: dict, secret: str) -> bool:
    got = body.pop("sign", "")
    parts = []
    for k in sorted(body):
        v = body[k]
        if v is None:                       # null 字段跳过
            continue
        if not isinstance(v, str):            # 非字符串取紧凑 JSON
            v = json.dumps(v, separators=(",", ":"))
        parts.append(f"{k}={v}")           # 原样拼,不要 urlencode
    calc = hmac.new(secret.encode(), "&".join(parts).encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(calc, got)    # 常数时间比较


# ---- 你的 HTTP handler ----
def on_notify(request):
    body = json.loads(request.body)

    if not verify(dict(body), SECRET):
        return text("invalid sign", 400)      # 验签失败别应答 success

    order = db.get(body["merchant_order_no"])
    if order is None:
        return text("order not found", 404)

    if order.paid:                              # 幂等:已处理过直接认
        return text("success")

    if body["status"] == "success":
        if int(body["paid_amount"]) < order.amount:   # 金额必须核对
            log.error("金额不符,人工介入")
            return text("amount mismatch", 400)
        order.mark_paid()
        deliver(order)                           # 发货

    return text("success")                     # ← 纯文本,必须

7六 · 查单

POST https://pay.payhub.asia/api/v1/pay/query

platform_nomerchant_order_no 至少一个,加上公共签名参数即可。

status什么意思你该做什么
已建单,用户还没付继续等,或引导用户回到 pay_url
success支付成功发货(记得幂等)
failed支付失败提示用户重新下单
closed超时未支付,已关单需要重新下单
已全额退款按你的业务规则处理

返回里还带 paid_amountpaid_atchannel_pay_url(上游原始支付地址)等字段。

建议加一条兜底轮询

回调再可靠,也可能因为你这边正好在发布重启而丢掉。建议起个定时任务,把 pending 状态超过 3 分钟的单子拿去主动查一次,以查单结果为准。这条兜底能救掉绝大多数"用户付了钱但没发货"的投诉。

8七 · 退款

POST https://pay.payhub.asia/api/v1/refund/create

参数必填说明
platform_no必填要退哪一笔单
refund_no必填你的退款单号,退款的幂等键
refund_amount必填退款金额,最小单位整数。支持部分退款
reason选填退款原因,会记进审计日志

服务端会硬校验三条,任意一条不过就直接拒绝:

  • 这笔订单属于你这个商户
  • 订单状态是支付成功
  • 累计退款金额不超过实付金额

退满之后订单状态转为 refunded。查询退款进度用 /api/v1/refund/query,传 refund_no

注意不是所有渠道都支持退款,不支持时报 3002,需要走线下处理。当前 mock 和 NuwaPay 支持退款。

9怎么联调

不需要单独的测试环境。直接用正式地址,把 pay_method 传成 mock 就是联调模式:

联调时会发生什么
建单正常建,返回真实的 platform_nopay_url
打开 pay_url看到 PayHub 的模拟收银页,两个按钮:支付成功 / 支付失败
点「支付成功」订单转 success,回调照常推给你,签名规则和真实渠道完全一样
资金不产生任何真实资金流,不对接任何上游渠道
退款可以正常退,即时成功

建议向运营方要一个专用的联调商户号

用独立商户号联调,好处是你的测试单不会混进正式业务的账单和对账报表里。联调完成后再切到正式商户号即可,代码一行都不用改——只换 merchant_nosecret 两个配置。

联调阶段唯一要注意的是:你的 notify_url 必须是公网能访问到的地址。本机 localhost 收不到回调,可以先用任意一个公网可达的测试服务,或者临时用内网穿透工具。

10易错点

下面这些都是实际踩过的,看一遍能省你半天时间:

你看到的现象真正的原因怎么改
回调一直验签失败拼串时对值做了 URL 编码,paid_at 的空格被转义成了 %20值原样拼接,不要编码
回调偶尔验签失败按固定字段列表拼串,漏了新字段,或把 null 字段也算进去了遍历 body 全部非 null 字段
PayHub 一直重推同一笔你应答了 JSON,或者大写的 SUCCESS应答纯文本小写 success
用户重复收到货回调接口没做幂等,重试时又发了一次先查订单状态,已处理就直接返回 success
下单报 1003currency 传了 USD 以外的值恒传 USD,币种由渠道处理
下单报 1005pay_method 没有对应的启用渠道,比如传了 usdt用「pay_method 取哪个值」表里标可用的
金额差了 100 倍amount 当成"元"传了传最小单位整数,$129 传 12900
下单报 2003时间戳用了毫秒,或者服务器时间不准用 Unix 秒,开 NTP 对时
重试时报 1004同一订单号重试时把金额改了重试必须原样重发
用户付了钱但没发货return_url 的跳转当成了支付成功只认回调或查单结果

11错误码

codeHTTP含义怎么处理
0200成功
1001400参数缺失或非法看返回的 msg,里面写了具体哪个字段
1002400金额无效必须是最小单位的正整数
1003400币种不支持目前只能传 USD
1004409订单号重复,但金额或币种不一致换订单号,或原样重发
1005400无可用支付渠道pay_method,或联系运营方开通
2001401商户不存在或已停用核对 merchant_no,联系运营方
2002403调用 IP 不在白名单把服务器出口 IP 报给运营方
2003401时间戳无效或超出窗口Unix 秒,误差 ±5 分钟内
2004401签名错误对照「签名」章节逐字符检查拼串
3001404订单不存在核对单号
3002400该渠道不支持退款走线下处理
5001502渠道下单失败可以用同一订单号重试,有幂等保护

12完整示例

下面这段可以直接跑:下单 → 查单 → 模拟支付 → 退款。把商户号和密钥换成你自己的即可。

import hmac, hashlib, time, json, urllib.request, urllib.parse

BASE   = "https://pay.payhub.asia"
MNO    = "你的商户号"
SECRET = "你的密钥"

def call(path, params):
    params = {k: str(v) for k, v in params.items() if v != ""}
    params["merchant_no"] = MNO
    params["timestamp"]   = str(int(time.time()))
    raw = "&".join(f"{k}={params[k]}" for k in sorted(params) if k != "sign")
    params["sign"] = hmac.new(SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
    req = urllib.request.Request(BASE + path,
        data=urllib.parse.urlencode(params).encode(),
        headers={"Content-Type": "application/x-www-form-urlencoded"})
    try:
        return json.loads(urllib.request.urlopen(req, timeout=15).read())
    except urllib.error.HTTPError as e:
        return json.loads(e.read())        # 错误也是 JSON,别当异常吞掉


# ① 下单
r = call("/api/v1/pay/create", {
    "merchant_order_no": f"T{int(time.time())}",
    "amount":      12900,              # $129.00
    "currency":    "USD",
    "pay_method":  "mock",             # 联调用 mock,正式换 rub_qr 等
    "subject":     "账户充值",
    "notify_url":  "https://your-service.example.com/payhub/notify",
})
assert r["code"] == 0, r
pno = r["data"]["platform_no"]
print("把用户跳转到:", r["data"]["pay_url"])

# ② 查单
print(call("/api/v1/pay/query", {"platform_no": pno})["data"]["status"])   # pending

# ③ 仅 mock 渠道:模拟用户点「支付成功」
#    真实渠道下这一步由用户在渠道页完成,你不用调
urllib.request.urlopen(urllib.request.Request(
    f"{BASE}/pay/{pno}/result",
    data=urllib.parse.urlencode({"action": "success"}).encode(),
    headers={"Content-Type": "application/x-www-form-urlencoded"}))
time.sleep(4)                              # 等回调投递

print(call("/api/v1/pay/query", {"platform_no": pno})["data"])       # success

# ④ 退款(部分退 $50.00)
print(call("/api/v1/refund/create", {
    "platform_no":   pno,
    "refund_no":     f"RF{int(time.time())}",
    "refund_amount": 5000,
    "reason":        "测试退款",
}))

13上线清单

正式放量之前,请逐条确认:

  • mock 渠道下单成功,拿到了 platform_nopay_url
  • 浏览器打开 pay_url,能正常看到收银页
  • 点「支付成功」后,你的 notify_url 确实收到了回调
  • 回调验签通过,并且你应答的是纯文本 success
  • 重复推送同一笔回调,你的系统不会重复发货
  • 主动查单能拿到 success,金额和你的订单对得上
  • 退款能成功,且累计退款超过实付时会被正确拒绝
  • 换成正式的 pay_methodrub_qr 等),跑通一笔小额真实订单
  • 把服务器出口 IP 报给运营方加了白名单
  • 密钥存进了密钥管理系统,没有硬编码在代码仓库里

遇到问题先自查这三处

拿「签名」章节里的实测待签名串,和你程序拼出来的串逐字符对比
看接口返回的 msg 字段,里面写了具体哪里不对
登录商户后台,看这笔订单的实际状态和回调投递记录

支付中枢 PayHub · 接入文档 2026-08-07 © 2026 AnkerYe