payment-async-flow
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAsynchronous Payment Flows & Callbacks
异步支付流程与回调
Overview
概述
What this skill covers: The complete asynchronous payment authorization flow in the VTEX Payment Provider Protocol. This includes returning status for pending payments, using the to notify the Gateway of final status, handling the difference between notification callbacks (non-VTEX IO) and retry callbacks (VTEX IO), and managing the 7-day retry window.
undefinedcallbackUrlWhen to use it: When implementing a payment connector that supports any asynchronous payment method — Boleto Bancário, Pix, bank transfers, redirect-based flows, or any method where the acquirer does not return a final status synchronously.
What you'll learn:
- When and how to return status from Create Payment
undefined - How the notification and retry flows work
callbackUrl - How to validate on callback URLs
X-VTEX-signature - How to handle the Gateway's automatic 7-day retry cycle
本技能涵盖内容:VTEX支付提供商协议中的完整异步支付授权流程,包括为待处理支付返回状态、使用通知网关最终状态、处理通知回调(非VTEX IO)与重试回调(VTEX IO)的差异,以及管理7天重试窗口。
undefinedcallbackUrl适用场景:当实现支持任意异步支付方式的支付连接器时——包括Boleto Bancário、Pix、银行转账、基于重定向的流程,或任何收单机构无法同步返回最终状态的支付方式。
你将学到:
- 何时以及如何在创建支付接口中返回状态
undefined - 通知与重试流程的工作机制
callbackUrl - 如何在回调URL上验证
X-VTEX-signature - 如何处理网关的自动7天重试周期
Key Concepts
核心概念
Essential knowledge before implementation:
实现前需掌握的关键知识:
Concept 1: The undefined
Status
undefined概念1:undefined
状态
undefinedWhen a payment cannot be resolved immediately (the acquirer needs time, the customer must complete an action), the connector returns in the Create Payment response. This tells the Gateway the payment is pending — not failed, not approved. The Gateway will then wait and retry until a final status ( or ) is received.
status: "undefined"approveddenied当支付无法立即完成(收单机构需要时间处理,或客户需完成操作)时,连接器需在创建支付的响应中返回。这会告知网关该支付处于待处理状态——既未失败也未获批。随后网关将等待并重试,直到收到最终状态(或)。
status: "undefined"approveddeniedConcept 2: Callback URL — Two Flows
概念2:Callback URL——两种流程
The is provided in the Create Payment request body. Its behavior depends on the hosting model:
callbackUrl- Without VTEX IO (partner infrastructure): The is a notification endpoint. The provider POSTs the updated status directly to this URL with
callbackUrlandX-VTEX-API-AppKeyheaders.X-VTEX-API-AppToken - With VTEX IO: The is a retry endpoint. The provider calls this URL (no payload required) to trigger the Gateway to re-call the Create Payment (
callbackUrl) endpoint. The Gateway then receives the updated status from the provider's response./payments
Both flows require the query parameter to be preserved when calling the callback URL.
X-VTEX-signaturecallbackUrl- 非VTEX IO环境(合作方基础设施):是一个通知端点。提供商直接向该URL POST更新后的状态,并携带
callbackUrl和X-VTEX-API-AppKey请求头。X-VTEX-API-AppToken - VTEX IO环境:是一个重试端点。提供商调用该URL(无需携带请求体),触发网关重新调用创建支付(
callbackUrl)接口,网关随后从提供商的响应中获取更新后的状态。/payments
两种流程都要求在调用callback URL时保留查询参数。
X-VTEX-signatureConcept 3: The 7-Day Retry Window
概念3:7天重试窗口
If a payment remains in status, the VTEX Gateway automatically retries the Create Payment endpoint periodically for up to 7 days. During this window, the connector must be prepared to receive repeated Create Payment calls with the same . When the payment is finally resolved, the connector returns the final status. If still after 7 days, the payment is automatically cancelled.
undefinedpaymentIdundefined如果支付始终处于状态,VTEX网关会在最多7天内定期重试创建支付接口。在此期间,连接器必须能够处理网关使用同一重复调用创建支付接口的情况。当支付最终完成时,连接器需返回最终状态。若7天后仍为,支付将被自动取消。
undefinedpaymentIdundefinedConcept 4: X-VTEX-signature
概念4:X-VTEX-signature
The includes a query parameter . This is a mandatory authentication token that identifies the transaction. When calling the callback URL, the provider must use the URL exactly as received (including all query parameters) to ensure the Gateway can authenticate the callback.
callbackUrlX-VTEX-signatureArchitecture/Data Flow (Non-VTEX IO):
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/deniedArchitecture/Data Flow (VTEX IO):
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")callbackUrlX-VTEX-signature架构/数据流(非VTEX IO):
text
1. 网关 → POST /payments → 连接器(返回status: "undefined")
2. 收单机构Webhook → 连接器(支付已确认)
3. 连接器 → POST callbackUrl(携带X-VTEX-API-AppKey和X-VTEX-API-AppToken请求头)
4. 网关更新支付状态为approved/denied架构/数据流(VTEX IO):
text
1. 网关 → POST /payments → 连接器(返回status: "undefined")
2. 收单机构Webhook → 连接器(支付已确认)
3. 连接器 → POST callbackUrl(重试,无需请求体)
4. 网关 → POST /payments → 连接器(返回status: "approved"/"denied")Constraints
约束条件
Rules that MUST be followed to avoid failures, security issues, or platform incompatibilities.
为避免失败、安全问题或平台兼容性问题必须遵守的规则:
Constraint: MUST Return undefined
for Async Payment Methods
undefined约束1:异步支付方式必须返回undefined
undefinedRule: For 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: 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 by the customer.
"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 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: 604800, // 7 days for async
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
// ... other fields
});
}❌ 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: 604800, // 异步支付的7天期限
paymentUrl: pending.qrCodeUrl ?? pending.boletoUrl ?? undefined,
});
return;
}
// 同步方式(信用卡)可返回最终状态
const result = await acquirer.authorizeSyncPayment(req.body);
res.status(200).json({
paymentId,
status: result.status, // 同步方式返回"approved"或"denied"是合法的
// ... 其他字段
});
}❌ 错误示例:
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
约束2:必须使用请求中的callbackUrl——禁止硬编码
Rule: 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: 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.
callbackUrlcallbackUrl✅ CORRECT:
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 undefined response
}
// 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; }
// Update local state
await store.updateStatus(payment.paymentId, status === "paid" ? "approved" : "denied");
// 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: status === "paid" ? "approved" : "denied",
}),
});
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();
}规则:连接器必须完全使用创建支付请求体中提供的,包括所有查询参数(如等)。连接器不得硬编码callback URL或手动构造该URL。
callbackUrlX-VTEX-signature原因:包含交易专属的认证令牌(),网关会用它来验证回调的合法性。硬编码或修改后的URL会被网关拒绝,导致支付永远卡在状态。此外,URL格式可能会因环境(生产 vs 沙箱)而有所不同。
callbackUrlX-VTEX-signatureundefined检测方式:如果连接器硬编码callback URL字符串、手动构造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响应
}
// 当收单机构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; }
// 更新本地状态
await store.updateStatus(payment.paymentId, status === "paid" ? "approved" : "denied");
// 使用存储的完整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: status === "paid" ? "approved" : "denied",
}),
});
res.status(200).send();
}❌ 错误示例:
typescript
// 错误:硬编码callback URL——忽略了X-VTEX-signature和环境差异
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
const { paymentReference, status } = req.body;
const payment = await store.findByAcquirerRef(paymentReference);
// 错误——硬编码URL,缺少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
约束3:必须准备好处理重复的创建支付调用
Rule: The connector MUST handle the Gateway calling Create Payment with the same multiple times during the 7-day retry window. Each call must return the current payment status (which may have been updated via callback since the last call).
paymentIdWhy: The Gateway retries payments automatically. If the connector treats each call as a new payment, it will create duplicate charges. If the connector always returns the original status without checking for updates, the Gateway never learns that the payment was approved, and eventually cancels it.
undefinedundefinedDetection: If the Create Payment handler does not check for an existing and return the latest status, STOP. The handler must support idempotent retries that reflect the current state.
paymentId✅ CORRECT:
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId } = req.body;
// Check for existing payment — may have been updated via callback
const existing = await store.findByPaymentId(paymentId);
if (existing) {
// Return current status — may have changed from "undefined" to "approved"
res.status(200).json({
...existing.response,
status: existing.status, // Reflects the latest state
});
return;
}
// First time — create new payment
// ...
}❌ WRONG:
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId } = req.body;
// WRONG: Always returns the original cached response without checking
// if the status has been updated. Gateway never sees "approved".
const existing = await store.findByPaymentId(paymentId);
if (existing) {
// Always returns the stale "undefined" response — never updates
res.status(200).json(existing.originalResponse);
return;
}
// ...
}规则:在7天重试窗口内,连接器必须能够处理网关使用同一多次调用创建支付接口的情况。每次调用都应返回当前的支付状态(自上次调用后可能已通过回调更新)。
paymentId原因:网关会自动重试处于状态的支付。如果连接器将每次调用都视为新支付,会创建重复扣款。如果连接器始终返回原始的状态而不检查更新,网关将永远无法获知支付已获批,最终会取消该支付。
undefinedundefined检测方式:如果创建支付处理程序不检查是否存在已有的并返回最新状态,请立即停止操作。处理程序必须支持反映当前状态的幂等重试。
paymentId✅ 正确示例:
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId } = req.body;
// 检查是否存在已有支付——可能已通过回调更新状态
const existing = await store.findByPaymentId(paymentId);
if (existing) {
// 返回当前状态——可能已从"undefined"变为"approved"
res.status(200).json({
...existing.response,
status: existing.status, // 反映最新状态
});
return;
}
// 首次调用——创建新支付
// ...
}❌ 错误示例:
typescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId } = req.body;
// 错误:始终返回原始缓存的响应,不检查状态是否已更新。网关永远无法看到"approved"状态。
const existing = await store.findByPaymentId(paymentId);
if (existing) {
// 始终返回过时的"undefined"响应——从不更新
res.status(200).json(existing.originalResponse);
return;
}
// ...
}Implementation Pattern
实现模式
The canonical, recommended way to implement this feature or pattern.
实现该功能或模式的标准推荐方式:
Step 1: Classify Payment Methods as Sync or Async
步骤1:将支付方法分类为同步或异步
Determine which payment methods require async handling at the start of the Create Payment flow.
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);
}在创建支付流程开始时,确定哪些支付方法需要异步处理。
typescript
const ASYNC_PAYMENT_METHODS = new Set([
"BankInvoice", // Boleto Bancário
"Pix", // Pix即时支付
]);
function isAsyncPaymentMethod(paymentMethod: string): boolean {
return ASYNC_PAYMENT_METHODS.has(paymentMethod);
}Step 2: Implement Async Create Payment with callbackUrl Storage
步骤2:实现带callbackUrl存储的异步创建支付逻辑
Return and store the for later use.
undefinedcallbackUrltypescript
async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod, callbackUrl } = req.body;
// Idempotency check — return latest status if payment exists
const existing = await store.findByPaymentId(paymentId);
if (existing) {
res.status(200).json({
...existing.response,
status: existing.status,
});
return;
}
if (isAsyncPaymentMethod(paymentMethod)) {
const pending = await acquirer.initiateAsync(req.body);
const response = {
paymentId,
status: "undefined" as const,
authorizationId: pending.authorizationId ?? null,
nsu: pending.nsu ?? null,
tid: pending.tid ?? null,
acquirer: "MyProvider",
code: "ASYNC-PENDING",
message: "Awaiting payment confirmation",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 604800,
paymentUrl: pending.paymentUrl ?? undefined,
};
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl,
acquirerRef: pending.reference,
response,
});
res.status(200).json(response);
return;
}
// Sync flow — process and return final status
const result = await acquirer.authorizeSync(req.body);
const response = {
paymentId,
status: result.approved ? "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,
};
await store.save(paymentId, {
paymentId,
status: response.status,
response,
});
res.status(200).json(response);
}返回并存储以备后用。
undefinedcallbackUrltypescript
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,
});
return;
}
if (isAsyncPaymentMethod(paymentMethod)) {
const pending = await acquirer.initiateAsync(req.body);
const response = {
paymentId,
status: "undefined" as const,
authorizationId: pending.authorizationId ?? null,
nsu: pending.nsu ?? null,
tid: pending.tid ?? null,
acquirer: "MyProvider",
code: "ASYNC-PENDING",
message: "等待支付确认",
delayToAutoSettle: 21600,
delayToAutoSettleAfterAntifraud: 1800,
delayToCancel: 604800,
paymentUrl: pending.paymentUrl ?? undefined,
};
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl,
acquirerRef: pending.reference,
response,
});
res.status(200).json(response);
return;
}
// 同步流程——处理并返回最终状态
const result = await acquirer.authorizeSync(req.body);
const response = {
paymentId,
status: result.approved ? "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,
};
await store.save(paymentId, {
paymentId,
status: response.status,
response,
});
res.status(200).json(response);
}Step 3: Implement the Acquirer Webhook Handler with Callback Notification
步骤3:实现收单机构Webhook处理程序与回调通知逻辑
When the acquirer confirms the payment, update local state and notify the Gateway.
typescript
// Non-VTEX IO: Use notification callback
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
const webhookData = req.body;
const acquirerRef = webhookData.transactionId;
const acquirerStatus = webhookData.status; // "paid", "expired", "failed"
const payment = await store.findByAcquirerRef(acquirerRef);
if (!payment || !payment.callbackUrl) {
res.status(404).json({ error: "Payment not found" });
return;
}
// Map acquirer status to PPP status
const pppStatus = acquirerStatus === "paid" ? "approved" : "denied";
// Update local state FIRST
await store.updateStatus(payment.paymentId, pppStatus);
// Notify the Gateway via callbackUrl — use it exactly as stored
try {
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,
}),
});
} catch (error) {
// Log the error but don't fail — the Gateway will also retry via /payments
console.error(`Callback failed for ${payment.paymentId}:`, error);
// The Gateway's 7-day retry on /payments acts as a safety net
}
res.status(200).json({ received: true });
}
// For VTEX IO: Use retry callback (no payload needed)
async function handleAcquirerWebhookVtexIO(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";
await store.updateStatus(payment.paymentId, pppStatus);
// VTEX IO: Just call the retry URL — Gateway will re-call POST /payments
try {
await fetch(payment.callbackUrl, { method: "POST" });
} catch (error) {
console.error(`Retry callback failed for ${payment.paymentId}:`, error);
}
res.status(200).json({ received: true });
}当收单机构确认支付后,更新本地状态并通知网关。
typescript
// 非VTEX IO环境:使用通知回调
async function handleAcquirerWebhook(req: Request, res: Response): Promise<void> {
const webhookData = req.body;
const acquirerRef = webhookData.transactionId;
const acquirerStatus = webhookData.status; // "paid", "expired", "failed"
const payment = await store.findByAcquirerRef(acquirerRef);
if (!payment || !payment.callbackUrl) {
res.status(404).json({ error: "未找到支付记录" });
return;
}
// 将收单机构状态映射为PPP状态
const pppStatus = acquirerStatus === "paid" ? "approved" : "denied";
// 先更新本地状态
await store.updateStatus(payment.paymentId, pppStatus);
// 通过callbackUrl通知网关——完全使用存储的URL
try {
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,
}),
});
} catch (error) {
// 记录错误但不返回失败——网关也会通过/payments接口重试
console.error(`支付${payment.paymentId}的回调失败:`, error);
// 网关在/payments接口上的7天重试机制可作为安全保障
}
res.status(200).json({ received: true });
}
// VTEX IO环境:使用重试回调(无需请求体)
async function handleAcquirerWebhookVtexIO(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);
// VTEX IO环境:只需调用重试URL——网关会重新调用POST /payments接口
try {
await fetch(payment.callbackUrl, { method: "POST" });
} catch (error) {
console.error(`支付${payment.paymentId}的重试回调失败:`, error);
}
res.status(200).json({ received: true });
}Complete Example
完整示例
Full async payment flow with webhook and callback:
typescript
import express, { Request, Response } from "express";
const app = express();
app.use(express.json());
const ASYNC_METHODS = new Set(["BankInvoice", "Pix"]);
// Create Payment — supports both sync and async methods
app.post("/payments", async (req: Request, res: Response) => {
const { paymentId, paymentMethod, callbackUrl } = req.body;
const existing = await store.findByPaymentId(paymentId);
if (existing) {
res.status(200).json({ ...existing.response, status: existing.status });
return;
}
if (ASYNC_METHODS.has(paymentMethod)) {
const pending = await acquirer.initiateAsync(req.body);
const response = buildAsyncResponse(paymentId, pending);
await store.save(paymentId, {
paymentId, status: "undefined", callbackUrl,
acquirerRef: pending.reference, response,
});
res.status(200).json(response);
} else {
const result = await acquirer.authorizeSync(req.body);
const response = buildSyncResponse(paymentId, result);
await store.save(paymentId, { paymentId, status: response.status, response });
res.status(200).json(response);
}
});
// Acquirer Webhook — receives payment confirmation, notifies Gateway
app.post("/webhooks/acquirer", async (req: Request, res: Response) => {
const { transactionId, status: acquirerStatus } = req.body;
const payment = await store.findByAcquirerRef(transactionId);
if (!payment) { res.status(404).send(); return; }
const pppStatus = acquirerStatus === "paid" ? "approved" : "denied";
await store.updateStatus(payment.paymentId, pppStatus);
if (payment.callbackUrl) {
try {
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 }),
});
} catch (err) {
console.error("Callback failed, Gateway will retry via /payments", err);
}
}
res.status(200).send();
});
app.listen(443);包含Webhook和回调的完整异步支付流程:
typescript
import express, { Request, Response } from "express";
const app = express();
app.use(express.json());
const ASYNC_METHODS = new Set(["BankInvoice", "Pix"]);
// 创建支付——支持同步和异步方式
app.post("/payments", async (req: Request, res: Response) => {
const { paymentId, paymentMethod, callbackUrl } = req.body;
const existing = await store.findByPaymentId(paymentId);
if (existing) {
res.status(200).json({ ...existing.response, status: existing.status });
return;
}
if (ASYNC_METHODS.has(paymentMethod)) {
const pending = await acquirer.initiateAsync(req.body);
const response = buildAsyncResponse(paymentId, pending);
await store.save(paymentId, {
paymentId, status: "undefined", callbackUrl,
acquirerRef: pending.reference, response,
});
res.status(200).json(response);
} else {
const result = await acquirer.authorizeSync(req.body);
const response = buildSyncResponse(paymentId, result);
await store.save(paymentId, { paymentId, status: response.status, response });
res.status(200).json(response);
}
});
// 收单机构Webhook——接收支付确认并通知网关
app.post("/webhooks/acquirer", async (req: Request, res: Response) => {
const { transactionId, status: acquirerStatus } = req.body;
const payment = await store.findByAcquirerRef(transactionId);
if (!payment) { res.status(404).send(); return; }
const pppStatus = acquirerStatus === "paid" ? "approved" : "denied";
await store.updateStatus(payment.paymentId, pppStatus);
if (payment.callbackUrl) {
try {
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 }),
});
} catch (err) {
console.error("回调失败,网关将通过/payments接口重试", err);
}
}
res.status(200).send();
});
app.listen(443);Anti-Patterns
反模式
Common mistakes developers make and how to fix them.
开发者常犯的错误及修复方法:
Anti-Pattern: Synchronous Approval of Async Payments
反模式:异步支付的同步获批
What happens: The connector receives a Pix or Boleto Create Payment request and immediately returns because the QR code or slip was generated successfully.
status: "approved"Why it fails: Generating a QR code or Boleto slip is not the same as receiving payment. The customer still needs to scan/pay. Returning triggers order fulfillment before payment is confirmed. The merchant ships products and never receives payment.
"approved"Fix: Always return for async methods and wait for acquirer confirmation:
"undefined"typescript
if (ASYNC_METHODS.has(paymentMethod)) {
const pending = await acquirer.initiateAsync(req.body);
res.status(200).json({
paymentId,
status: "undefined", // Never "approved" for async
// ...
paymentUrl: pending.qrCodeUrl, // Customer scans this to pay
});
}问题表现:连接器收到Pix或Boleto的创建支付请求后,因成功生成二维码或账单,立即返回。
status: "approved"失败原因:生成二维码或Boleto账单不等同于收到款项,客户仍需完成扫码/支付操作。返回会在支付确认前触发订单履约,导致商家发货却未收到款项。
"approved"修复方法:异步方式始终返回,等待收单机构的确认:
"undefined"typescript
if (ASYNC_METHODS.has(paymentMethod)) {
const pending = await acquirer.initiateAsync(req.body);
res.status(200).json({
paymentId,
status: "undefined", // 异步方式绝不返回"approved"
// ...
paymentUrl: pending.qrCodeUrl, // 客户扫码完成支付
});
}Anti-Pattern: Ignoring the callbackUrl
反模式:忽略callbackUrl
What happens: The connector does not store the from the Create Payment request and relies entirely on the Gateway's automatic retries to detect payment completion.
callbackUrlWhy it fails: The Gateway's retry interval increases over time. Without callback notification, there can be a long delay between the customer paying and the order being approved. This creates a poor customer experience and increases support tickets. In worst cases, the 7-day window expires and the payment is cancelled even though the customer paid.
Fix: Always store and use the :
callbackUrltypescript
// Store the callbackUrl when creating the payment
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl: req.body.callbackUrl, // Store this!
acquirerRef: pending.reference,
});
// Use it when the acquirer confirms payment
async function onAcquirerConfirmation(paymentId: string): Promise<void> {
const payment = await store.findByPaymentId(paymentId);
if (payment?.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, status: "approved" }),
});
}
}问题表现:连接器不存储创建支付请求中的,完全依赖网关的自动重试来检测支付完成状态。
callbackUrl失败原因:网关的重试间隔会逐渐变长。如果没有回调通知,客户完成支付到订单获批之间可能存在较长延迟,影响客户体验并增加支持工单数量。最坏情况下,7天窗口过期后,即使客户已支付,支付仍会被自动取消。
修复方法:始终存储并使用:
callbackUrltypescript
// 创建支付时存储callbackUrl
await store.save(paymentId, {
paymentId,
status: "undefined",
callbackUrl: req.body.callbackUrl, // 务必存储!
acquirerRef: pending.reference,
});
// 收单机构确认支付时使用该URL
async function onAcquirerConfirmation(paymentId: string): Promise<void> {
const payment = await store.findByPaymentId(paymentId);
if (payment?.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, status: "approved" }),
});
}
}Anti-Pattern: No Retry Logic for Failed Callbacks
反模式:回调失败无重试逻辑
What happens: The connector calls the once, and if the request fails (network error, timeout, 5xx), it silently drops the notification.
callbackUrlWhy it fails: If the callback fails and the connector doesn't retry, the Gateway never learns the payment was approved. The payment sits in until the Gateway's next retry of Create Payment, which may be hours away. In the worst case, the payment is auto-cancelled after 7 days.
undefinedFix: Implement retry logic with exponential backoff for callback failures:
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.");
}问题表现:连接器调用一次,若请求失败(网络错误、超时、5xx状态码),则静默丢弃通知。
callbackUrl失败原因:如果回调失败且连接器不重试,网关将永远无法获知支付已获批。支付会一直处于状态,直到网关下次重试创建支付接口,这可能需要数小时。最坏情况下,7天后支付会被自动取消。
undefined修复方法:为回调失败实现带指数退避的重试逻辑:
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("所有回调重试均已耗尽,依赖网关重试机制。");
}Reference
参考资料
Links to VTEX documentation and related resources.
- 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
VTEX文档及相关资源链接:
- 支付提供商协议(帮助中心) — 详细解释状态、callback URL通知与重试流程,以及7天重试窗口
undefined - 购买流程 — 授权流文档,包括异步重试机制以及VTEX IO与非VTEX IO环境下的callback URL行为
- 实现支付提供商 — 端点级实现指南,包含callbackUrl和returnUrl的使用方法
- Pix:巴西即时支付 — Pix专属异步流程实现,包括二维码生成与回调处理
- Callback URL签名认证 — callback URL认证的强制X-VTEX-signature要求(2024年6月生效)
- 支付提供商协议API参考 — 完整API规范,包含callbackUrl字段文档