接入文档
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_urlsuccess。这一步是发货依据发货以什么为准
只认验签通过的回调,或主动查单返回的 success。用户浏览器跳回 return_url 只是界面跳转,地址栏可以被人手动敲出来,绝不能作为发货依据。
2一 · 开户
联系运营方开通商户。你需要提供这几项:
| 提供什么 | 用途 | 以后能改吗 |
|---|---|---|
| 部门名称 | 内部识别用 | 能,找运营方 |
| 支付页显示名 | 用户在支付页看到的收款方名字 | 能,商户后台自己改 |
| 回调地址 | 接收支付结果的接口,必须公网可访问 | 能,商户后台自己改 |
| 服务器出口 IP 选填 | 配了之后只有这些 IP 能调 API | 能,找运营方 |
开户完成后你会拿到三样东西:
| 拿到什么 | 长什么样 | 怎么用 |
|---|---|---|
商户号 merchant_no | M1003 | 每次请求都要带上 |
签名密钥 secret | sk_live_538fe2…… | 算签名用。只显示一次 |
| 商户后台账号 | 部门名 / 初始密码 | 登录看自己的订单、账单、改配置 |
密钥只显示一次
开户页面上的 secret 关掉就再也看不到了。请当场存进你自己的密钥管理系统,不要截图发群里。丢了只能找运营方重置——重置后旧密钥立即失效,所有在途请求会开始报签名错误。
IP 白名单怎么生效
白名单留空就是不限制任何来源。一旦填了,就只有名单内的 IP 能调用,其他一律拒绝并返回 2002。支持单个 IP 和 CIDR 网段两种写法,填单个 IP 时会自动按 /32 处理。建议生产环境配上——多一道防线,密钥万一泄露也调不动。
3二 · 签名
四个接口用同一套签名规则。回调验签也是这套规则,只是方向相反。一共五步:
sign 外的全部参数timestamp,一个都不能漏k1=v1&k2=v2…signsecret 做密钥,输出 hex 小写▸ 一个真实的待签名串(下单请求)
amount=12900¤cy=USD&merchant_no=M1003&merchant_order_no=G1-20260807-001
¬ify_url=https://your-service.example.com/payhub/notify&pay_method=mock
&subject=账户充值×tamp=1786073088
# 上面的换行只是为了排版好看
# 实际是一整行,中间没有空格、没有换行
# sign = HMAC_SHA256(这一整行, secret) 取 hex 小写
时间戳窗口 ±5 分钟
timestamp 是 Unix 秒,不是毫秒。服务端校验偏差不超过 5 分钟,超了直接报 2003。请确保你的服务器开了 NTP 对时——机器时间飘了会表现为"签名突然全错",很难查。
签名实现
▸ 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_qr | NuwaPay | 俄罗斯 SBP 扫码 | 可用 |
rub_card | NuwaPay | 俄罗斯银行卡 | 可用 |
card_intl | NuwaPay | 国际信用卡 | 可用 |
usdt | Actyve | USDT 转账 | 未开通 |
币种和支付方式是两回事
currency 恒传 USD,那是你的记账币种。用户实际掏卢布还是别的,由 pay_method 选中的渠道自己处理,汇率也是渠道换。所以 currency=USD 配 pay_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 存进你的订单表,后续查单、退款、对账都要用它。
同一个订单号重复下单会怎样
金额和币种一致时:直接返回原来那一笔的 platform_no,不会重复建单——所以网络超时后可以安全重试,不用担心重复扣款。
金额或币种不一致时:报 1004(HTTP 409)。这是在保护你——同一个订单号对应两个金额,说明你的代码有 bug。
5四 · 跳转支付
拿到 pay_url 之后,把用户浏览器整页跳转过去就行,不要用 iframe 嵌(部分渠道禁止被嵌套)。这个地址永远是 PayHub 自己的地址,用户访问时由 PayHub 决定后续走向:
| 订单当时的情况 | 用户看到什么 |
|---|---|
| mock 渠道 | PayHub 的模拟收银页,上面有「支付成功 / 支付失败」两个按钮 |
| 真实渠道 | 302 跳转到上游渠道的支付页(扫码页或卡号输入页) |
| 已经付过了 | PayHub 的状态页,不会让用户重复付款 |
| 已超时关闭 | 过期提示页,需要你重新下单 |
为什么不直接给你上游地址
这一层中转是刻意设计的:你永远只对接一个地址,平台换渠道、加渠道,你的代码一行都不用改。如果你确实需要上游的原始地址(比如想自己渲染二维码),调 pay/query 取 channel_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¤cy=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×tamp=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 次,之后标记为已耗尽不再推送。
回调接口必须做幂等
因为有重试,同一笔单你可能收到多次回调。如果你的处理逻辑是"收到就发货",用户会收到多份货。请务必先查自己的订单状态,已处理过的直接应答 success 返回。
▸ 回调接收端完整实现(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_no 或 merchant_order_no 至少一个,加上公共签名参数即可。
| status | 什么意思 | 你该做什么 |
|---|---|---|
| pending | 已建单,用户还没付 | 继续等,或引导用户回到 pay_url |
| success | 支付成功 | 发货(记得幂等) |
| failed | 支付失败 | 提示用户重新下单 |
| closed | 超时未支付,已关单 | 需要重新下单 |
| refunded | 已全额退款 | 按你的业务规则处理 |
返回里还带 paid_amount、paid_at、channel_pay_url(上游原始支付地址)等字段。
建议加一条兜底轮询
回调再可靠,也可能因为你这边正好在发布重启而丢掉。建议起个定时任务,把 pending 状态超过 3 分钟的单子拿去主动查一次,以查单结果为准。这条兜底能救掉绝大多数"用户付了钱但没发货"的投诉。
8七 · 退款
POST https://pay.payhub.asia/api/v1/refund/create
验签通过即执行,平台不做审核
PayHub 收到退款请求,验签通过就直接执行,没有人工审批环节。审批逻辑请在你自己的系统里做完再调这个接口。
| 参数 | 必填 | 说明 |
|---|---|---|
platform_no | 必填 | 要退哪一笔单 |
refund_no | 必填 | 你的退款单号,退款的幂等键 |
refund_amount | 必填 | 退款金额,最小单位整数。支持部分退款 |
reason | 选填 | 退款原因,会记进审计日志 |
服务端会硬校验三条,任意一条不过就直接拒绝:
- 这笔订单属于你这个商户
- 订单状态是支付成功
- 累计退款金额不超过实付金额
退满之后订单状态转为 refunded。查询退款进度用 /api/v1/refund/query,传 refund_no。
注意不是所有渠道都支持退款,不支持时报 3002,需要走线下处理。当前 mock 和 NuwaPay 支持退款。
9怎么联调
不需要单独的测试环境。直接用正式地址,把 pay_method 传成 mock 就是联调模式:
| 联调时 | 会发生什么 |
|---|---|
| 建单 | 正常建,返回真实的 platform_no 和 pay_url |
| 打开 pay_url | 看到 PayHub 的模拟收银页,两个按钮:支付成功 / 支付失败 |
| 点「支付成功」 | 订单转 success,回调照常推给你,签名规则和真实渠道完全一样 |
| 资金 | 不产生任何真实资金流,不对接任何上游渠道 |
| 退款 | 可以正常退,即时成功 |
建议向运营方要一个专用的联调商户号
用独立商户号联调,好处是你的测试单不会混进正式业务的账单和对账报表里。联调完成后再切到正式商户号即可,代码一行都不用改——只换 merchant_no 和 secret 两个配置。
联调阶段唯一要注意的是:你的 notify_url 必须是公网能访问到的地址。本机 localhost 收不到回调,可以先用任意一个公网可达的测试服务,或者临时用内网穿透工具。
10易错点
下面这些都是实际踩过的,看一遍能省你半天时间:
| 你看到的现象 | 真正的原因 | 怎么改 |
|---|---|---|
| 回调一直验签失败 | 拼串时对值做了 URL 编码,paid_at 的空格被转义成了 %20 | 值原样拼接,不要编码 |
| 回调偶尔验签失败 | 按固定字段列表拼串,漏了新字段,或把 null 字段也算进去了 | 遍历 body 全部非 null 字段 |
| PayHub 一直重推同一笔 | 你应答了 JSON,或者大写的 SUCCESS | 应答纯文本小写 success |
| 用户重复收到货 | 回调接口没做幂等,重试时又发了一次 | 先查订单状态,已处理就直接返回 success |
下单报 1003 | currency 传了 USD 以外的值 | 恒传 USD,币种由渠道处理 |
下单报 1005 | pay_method 没有对应的启用渠道,比如传了 usdt | 用「pay_method 取哪个值」表里标可用的 |
| 金额差了 100 倍 | 把 amount 当成"元"传了 | 传最小单位整数,$129 传 12900 |
下单报 2003 | 时间戳用了毫秒,或者服务器时间不准 | 用 Unix 秒,开 NTP 对时 |
重试时报 1004 | 同一订单号重试时把金额改了 | 重试必须原样重发 |
| 用户付了钱但没发货 | 把 return_url 的跳转当成了支付成功 | 只认回调或查单结果 |
11错误码
| code | HTTP | 含义 | 怎么处理 |
|---|---|---|---|
| 0 | 200 | 成功 | — |
| 1001 | 400 | 参数缺失或非法 | 看返回的 msg,里面写了具体哪个字段 |
| 1002 | 400 | 金额无效 | 必须是最小单位的正整数 |
| 1003 | 400 | 币种不支持 | 目前只能传 USD |
| 1004 | 409 | 订单号重复,但金额或币种不一致 | 换订单号,或原样重发 |
| 1005 | 400 | 无可用支付渠道 | 换 pay_method,或联系运营方开通 |
| 2001 | 401 | 商户不存在或已停用 | 核对 merchant_no,联系运营方 |
| 2002 | 403 | 调用 IP 不在白名单 | 把服务器出口 IP 报给运营方 |
| 2003 | 401 | 时间戳无效或超出窗口 | Unix 秒,误差 ±5 分钟内 |
| 2004 | 401 | 签名错误 | 对照「签名」章节逐字符检查拼串 |
| 3001 | 404 | 订单不存在 | 核对单号 |
| 3002 | 400 | 该渠道不支持退款 | 走线下处理 |
| 5001 | 502 | 渠道下单失败 | 可以用同一订单号重试,有幂等保护 |
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_no和pay_url - 浏览器打开
pay_url,能正常看到收银页 - 点「支付成功」后,你的
notify_url确实收到了回调 - 回调验签通过,并且你应答的是纯文本
success - 重复推送同一笔回调,你的系统不会重复发货
- 主动查单能拿到
success,金额和你的订单对得上 - 退款能成功,且累计退款超过实付时会被正确拒绝
- 换成正式的
pay_method(rub_qr等),跑通一笔小额真实订单 - 把服务器出口 IP 报给运营方加了白名单
- 密钥存进了密钥管理系统,没有硬编码在代码仓库里
遇到问题先自查这三处
① 拿「签名」章节里的实测待签名串,和你程序拼出来的串逐字符对比
② 看接口返回的 msg 字段,里面写了具体哪里不对
③ 登录商户后台,看这笔订单的实际状态和回调投递记录