跳到正文
PayHub开发者文档

幂等、重试与查单

同一次业务提交无论发送多少次只产生一次业务效果:幂等键规则、13 个场景、换卡重试、结果未知时如何查单与查单频率。

最后更新于 2026-10-14 · 契约 v1.0.0

为什么需要幂等

网络会丢响应:请求可能已经被平台受理,但响应没有回到你的服务器。幂等保证同一次业务提交无论发送多少次,只产生一次业务效果——重试不会扣两次款。

哪些请求需要幂等键

接口方法与路径幂等键业务唯一约束(键过期后仍有效)
创建支付POST /v1/payments必填(商户, 环境, merchant_order_no)
确认支付POST /v1/payments/{payment_id}/confirm必填每个 Payment 同时最多一个未结束尝试
取消支付POST /v1/payments/{payment_id}/cancel必填(无请求体)—
创建收银会话POST /v1/checkout/sessions必填关联同一 Payment,不产生第二个 Payment
创建退款POST /v1/refunds必填(商户, 环境, merchant_refund_no)
全部查询接口GET …不需要—

请求头 Idempotency-Key,1–255 个字符;缺失返回 400,error.field 为 Idempotency-Key。

平台的幂等规则

作用域是 商户 + 环境 + 方法 + 路径 + 幂等键:同一个键在测试与生产互不影响;轮换 API Key 不改变作用域。

情况平台行为你应该
相同商户、环境、操作路径及幂等键,安全业务参数一致复用原业务对象,不产生第二次业务执行;返回对象最新状态(HTTP 200)正常处理返回的对象
同键但金额、币种、订单号等安全业务参数冲突HTTP 409 idempotency_conflict属于集成缺陷:修复键的生成逻辑;不要改参数后重放原键
同键并发提交只有一个请求取得执行权,其余请求等待它完成后返回同一对象;等待超时返回 409 operation_in_progress按返回对象处理;在途冲突稍后用同一个键重试
更换 API Key,但仍属同一商户和环境不改变幂等作用域密钥轮换期间照常用原键重试
更换幂等键但商户订单号相同(创建支付)业务唯一约束阻止创建第二个 Payment,返回已有对象标识或冲突查询已有对象,不要为此换订单号
幂等记录超过保留期(7 天)订单号与退款号的唯一保护继续有效,不会重新解释为新业务无需特殊处理
  • “安全业务参数”包括金额、币种、商户订单号、商户退款号、关联支付号等,每个接口参与比较的字段列在接口页(如创建支付)。卡信息不参与比较、也不会被保存:同键重放不会用新传入的卡信息扣款。
  • 被 400、401、403、422 拒绝的请求不占用幂等键,修正后用新键提交。
  • 创建收银会话时换了新键但订单号、金额、币种一致,平台为同一个 Payment 生成新的收银会话(例如旧会话已过期),这不是冲突,也不会产生第二个 Payment。

生成与保存幂等键

方式示例优点注意
由业务确定性生成(推荐)payment.create:ORDER-DEMO-001:1;换卡第 2 次确认 payment.confirm:ORDER-DEMO-001:2;退款 refund.create:REFUND-DEMO-001:1多台服务器、进程重启后都能算出同一个键;天然防止你自己的系统并发重复提交序号只在“新的业务提交”(如换卡)时加 1;不能包含邮箱、姓名、卡号等敏感信息
随机 UUIDcrypto.randomUUID() 生成后与订单一起保存简单必须先写入数据库再发送;重试时从数据库读取,不能重新生成

一次业务提交对应一个键;同一个键只用于同一个接口;先把键和订单一起持久化再发送,进程崩溃后从数据库取回原键重试。

保留期

幂等记录在线保留 7 天。过期后,商户订单号与退款号的唯一保护继续有效:同一订单不会被当作新业务重复扣款。

场景示例

点开每个场景可以看到请求与响应序列:

S1网络超时,没收到响应用键 K 创建 → 超时 → 用 K 重试AC-014

平台行为:返回同一个 Payment(200),不重复扣款

