payment-async-flow

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Asynchronous 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
    callbackUrl
    notification or retry flows
  • Managing the Gateway's 7-day automatic retry cycle for
    undefined
    status payments
Do not use this skill for:
  • PPP endpoint contracts and response shapes — use
    payment-provider-protocol
  • paymentId
    /
    requestId
    idempotency and state machine logic — use
    payment-idempotency
  • PCI compliance and Secure Proxy card handling — use
    payment-pci-security
在以下场景使用本指南:
  • 实现支持Boleto Bancário、Pix、银行转账或跳转式流程的支付连接器
  • 对接收单机构无法同步返回最终状态的任意支付方式
  • 处理
    callbackUrl
    通知或重试流程
  • 管理网关针对
    undefined
    状态支付的7天自动重试周期
本指南不适用于以下场景:
  • PPP端点约定与响应格式 — 请参考
    payment-provider-protocol
  • paymentId
    /
    requestId
    幂等性与状态机逻辑 — 请参考
    payment-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 (
    BankInvoice
    ), Pix, bank transfers, redirect-based auth.
  • Common sync methods: credit cards, debit cards with instant authorization.
  • Without VTEX IO: the
    callbackUrl
    is a notification endpoint — POST the updated status with
    X-VTEX-API-AppKey
    /
    X-VTEX-API-AppToken
    headers.
  • With VTEX IO: the
    callbackUrl
    is a retry endpoint — POST to it (no payload) to trigger the Gateway to re-call POST
    /payments
    .
  • Always preserve the
    X-VTEX-signature
    query parameter in the
    callbackUrl
    — never strip or modify it.
  • For asynchronous methods,
    delayToCancel
    MUST reflect the actual validity of the payment method, not the 7‑day internal Gateway retry window:
    • 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(
    BankInvoice
    )、Pix、银行转账、跳转式鉴权。
  • 常见同步支付方式:信用卡、可即时授权的借记卡。
  • 非VTEX IO环境
    callbackUrl
    是通知端点 — 携带
    X-VTEX-API-AppKey
    /
    X-VTEX-API-AppToken
    请求头POST更新后的状态。
  • VTEX IO环境
    callbackUrl
    是重试端点 — 向其发送无请求体的POST请求,触发网关重新调用POST
    /payments
    接口。
  • 始终保留
    callbackUrl
    中的
    X-VTEX-signature
    查询参数 — 不得删除或修改。
  • 对于异步支付方式,
    delayToCancel
    必须匹配支付方式的实际有效期,而非网关内部7天的重试窗口:
    • Pix:900到3600秒(15–60分钟),与二维码有效期保持一致。
    • BankInvoice(Boleto):与供应商配置的账单到期日/支付截止日期保持一致。
    • 其他异步支付方式:与供应商文档公布的过期SLA保持一致。

Hard constraints

硬性约束

Constraint: MUST return
undefined
for async payment methods

约束:异步支付方式必须返回
undefined

