快速开始
用测试 Key 跑通第一笔测试支付:直接 API(含 3DS)、托管收银台、接收并验签 Webhook、查询支付与发起退款。
最后更新于 2026-10-15 · 契约 v1.0.0
开始之前
- 一个商户后台账号:由平台邀请开通,不提供公开自助注册;你的角色是所有者、管理员或开发者之一。
- 一个能发起 HTTPS 请求的服务端。示例使用 Node.js 18+ 内置的
fetch(不依赖 SDK),并附 Python 版本。 - 测试与生产使用同一个 API 地址
https://api.payhub.example,环境由 API Key 决定:测试 Key 不能访问生产对象,生产 Key 也不能把交易发往测试上游。 - 测试环境不产生真实资金,只能使用上游沙箱测试卡。测试卡取值只在商户后台登录后展示(查看测试卡(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)),各场景的预期结果见测试指南。
选择接入方式
| 直接 API 支付 | 托管收银台 | |
|---|---|---|
| 你承担的工作 | 自己的付款页面采集卡信息,服务端调用 API 创建并确认支付,处理 3DS 跳转 | 服务端创建收银会话,把付款人带到平台收银页 |
| 平台承担的工作 | 风控、提交上游、3DS 认证页、结果查证与通知 | 收银页、卡信息采集、3DS、风控、提交上游、结果查证与通知 |
| 卡信息是否经过你的服务器 | 是(只在内存中转发,不落库、不写日志) | 否 |
| 是否需要平台开通“直接传卡” | 需要 | 不需要 |
| 3DS 如何呈现 | 浏览器整页跳转平台认证页 | 从收银页跳转平台认证页,完成后回到收银页 |
选型建议:没有自己的付款页面、或不想处理卡信息 → 托管收银台;已有自己的付款页面且平台已为你开通直接传卡 → 直接 API。
两种方式使用同一套 Payment、状态、计费、账务与通知:都可以用同一个商户订单号查询、退款与对账。
获取测试 API Key
- 登录商户后台,在顶栏切换到“测试”。
- 打开 开发者中心 › API Key,点击新建:管理 API Key(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)。
- 填写名称、选择权限范围,创建。
- 立即复制保存:完整密钥只显示一次。
跑通本页全部步骤需要 payments:read、payments:write、refunds:read、refunds:write。开发者角色默认的可授予范围不含 refunds:write,需要所有者或管理员创建该 Key,或为你调整可授予范围。
注意
密钥只放在服务端的环境变量或密钥管理服务中,不要写进前端代码、App、代码仓库或日志。怀疑泄漏时立即在商户后台撤销,撤销后 60 秒内生效。
方式 A:直接 API 支付
“直接传卡”能力需要平台为你开通,未开通时返回 403 capability_disabled(可改用托管收银台或联系平台运营)。卡号、有效期、CVC 只在你的服务端内存中转发,不要写入数据库、日志、队列或错误上报。
创建并确认支付
POST/v1/payments:confirm=true 时立即提交首次尝试。Idempotency-Key 在发送前生成,并随订单保存。
curl https://api.payhub.example/v1/payments \
-H "Authorization: Bearer <TEST_SERVER_API_KEY>" \
-H "Idempotency-Key: order-demo-001-create-v1" \
-H "Content-Type: application/json" \
-d '{
"merchant_order_no": "ORDER-DEMO-001",
"amount": 10000,
"currency": "USD",
"confirm": true,
"payment_method_data": {
"type": "card",
"card": { "number": "<UPSTREAM_TEST_CARD_NUMBER>", "exp_month": 12, "exp_year": 2030, "cvc": "<TEST_CVC>" }
},
"return_url": "https://merchant.example/payment/return",
"description": "示例订单",
"billing_details": { "name": "Test Customer", "email": "customer@example.com" },
"browser_info": {
"user_agent": "<ACTUAL_BROWSER_USER_AGENT>",
"accept_header": "text/html,application/xhtml+xml",
"language": "zh-CN",
"screen_width": 1366,
"screen_height": 768,
"color_depth": 24,
"timezone_offset_minutes": -480,
"javascript_enabled": true,
"ip_address": "<PAYER_IP_ADDRESS>"
}
}'需要替换的占位符
<TEST_SERVER_API_KEY>:测试环境服务端 API Key:在商户后台「API Key」页面创建<UPSTREAM_TEST_CARD_NUMBER>:测试卡号:登录商户后台「接口文档与测试指南」查看<TEST_CVC>:测试卡 CVC:与测试卡号一起在商户后台查看<ACTUAL_BROWSER_USER_AGENT>:付款人浏览器的 navigator.userAgent,由你的付款页采集<PAYER_IP_ADDRESS>:按受信代理链取得的付款人 IP;不能用服务器自己的 IP
处理响应
HTTP 200(幂等复用)、201(新建)、202(已受理、结果待确认)都不等于扣款成功,要看 status:
status | 常见 HTTP | 含义 | 你该做什么 | 不要做什么 |
|---|---|---|---|---|
succeeded | 201 / 200 | 平台已确认扣款成功,captured_amount = amount | 按 Payment ID 幂等履约 | 只看 HTTP 状态码就履约 |
requires_action | 201 / 200 | 当前尝试需要付款人完成 3DS 等动作 | 把 next_action.url 交给浏览器整页跳转 | 由服务端代替付款人完成认证;把地址写进日志 |
processing | 202 | 已受理,结果待确认(resolution_status=needs_query 时平台正在用原标识查证) | 显示“正在确认支付结果”,等待 Webhook 或按查单频率查询 | 当作失败;换幂等键或换订单号重新扣款 |
failed | 200 / 201 | 最近一次尝试明确失败,不是终态 | 展示 failure.message;在次数与有效期内让付款人换卡,用新键调用 confirm | 直接关闭订单;用旧幂等键换卡 |
canceled / expired | 200 | 订单已关闭 | 关闭本地订单 | 用同一订单号再次收款 |
created | 201 | confirm=false 创建后尚未提交 | 调用 confirm | 当作失败 |
| 未知值 | — | 平台新增的状态 | 当作“未成功”,稍后查询并告警 | 当作成功 |
处理 3DS 跳转
requires_action 时,把 next_action.url 交给付款人浏览器整页跳转。地址在 next_action.expires_at(最长 10 分钟)前有效;不要写进日志或分析工具,也不要放进你自己页面的 URL。
// 浏览器端:拿到服务端返回的认证地址后整页跳转(不要用 iframe,不要把地址写进日志或分析工具)
const res = await fetch('/checkout/pay', {
method: 'POST',
body: new FormData(document.querySelector('#pay-form'))
});
const result = await res.json();
if (result.redirectUrl) {
window.location.assign(result.redirectUrl);
} else if (result.pending) {
showMessage('正在确认支付结果,请稍候'); // 不要当作失败
}付款人回到 return_url 之后
- return_url 页面只用于展示:由服务端查询支付状态或等待 Webhook 决定结果。
- 平台回跳时原样使用你提供的地址,不追加任何参数;URL 上的任何参数(包括你自己放的订单号)只能当作“去查哪一笔”的线索,不能当作结果。
failed时展示脱敏的failure.message;在有效期(默认 30 分钟)与次数(默认最多 3 次)内可以让付款人换卡,用新的幂等键调用 POST/v1/payments/{payment_id}/confirm,详见换卡重试。
方式 B:托管收银台
创建收银会话
POST/v1/checkout/sessions:按订单信息创建,返回 checkout_url;请求中没有任何卡数据。另一种方式(传已有的 payment_id)见托管收银台接入。
curl https://api.payhub.example/v1/checkout/sessions \
-H "Authorization: Bearer <TEST_SERVER_API_KEY>" \
-H "Idempotency-Key: order-demo-002-session-v1" \
-H "Content-Type: application/json" \
-d '{
"merchant_order_no": "ORDER-DEMO-002",
"amount": 10000,
"currency": "USD",
"description": "示例托管收银订单",
"return_url": "https://merchant.example/payment/return",
"cancel_url": "https://merchant.example/payment/cancel",
"locale": "zh-CN"
}'需要替换的占位符
<TEST_SERVER_API_KEY>:测试环境服务端 API Key:在商户后台「API Key」页面创建
return_url、cancel_url 必须先登记(登记回跳地址(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)),否则返回 422 url_not_allowed。
跳转收银台
服务端把 checkout_url 返回给浏览器并整页跳转。checkout_url 含收银令牌:不要记录(日志、分析工具、错误上报),也不要分享给他人。会话最长 30 分钟,且不超过订单剩余的可支付时间。
return_url 只用于体验
履约以 Webhook 或查单为准,不依据浏览器回跳。付款人点击“返回商户”(cancel_url)不等于取消已提交的扣款。
接收 Webhook 并验签
- 在商户后台为测试环境配置端点(HTTPS、公网可达,或使用受控隧道),订阅
payment.*、refund.*:配置 Webhook(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)。 - 签名密钥只在创建端点与轮换时显示一次,立即存入服务端配置(遗失只能轮换)。签名密钥与 API Key 是两套东西。
- 最小接收器:用原始请求体验签 → 校验环境 → 按事件 ID 持久化去重 → 尽快返回 2xx → 异步处理。
import express from 'express';
import { verifySignature } from './verify.mjs';
const app = express();
// 只对 Webhook 路由使用 raw 解析,拿到原始字节;不要在它之前全局挂 express.json()
app.post(
'/webhooks/payhub',
express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
const secrets = [
process.env.PAYHUB_WEBHOOK_SECRET,
process.env.PAYHUB_WEBHOOK_SECRET_PREVIOUS
].filter(Boolean);
if (!verifySignature(req.body, req.get('X-Pay-Signature'), secrets)) {
return res.status(400).send('invalid signature'); // 不执行任何业务
}
const event = JSON.parse(req.body.toString('utf8'));
if (
event.environment !== process.env.PAYHUB_ENVIRONMENT ||
event.merchant_id !== process.env.PAYHUB_MERCHANT_ID
) {
return res.status(400).send('unexpected environment or merchant');
}
if (req.get('X-Pay-Test-Event') === 'true') {
await recordTestEvent(event.id); // 测试事件只记录、不履约
return res.status(200).end();
}
let isNew;
try {
isNew = await saveEventIfAbsent(event.id, req.body); // INSERT ... ON CONFLICT (event_id) DO NOTHING
} catch {
return res.status(500).end(); // 没能持久化:返回非 2xx,让平台稍后重试
}
res.status(200).end(); // 持久化后尽快应答
if (isNew) enqueue(event.id).catch(() => {}); // 入队失败由定时任务扫描"已接收未处理"的事件补偿
}
);
app.listen(Number(process.env.PORT ?? 8080));事件至少投递一次,可能重复、可能乱序:用 data.object.version 丢弃旧事件。验签函数、Python 版本与测试向量见 Webhook 验签。
查询支付
# 按 Payment ID 查询
curl https://api.payhub.example/v1/payments/<PAYMENT_ID> \
-H "Authorization: Bearer <TEST_SERVER_API_KEY>"
# 没有 Payment ID 时按商户订单号查询(精确匹配、区分大小写)
curl "https://api.payhub.example/v1/payments?merchant_order_no=ORDER-DEMO-001" \
-H "Authorization: Bearer <TEST_SERVER_API_KEY>"需要替换的占位符
<PAYMENT_ID>:创建支付返回的 id(pay_ 开头)<TEST_SERVER_API_KEY>:测试环境服务端 API Key:在商户后台「API Key」页面创建
查询只读取平台当前已知的事实,不会触发上游查询,加快轮询不会更快得到结果。优先依赖 Webhook,查询频率见查单频率。
发起退款
POST/v1/refunds:显式提交 amount、merchant_refund_no、reason 与 Idempotency-Key。
curl https://api.payhub.example/v1/refunds \
-H "Authorization: Bearer <TEST_SERVER_API_KEY>" \
-H "Idempotency-Key: refund-demo-001-create-v1" \
-H "Content-Type: application/json" \
-d '{
"payment_id": "<PAYMENT_ID>",
"merchant_refund_no": "REFUND-DEMO-001",
"amount": 2500,
"reason": "requested_by_customer"
}'需要替换的占位符
<TEST_SERVER_API_KEY>:测试环境服务端 API Key:在商户后台「API Key」页面创建<PAYMENT_ID>:创建支付返回的 id(pay_ 开头)
- 测试环境退款默认自动批准,响应通常为
processing;生产环境默认人工审核,响应为pending_review。两者都不是最终结果:以refund.*事件或GET /v1/refunds/{refund_id}为准。 - 退款币种继承原支付,不能指定;全额退款也必须显式传金额,不能省略金额表示“退剩余全部”。
关键提醒
警告:HTTP 2xx 不等于扣款成功
200、201、202 只说明请求被处理或受理,是否扣款成功只看 status === 'succeeded' 或经过验签的 payment.succeeded 事件。详见处理响应。
警告:未知状态不当成功
忽略未知字段、容忍未知状态;遇到不认识的 status 一律按“未成功”处理并查询。详见兼容性与版本。
警告:金额是最小货币单位整数
USD 10000 表示 100.00 美元,JPY 10000 表示 10,000 日元,不能统一除以 100;不同币种的金额不能相加。详见数据格式。
警告:全额退款也要显式传金额
省略金额不代表“退剩余全部”,避免并发时金额含义变化。详见发起退款。
警告:结果未知时不要换幂等键或订单号重新扣款
超时、5xx、202 都可能已经扣款;用同一个幂等键重试,或按订单号查询。详见结果未知时如何查单。
金额换算不要经过浮点数(反例:19.99 * 100 = 1998.9999999999998):
| 币种 | 小数位 | 页面金额 | amount |
|---|---|---|---|
| USD | 2 | 100.00 | 10000 |
| USD | 2 | 19.99 | 1999 |
| JPY | 0 | 10,000 | 10000 |
// 把 "100.00" 这样的十进制字符串换算为最小货币单位整数;exponent 为币种小数位(USD 2、JPY 0)
export function toMinorUnits(decimalStr, exponent) {
const m = /^(\d+)(?:\.(\d+))?$/.exec(decimalStr);
if (!m) throw new Error('金额格式不正确');
const frac = m[2] ?? '';
if (frac.length > exponent) throw new Error(`该币种最多 ${exponent} 位小数`);
const minor = BigInt(m[1] + frac.padEnd(exponent, '0'));
if (minor <= 0n || minor > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('金额超出范围');
return Number(minor);
}
// toMinorUnits('100.00', 2) → 10000(USD 100.00);toMinorUnits('10000', 0) → 10000(JPY 10,000)
// 反例:19.99 * 100 = 1998.9999999999998,不要用浮点数换算下一步
- 测试指南:按测试卡场景与 33 条自测清单验证你的集成。
- 幂等、重试与查单:结果未知时的正确处理。
- Webhook 事件与验签:全部事件类型、重试计划与密钥轮换。
- 3DS 与托管收银台接入:浏览器信息采集与回跳地址规则。
- 上线检查清单:切换到生产前逐项确认。