你该做什么:按返回的 status 处理

POST /v1/payments  Idempotency-Key: K   → (超时,没有响应)
POST /v1/payments  Idempotency-Key: K   → 200 {"id":"pay_demo_001","status":"succeeded",…}
S2收到 5xx用 K 创建 → 500 → 用 K 重试,或按订单号查询AC-069

平台行为:可能已受理;重试得到原对象或执行一次

你该做什么:同键重试或查询;不能换键

POST /v1/payments  Idempotency-Key: K   → 500 {"error":{"code":"internal_error","retryable":true},…}
POST /v1/payments  Idempotency-Key: K   → 200 {"id":"pay_demo_001","status":"processing",…}
S3同键不同金额用 K 提交 USD 10000 → 用 K 提交 USD 12000AC-016

平台行为:409 idempotency_conflict,原交易不变

你该做什么:修复键生成逻辑

POST /v1/payments  Idempotency-Key: K  {"amount":10000,…} → 201
POST /v1/payments  Idempotency-Key: K  {"amount":12000,…} → 409 {"error":{"code":"idempotency_conflict",…}}
S4换键同订单号用 K1 创建 ORDER-001 → 用 K2 再创建 ORDER-001AC-017

平台行为:不创建第二个 Payment,返回已有对象标识或冲突

你该做什么:查询已有 Payment

POST /v1/payments  Idempotency-Key: K1  {"merchant_order_no":"ORDER-001",…} → 201 pay_demo_001
POST /v1/payments  Idempotency-Key: K2  {"merchant_order_no":"ORDER-001",…} → 409 {"error":{"code":"order_no_conflict","related_resource_id":"pay_demo_001",…}}
S5同键并发两台服务器同时用 K 提交AC-015

平台行为:只有一个请求执行;另一个返回同一对象或在途冲突

你该做什么:按返回处理;在途冲突稍后用 K 重试

服务器 A:POST /v1/payments  Idempotency-Key: K → 201 pay_demo_001
服务器 B:POST /v1/payments  Idempotency-Key: K → 200 pay_demo_001(或 409 operation_in_progress,稍后用 K 重试)
S6同键顺序重复 10 次用 K 连续提交 10 次AC-014

平台行为:只有一个 Payment、一次扣款、一次计费

你该做什么:—

POST /v1/payments  Idempotency-Key: K → 201 pay_demo_001
POST /v1/payments  Idempotency-Key: K → 200 pay_demo_001(第 2~10 次相同)
S7重试时带了新的卡信息用 K 提交卡 A → 超时 → 用 K 提交卡 BAC-018

平台行为:复用原操作,不用卡 B 扣款

你该做什么:想换卡必须等原尝试明确失败后用新键 confirm

POST /v1/payments  Idempotency-Key: K  (卡 A) → (超时)
POST /v1/payments  Idempotency-Key: K  (卡 B) → 200 原操作的结果(卡 B 不会被扣款)
S8处理中用新键 confirm支付 processing 时用 K2 confirmAC-027

平台行为:409 payment_in_progress 或返回原状态,不新扣款

你该做什么:等待结果或查询

POST /v1/payments/pay_demo_001/confirm  Idempotency-Key: K2 → 409 {"error":{"code":"payment_in_progress","related_resource_id":"pay_demo_001",…}}
S9明确失败后换卡failed 后用新键 K3 confirm(卡 B)AC-026

平台行为:在次数与有效期内创建新尝试,沿用原 Payment 与费率快照

你该做什么:—

GET  /v1/payments/pay_demo_001 → 200 {"status":"failed","failure":{"code":"card_declined",…}}
POST /v1/payments/pay_demo_001/confirm  Idempotency-Key: K3 (卡 B) → 200 {"status":"succeeded","attempt_count":2,…}
S10收到 429用 K 提交 → 429(Retry-After: 2)

平台行为:本次请求未执行

你该做什么:等 2 秒后用 K 重试

