Loading...
Loading...
Apply when implementing asynchronous payment methods (Boleto, Pix, bank redirects) or working with callback URLs in payment connector code. Covers undefined status response, callbackUrl notification, X-VTEX-signature validation, sync vs async handling, and the 7-day retry window. Use for any payment flow where authorization does not complete synchronously.
npx skill4agent add vtexdocs/ai-skills payment-async-flowundefinedcallbackUrlundefinedcallbackUrlX-VTEX-signatureundefinedstatus: "undefined"approveddeniedcallbackUrlcallbackUrlX-VTEX-API-AppKeyX-VTEX-API-AppTokencallbackUrl/paymentsX-VTEX-signatureundefinedpaymentIdundefinedcallbackUrlX-VTEX-signature1. 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/denied1. 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")undefinedstatus: "undefined""approved""denied""approved""denied"status: "approved"status: "denied""undefined"async function createPaymentHandler(req: Request, res: Response): Promise<void> {
const { paymentId, paymentMethod, callbackUrl } = req.body;
const asyncMethods = ["BankInvoice", "Pix"];
const isAsync = asyncMethods.includes(paymentMethod);
if (isAsync) {
const pending = await acquirer.initiateAsyncPayment(req.body);
// Store 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
});
}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,
});
}callbackUrlX-VTEX-signaturecallbackUrlX-VTEX-signatureundefinedcallbackUrlcallbackUrlasync 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: 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();
}paymentIdundefinedundefinedpaymentIdasync 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
// ...
}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;
}
// ...
}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);
}undefinedcallbackUrlasync 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);
}// 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 });
}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);status: "approved""approved""undefined"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
});
}callbackUrlcallbackUrl// 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" }),
});
}
}callbackUrlundefinedasync 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.");
}undefined