For any payment method where authorization does not complete synchronously (Boleto, Pix, bank transfer, redirect-based auth), the Create Payment response MUST use
status: "undefined"
. The connector MUST NOT return
"approved"
or
"denied"
until the payment is actually confirmed or rejected by the acquirer.
Why this matters Returning
"approved"
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
"denied"
prematurely cancels a payment that might still be completed.
Detection If the Create Payment handler returns
status: "approved"
or
status: "denied"
for an asynchronous payment method (Boleto, Pix, bank transfer, redirect), STOP. Async methods must return
"undefined"
and resolve via callback.
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"
设计原因 为未确认的支付返回
"approved"
会告知网关资金已到账,订单会立即进入发货流程。如果客户最终未完成支付(比如从未扫描Pix二维码),商家就会出现货发了但没收到钱的损失。过早返回
"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
callbackUrl
provided in the Create Payment request body, including all query parameters (
X-VTEX-signature
, etc.). The connector MUST NOT hardcode callback URLs or construct them manually.
Why this matters The
callbackUrl
contains transaction-specific authentication tokens (
X-VTEX-signature
) that the Gateway uses to validate the callback. A hardcoded or modified URL will be rejected by the Gateway, leaving the payment stuck in
undefined
status forever. The URL format may also change between environments (production vs sandbox).
Detection If the connector hardcodes a callback URL string, constructs the URL manually, or strips query parameters from the
callbackUrl
, warn the developer. The
callbackUrl
must be stored and used exactly as received.
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 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();
}
连接器必须使用创建支付请求体中提供的完整
callbackUrl
,包括所有查询参数(
X-VTEX-signature
等)。连接器不得硬编码回调地址,也不得手动拼接地址。
设计原因
callbackUrl
包含交易专属的鉴权令牌(
X-VTEX-signature
),网关会用它验证回调请求的合法性。硬编码或修改过的URL会被网关拒绝,导致支付永远卡在
undefined
状态。同时URL格式在不同环境(生产/沙箱)下也可能发生变化。
检测规则 如果连接器硬编码了回调地址、手动拼接URL,或者删除了
callbackUrl
的查询参数,请警告开发人员。
callbackUrl
必须完全按照接收时的内容存储和使用。
正确示例
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
/payments
) with the same
paymentId
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
paymentId
, and must reflect the current status (
"undefined"
,
"approved"
, or
"denied"
) which may have changed after a callback.
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
"undefined"
to
"approved"
or
"denied"
, but only because your local store was updated by the webhook.
Why this matters The Gateway retries
POST /payments
for
undefined
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
"undefined"
response without checking for an updated status, the Gateway never learns that the payment was approved, and eventually cancels it.
Detection If the Create Payment handler does not check for an existing
paymentId
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.
Correct
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,
  });
}
连接器必须支持网关在重试窗口内使用相同
paymentId
多次调用创建支付接口(POST
/payments
)。每次调用都不得在收单机构侧创建新的订单,必须基于本地存储的
paymentId
状态返回响应,且要返回最新的状态(
"undefined"
"approved"
"denied"
),状态可能已经通过回调更新。
幂等性针对的是收单机构侧的副作用:首次调用创建订单,重试时绝对不能再次调用收单机构接口。对于异步方式,响应状态可以从
"undefined"
合法更新为
"approved"
"denied"
,但前提是本地存储的状态已经被webhook更新过。
设计原因 网关会自动重试
undefined
状态支付的
POST /payments
请求,最长可达7天。如果连接器把每次调用都当成新支付,会在收单机构侧生成重复订单。如果连接器不检查最新状态,始终返回最初的
"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必须与支付有效期对齐(不要固定为7天)

For asynchronous methods, the
delayToCancel
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.
Rules:
  • Pix:
    delayToCancel
    MUST be between 900 and 3600 seconds (15–60 minutes). This value MUST match the QR code validity configured on the provider.
  • BankInvoice (Boleto):
    delayToCancel
    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.
  • Other async methods:
    delayToCancel
    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.
The 7‑day window is an internal Gateway safety limit for retries on
undefined
status. It does not mean every async method should use
delayToCancel = 604800
.
Why this matters For Pix, using a multi‑day
delayToCancel
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
delayToCancel
breaks the consistency between the provider's notion of a valid payment and when VTEX auto‑cancels the payment.
Detection If the connector always uses
delayToCancel = 604800
for any async method, or sets
delayToCancel
greater than the Pix or Boleto validity window, the agent MUST warn that
delayToCancel
is misconfigured.
Correct
(See the
computeDelayToCancel
function in the "MUST return undefined" example above.)
Wrong
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:
    delayToCancel
    必须在900到3600秒之间(15–60分钟),必须与供应商配置的二维码有效期一致。
  • BankInvoice(Boleto):
    delayToCancel
    必须根据配置的到期日/支付截止日期计算(比如距离账单到期日的秒数)。不得为了“匹配”网关内部的重试窗口就硬编码为7天。
  • 其他异步支付方式:
    delayToCancel
    必须遵循供应商定义的过期SLA(根据实际情况可为几小时或几天),绝对不能超过供应商侧支付的实际有效期。
