payment-async-flow

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Asynchronous Payment Flows & Callbacks

异步支付流程与回调

Overview

概述

What this skill covers: The complete asynchronous payment authorization flow in the VTEX Payment Provider Protocol. This includes returning
undefined
status for pending payments, using the
callbackUrl
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.
When 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
    undefined
    status from Create Payment
  • How the
    callbackUrl
    notification and retry flows work
  • How to validate
    X-VTEX-signature
    on callback URLs
  • How to handle the Gateway's automatic 7-day retry cycle
本技能涵盖内容:VTEX支付提供商协议中的完整异步支付授权流程,包括为待处理支付返回
undefined
状态、使用
callbackUrl
通知网关最终状态、处理通知回调(非VTEX IO)与重试回调(VTEX IO)的差异,以及管理7天重试窗口。
适用场景:当实现支持任意异步支付方式的支付连接器时——包括Boleto Bancário、Pix、银行转账、基于重定向的流程,或任何收单机构无法同步返回最终状态的支付方式。
你将学到
  • 何时以及如何在创建支付接口中返回
    undefined
    状态
  • callbackUrl
    通知与重试流程的工作机制
  • 如何在回调URL上验证
    X-VTEX-signature
  • 如何处理网关的自动7天重试周期

Key Concepts

核心概念

Essential knowledge before implementation:
实现前需掌握的关键知识

Concept 1: The
undefined
Status

概念1:
undefined
状态

When a payment cannot be resolved immediately (the acquirer needs time, the customer must complete an action), the connector returns
status: "undefined"
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 (
approved
or
denied
) is received.
当支付无法立即完成(收单机构需要时间处理,或客户需完成操作)时,连接器需在创建支付的响应中返回
status: "undefined"
。这会告知网关该支付处于待处理状态——既未失败也未获批。随后网关将等待并重试,直到收到最终状态(
approved
denied
)。

Concept 2: Callback URL — Two Flows

概念2:Callback URL——两种流程

The
callbackUrl
is provided in the Create Payment request body. Its behavior depends on the hosting model:
  • Without VTEX IO (partner infrastructure): The
    callbackUrl
    is a notification endpoint. The provider POSTs the updated status directly to this URL with
    X-VTEX-API-AppKey
    and
    X-VTEX-API-AppToken
    headers.
  • With VTEX IO: The
    callbackUrl
    is a retry endpoint. The provider calls this URL (no payload required) to trigger the Gateway to re-call the Create Payment (
    /payments
    ) endpoint. The Gateway then receives the updated status from the provider's response.
Both flows require the
X-VTEX-signature
query parameter to be preserved when calling the callback URL.
callbackUrl
会在创建支付的请求体中提供,其行为取决于托管模式:
  • 非VTEX IO环境(合作方基础设施):
    callbackUrl
    是一个通知端点。提供商直接向该URL POST更新后的状态,并携带
    X-VTEX-API-AppKey
    X-VTEX-API-AppToken
    请求头。
  • VTEX IO环境
    callbackUrl
    是一个重试端点。提供商调用该URL(无需携带请求体),触发网关重新调用创建支付(
    /payments
    )接口,网关随后从提供商的响应中获取更新后的状态。
两种流程都要求在调用callback URL时保留
X-VTEX-signature
查询参数。

Concept 3: The 7-Day Retry Window

概念3:7天重试窗口

If a payment remains in
undefined
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
paymentId
. When the payment is finally resolved, the connector returns the final status. If still
undefined
after 7 days, the payment is automatically cancelled.
如果支付始终处于
undefined
状态,VTEX网关会在最多7天内定期重试创建支付接口。在此期间,连接器必须能够处理网关使用同一
paymentId
重复调用创建支付接口的情况。当支付最终完成时,连接器需返回最终状态。若7天后仍为
undefined
,支付将被自动取消。

Concept 4: X-VTEX-signature

概念4:X-VTEX-signature

The
callbackUrl
includes a query parameter
X-VTEX-signature
. 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.
Architecture/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/denied
Architecture/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")
callbackUrl
包含一个查询参数
X-VTEX-signature
,这是用于识别交易的强制认证令牌。调用callback URL时,提供商必须完全使用收到的URL(包括所有查询参数),以确保网关能够验证回调的合法性。
架构/数据流(非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

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

Rule: 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: 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 by the customer.
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 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"
原因:为未确认的支付返回
"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: 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
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: 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 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();
}

规则:连接器必须完全使用创建支付请求体中提供的
callbackUrl
,包括所有查询参数(如
X-VTEX-signature
等)。连接器不得硬编码callback URL或手动构造该URL。
原因
callbackUrl
包含交易专属的认证令牌(
X-VTEX-signature
),网关会用它来验证回调的合法性。硬编码或修改后的URL会被网关拒绝,导致支付永远卡在
undefined
状态。此外,URL格式可能会因环境(生产 vs 沙箱)而有所不同。
检测方式:如果连接器硬编码callback 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响应
}

// 当收单机构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
paymentId
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).
Why: The Gateway retries
undefined
payments automatically. If the connector treats each call as a new payment, it will create duplicate charges. If the connector always returns the original
undefined
status without checking for updates, 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
and return the latest status, STOP. The handler must support idempotent retries that reflect the current state.
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
多次调用创建支付接口的情况。每次调用都应返回当前的支付状态(自上次调用后可能已通过回调更新)。
原因:网关会自动重试处于
undefined
状态的支付。如果连接器将每次调用都视为新支付,会创建重复扣款。如果连接器始终返回原始的
undefined
状态而不检查更新,网关将永远无法获知支付已获批,最终会取消该支付。
检测方式:如果创建支付处理程序不检查是否存在已有的
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
undefined
and store the
callbackUrl
for later use.
typescript
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);
}
返回
undefined
并存储
callbackUrl
以备后用。
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,
    });
    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
status: "approved"
because the QR code or slip was generated successfully.
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
"approved"
triggers order fulfillment before payment is confirmed. The merchant ships products and never receives payment.
Fix: Always return
"undefined"
for async methods and wait for acquirer confirmation:
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
callbackUrl
from the Create Payment request and relies entirely on the Gateway's automatic retries to detect payment completion.
Why 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
callbackUrl
:
typescript
// 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天窗口过期后,即使客户已支付,支付仍会被自动取消。
修复方法:始终存储并使用
callbackUrl
typescript
// 创建支付时存储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
callbackUrl
once, and if the request fails (network error, timeout, 5xx), it silently drops the notification.
Why it fails: If the callback fails and the connector doesn't retry, the Gateway never learns the payment was approved. The payment sits in
undefined
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.
Fix: 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.");
}
问题表现:连接器调用
callbackUrl
一次,若请求失败(网络错误、超时、5xx状态码),则静默丢弃通知。
失败原因:如果回调失败且连接器不重试,网关将永远无法获知支付已获批。支付会一直处于
undefined
状态,直到网关下次重试创建支付接口,这可能需要数小时。最坏情况下,7天后支付会被自动取消。
修复方法:为回调失败实现带指数退避的重试逻辑:
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.
VTEX文档及相关资源链接