POST /v1/payments  Idempotency-Key: K → 429  Retry-After: 2
(等待 2 秒)
POST /v1/payments  Idempotency-Key: K → 201 pay_demo_001
S11退款提交后超时用 K 创建退款 R-001 → 超时AC-033

平台行为:平台持续查证,不会自动重复创建上游退款

你该做什么:用 K 重试或按 merchant_refund_no 查询;不要换退款号重新申请

POST /v1/refunds  Idempotency-Key: K  {"merchant_refund_no":"R-001",…} → (超时)
POST /v1/refunds  Idempotency-Key: K  {"merchant_refund_no":"R-001",…} → 200 ref_demo_001(同一笔退款)
S12取消返回 202对 requires_action 的支付请求取消,上游未能确认 → 202AC-029

平台行为:未取得安全取消的证据,保持原状态并继续查证

你该做什么:不要当作已取消;等 payment.canceled 或查询

POST /v1/payments/pay_demo_007/cancel  Idempotency-Key: K → 202 {"status":"requires_action",…}
(之后收到 payment.canceled,或查询得到最终状态)
S13通道暂停新交易用 K 创建 → 503 temporarily_unavailable

平台行为:本次请求未受理,没有创建 Payment,也没有扣款;响应不说明内部原因

你该做什么:向付款人显示“暂时无法支付,请稍后再试”;稍后用 K 重试

POST /v1/payments  Idempotency-Key: K → 503 {"error":{"code":"temporarily_unavailable","retryable":true},…}
(稍后)
POST /v1/payments  Idempotency-Key: K → 201 pay_demo_001
网络超时后用同一个键重试
网络超时后用同一个键重试

换卡重试

  • 只在支付 status=failed(上一次尝试明确失败)之后发起。
  • 用新的幂等键调用 POST/v1/payments/{payment_id}/confirm,由付款人重新提供卡信息。
  • 必须在 expires_at 之前、且未超过最多尝试次数(默认 3 次)。新尝试沿用原 Payment 与当时的费率。
  • 支付仍在处理(processing、requires_action)时用新键确认会被拒绝(409 payment_in_progress),不会产生新的扣款。

结果未知时如何查单

  • 有 Payment ID:GET/v1/payments/{payment_id}。
  • 没有 Payment ID(例如崩溃前没存下响应):GET /v1/payments?merchant_order_no=...(精确匹配、区分大小写)。
  • 退款:GET /v1/refunds/{refund_id},或 GET /v1/refunds?merchant_refund_no=...。
statusresolution_status含义你该做什么
processingneeds_query结果未知,平台正用原标识向上游查证向付款人显示“正在确认支付结果”;等 Webhook;按下面的频率查询
processingknown已提交,正在处理同上
processing / requires_actionmanual_review超过自动查询窗口,已转人工跟进;平台仍每 24 小时向上游查询一次,不会自动判失败订单保持“待确认”;不要重扣;超过 24 小时仍未确认可联系技术支持并提供支付号
requires_actionknown需要付款人完成认证引导付款人完成认证(next_action),或等待
succeededknown支付成功按 Payment ID 幂等履约
failedknown最近一次尝试明确失败(failure.attempt_id)可让付款人换卡
canceled / expiredknown已关闭关闭订单
查无记录(按订单号)—平台没有为该订单号创建过 Payment用原幂等键重试创建(避免与仍在途的原请求冲突)

订单的有效期只限制新的付款操作,不否定已提交的支付:processing 的支付过了 expires_at 仍会继续查证,并可能成功。

结果未知时的决策
结果未知时的决策

不要换键猜测重扣

警告:结果未知时不要换幂等键或订单号重新扣款

超时、5xx、202 都可能已经扣款;用同一个幂等键重试,或按订单号查询。详见结果未知时如何查单。

原因:请求超时、返回 5xx 或 202 时,钱可能已经扣了。这时换一个新的幂等键、或换一个新的商户订单号重新发起支付,平台会把它当作一次新的业务——付款人可能被扣两次款。正确做法只有两种:用原来的幂等键重试,或按支付号 / 商户订单号查询。

