payment-async-flow
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAsynchronous Payment Flows & Callbacks
异步支付流程与回调
When this skill applies
适用场景
Use this skill when:
- Implementing a payment connector that supports Boleto Bancário, Pix, bank transfers, or redirect-based flows
- Working with any payment method where the acquirer does not return a final status synchronously
- Handling notification or retry flows
callbackUrl - Managing the Gateway's 7-day automatic retry cycle for status payments
undefined
Do not use this skill for:
- PPP endpoint contracts and response shapes — use
payment-provider-protocol - /
paymentIdidempotency and state machine logic — userequestIdpayment-idempotency - PCI compliance and Secure Proxy card handling — use
payment-pci-security
在以下场景使用本指南:
- 实现支持Boleto Bancário、Pix、银行转账或跳转式流程的支付连接器
- 对接收单机构无法同步返回最终状态的任意支付方式
- 处理通知或重试流程
callbackUrl - 管理网关针对状态支付的7天自动重试周期
undefined
本指南不适用于以下场景:
- PPP端点约定与响应格式 — 请参考
payment-provider-protocol - /
paymentId幂等性与状态机逻辑 — 请参考requestIdpayment-idempotency - PCI合规与安全代理卡处理 — 请参考
payment-pci-security
Decision rules
决策规则
- If the acquirer cannot return a final status synchronously, the payment method is async — return .
status: "undefined" - Common async methods: Boleto Bancário (), Pix, bank transfers, redirect-based auth.
BankInvoice - Common sync methods: credit cards, debit cards with instant authorization.
- Without VTEX IO: the is a notification endpoint — POST the updated status with
callbackUrl/X-VTEX-API-AppKeyheaders.X-VTEX-API-AppToken - With VTEX IO: the is a retry endpoint — POST to it (no payload) to trigger the Gateway to re-call POST
callbackUrl./payments - Always preserve the query parameter in the
X-VTEX-signature— never strip or modify it.callbackUrl - For asynchronous methods, MUST reflect the actual validity of the payment method, not the 7‑day internal Gateway retry window:
delayToCancel- Pix: between 900 and 3600 seconds (15–60 minutes), aligned with QR code expiration.
- BankInvoice (Boleto): aligned with the invoice due date / payment deadline configured in the provider.
- Other async methods: aligned with the provider's documented expiry SLA.
- 如果收单机构无法同步返回最终状态,则该支付方式为异步方式 — 返回。
status: "undefined" - 常见异步支付方式:Boleto Bancário()、Pix、银行转账、跳转式鉴权。
BankInvoice - 常见同步支付方式:信用卡、可即时授权的借记卡。
- 非VTEX IO环境:是通知端点 — 携带
callbackUrl/X-VTEX-API-AppKey请求头POST更新后的状态。X-VTEX-API-AppToken - VTEX IO环境:是重试端点 — 向其发送无请求体的POST请求,触发网关重新调用POST
callbackUrl接口。/payments - 始终保留中的
callbackUrl查询参数 — 不得删除或修改。X-VTEX-signature - 对于异步支付方式,必须匹配支付方式的实际有效期,而非网关内部7天的重试窗口:
delayToCancel- Pix:900到3600秒(15–60分钟),与二维码有效期保持一致。
- BankInvoice(Boleto):与供应商配置的账单到期日/支付截止日期保持一致。
- 其他异步支付方式:与供应商文档公布的过期SLA保持一致。
Hard constraints
硬性约束
Constraint: MUST return undefined
for async payment methods
undefined约束:异步支付方式必须返回undefined
undefinedFor any payment method where authorization does not complete synchronously (Boleto, Pix, bank transfer, redirect-based auth), the Create Payment response MUST use . The connector MUST NOT return or until the payment is actually confirmed or rejected by the acquirer.
status: "undefined""approved""denied"Why this matters
Returning for an unconfirmed payment tells the Gateway the money has been collected. The order is released for fulfillment immediately. If the customer never actually pays (e.g., never scans the Pix QR code), the merchant ships products without payment. Returning prematurely cancels a payment that might still be completed.
"approved""denied"Detection
If the Create Payment handler returns or for an asynchronous payment method (Boleto, Pix, bank transfer, redirect), STOP. Async methods must return and resolve via callback.
status: "approved"status: "denied""undefined"Correct
typescript
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;
}Wrong
typescript
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,
});
}对于任何授权无法同步完成的支付方式(Boleto、Pix、银行转账、跳转式鉴权),创建支付的响应必须使用。在收单机构实际确认或驳回支付之前,连接器不得返回或。
status: "undefined""approved""denied"设计原因
为未确认的支付返回会告知网关资金已到账,订单会立即进入发货流程。如果客户最终未完成支付(比如从未扫描Pix二维码),商家就会出现货发了但没收到钱的损失。过早返回则会取消原本可能完成的支付。
"approved""denied"检测规则
如果创建支付的处理逻辑为异步支付方式(Boleto、Pix、银行转账、跳转)返回或,请立即停止开发。异步方式必须返回,最终状态通过回调更新。
status: "approved"status: "denied""undefined"正确示例
typescript
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);
// 存储支付信息与callbackUrl,用于后续通知
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: "等待用户操作",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: computeDelayToCancel(paymentMethod, pending),
paymentUrl: pending.qrCodeUrl ?? pending.boletoUrl ?? undefined,
});
return;
}
// 同步支付方式(信用卡)可以返回最终状态
const result = await acquirer.authorizeSyncPayment(req.body);
res.status(200).json({
paymentId,
status: result.status, // 同步方式返回"approved"或"denied"是合法的
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分钟
const PIX_MAX_DELAY = 3600; // 60分钟
function computeDelayToCancel(paymentMethod: string, pending: any): number {
if (paymentMethod === "Pix") {
// 使用供应商提供的二维码有效期,限制在15-60分钟区间内
const providerTtlSeconds = pending.pixTtlSeconds ?? 1800; // 默认30分钟
return Math.min(Math.max(providerTtlSeconds, PIX_MIN_DELAY), PIX_MAX_DELAY);
}
if (paymentMethod === "BankInvoice") {
// 示例:计算距离Boleto到期日的秒数
const now = Date.now();
const dueDate = new Date(pending.dueDate).getTime();
const diffSeconds = Math.max(Math.floor((dueDate - now) / 1000), 0);
return diffSeconds;
}
// 其他异步支付方式:如果供应商提供了SLA则遵循
if (pending.expirySeconds) {
return pending.expirySeconds;
}
// 保守兜底:24小时
return 86400;
}错误示例
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod } = req.body;
// 错误:创建Pix订单后立即返回"approved"
// 客户还未扫描二维码,资金并未到账
const pixCharge = await acquirer.createPixCharge(req.body);
res.status(200).json({
paymentId,
status: "approved", // 错误 — Pix还未完成支付!
authorizationId: pixCharge.id,
nsu: null,
tid: null,
acquirer: "MyProvider",
code: null,
message: null,
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 21600,
});
}Constraint: MUST use callbackUrl from request — never hardcode
约束:必须使用请求中携带的callbackUrl,禁止硬编码
The connector MUST use the exact provided in the Create Payment request body, including all query parameters (, etc.). The connector MUST NOT hardcode callback URLs or construct them manually.
callbackUrlX-VTEX-signatureWhy this matters
The contains transaction-specific authentication tokens () that the Gateway uses to validate the callback. A hardcoded or modified URL will be rejected by the Gateway, leaving the payment stuck in status forever. The URL format may also change between environments (production vs sandbox).
callbackUrlX-VTEX-signatureundefinedDetection
If the connector hardcodes a callback URL string, constructs the URL manually, or strips query parameters from the , warn the developer. The must be stored and used exactly as received.
callbackUrlcallbackUrlCorrect
typescript
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
typescript
// 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();
}连接器必须使用创建支付请求体中提供的完整,包括所有查询参数(等)。连接器不得硬编码回调地址,也不得手动拼接地址。
callbackUrlX-VTEX-signature设计原因
包含交易专属的鉴权令牌(),网关会用它验证回调请求的合法性。硬编码或修改过的URL会被网关拒绝,导致支付永远卡在状态。同时URL格式在不同环境(生产/沙箱)下也可能发生变化。
callbackUrlX-VTEX-signatureundefined检测规则
如果连接器硬编码了回调地址、手动拼接URL,或者删除了的查询参数,请警告开发人员。必须完全按照接收时的内容存储和使用。
callbackUrlcallbackUrl正确示例
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, callbackUrl } = req.body;
// 存储请求中携带的完整callbackUrl
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl, // 完全按接收内容存储,包括所有查询参数
});
// 返回异步"undefined"响应(参考上一个约束)
res.status(200).json({
paymentId,
status: "undefined",
authorizationId: null,
nsu: null,
tid: null,
acquirer: "MyProvider",
code: "PENDING",
message: "等待用户操作",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 86400,
});
}
// 收到收单机构webhook时,使用存储的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";
// 先更新本地状态
await store.updateStatus(payment.paymentId, pppStatus);
// 使用存储的完整callbackUrl — 不要修改
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();
}错误示例
typescript
// 错误:硬编码回调地址 — 忽略了X-VTEX-signature与环境差异
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
const { paymentReference, status } = req.body;
const payment = await store.findByAcquirerRef(paymentReference);
// 错误 — 硬编码地址,缺少X-VTEX-signature鉴权信息
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();
}Constraint: MUST be ready for repeated Create Payment calls (idempotent, but status can evolve)
约束:必须支持重复调用创建支付接口(幂等,且状态可更新)
The connector MUST handle the Gateway calling Create Payment (POST ) with the same multiple times during the retry window. Each call MUST not create a new charge at the acquirer, must return a response based on the locally persisted state for that , and must reflect the current status (, , or ) which may have changed after a callback.
/paymentspaymentIdpaymentId"undefined""approved""denied"Idempotency is about side effects on the acquirer: the first call creates the charge, retries MUST NOT call the acquirer again. For async methods, the response status may legitimately evolve from to or , but only because your local store was updated by the webhook.
"undefined""approved""denied"Why this matters
The Gateway retries for payments automatically for up to 7 days. If the connector treats each call as a new payment, it will create duplicate charges at the acquirer. If the connector always returns the original response without checking for an updated status, the Gateway never learns that the payment was approved, and eventually cancels it.
POST /paymentsundefined"undefined"Detection
If the Create Payment handler does not check for an existing before calling the acquirer, or always returns the original response without looking at the current status in storage, the agent MUST stop and guide the developer to implement proper idempotency with status evolution based on stored state only.
paymentIdCorrect
typescript
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);
}Wrong
typescript
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,
});
}连接器必须支持网关在重试窗口内使用相同多次调用创建支付接口(POST )。每次调用都不得在收单机构侧创建新的订单,必须基于本地存储的状态返回响应,且要返回最新的状态(、或),状态可能已经通过回调更新。
paymentId/paymentspaymentId"undefined""approved""denied"幂等性针对的是收单机构侧的副作用:首次调用创建订单,重试时绝对不能再次调用收单机构接口。对于异步方式,响应状态可以从合法更新为或,但前提是本地存储的状态已经被webhook更新过。
"undefined""approved""denied"设计原因
网关会自动重试状态支付的请求,最长可达7天。如果连接器把每次调用都当成新支付,会在收单机构侧生成重复订单。如果连接器不检查最新状态,始终返回最初的响应,网关永远不会知道支付已经完成,最终会自动取消订单。
undefinedPOST /payments"undefined"检测规则
如果创建支付处理逻辑在调用收单机构前没有检查是否已存在相同,或者始终返回初始响应而不查询存储的最新状态,必须停止开发,引导开发者实现基于存储状态的、支持状态更新的幂等逻辑。
paymentId正确示例
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod, callbackUrl } = req.body;
// 检查是否已存在该支付记录 — 状态可能已经被回调更新
const existing = await store.findByPaymentId(paymentId);
if (existing) {
// 不要再次调用收单机构接口
// 基于当前存储的状态返回响应
res.status(200).json({
...existing.response,
status: existing.status, // 反映最新状态:"undefined" | "approved" | "denied"
});
return;
}
// 首次调用 — 仅调用一次收单机构接口
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);
}错误示例
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId } = req.body;
// 错误:无幂等处理 — 每次重试都会再次调用收单机构接口
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,
});
}Constraint: MUST align delayToCancel
with payment validity (not always 7 days)
delayToCancel约束:delayToCancel必须与支付有效期对齐(不要固定为7天)
For asynchronous methods, the field in the Create Payment response MUST represent how long that payment is considered valid for the shopper. It defines when the Gateway is allowed to automatically cancel payments that never reached a final status.
delayToCancelRules:
- Pix: MUST be between 900 and 3600 seconds (15–60 minutes). This value MUST match the QR code validity configured on the provider.
delayToCancel - BankInvoice (Boleto): MUST be computed from the configured due date / payment deadline (for example, seconds until invoice due date). It MUST NOT be hardcoded to 7 days just to "match" the Gateway's internal retry window.
delayToCancel - Other async methods: MUST follow the expiry SLA defined by the provider (hours or days, as applicable). It MUST NEVER exceed the actual validity of the underlying payment from the provider's perspective.
delayToCancel
The 7‑day window is an internal Gateway safety limit for retries on status. It does not mean every async method should use .
undefineddelayToCancel = 604800Why this matters
For Pix, using a multi‑day keeps orders stuck in "Authorizing" with expired QR codes, creating poor UX and operational noise. For Boleto, cancelling before the real due date loses sales; cancelling much later creates reconciliation risk and "zombie" orders. Misaligned breaks the consistency between the provider's notion of a valid payment and when VTEX auto‑cancels the payment.
delayToCanceldelayToCancelDetection
If the connector always uses for any async method, or sets greater than the Pix or Boleto validity window, the agent MUST warn that is misconfigured.
delayToCancel = 604800delayToCanceldelayToCancelCorrect
(See the function in the "MUST return undefined" example above.)
computeDelayToCancelWrong
typescript
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;
}
// ...
}对于异步支付方式,创建支付响应中的字段必须表示该支付对用户有效的时长,它定义了网关何时可以自动取消从未到达最终状态的支付。
delayToCancel规则:
- Pix:必须在900到3600秒之间(15–60分钟),必须与供应商配置的二维码有效期一致。
delayToCancel - BankInvoice(Boleto):必须根据配置的到期日/支付截止日期计算(比如距离账单到期日的秒数)。不得为了“匹配”网关内部的重试窗口就硬编码为7天。
delayToCancel - 其他异步支付方式:必须遵循供应商定义的过期SLA(根据实际情况可为几小时或几天),绝对不能超过供应商侧支付的实际有效期。
delayToCancel
7天窗口是网关针对状态重试的内部安全上限,不代表所有异步方式都要设置。
undefineddelayToCancel = 604800设计原因
对于Pix,设置多天的会让订单卡在“授权中”状态,而二维码早已过期,会造成极差的用户体验和运营负担。对于Boleto,早于实际到期日取消会损失销量,晚于到期日取消则会带来对账风险和“僵尸”订单。配置不当会打破供应商侧有效支付状态与VTEX自动取消时机的一致性。
delayToCanceldelayToCancel检测规则
如果连接器为任意异步支付方式统一设置,或者设置的超过了Pix或Boleto的有效期,必须警告配置错误。
delayToCancel = 604800delayToCanceldelayToCancel正确示例
(参考上文“必须返回undefined”示例中的函数)
computeDelayToCancel错误示例
typescript
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: "等待用户操作",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
// 错误:所有异步方式都硬编码为7天
delayToCancel: 604800,
paymentUrl: pending.qrCodeUrl ?? pending.boletoUrl ?? undefined,
});
return;
}
// ...
}Preferred pattern
推荐实现模式
Data flow for non-VTEX IO (notification callback):
text
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/deniedData flow for VTEX IO (retry callback):
text
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")Classify payment methods:
typescript
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);
}Acquirer webhook handler with callback notification (non-VTEX IO):
typescript
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 });
}Callback retry with exponential backoff:
typescript
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.");
}非VTEX IO环境的数据流转(通知型回调):
text
1. 网关 → POST /payments → 连接器(返回status: "undefined")
2. 收单机构webhook → 连接器(支付确认)
3. 连接器 → POST callbackUrl(携带X-VTEX-API-AppKey/AppToken请求头)
4. 网关将支付状态更新为approved/deniedVTEX IO环境的数据流转(重试型回调):
text
1. 网关 → POST /payments → 连接器(返回status: "undefined")
2. 收单机构webhook → 连接器(支付确认)
3. 连接器 → POST callbackUrl(重试,无请求体)
4. 网关 → POST /payments → 连接器(返回status: "approved"/"denied")支付方式分类:
typescript
const ASYNC_PAYMENT_METHODS = new Set([
"BankInvoice", // Boleto Bancário
"Pix", // Pix即时支付
]);
function isAsyncPaymentMethod(paymentMethod: string): boolean {
return ASYNC_PAYMENT_METHODS.has(paymentMethod);
}收单机构webhook处理逻辑(非VTEX IO通知型回调):
typescript
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: "未找到对应支付记录" });
return;
}
const pppStatus = webhookData.status === "paid" ? "approved" : "denied";
// 先更新本地状态
await store.updateStatus(payment.paymentId, pppStatus);
// 通过callbackUrl通知网关,附带重试逻辑
await notifyGateway(payment.callbackUrl, {
paymentId: payment.paymentId,
status: pppStatus,
});
res.status(200).json({ received: true });
}带指数退避的回调重试逻辑:
typescript
async function notifyGateway(callbackUrl: string, payload: object): Promise<void> {
const maxRetries = 3;
const baseDelay = 1000; // 1秒
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(`回调第${attempt + 1}次尝试失败:${response.status}`);
} catch (error) {
console.error(`回调第${attempt + 1}次尝试报错:`, error);
}
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt)));
}
}
// 所有重试失败 — 网关仍会通过/payments重试作为兜底
console.error("所有回调重试失败,依赖网关重试机制兜底。");
}Common failure modes
常见故障模式
- Synchronous approval of async payments — Returning for Pix or Boleto because the QR code or slip was generated successfully. Generating a QR code is not the same as receiving payment. The order ships without money collected.
status: "approved" - Ignoring the callbackUrl — Not storing the from the Create Payment request and relying entirely on the Gateway's automatic retries. The retry interval increases over time, causing long delays between payment and order approval. Worst case: the 7-day window expires and the payment is cancelled even though the customer paid.
callbackUrl - Hardcoding callback URLs — Constructing callback URLs manually instead of using the one from the request, stripping the parameter. The Gateway rejects the callback and the payment stays stuck in
X-VTEX-signature.undefined - No retry logic for failed callbacks — Calling the once and silently dropping the notification on failure. The Gateway never learns the payment was approved, and the payment sits in
callbackUrluntil the next retry or is auto-cancelled.undefined - Returning stale status on retries — Always returning the original response without checking if the status was updated via callback. The Gateway never sees the
undefinedstatus and eventually cancels the payment.approved - Misaligned — Using 7 days for Pix, leaving expired QR codes with orders stuck in "Authorizing". Using arbitrary values for Boleto that do not match invoice due dates.
delayToCancel
- 异步支付同步返回通过 — 仅因为Pix二维码或Boleto单据生成成功就返回。生成支付凭证不等于收到付款,会导致商家未收款就发货。
status: "approved" - 忽略callbackUrl — 不存储创建支付请求中的,完全依赖网关的自动重试。重试间隔会随时间拉长,导致支付完成后订单审批延迟很久。最坏情况:7天窗口过期,即使客户已经付款,支付也会被取消。
callbackUrl - 硬编码回调地址 — 手动拼接回调地址而不使用请求中提供的地址,删除参数。网关会拒绝回调请求,支付永远卡在
X-VTEX-signature状态。undefined - 回调失败无重试逻辑 — 仅调用一次,失败后无后续处理。网关永远不会知道支付已完成,支付会停留在
callbackUrl状态直到下次重试或被自动取消。undefined - 重试时返回过期状态 — 不检查回调是否更新了状态,始终返回最初的响应。网关永远收不到
undefined状态,最终会取消支付。approved - delayToCancel配置不当 — 为Pix设置7天有效期,导致二维码过期后订单仍卡在“授权中”。为Boleto设置的有效期与实际账单到期日不匹配。
Review checklist
审核 checklist
- Do async payment methods (Boleto, Pix) return in Create Payment?
status: "undefined" - Is the stored exactly as received from the request (including all query params)?
callbackUrl - Does the webhook handler update local state before calling the ?
callbackUrl - Is preserved in the
X-VTEX-signaturewhen calling it?callbackUrl - Are and
X-VTEX-API-AppKeyheaders included in notification callbacks (non-VTEX IO)?X-VTEX-API-AppToken - Is there retry logic with exponential backoff for failed callback calls?
- Does the Create Payment handler check for an existing , avoid calling the acquirer again for retries, and return a response derived from the current stored state (status may evolve from
paymentIdto"undefined"/"approved"after callback)?"denied" - For Pix, is between 900 and 3600 seconds (15–60 minutes), aligned with QR code validity?
delayToCancel - For BankInvoice (Boleto), does reflect the real payment deadline / due date configured in the provider?
delayToCancel - For other async methods, is aligned with the provider's documented expiry SLA (and never greater than the actual payment validity)?
delayToCancel
- 异步支付方式(Boleto、Pix)在创建支付时返回吗?
status: "undefined" - 是否完全按照请求中接收的内容存储(包括所有查询参数)?
callbackUrl - webhook处理逻辑是否在调用之前先更新本地状态?
callbackUrl - 调用时是否保留了
callbackUrl参数?X-VTEX-signature - 通知型回调(非VTEX IO)是否携带了与
X-VTEX-API-AppKey请求头?X-VTEX-API-AppToken - 回调失败是否有指数退避的重试逻辑?
- 创建支付处理逻辑是否会检查是否已存在相同、重试时不重复调用收单机构接口、基于存储的最新状态返回响应(状态可在回调后从
paymentId更新为"undefined"/"approved")?"denied" - Pix的是否在900到3600秒之间(15–60分钟),与二维码有效期对齐?
delayToCancel - BankInvoice(Boleto)的是否与供应商配置的实际支付截止日/到期日对齐?
delayToCancel - 其他异步支付方式的是否与供应商文档公布的过期SLA对齐(且永远不超过支付的实际有效期)?
delayToCancel
Related skills
相关指南
- — Endpoint contracts and response shapes
payment-provider-protocol - —
payment-idempotency/paymentIdidempotency and state machinerequestId - — PCI compliance and Secure Proxy
payment-pci-security
- — 端点约定与响应格式
payment-provider-protocol - —
payment-idempotency/paymentId幂等性与状态机requestId - — PCI合规与安全代理
payment-pci-security
Reference
参考资料
- Payment Provider Protocol (Help Center) — Detailed explanation of the status, callback URL notification and retry flows, and the 7-day retry window
undefined - Purchase Flows — Authorization flow documentation including async retry mechanics and callback URL behavior for VTEX IO vs non-VTEX IO
- Implementing a Payment Provider — Endpoint-level implementation guide with callbackUrl and returnUrl usage
- Pix: Instant Payments in Brazil — Pix-specific async flow implementation including QR code generation and callback handling
- Callback URL Signature Authentication — Mandatory X-VTEX-signature requirement for callback URL authentication (effective June 2024)
- Payment Provider Protocol API Reference — Full API specification with callbackUrl field documentation
- 支付提供商协议(帮助中心) — 详细解释状态、回调URL通知与重试流程、7天重试窗口
undefined - 购买流程 — 授权流程文档,包含异步重试机制、VTEX IO与非VTEX IO环境下的回调URL行为差异
- 实现支付提供商 — 端点级实现指南,包含callbackUrl与returnUrl的使用方法
- Pix:巴西即时支付 — Pix专属异步流程实现指南,包含二维码生成与回调处理
- 回调URL签名鉴权 — callbackURL鉴权强制要求X-VTEX-signature(2024年6月生效)
- 支付提供商协议API参考 — 完整API规范,包含callbackUrl字段说明