关于 payment-async-flow
payment-async-flow 是指导 VTEX 支付连接器实现异步付款与回调处理的技能,面向开发支付集成的人员。内容强调使用原始 callbackUrl、处理重复请求并依据已保存状态更新结果。
主要功能
- 01识别异步支付方式并返回 undefined 状态
- 02按原样使用请求中的 callbackUrl
- 03为浏览器重定向创建自定义公开路由
- 04重复请求时避免创建重复扣款
- 05按支付有效期设置 delayToCancel
适用场景
- 01实现 Pix 或 Boleto Bancário 等异步支付
- 02接收付款状态回调并触发重试
- 03处理 PayPal 或 3DS 浏览器返回
- 04管理 Gateway 对待定付款的重复调用
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vtex/ skills --skill payment-async-flow安装后在 Claude Code 中输入/payment-async-flow确认已加载。
详细介绍
异步支付流程与回调
适用场景
在以下情况下使用此技能:
- 实现支持 Boleto Bancário、Pix、银行转账或基于重定向的流程的支付连接器
- 处理任何收单机构不会同步返回最终状态的支付方式
- 处理
callbackUrl通知或重试流程 - 管理网关针对状态为
undefined的支付的 7 天自动重试周期 - 实现基于重定向的身份验证流程(例如 PayPal、3DS 重定向),用户会离开 VTEX 结账页面,并在外部网站完成支付后返回
以下情况不要使用此技能:
- PPP 端点契约和响应结构——使用
payment-provider-protocol paymentId/requestId幂等性和状态机逻辑——使用payment-idempotency- PCI 合规性和安全代理卡处理——使用
payment-pci-security - POS / VTEX Sales App 终端支付(
Venda Direta Credito、Venda Direta Debito、Payment Apps)——使用payment-ppp-pos
决策规则
- 如果收单机构无法同步返回最终状态,则该支付方式为异步方式——返回
status: "undefined"。 - 常见的异步方式:Boleto Bancário(
BankInvoice)、Pix、银行转账、基于重定向的身份验证。 - 常见的同步方式:信用卡、即时授权的借记卡。
- 不使用 VTEX IO 时:
callbackUrl是通知端点——使用X-VTEX-API-AppKey/X-VTEX-API-AppToken标头 POST 更新后的状态。 - 使用 VTEX IO 时:
callbackUrl是重试端点——向其 POST(不带负载),触发网关重新调用 POST/payments。callbackUrl的格式通常为https://{account}.vtexpayments.com.br/payment-provider/transactions/{txId}/payments/{paymentId}/retry。向此 URL 发送正文为{ paymentId }的 POST 请求,会使网关重新调用你的authorize()方法。 - 始终保留
callbackUrl中的X-VTEX-signature查询参数——切勿将其移除或修改。 inboundRequestsUrl仅用于服务器到服务器的 POST 请求——不支持浏览器 GET 重定向。如果支付流程需要将用户的浏览器重定向回来(例如 PayPal、3DS),必须创建自定义公开路由。请参阅下方的“基于重定向的流程”限制。- 对于异步方式,
delayToCancel必须反映支付方式的实际有效期,而不是网关内部的 7 天重试窗口:- Pix:900 到 3600 秒(15–60 分钟),与二维码有效期一致。
- BankInvoice(Boleto):与服务提供商配置的发票到期日/付款截止日期一致。
- 其他异步方式:与服务提供商文档说明的有效期 SLA 一致。
硬性限制
限制:异步支付方式必须返回 undefined
对于任何授权无法同步完成的支付方式(Boleto、Pix、银行转账、基于重定向的身份验证),创建支付的响应必须使用 status: "undefined"。在收单机构实际确认或拒绝支付之前,连接器不得返回 "approved" 或 "denied"。
为何重要
对未确认的支付返回 "approved",会告知网关款项已收取。订单会立即进入履约流程。如果客户最终没有付款(例如,从未扫描 Pix 二维码),商家就会在未收到款项的情况下发货。过早返回 "denied" 则会取消仍有可能完成的支付。
检测
如果创建支付处理程序针对异步支付方式(Boleto、Pix、银行转账、重定向)返回 status: "approved" 或 status: "denied",请立即停止。异步方式必须返回 "undefined",并通过回调确定最终状态。
正确
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod, callbackUrl } = req.body;
const asyncMethods = ["BankInvoice", "Pix"];
const isAsync = asyncMethods.includes(paymentMethod);
if (isAsync) {
const pending = await acquirer.initiateAsyncPayment(req.body);
// Store payment and callbackUrl for later notification
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl,
acquirerReference: pending.reference,
});
res.status(200).json({
paymentId,
status: "undefined", // Correct: payment is pending
authorizationId: pending.authorizationId ?? null,
nsu: pending.nsu ?? null,
tid: pending.tid ?? null,
acquirer: "MyProvider",
code: "PENDING",
message: "Awaiting customer action",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: computeDelayToCancel(paymentMethod, pending),
paymentUrl: pending.qrCodeUrl ?? pending.boletoUrl ?? undefined,
});
return;
}
// Synchronous methods (credit card) can return final status
const result = await acquirer.authorizeSyncPayment(req.body);
res.status(200).json({
paymentId,
status: result.status, // "approved" or "denied" is OK for sync
authorizationId: result.authorizationId ?? null,
nsu: result.nsu ?? null,
tid: result.tid ?? null,
acquirer: "MyProvider",
code: result.code ?? null,
message: result.message ?? null,
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 21600,
});
}
const PIX_MIN_DELAY = 900; // 15 minutes
const PIX_MAX_DELAY = 3600; // 60 minutes
function computeDelayToCancel(paymentMethod: string, pending: any): number {
if (paymentMethod === "Pix") {
// Use provider QR TTL but clamp to 15–60 minutes
const providerTtlSeconds = pending.pixTtlSeconds ?? 1800; // default 30 min
return Math.min(Math.max(providerTtlSeconds, PIX_MIN_DELAY), PIX_MAX_DELAY);
}
if (paymentMethod === "BankInvoice") {
// Example: seconds until boleto due date
const now = Date.now();
const dueDate = new Date(pending.dueDate).getTime();
const diffSeconds = Math.max(Math.floor((dueDate - now) / 1000), 0);
return diffSeconds;
}
// Other async methods: follow provider SLA if provided
if (pending.expirySeconds) {
return pending.expirySeconds;
}
// Conservative fallback: 24h
return 86400;
}
错误
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod } = req.body;
// WRONG: Creating a Pix charge and immediately returning "approved"
// The customer hasn't scanned the QR code yet — no money collected
const pixCharge = await acquirer.createPixCharge(req.body);
res.status(200).json({
paymentId,
status: "approved", // WRONG — Pix hasn't been paid yet!
authorizationId: pixCharge.id,
nsu: null,
tid: null,
acquirer: "MyProvider",
code: null,
message: null,
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 21600,
});
}
约束:必须使用请求中的 callbackUrl,绝不能硬编码
连接器必须使用 Create Payment 请求正文中提供的原始 callbackUrl,包括所有查询参数(X-VTEX-signature 等)。连接器绝不能硬编码回调 URL 或手动构造回调 URL。
为什么这很重要
callbackUrl 包含特定于交易的身份验证令牌(X-VTEX-signature),Gateway 会使用该令牌验证回调。硬编码或修改过的 URL 会被 Gateway 拒绝,导致支付永远卡在 undefined 状态。URL 格式也可能因环境而异(生产环境与沙盒环境)。
检测
如果连接器硬编码了回调 URL 字符串、手动构造 URL,或从 callbackUrl 中删除查询参数,请提醒开发者。必须按原样存储和使用收到的 callbackUrl。
正确
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, callbackUrl } = req.body;
// Store the exact callbackUrl from the request
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl, // Stored exactly as received, including query params
});
// Return async "undefined" response (see previous constraint)
res.status(200).json({
paymentId,
status: "undefined",
authorizationId: null,
nsu: null,
tid: null,
acquirer: "MyProvider",
code: "PENDING",
message: "Awaiting customer action",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 86400,
});
}
// When the acquirer webhook arrives, use the stored callbackUrl
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
const { paymentReference, status } = req.body;
const payment = await store.findByAcquirerRef(paymentReference);
if (!payment) {
res.status(404).send();
return;
}
const pppStatus = status === "paid" ? "approved" : "denied";
// Update local state first
await store.updateStatus(payment.paymentId, pppStatus);
// Use the EXACT stored callbackUrl — do not modify it
await fetch(payment.callbackUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-VTEX-API-AppKey": process.env.VTEX_APP_KEY!,
"X-VTEX-API-AppToken": process.env.VTEX_APP_TOKEN!,
},
body: JSON.stringify({
paymentId: payment.paymentId,
status: pppStatus,
}),
});
res.status(200).send();
}
错误
// WRONG: Hardcoding callback URL — ignores X-VTEX-signature and environment
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
const { paymentReference, status } = req.body;
const payment = await store.findByAcquirerRef(paymentReference);
// WRONG — hardcoded URL, missing X-VTEX-signature authentication
await fetch("https://mystore.vtexpayments.com.br/api/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paymentId: payment.paymentId,
status: status === "paid" ? "approved" : "denied",
}),
});
res.status(200).send();
}
约束:inboundRequestsUrl 仅用于服务器到服务器的通信——重定向流程需要自定义公共路由
PPP 提供的 inboundRequestsUrl 仅接受来自服务器的 POST 请求。浏览器 GET 重定向(例如用户从 PayPal、3DS 或任何外部结账流程返回时)访问此 URL 会收到 400 Bad Request。
对于基于重定向的支付流程,连接器必须在 VTEX IO 应用中创建自定义公共路由来接收浏览器重定向,然后通过已存储的 callbackUrl 触发 Gateway 重试。
为什么这很重要
许多支付服务提供商(PayPal、MercadoPago、银行重定向、3DS)要求用户完成支付后,由浏览器重定向回商家。如果连接器将 inboundRequestsUrl 用作重定向目标,用户会看到 400 错误,支付也永远无法确认。这是一种常见且令人沮丧的故障模式,但标准 PPP 或 PPF 文档中并未说明。
检测
如果连接器将 inboundRequestsUrl 用作外部支付服务提供商的 return_url 或 redirect_url,且该服务提供商会重定向用户的浏览器,请停止。应改为创建自定义公共路由。
正确 — 为重定向回调创建自定义路由
步骤 1:在 service.json(项目根目录)中添加路由:
{
"memory": 256,
"ttl": 10,
"timeout": 10,
"minReplicas": 2,
"maxReplicas": 10,
"routes": {
"providerCallback": {
"path": "/_v/my-connector/callback",
"public": true
}
}
}
步骤 2:在 node/index.ts 中注册处理程序:
import { PaymentProviderService } from '@vtex/payment-provider'
import MyConnector from './connector'
import { Clients } from './clients'
import { callbackHandler } from './handlers/callback'
export default new PaymentProviderService({
connector: MyConnector,
clients: {
implementation: Clients,
options: { default: { retries: 2, timeout: 15000 } },
},
routes: {
providerCallback: callbackHandler,
},
})
步骤 3:实现处理程序,以接收浏览器 GET 请求、更新状态并触发 Gateway 重试:
// node/handlers/callback.ts
export async function callbackHandler(ctx: Context) {
const { paymentId, cancel } = ctx.query
const { vbase } = ctx.clients
// Load the stored payment record
const payment = await vbase.getJSON<PaymentRecord>('payments', paymentId as string)
if (!payment) {
ctx.status = 404
ctx.body = 'Payment not found'
return
}
// Update status based on user action
if (cancel === 'true') {
payment.status = 'user-cancelled'
} else {
payment.status = 'user-returned'
}
await vbase.saveJSON('payments', paymentId as string, payment)
// Trigger Gateway retry — POST to the stored callbackUrl
// This makes the Gateway re-call authorize(), which will read the updated status
await fetch(payment.callbackUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentId }),
})
// Redirect user back to VTEX checkout
const returnUrl = payment.returnUrl || `https://${ctx.vtex.account}.myvtex.com/checkout`
ctx.redirect(returnUrl)
}
步骤 4:在 authorize() 中,将自定义路由用作外部服务提供商的返回 URL:
// Inside the PaymentProvider subclass
async authorize(authorization: AuthorizationRequest) {
const vtexCtx = (this.context as any).vtex as { account: string; workspace: string }
const account = vtexCtx?.account || ''
const workspace = vtexCtx?.workspace || 'master'
const workspacePrefix = workspace !== 'master' ? `${workspace}--` : ''
const callbackBase = `https://${workspacePrefix}${account}.myvtex.com/_v/my-connector/callback`
// Use YOUR custom route as the return URL — not inboundRequestsUrl
const returnUrl = `${callbackBase}?paymentId=${authorization.paymentId}`
const cancelUrl = `${callbackBase}?paymentId=${authorization.paymentId}&cancel=true`
// Create the payment at the external provider with these URLs
const externalOrder = await this.clients.psp.createOrder({
amount: authorization.value,
return_url: returnUrl,
cancel_url: cancelUrl,
})
// Store the callbackUrl and return undefined status with paymentUrl
await this.vbase.saveJSON('payments', authorization.paymentId, {
status: 'pending',
callbackUrl: authorization.callbackUrl,
returnUrl: authorization.returnUrl,
externalOrderId: externalOrder.id,
})
return {
paymentId: authorization.paymentId,
status: 'undefined',
paymentUrl: externalOrder.checkoutUrl, // User is redirected here
// ... other required fields
}
}
错误
// WRONG — using inboundRequestsUrl as browser redirect target
async authorize(authorization: AuthorizationRequest) {
const externalOrder = await this.clients.psp.createOrder({
amount: authorization.value,
// inboundRequestsUrl only accepts POST from servers — browser GET returns 400
return_url: authorization.inboundRequestsUrl,
})
return {
paymentId: authorization.paymentId,
status: 'undefined',
paymentUrl: externalOrder.checkoutUrl,
}
}
约束:必须支持重复调用 Create Payment(幂等,但状态可以演变)
连接器必须能够处理 Gateway 在重试窗口内使用相同的 paymentId 多次调用 Create Payment(POST /payments)。每次调用都不得在收单机构创建新的扣款,必须根据该 paymentId 的本地持久化状态返回响应,并反映当前状态("undefined"、"approved" 或 "denied");该状态可能已在回调后发生变化。
幂等性针对的是在收单机构产生的副作用:首次调用会创建扣款,重试时不得再次调用收单机构。对于异步方式,响应状态可以从 "undefined" 合理地演变为 "approved" 或 "denied",但这种变化只能是因为 Webhook 更新了本地存储。
为何重要
对于状态为 undefined 的付款,Gateway 会在最长 7 天内自动重试 POST /payments。如果连接器将每次调用都视为新付款,就会在收单机构创建重复扣款。如果连接器始终返回最初的 "undefined" 响应,而不检查状态是否已更新,Gateway 就永远无法得知付款已获批准,最终会取消付款。
检测
如果 Create Payment 处理程序在调用收单机构之前没有检查是否已存在该 paymentId,或者始终返回最初的响应而不查看存储中的当前状态,Agent 必须停止并指导开发者实现正确的幂等性,且状态演变只能基于存储的状态。
正确
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod, callbackUrl } = req.body;
// Check for existing payment — may have been updated via callback
const existing = await store.findByPaymentId(paymentId);
if (existing) {
// Do NOT call the acquirer again.
// Return a response derived from the current stored state.
res.status(200).json({
...existing.response,
status: existing.status, // Reflect the latest state: "undefined" | "approved" | "denied"
});
return;
}
// First time — call the acquirer once
const asyncMethods = ["BankInvoice", "Pix"];
const isAsync = asyncMethods.includes(paymentMethod);
const acquirerResult = await acquirer.authorize(req.body);
const initialStatus = isAsync ? "undefined" : acquirerResult.status;
const response = {
paymentId,
status: initialStatus,
authorizationId: acquirerResult.authorizationId ?? null,
nsu: acquirerResult.nsu ?? null,
tid: acquirerResult.tid ?? null,
acquirer: "MyProvider",
code: acquirerResult.code ?? null,
message: acquirerResult.message ?? null,
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: isAsync
? computeDelayToCancel(paymentMethod, acquirerResult)
: 21600,
...(acquirerResult.paymentUrl
? { paymentUrl: acquirerResult.paymentUrl }
: {}),
};
await store.save(paymentId, {
paymentId,
status: initialStatus,
response,
callbackUrl,
acquirerReference: acquirerResult.reference,
});
res.status(200).json(response);
}
错误
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId } = req.body;
// WRONG: No idempotency — every retry hits the acquirer again
const result = await acquirer.authorize(req.body);
res.status(200).json({
paymentId,
status: result.status,
authorizationId: result.authorizationId ?? null,
nsu: result.nsu ?? null,
tid: result.tid ?? null,
acquirer: "MyProvider",
code: null,
message: null,
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 21600,
});
}
约束:必须使 delayToCancel 与付款有效期一致(不能始终设为 7 天)
对于异步方式,Create Payment 响应中的 delayToCancel 字段必须表示该付款对购物者有效的时长。它决定了 Gateway 何时可以自动取消一直未达到最终状态的付款。
规则:
- Pix:
delayToCancel必须在 900 到 3600 秒(15–60 分钟)之间。此值必须与提供商配置的 QR 码有效期一致。 - BankInvoice(Boleto):
delayToCancel必须根据配置的到期日/付款截止时间计算(例如,距发票到期日的秒数)。不得为了“匹配”Gateway 的内部重试窗口而硬编码为 7 天。 - 其他异步方式:
delayToCancel必须遵循提供商定义的有效期 SLA(视情况以小时或天为单位)。从提供商的角度看,该值绝不能超过底层付款的实际有效期。
7 天期限是 Gateway 针对状态为 undefined 的付款设置的内部重试安全上限。这并不意味着所有异步方式都应使用 delayToCancel = 604800。
为何重要
对于 Pix,使用多天的 delayToCancel 会导致订单在 QR 码过期后仍卡在“Authorizing”状态,造成糟糕的用户体验和运营噪音。对于 Boleto,过早取消会损失销售额;过晚取消则会带来对账风险,并产生“僵尸”订单。delayToCancel 不一致会破坏提供商对有效付款的定义与 VTEX 自动取消付款时间之间的一致性。
检测
如果连接器对任何异步方式都始终使用 delayToCancel = 604800,或者将 delayToCancel 设置为超过 Pix 或 Boleto 的有效期,Agent 必须警告 delayToCancel 配置错误。
正确
(请参阅上方“MUST return undefined”示例中的 computeDelayToCancel 函数。)
错误
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod, callbackUrl } = req.body;
const isAsync = ["BankInvoice", "Pix"].includes(paymentMethod);
if (isAsync) {
const pending = await acquirer.initiateAsyncPayment(req.body);
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl,
acquirerReference: pending.reference,
});
res.status(200).json({
paymentId,
status: "undefined",
authorizationId: pending.authorizationId ?? null,
nsu: pending.nsu ?? null,
tid: pending.tid ?? null,
acquirer: "MyProvider",
code: "PENDING",
message: "Awaiting customer action",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
// WRONG: hardcoded 7 days for every async method
delayToCancel: 604800,
paymentUrl: pending.qrCodeUrl ?? pending.boletoUrl ?? undefined,
});
return;
}
// ...
}
首选模式
非 VTEX IO 的数据流(通知回调):
1. Gateway → POST /payments → Connector (returns status: "undefined")
2. Acquirer webhook → Connector (payment confirmed)
3. Connector → POST callbackUrl (with X-VTEX-API-AppKey/AppToken headers)
4. Gateway updates payment status to approved/denied
VTEX IO 的数据流(重试回调):
1. Gateway → POST /payments → Connector (returns status: "undefined")
2. Acquirer webhook → Connector (payment confirmed)
3. Connector → POST callbackUrl (retry, no payload)
4. Gateway → POST /payments → Connector (returns status: "approved"/"denied")
VTEX IO 中基于重定向的流程的数据流:
1. Gateway → POST /payments → Connector (returns status: "undefined" + paymentUrl)
2. Checkout redirects user to paymentUrl (external provider)
3. User completes payment on external site
4. External provider redirects user's browser to custom route (/_v/connector/callback?paymentId=...)
5. Custom route handler updates VBase status, POSTs to callbackUrl
6. Gateway → POST /payments → Connector (reads updated status, returns "approved"/"denied")
7. Custom route redirects user back to VTEX checkout
重定向流程在 VBase 中的状态演变:
pending → (user completes on external site) → user-returned → (authorize re-called) → approved
pending → (user cancels on external site) → user-cancelled → (authorize re-called) → denied
付款方式分类:
const ASYNC_PAYMENT_METHODS = new Set([
"BankInvoice", // Boleto Bancário
"Pix", // Pix instant payments
]);
function isAsyncPaymentMethod(paymentMethod: string): boolean {
return ASYNC_PAYMENT_METHODS.has(paymentMethod);
}
带回调通知的收单机构 Webhook 处理程序(非 VTEX IO):
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
const webhookData = req.body;
const acquirerRef = webhookData.transactionId;
const payment = await store.findByAcquirerRef(acquirerRef);
if (!payment || !payment.callbackUrl) {
res.status(404).json({ error: "Payment not found" });
return;
}
const pppStatus = webhookData.status === "paid" ? "approved" : "denied";
// Update local state FIRST
await store.updateStatus(payment.paymentId, pppStatus);
// Notify the Gateway via callbackUrl with retry logic
await notifyGateway(payment.callbackUrl, {
paymentId: payment.paymentId,
status: pppStatus,
});
res.status(200).json({ received: true });
}
采用指数退避的回调重试:
async function notifyGateway(callbackUrl: string, payload: object): Promise<void> {
const maxRetries = 3;
const baseDelay = 1000; // 1 second
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(callbackUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-VTEX-API-AppKey": process.env.VTEX_APP_KEY!,
"X-VTEX-API-AppToken": process.env.VTEX_APP_TOKEN!,
},
body: JSON.stringify(payload),
});
if (response.ok) return;
console.error(`Callback attempt ${attempt + 1} failed: ${response.status}`);
} catch (error) {
console.error(`Callback attempt ${attempt + 1} error:`, error);
}
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt)));
}
}
// All retries failed — Gateway will still retry via /payments as safety net
console.error("All callback retries exhausted. Relying on Gateway retry.");
}
常见失败模式
- 异步支付同步审批 — 因为成功生成了二维码或付款单,就为 Pix 或 Boleto 返回
status: "approved"。生成二维码不等于收到付款。订单会在未收款的情况下发货。 - 将
inboundRequestsUrl用作浏览器重定向目标 —inboundRequestsUrl只接受服务器到服务器的 POST 请求。浏览器 GET 重定向(来自 PayPal、3DS 等)会返回 400 Bad Request。请改为创建自定义公开路由(/_v/{connector}/callback)。 - 忽略 callbackUrl — 未保存 Create Payment 请求中的
callbackUrl,而是完全依赖 Gateway 自动重试。重试间隔会逐渐延长,导致付款与订单审批之间出现长时间延迟。最坏情况下,7 天窗口到期后,即使客户已付款,支付也会被取消。 - 硬编码 callback URL — 手动拼接 callback URL,而不是使用请求中的 URL,导致
X-VTEX-signature参数被剥除。Gateway 会拒绝回调,支付会一直卡在undefined状态。 - 没有失败回调的重试逻辑 — 只调用一次
callbackUrl,失败时静默丢弃通知。Gateway 永远无法得知支付已获批准,支付会一直处于undefined状态,直到下一次重试或被自动取消。 - 重试时返回过期状态 — 始终返回原始的
undefined响应,而不检查状态是否已通过回调更新。Gateway 永远看不到approved状态,最终会取消支付。 delayToCancel设置不当 — Pix 使用 7 天,导致过期二维码对应的订单卡在“Authorizing”状态。Boleto 使用任意值,与发票到期日不符。- 缺少自定义回调的
service.json或路由注册 — 代码中存在自定义路由,但未在service.json中声明,或未通过PaymentProviderService中的routes传入,因此 VTEX IO 不会公开该路由。 - 构建回调 URL 时错误访问
this.context— 在 PPF 中使用this.context.account,而不是this.context.vtex.account。有关正确的 IOContext 访问方式,请参阅payment-provider-framework技能。
审查清单
- 异步支付方式(Boleto、Pix)在 Create Payment 中是否返回
status: "undefined"? - 是否按请求原样保存了
callbackUrl(包括所有查询参数)? - Webhook 处理程序是否在调用
callbackUrl之前更新了本地状态? - 调用
callbackUrl时是否保留了X-VTEX-signature? - 通知回调中是否包含
X-VTEX-API-AppKey和X-VTEX-API-AppToken请求头(非 VTEX IO)? - 回调调用失败时是否有采用指数退避的重试逻辑?
- Create Payment 处理程序是否会检查现有的
paymentId、在重试时避免再次调用收单机构,并返回根据当前存储状态生成的响应(回调后,状态可能从"undefined"变为"approved"/"denied")? - 对于 Pix,
delayToCancel是否在 900 到 3600 秒之间(15–60 分钟),并与二维码有效期一致? - 对于 BankInvoice(Boleto),
delayToCancel是否反映服务商配置的实际付款期限/到期日? - 对于其他异步支付方式,
delayToCancel是否与服务商文档规定的过期 SLA 一致,且从不超过实际支付有效期? - 对于基于重定向的流程,是否使用了自定义公开路由,而不是
inboundRequestsUrl? - 是否在
service.json中声明了自定义路由,并将其注册到PaymentProviderService的路由中? - 重定向处理程序是否会在 VBase 中更新状态、向 callbackUrl 发送 POST 请求,并将用户重定向回结账页面?
相关技能
payment-provider-framework— PPF IO 接线、service.json、自定义路由、IOContext 访问和PaymentProviderService配置payment-provider-protocol— 端点契约和响应结构payment-idempotency—paymentId/requestId幂等性和状态机payment-pci-security— PCI 合规性和 Secure Proxypayment-ppp-pos— POS / Sales App 终端流程;不适用于 Pix/Boleto
参考资料
- Payment Provider Protocol (Help Center) — 详细说明
undefined状态、回调 URL 通知和重试流程,以及 7 天重试期限 - Purchase Flows — 授权流程文档,包括异步重试机制,以及 VTEX IO 与非 VTEX IO 的回调 URL 行为
- Implementing a Payment Provider — 端点级实施指南,介绍 callbackUrl 和 returnUrl 的用法
- Pix: Instant Payments in Brazil — Pix 专用异步流程实施指南,包括二维码生成和回调处理
- Callback URL Signature Authentication — 回调 URL 身份验证的强制性 X-VTEX-signature 要求(自 2024 年 6 月起生效)
- Payment Provider Protocol API Reference — 完整的 API 规范,包含 callbackUrl 字段文档