7天窗口是网关针对
undefined
状态重试的内部安全上限,不代表所有异步方式都要设置
delayToCancel = 604800
设计原因 对于Pix,设置多天的
delayToCancel
会让订单卡在“授权中”状态,而二维码早已过期,会造成极差的用户体验和运营负担。对于Boleto,早于实际到期日取消会损失销量,晚于到期日取消则会带来对账风险和“僵尸”订单。
delayToCancel
配置不当会打破供应商侧有效支付状态与VTEX自动取消时机的一致性。
检测规则 如果连接器为任意异步支付方式统一设置
delayToCancel = 604800
,或者设置的
delayToCancel
超过了Pix或Boleto的有效期,必须警告
delayToCancel
配置错误。
正确示例
(参考上文“必须返回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/denied
Data 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/denied
VTEX 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
    status: "approved"
    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.
  • Ignoring the callbackUrl — Not storing the
    callbackUrl
    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.
  • Hardcoding callback URLs — Constructing callback URLs manually instead of using the one from the request, stripping the
    X-VTEX-signature
    parameter. The Gateway rejects the callback and the payment stays stuck in
    undefined
    .
  • No retry logic for failed callbacks — Calling the
    callbackUrl
    once and silently dropping the notification on failure. The Gateway never learns the payment was approved, and the payment sits in
    undefined
    until the next retry or is auto-cancelled.
  • Returning stale status on retries — Always returning the original
    undefined
    response without checking if the status was updated via callback. The Gateway never sees the
    approved
    status and eventually cancels the payment.
  • Misaligned
    delayToCancel
    — 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.
  • 异步支付同步返回通过 — 仅因为Pix二维码或Boleto单据生成成功就返回
    status: "approved"
    。生成支付凭证不等于收到付款,会导致商家未收款就发货。
  • 忽略callbackUrl — 不存储创建支付请求中的
    callbackUrl
    ,完全依赖网关的自动重试。重试间隔会随时间拉长,导致支付完成后订单审批延迟很久。最坏情况:7天窗口过期,即使客户已经付款,支付也会被取消。
  • 硬编码回调地址 — 手动拼接回调地址而不使用请求中提供的地址,删除
    X-VTEX-signature
    参数。网关会拒绝回调请求,支付永远卡在
    undefined
    状态。
  • 回调失败无重试逻辑 — 仅调用一次
    callbackUrl
    ,失败后无后续处理。网关永远不会知道支付已完成,支付会停留在
    undefined
    状态直到下次重试或被自动取消。
  • 重试时返回过期状态 — 不检查回调是否更新了状态,始终返回最初的
    undefined
    响应。网关永远收不到
    approved
    状态,最终会取消支付。
  • delayToCancel配置不当 — 为Pix设置7天有效期,导致二维码过期后订单仍卡在“授权中”。为Boleto设置的有效期与实际账单到期日不匹配。

Review checklist

审核 checklist

  • Do async payment methods (Boleto, Pix) return
    status: "undefined"
    in Create Payment?
  • Is the
    callbackUrl
    stored exactly as received from the request (including all query params)?
  • Does the webhook handler update local state before calling the
    callbackUrl
    ?
  • Is
    X-VTEX-signature
    preserved in the
    callbackUrl
    when calling it?
  • Are
    X-VTEX-API-AppKey
    and
    X-VTEX-API-AppToken
    headers included in notification callbacks (non-VTEX IO)?
  • Is there retry logic with exponential backoff for failed callback calls?
  • Does the Create Payment handler check for an existing
    paymentId
    , avoid calling the acquirer again for retries, and return a response derived from the current stored state (status may evolve from
    "undefined"
    to
    "approved"
    /
    "denied"
    after callback)?
  • For Pix, is
    delayToCancel
    between 900 and 3600 seconds (15–60 minutes), aligned with QR code validity?
  • For BankInvoice (Boleto), does
    delayToCancel
    reflect the real payment deadline / due date configured in the provider?
  • For other async methods, is
    delayToCancel
    aligned with the provider's documented expiry SLA (and never greater than the actual payment validity)?
  • 异步支付方式(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的
    delayToCancel
    是否在900到3600秒之间(15–60分钟),与二维码有效期对齐?
  • BankInvoice(Boleto)的
    delayToCancel
    是否与供应商配置的实际支付截止日/到期日对齐?
  • 其他异步支付方式的
    delayToCancel
    是否与供应商文档公布的过期SLA对齐(且永远不超过支付的实际有效期)?

Related skills

相关指南

  • payment-provider-protocol
    — Endpoint contracts and response shapes
  • payment-idempotency
    paymentId
    /
    requestId
    idempotency and state machine
  • payment-pci-security
    — PCI compliance and Secure Proxy
  • payment-provider-protocol
    — 端点约定与响应格式
  • payment-idempotency
    paymentId
    /
    requestId
    幂等性与状态机
  • payment-pci-security
    — PCI合规与安全代理

Reference

参考资料