跳到正文
PayHub开发者文档

快速开始

用测试 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

  1. 登录商户后台,在顶栏切换到“测试”。
  2. 打开 开发者中心 › API Key,点击新建:管理 API Key(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)。
  3. 填写名称、选择权限范围,创建。
  4. 立即复制保存:完整密钥只显示一次。

跑通本页全部步骤需要 payments:read、payments:write、refunds:read、refunds:write。开发者角色默认的可授予范围不含 refunds:write,需要所有者或管理员创建该 Key,或为你调整可授予范围。

注意

密钥只放在服务端的环境变量或密钥管理服务中,不要写进前端代码、App、代码仓库或日志。怀疑泄漏时立即在商户后台撤销,撤销后 60 秒内生效。

方式 A:直接 API 支付

“直接传卡”能力需要平台为你开通,未开通时返回 403 capability_disabled(可改用托管收银台或联系平台运营)。卡号、有效期、CVC 只在你的服务端内存中转发,不要写入数据库、日志、队列或错误上报。

直接 API 支付
直接 API 支付

创建并确认支付

POST/v1/payments:confirm=true 时立即提交首次尝试。Idempotency-Key 在发送前生成,并随订单保存。

curl
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含义你该做什么不要做什么
succeeded201 / 200平台已确认扣款成功,captured_amount = amount按 Payment ID 幂等履约只看 HTTP 状态码就履约
requires_action201 / 200当前尝试需要付款人完成 3DS 等动作把 next_action.url 交给浏览器整页跳转由服务端代替付款人完成认证;把地址写进日志
processing202已受理,结果待确认(resolution_status=needs_query 时平台正在用原标识查证)显示“正在确认支付结果”,等待 Webhook 或按查单频率查询当作失败;换幂等键或换订单号重新扣款
failed200 / 201最近一次尝试明确失败,不是终态展示 failure.message;在次数与有效期内让付款人换卡,用新键调用 confirm直接关闭订单;用旧幂等键换卡
canceled / expired200订单已关闭关闭本地订单用同一订单号再次收款
created201confirm=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
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 并验签

  1. 在商户后台为测试环境配置端点(HTTPS、公网可达,或使用受控隧道),订阅 payment.*、refund.*:配置 Webhook(在新标签页打开商户后台)(测试环境;需要所有者、管理员或开发者)。
  2. 签名密钥只在创建端点与轮换时显示一次,立即存入服务端配置(遗失只能轮换)。签名密钥与 API Key 是两套东西。
  3. 最小接收器:用原始请求体验签 → 校验环境 → 按事件 ID 持久化去重 → 尽快返回 2xx → 异步处理。
Node.js(Express)
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 验签。

查询支付

curl
# 按 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
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
USD2100.0010000
USD219.991999
JPY010,00010000
Node.js
// 把 "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,不要用浮点数换算

下一步

这页有帮助吗?