不要这样做
// 不要这样做:超时后换新的幂等键或新的订单号重新扣款,付款人可能被扣两次款
try {
  await createPayment(order, { idempotencyKey: order.paymentIdempotencyKey });
} catch (timeoutError) {
  await createPayment(order, { idempotencyKey: crypto.randomUUID() }); // ✗ 新键 = 新业务
  await createPayment({ ...order, orderNo: `${order.orderNo}-retry` }); // ✗ 新订单号 = 新支付
}

查单频率建议

对象何时开始建议查询时点(距首次受理)何时停止
支付(processing、requires_action)收到 202、超时或 5xx 后10 秒、30 秒、2 分钟、5 分钟、15 分钟、1 小时;之后每 4 小时一次直到 24 小时进入 succeeded / failed / canceled / expired,或收到对应 Webhook
退款(pending_review、processing)创建后不建议轮询;依赖 refund.* 事件;每日对账时用 GET /v1/refunds?status=processing 补查进入终态
结算与打款—依赖 settlement.* 事件;需要时用结算、打款查询接口—
  • 同一对象两次查询间隔不少于 10 秒;不要并发批量轮询。
  • 查询只读取平台当前已知的状态,不会触发向上游的查询:平台向上游查证后才会更新状态,你查得再快也不会更早得到结果。
  • Webhook 是主通道,查询是兜底;两者结果冲突时以最新 version 为准。

429 与 Retry-After

  • 429 rate_limited:本次请求没有执行。等待 Retry-After 秒后用原幂等键重试。换 Key 不能绕过商户的总限额。
  • 503 temporarily_unavailable:平台暂停了该通道的新交易,本次请求同样没有受理、没有产生 Payment;稍后用原幂等键重试。它是唯一明确“未受理”的 5xx,其他 5xx 按结果未知处理。
  • 客户端超时:创建、确认支付不少于 20 秒(平台同步等待上游约 15 秒,超出返回 202);查询不少于 10 秒。

重试参考实现

网络错误、429、5xx 用同一个幂等键重试;仍无结果时转入查询或等待 Webhook。

Node.js
const API_BASE = 'https://api.payhub.example';

// 网络错误、429、5xx(含 503 temporarily_unavailable)用同一个幂等键重试;其余响应直接返回给调用方处理
export async function postWithRetry(path, body, { idempotencyKey, maxAttempts = 5 } = {}) {
  let lastError;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    let res = null;
    try {
      res = await fetch(`${API_BASE}${path}`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.PAYHUB_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey // 每次重试都是同一个键
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(20_000) // 平台同步等待约 15 秒,超时设长一些
      });
    } catch (err) {
      lastError = err;
    }
    if (res && res.status !== 429 && res.status < 500) return res;
    if (attempt === maxAttempts) break;
    let waitMs = Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250);
    if (res?.status === 429) {
      const raw = res.headers.get('Retry-After');
      const seconds = raw === null ? NaN : Number(raw);
      if (Number.isFinite(seconds) && seconds >= 0) waitMs = seconds * 1000;
    }
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }
  // 仍无结果:标记订单"正在确认",转入查询或等待 Webhook;以后再重试也只能用同一个键
  throw Object.assign(new Error('结果未知'), { code: 'RESULT_UNKNOWN', cause: lastError });
}

// 崩溃后没存下 Payment ID 时,按商户订单号查询(精确匹配、区分大小写)
export async function findPaymentByOrderNo(orderNo) {
  const qs = new URLSearchParams({ merchant_order_no: orderNo });
  const res = await fetch(`${API_BASE}/v1/payments?${qs}`, {
    headers: { Authorization: `Bearer ${process.env.PAYHUB_API_KEY}` },
    signal: AbortSignal.timeout(10_000)
  });
  if (!res.ok) throw new Error(`查询失败:HTTP ${res.status}`);
  const page = await res.json();
  return page.data[0] ?? null; // 同一环境内订单号唯一,最多一条;null 表示平台未创建过该订单的支付
}

用测试指南的自测清单逐条验证你的实现。

这页有帮助吗?