关于 headless-checkout-proxy
面向无头 VTEX 店铺结账开发的技能,指导购物车、订单表单和下单流程的实现。明确要求 Checkout API 经由 BFF,并将银行卡数据直接发送至 VTEX Payment Gateway。
主要功能
- 01Checkout API 经由 BFF 代理
- 02服务端会话管理 orderFormId
- 03订单表单附件管理
- 04三步下单流程指导
- 05银行卡数据直达支付网关
适用场景
- 01实现购物车增删改操作
- 02附加客户、配送和支付信息
- 03构建订单提交与处理流程
- 04审查结账数据的安全路径
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vtex/ skills --skill headless-checkout-proxy安装后在 Claude Code 中输入/headless-checkout-proxy确认已加载。
详细介绍
Checkout API 代理与 OrderForm 管理
适用场景
为任何无头 VTEX 店面构建购物车和结账功能时,请使用此技能。所有购物车和结账操作都必须通过 BFF。
- 实现购物车创建、商品添加/更新/移除操作
- 向 OrderForm 附加个人资料、配送或支付数据
- 实现 3 步下单流程(下单 → 支付 → 处理)
- 在服务端管理
orderFormId和CheckoutOrderFormOwnershipCookie
以下情况不要使用此技能:
- 常规 BFF 架构和 API 路由(请使用
headless-bff-architecture) - Search API 集成(请使用
headless-intelligent-search) - 缓存策略决策(请使用
headless-caching-strategy)
决策规则
- 所有 Checkout API 调用(
vtexcommercestable.com.br上的/api/checkout/...)都必须通过 BFF 代理。Checkout API 会处理敏感个人数据(个人资料、地址、支付方式选择)。 - **PCI 例外:**只要 Send payments information 调用携带卡数据,就必须从浏览器/应用直接发送至 VTEX Payment Gateway(
POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments)。商家 BFF 不得位于卡数据传输路径中。请参阅下方的专门硬性约束和payment-pci-security。 - 将
orderFormId存储在服务端会话中,绝不要存储在localStorage或sessionStorage中。 - 每次请求都要在 BFF 和 VTEX 之间捕获并转发
CheckoutOrderFormOwnership和checkout.vtex.comCookie。 - 将任何输入转发给 VTEX 之前,必须在服务端进行验证——绝不要直接传递原始
req.body。 - 在 5 分钟窗口内,将 3 步下单流程(下单 → 发送支付信息 → 处理订单)作为一次同步用户交互执行。步骤 1(下单)和步骤 3(网关回调)在 BFF 中运行;根据上述 PCI 例外,步骤 2(向
vtexpayments.com.br发送支付数据)在浏览器中运行。 - 始终存储并复用会话中已有的
orderFormId——仅在不存在orderFormId时创建新购物车。 - 步骤 1(
/place)和步骤 3(/process)之间的orderGroup等中间流程值,必须通过请求正文传递,而不是通过req.session传递。生产环境中的 BFF 通常采用多副本部署;express-session的默认MemoryStore(以及任何按 Pod 缓存)在两个请求落到不同 Pod 时会丢失该值,并返回误导性的“没有待处理订单可处理”400 错误。如果orderFormId/vtexCookies已要求使用共享会话存储(Redis、Memcached、数据库),请参阅下方的硬性约束,了解为何跨步骤传递仍应通过请求正文完成——这样在局部故障、粘性路由失败以及标签页重新加载后的重放/刷新情况下仍能正常工作。
OrderForm 附加端点:
| 附加数据 | 端点 | 用途 |
|---|---|---|
| 商品 | POST .../orderForm/{id}/items | 添加、移除或更新购物车商品 |
| clientProfileData | POST .../orderForm/{id}/attachments/clientProfileData | 客户个人资料信息 |
| shippingData | POST .../orderForm/{id}/attachments/shippingData | 地址和配送选项 |
| paymentData | POST .../orderForm/{id}/attachments/paymentData | 支付方式选择 |
| marketingData | POST .../orderForm/{id}/attachments/marketingData | 优惠券和 UTM 数据 |
硬性约束
约束:所有 Checkout API 操作都必须经过 BFF
客户端代码不得直接向 vtexcommercestable.com.br/api/checkout/... 上的任何 VTEX Checkout API 端点发起 HTTP 请求。所有 Checkout API 操作——购物车创建、商品管理、个人资料更新、配送、支付方式选择(附加 paymentData)、下单(/transaction)和订单处理(/gatewayCallback)——都必须通过 BFF 代理。
此规则仅适用于 Checkout API。出于 PCI 原因,下一条约束中关于在 vtexpayments.com.br 上发送支付信息的调用受另一条约束管辖,并且方向相反(由浏览器直接调用);不要将此规则套用于该端点。
为何重要
Checkout 端点会处理敏感个人数据(电子邮件、地址、电话号码、支付方式选择)。前端直接调用会使请求/响应流程暴露给浏览器开发者工具、扩展程序和 XSS 攻击。此外,BFF 层对于在服务器端管理 VtexIdclientAutCookie 和 CheckoutOrderFormOwnership Cookie、验证输入以及防止购物车被篡改(例如价格篡改)必不可少。
检测
如果你在任何客户端代码(由浏览器执行的 JavaScript、前端源文件)中看到对 vtexcommercestable.com.br/api/checkout/... 的 fetch 或 axios 调用 → 立即停止。所有 Checkout API 调用都必须通过 BFF 端点。
正确
// Frontend — calls BFF endpoint, never VTEX directly
async function addItemToCart(
skuId: string,
quantity: number,
seller: string,
): Promise<OrderForm> {
const response = await fetch("/api/bff/cart/items", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skuId, quantity, seller }),
});
if (!response.ok) {
throw new Error(`Failed to add item: ${response.status}`);
}
return response.json();
}
错误
// Frontend — calls VTEX Checkout API directly (SECURITY VULNERABILITY)
async function addItemToCart(
skuId: string,
quantity: number,
seller: string,
): Promise<OrderForm> {
const orderFormId = localStorage.getItem("orderFormId"); // Also wrong: see next constraint
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}/items`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orderItems: [{ id: skuId, quantity, seller }],
}),
},
);
return response.json();
}
限制:银行卡数据必须从浏览器/应用直接发送到 vtexpayments.com.br,绝不能经过 BFF
发送支付信息调用(POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments?orderId={orderGroup})会在购物者使用信用卡、借记卡或联名卡付款时传输银行卡数据。此调用必须从购物者的浏览器或原生应用发起,并且必须直接发送到 vtexpayments.com.br。涉及银行卡数据时,BFF 绝不能代理此调用,即使进行了脱敏、即使服务器端使用了 appKey/appToken,也即使转发的字段看起来只是“令牌化”字段。
这与前一项限制恰好相反。将其当作普通的“结账”调用,并通过 BFF 路由——有些 Agent 会因过度套用“所有结账请求都经 BFF”的规则而这样做——这违反 PCI DSS,而不是提升安全性。
为何这很重要
运营无头店面的商家很少获得 PCI DSS Level 1 认证。将卡号、持卡人姓名或 CVV 经由商家的 BFF 传输,会使 BFF 及其接触到的所有系统——应用日志、APM/可观测性工具、反向代理、负载均衡器、错误跟踪器——都纳入 PCI 范围。运营会处理银行卡数据的非 PCI 环境违反 PCI DSS 第 3 和第 4 项要求,可能导致银行卡组织处以每月 5,000 美元至超过 100,000 美元的罚款、强制取证调查费用、丧失银行卡处理能力以及法律责任。
浏览器 → vtexpayments.com.br 是符合 PCI 要求的模式:VTEX Payment Gateway 已通过 PCI DSS Level 1 认证,是此流程中唯一获准接收原始银行卡数据的环境。发送支付信息调用使用前一步“下单”设置的购物者会话 Cookie 进行身份验证——此跳转不需要 BFF 提供商家凭据。
检测
如果在 BFF/服务器端代码(server/、bff/、api/、路由处理程序、中间件、边缘函数、lambda)中发现以下任一情况,请立即停止:
- BFF 向
https://*.vtexpayments.com.br/api/pub/transactions/.../payments发起请求, - 某个处理程序接收来自浏览器的
cardNumber、holderName、validationCode、csc、dueDate或完整支付fields,并将其转发到任何 VTEX 端点, - 某个面向“支付”的客户端/
ExternalClient/axios实例将vtexpayments.com.br设为发送支付信息端点的目标。
将该调用移到浏览器/应用中。BFF 应改为在“下单”步骤中返回 transactionId、orderGroup 和 merchantName,以便前端直接向 Payment Gateway 发送支付数据。
即使出现以下情况,这条规则仍然适用:
- 商家拥有具备支付权限的 VTEX
appKey/appToken——拥有凭据并不代表获得 PCI 授权。 - 只转发“令牌化”的银行卡字段——令牌值仍然关联真实银行卡数据,仍属于 PCI 范围。
- BFF 代码在记录日志前会“脱敏”敏感字段——请求在脱敏前仍会经过商家的基础设施。
- BFF 运行在带 TLS 的私有 VPC 中——PCI 范围取决于经过的数据,而非网络配置方式。
正确
async function sendPaymentDataDirect(args: {
account: string;
transactionId: string;
orderGroup: string;
merchantName: string;
paymentInformation: PaymentField[];
}): Promise<void> {
const {
account,
transactionId,
orderGroup,
merchantName,
paymentInformation,
} = args;
const response = await fetch(
`https://${account}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`,
{
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(
paymentInformation.map((p) => ({
...p,
transaction: { id: transactionId, merchantName },
})),
),
},
);
if (!response.ok) {
throw new Error(`Payment submission failed: ${response.status}`);
}
}
错误
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;
paymentRoutes.post("/", async (req: Request, res: Response) => {
const { transactionId, orderGroup, paymentInformation } = req.body as {
transactionId: string;
orderGroup: string;
paymentInformation: Array<{
paymentSystem: number;
installments: number;
value: number;
fields: {
cardNumber: string;
holderName: string;
validationCode: string;
dueDate: string;
};
}>;
};
const url = `https://${process.env.VTEX_ACCOUNT}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-VTEX-API-AppKey": VTEX_APP_KEY,
"X-VTEX-API-AppToken": VTEX_APP_TOKEN,
},
body: JSON.stringify(paymentInformation),
});
res.json({ status: response.status });
});
约束:orderFormId 必须由服务器端管理
orderFormId 必须存储在安全的服务器端会话中。不应将其存储在 localStorage、sessionStorage 中,也不应以允许直接调用 VTEX API 的方式暴露给前端。
为什么这很重要
orderFormId 是客户购物车及其中所有数据的关键,包括个人资料信息、配送地址和付款详情。如果在客户端暴露,攻击者就能用它直接查询 VTEX 并获取个人数据,或绕过所有验证逻辑,通过直接调用 API 添加或移除商品来操纵购物车。
检测
如果发现 orderFormId 存储在 localStorage 或 sessionStorage 中 → 立即停止。它应由 BFF 会话管理。
正确
// BFF — manages orderFormId in server-side session
import { Router, Request, Response } from "express";
import { vtexCheckoutRequest } from "../vtex-api-client";
export const cartRoutes = Router();
// Get or create cart — orderFormId stays server-side
cartRoutes.get("/", async (req: Request, res: Response) => {
try {
let orderFormId = req.session.orderFormId;
if (orderFormId) {
// Retrieve existing cart
const orderForm = await vtexCheckoutRequest({
path: `/api/checkout/pub/orderForm/${orderFormId}`,
method: "GET",
cookies: req.session.vtexCookies,
});
return res.json(sanitizeOrderForm(orderForm));
}
// Create new cart
const orderForm = await vtexCheckoutRequest({
path: "/api/checkout/pub/orderForm",
method: "GET",
cookies: req.session.vtexCookies,
});
// Store orderFormId in session — never expose raw ID to frontend
req.session.orderFormId = orderForm.orderFormId;
req.session.vtexCookies = orderForm._cookies; // Store checkout cookies
res.json(sanitizeOrderForm(orderForm));
} catch (error) {
console.error("Error getting cart:", error);
res.status(500).json({ error: "Failed to get cart" });
}
});
// Remove sensitive data before sending to frontend
function sanitizeOrderForm(
orderForm: Record<string, unknown>,
): Record<string, unknown> {
const sanitized = { ...orderForm };
delete sanitized._cookies;
return sanitized;
}
错误
// Frontend — stores orderFormId in localStorage (INSECURE)
async function getCart(): Promise<OrderForm> {
let orderFormId = localStorage.getItem("orderFormId"); // EXPOSED to client
if (!orderFormId) {
const response = await fetch(
"https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm",
);
const data = await response.json();
orderFormId = data.orderFormId;
localStorage.setItem("orderFormId", orderFormId!); // Stored client-side!
}
const response = await fetch(
`https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}`,
);
return response.json();
}
约束:多步骤订单流程交接数据必须通过请求正文传递,而不是放在 req.session 中
用于关联 /place(第 1 步)和 /process(第 3 步)的值——最重要的是 orderGroup,以及前端需要回传的每次尝试对应的 transactionId 或商户上下文——必须由 /place 返回给浏览器,并由浏览器放入 /process 请求正文中发回。处理程序不得将 req.session.pendingOrderGroup(或任何按 Pod 缓存)作为进行中订单的唯一事实来源。
为什么这很重要
生产环境中的 BFF 会运行多个副本(Kubernetes 部署、ECS 服务、负载均衡器 ALB 后的 lambda、边缘运行时)。express-session 默认使用 MemoryStore,其数据按 Pod 存储,因此一个 Pod 写入的会话值对其他 Pod 都不可见。即使使用共享会话存储(Redis、DynamoDB、Memcached),第 1 步和第 3 步之间的间隔也只有用户往返 vtexpayments.com.br 所需的时间,因此任何 Redis 的部分故障、写入复制延迟、粘性路由故障、标签页刷新或登录时的会话重新生成,都会导致 400 — no pending order to process。从购物者的角度来看,订单已经付款(其银行卡在第 2 步已扣款),但店面却报告失败,客服不得不手动核对。订单处理窗口只有 5 分钟,没有时间重试。
orderGroup 不是机密信息。它在第 1 步已返回给浏览器,且第 2 步中浏览器直接调用 vtexpayments.com.br 时也需要它。在第 3 步将其回传,比依赖会话状态的暴露面更小。身份验证和所有权仍通过绑定到会话的 vtexCookies、orderFormId 和(如有)vtexAuthToken 在服务器端强制执行——BFF 绝不会仅凭 orderGroup 就予以信任。
检测
如果在 BFF/服务器端结账代码中发现以下任一情况,请立即停止:
- 读取或写入
req.session.pendingOrderGroup、req.session.orderGroup、req.session.lastTransactionId,或其他类似的流程值,且这些值在/place中写入、在/process中读取。 /process(或/gatewayCallback)处理程序只从req.session获取orderGroup,没有req.body.orderGroup回退逻辑或验证。- 前端
placeOrder流程调用/api/bff/order/process时没有请求正文,也没有orderGroup,依赖 BFF“记住”订单。 - 出现类似 "// rely on session to recover orderGroup" 的注释,或有提交为了“整洁”将
orderGroup从请求正文移入会话的更改。
正确
orderRoutes.post("/place", async (req: Request, res: Response) => {
const orderFormId = req.session.orderFormId;
if (!orderFormId) return res.status(400).json({ error: "No active cart" });
const { data } = await vtexCheckout<PlaceOrderResponse>({
path: `/api/checkout/pub/orderForm/${orderFormId}/transaction`,
method: "POST",
body: { referenceId: orderFormId },
cookies: req.session.vtexCookies || {},
userToken: req.session.vtexAuthToken,
});
res.json({
account: process.env.VTEX_ACCOUNT,
orderId: data.orders[0].orderId,
orderGroup: data.orderGroup,
transactionId: data.orders[0].transactionData.merchantTransactions[0].transactionId,
merchantName: data.orders[0].transactionData.merchantTransactions[0].merchantName,
});
});
orderRoutes.post("/process", async (req: Request, res: Response) => {
const orderGroup =
typeof req.body?.orderGroup === "string" ? req.body.orderGroup : undefined;
if (!orderGroup || !/^[A-Za-z0-9-]+$/.test(orderGroup)) {
return res.status(400).json({ error: "Missing or invalid orderGroup" });
}
if (!req.session.orderFormId) {
return res.status(400).json({ error: "No active checkout session" });
}
await vtexCheckout({
path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
method: "POST",
cookies: req.session.vtexCookies || {},
});
delete req.session.orderFormId;
delete req.session.vtexCookies;
res.json({ orderGroup, status: "processed" });
});
下方的首选模式展示了相应的浏览器流程:/place 返回 orderGroup,浏览器再将其放入 /process 请求正文中回传。
错误
orderRoutes.post("/place", async (req: Request, res: Response) => {
// ... call /transaction ...
req.session.pendingOrderGroup = orderGroup;
res.json({ account, orderId, orderGroup, transactionId, merchantName });
});
orderRoutes.post("/process", async (req: Request, res: Response) => {
// BUG: works in dev with one Node process and MemoryStore, returns
// `400 — no pending order to process` the moment the load balancer routes
// /place and /process to different replicas.
const orderGroup = req.session.pendingOrderGroup;
if (!orderGroup) {
return res.status(400).json({ error: "No pending order to process" });
}
await vtexCheckout({
path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
method: "POST",
cookies: req.session.vtexCookies || {},
});
delete req.session.pendingOrderGroup;
res.json({ status: "processed" });
});
约束:转发到 VTEX 前,必须在服务端验证所有输入
BFF 必须在转发请求到 VTEX Checkout API 前验证所有输入数据。这包括验证 SKU ID、数量、电子邮件格式、地址字段和优惠码。
为何这很重要
如果没有服务端验证,恶意用户可能会通过 BFF 向 VTEX 发送精心构造的请求,其中包含无效或具有操纵性的 数据,例如负数数量、文本字段中的 SQL 注入或伪造的卖家 ID。虽然 VTEX 有自己的验证机制,但纵深防御要求在 BFF 层进行验证,以便及早发现问题并提供清晰的错误消息。
检测
如果 BFF 路由处理程序在没有任何验证或清理的情况下,直接将 req.body 传递给 VTEX API 调用 → 立即停止。代理请求前必须验证所有输入。
正确
// BFF — validates inputs before forwarding to VTEX
import { Router, Request, Response } from "express";
import { vtexCheckoutRequest } from "../vtex-api-client";
export const cartItemsRoutes = Router();
interface AddItemRequest {
skuId: string;
quantity: number;
seller: string;
}
function validateAddItemInput(body: unknown): body is AddItemRequest {
if (typeof body !== "object" || body === null) return false;
const b = body as Record<string, unknown>;
return (
typeof b.skuId === "string" &&
/^\d+$/.test(b.skuId) &&
typeof b.quantity === "number" &&
Number.isInteger(b.quantity) &&
b.quantity > 0 &&
b.quantity <= 100 &&
typeof b.seller === "string" &&
/^[a-zA-Z0-9]+$/.test(b.seller)
);
}
cartItemsRoutes.post("/", async (req: Request, res: Response) => {
if (!validateAddItemInput(req.body)) {
return res.status(400).json({
error: "Invalid input",
details:
"skuId must be numeric, quantity must be 1-100, seller must be alphanumeric",
});
}
const { skuId, quantity, seller } = req.body;
const orderFormId = req.session.orderFormId;
if (!orderFormId) {
return res.status(400).json({ error: "No active cart" });
}
try {
const orderForm = await vtexCheckoutRequest({
path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
method: "POST",
body: {
orderItems: [{ id: skuId, quantity, seller }],
},
cookies: req.session.vtexCookies,
});
res.json(sanitizeOrderForm(orderForm));
} catch (error) {
console.error("Error adding item:", error);
res.status(500).json({ error: "Failed to add item to cart" });
}
});
错误
// BFF — passes raw input to VTEX without validation (UNSAFE)
cartRoutes.post("/items", async (req: Request, res: Response) => {
const orderFormId = req.session.orderFormId;
// No validation — attacker can send any payload
const orderForm = await vtexCheckoutRequest({
path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
method: "POST",
body: req.body, // Raw, unvalidated input passed directly!
cookies: req.session.vtexCookies,
});
res.json(orderForm);
});
首选模式
结账操作通过 BFF 的请求流程:
Frontend
│
└── POST /api/bff/cart/items/add {skuId, quantity, seller}
│
BFF Layer
│ 1. Validates input (skuId format, quantity > 0, seller exists)
│ 2. Reads orderFormId from server-side session
│ 3. Forwards CheckoutOrderFormOwnership cookie
│ 4. Calls VTEX: POST /api/checkout/pub/orderForm/{id}/items
│ 5. Updates session with new orderFormId if changed
│ 6. Returns sanitized orderForm to frontend
│
VTEX Checkout API
具有 Cookie 管理功能的 VTEX Checkout API 客户端:
// server/vtex-checkout-client.ts
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_ENVIRONMENT = process.env.VTEX_ENVIRONMENT || "vtexcommercestable";
const BASE_URL = `https://${VTEX_ACCOUNT}.${VTEX_ENVIRONMENT}.com.br`;
interface CheckoutRequestOptions {
path: string;
method?: string;
body?: unknown;
cookies?: Record<string, string>;
userToken?: string;
}
interface CheckoutResponse<T = unknown> {
data: T;
cookies: Record<string, string>;
}
export async function vtexCheckout<T>(
options: CheckoutRequestOptions,
): Promise<CheckoutResponse<T>> {
const { path, method = "GET", body, cookies = {}, userToken } = options;
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
// Build cookie header from stored cookies
const cookieParts: string[] = [];
if (cookies["checkout.vtex.com"]) {
cookieParts.push(`checkout.vtex.com=${cookies["checkout.vtex.com"]}`);
}
if (cookies["CheckoutOrderFormOwnership"]) {
cookieParts.push(
`CheckoutOrderFormOwnership=${cookies["CheckoutOrderFormOwnership"]}`,
);
}
if (userToken) {
cookieParts.push(`VtexIdclientAutCookie=${userToken}`);
}
if (cookieParts.length > 0) {
headers["Cookie"] = cookieParts.join("; ");
}
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`Checkout API error: ${response.status} for ${method} ${path}: ${errorBody}`,
);
}
// Extract cookies from response for session storage
const responseCookies: Record<string, string> = {};
const setCookieHeaders = response.headers.getSetCookie?.() ?? [];
for (const setCookie of setCookieHeaders) {
const [nameValue] = setCookie.split(";");
const [name, value] = nameValue.split("=");
if (name && value) {
responseCookies[name.trim()] = value.trim();
}
}
const data = (await response.json()) as T;
return { data, cookies: { ...cookies, ...responseCookies } };
}
购物车管理 BFF 路由:
// server/routes/cart.ts
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";
export const cartRoutes = Router();
// GET /api/bff/cart — get or create cart
cartRoutes.get("/", async (req: Request, res: Response) => {
try {
const result = await vtexCheckout<OrderForm>({
path: req.session.orderFormId
? `/api/checkout/pub/orderForm/${req.session.orderFormId}`
: "/api/checkout/pub/orderForm",
cookies: req.session.vtexCookies || {},
userToken: req.session.vtexAuthToken,
});
req.session.orderFormId = result.data.orderFormId;
req.session.vtexCookies = result.cookies;
res.json(result.data);
} catch (error) {
console.error("Error getting cart:", error);
res.status(500).json({ error: "Failed to get cart" });
}
});
// POST /api/bff/cart/items — add items to cart
cartRoutes.post("/items", async (req: Request, res: Response) => {
const { items } = req.body as {
items: Array<{ id: string; quantity: number; seller: string }>;
};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: "Items array is required" });
}
for (const item of items) {
if (
!item.id ||
typeof item.quantity !== "number" ||
item.quantity < 1 ||
!item.seller
) {
return res
.status(400)
.json({ error: "Each item must have id, quantity (>0), and seller" });
}
}
const orderFormId = req.session.orderFormId;
if (!orderFormId) {
return res
.status(400)
.json({ error: "No active cart. Call GET /api/bff/cart first." });
}
try {
const result = await vtexCheckout<OrderForm>({
path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
method: "POST",
body: { orderItems: items },
cookies: req.session.vtexCookies || {},
userToken: req.session.vtexAuthToken,
});
req.session.vtexCookies = result.cookies;
res.json(result.data);
} catch (error) {
console.error("Error adding items:", error);
res.status(500).json({ error: "Failed to add items to cart" });
}
});
下单 — 步骤 1 和步骤 3 在 BFF 中运行;根据 PCI 豁免条款,步骤 2 在浏览器中运行。全部 3 个步骤必须在 5 分钟窗口内完成。
// server/routes/order.ts — BFF handles Step 1 (place) and Step 3 (process) only.
// Step 2 (send payment data) runs in the browser; see the card-data carve-out constraint.
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";
export const orderRoutes = Router();
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
// POST /api/bff/order/place — Step 1: place order from existing cart.
// Returns the data the browser needs to call vtexpayments.com.br directly in
// Step 2 AND to call /api/bff/order/process in Step 3. The browser is the
// authority for `orderGroup` between Step 1 and Step 3 — the BFF does NOT
// stash it in `req.session`, because that would silently break in any
// multi-replica deployment whose session store is in-memory or sticky-routed.
orderRoutes.post("/place", async (req: Request, res: Response) => {
const orderFormId = req.session.orderFormId;
if (!orderFormId) {
return res.status(400).json({ error: "No active cart" });
}
try {
const placeResult = await vtexCheckout<PlaceOrderResponse>({
path: `/api/checkout/pub/orderForm/${orderFormId}/transaction`,
method: "POST",
body: { referenceId: orderFormId },
cookies: req.session.vtexCookies || {},
userToken: req.session.vtexAuthToken,
});
const { orders, orderGroup } = placeResult.data;
if (!orders?.length) {
return res
.status(500)
.json({ error: "Order placement returned no orders" });
}
const orderId = orders[0].orderId;
const merchantTransaction =
orders[0].transactionData.merchantTransactions[0];
const transactionId = merchantTransaction?.transactionId;
const merchantName = merchantTransaction?.merchantName ?? VTEX_ACCOUNT;
res.json({
account: VTEX_ACCOUNT,
orderId,
orderGroup,
transactionId,
merchantName,
});
} catch (error) {
console.error("Error placing order:", error);
res.status(500).json({ error: "Failed to place order" });
}
});
// POST /api/bff/order/process — Step 3: process gateway callback after the
// browser has submitted payment data directly to vtexpayments.com.br in Step 2.
// Carries no card data; safe to run server-side. The browser passes back the
// same `orderGroup` it received from /place so the handler is stateless across
// replicas. The BFF still validates ownership through the session-bound
// orderFormId + checkout cookies before calling VTEX.
orderRoutes.post("/process", async (req: Request, res: Response) => {
const orderGroup =
typeof req.body?.orderGroup === "string" ? req.body.orderGroup : undefined;
if (!orderGroup || !/^[A-Za-z0-9-]+$/.test(orderGroup)) {
return res.status(400).json({ error: "Missing or invalid orderGroup" });
}
if (!req.session.orderFormId) {
return res.status(400).json({ error: "No active checkout session" });
}
try {
await vtexCheckout<unknown>({
path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
method: "POST",
cookies: req.session.vtexCookies || {},
});
delete req.session.orderFormId;
delete req.session.vtexCookies;
res.json({ orderGroup, status: "processed" });
} catch (error) {
console.error("Error processing order:", error);
res.status(500).json({ error: "Failed to process order" });
}
});
// frontend/checkout/placeOrder.ts — Step 2 runs in the browser.
// Card fields stay on the device; the BFF never sees them.
// Step 3 echoes `orderGroup` back to the BFF in the request body so it works
// the same way whether the BFF runs on one pod or fifty.
async function placeOrderWithCard(card: CardPaymentInformation) {
const placeResp = await fetch("/api/bff/order/place", {
method: "POST",
credentials: "include",
});
const { account, orderGroup, transactionId, merchantName } =
await placeResp.json();
await fetch(
`https://${account}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`,
{
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify([
{ ...card, transaction: { id: transactionId, merchantName } },
]),
},
);
await fetch("/api/bff/order/process", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderGroup }),
});
}
常见失败模式
-
每次页面加载都创建新购物车:每次页面加载时调用不带
orderFormId的GET /api/checkout/pub/orderForm,都会创建一个新的空购物车,并遗弃之前的购物车。始终在服务端会话中存储并重复使用orderFormId。// Always check for existing orderFormId first cartRoutes.get("/", async (req: Request, res: Response) => { const orderFormId = req.session.orderFormId; const path = orderFormId ? `/api/checkout/pub/orderForm/${orderFormId}` // Retrieve existing cart : "/api/checkout/pub/orderForm"; // Create new cart only if none exists const result = await vtexCheckout<OrderForm>({ path, cookies: req.session.vtexCookies || {}, userToken: req.session.vtexAuthToken, }); req.session.orderFormId = result.data.orderFormId; req.session.vtexCookies = result.cookies; res.json(result.data); }); -
忽略 5 分钟的订单处理窗口:下单(步骤 1)后,如果延迟超过 5 分钟才付款或处理,VTEX 会自动将订单取消为
incomplete。应将步骤 1 → 2 → 3 作为一次用户交互,按顺序立即执行。步骤 1(下单)和步骤 3(网关回调)在 BFF 中运行;根据上文的银行卡数据豁免条款,步骤 2(发送付款数据)在浏览器中运行。各步骤之间不要等待额外的 UI 操作。// Frontend orchestrates Place (BFF) → Pay (browser → vtexpayments.com.br) → Process (BFF) // on a single click, well within the 5-minute window. async function onPlaceOrderClick(card: CardPaymentInformation) { const { account, orderGroup, transactionId, merchantName } = await fetchJson("/api/bff/order/place", { method: "POST" }); await sendPaymentDirectToGateway({ account, transactionId, orderGroup, merchantName, card, }); await fetchJson("/api/bff/order/process", { method: "POST", body: { orderGroup }, }); } -
为了“保持一致性”而通过 BFF 代理
vtexpayments.com.br付款提交:将 Send payments information 调用通过 BFF 路由,表面上看与 BFF 的其他要求对称;一些 Agent 会将其加入,以“集中处理所有 VTEX 调用”。涉及银行卡数据时,这违反 PCI DSS,并非重构。步骤 2 应保留在浏览器中;BFF 应提供/api/bff/order/place(返回transactionId/orderGroup/merchantName)和/api/bff/order/process(调用/gatewayCallback),但银行卡流程中绝不要提供/api/bff/order/payment。 -
在
/place和/process之间将orderGroup存储在req.session中:在步骤 1 中写入req.session.pendingOrderGroup,并在步骤 3 中读取,看起来简洁,在单进程本地开发中也能正常工作。但使用express-session的默认MemoryStore(以及任何按 Pod 划分的缓存)时,横向扩展的 BFF 一旦两次请求落到不同副本上,就会丢失该值,导致/process返回400 — no pending order to process,而购物者的银行卡已在步骤 2 中扣款。应由/place将orderGroup返回给浏览器,并要求浏览器在/process请求正文中将其发回 — 参见上文专门说明的硬性约束。即便已经为orderFormId/vtexCookies配置了共享会话存储(Redis、Memcached、数据库),跨步骤传递仍应放在请求正文中,这样即使发生部分故障、粘性路由失效、复制延迟和标签页刷新,流程仍能正确运行。 -
向前端暴露 VTEX 原始错误消息:直接将 VTEX API 错误响应转发给前端会泄露内部细节(账户名称、API 路径、数据结构)。应在 BFF 中将 VTEX 错误映射为用户友好的消息,并在服务端记录完整错误。
// Map VTEX errors to safe, user-friendly messages function mapCheckoutError( vtexError: string, statusCode: number, ): { code: string; message: string } { if (statusCode === 400 && vtexError.includes("item")) { return { code: "INVALID_ITEM", message: "One or more items are unavailable", }; } if (statusCode === 400 && vtexError.includes("address")) { return { code: "INVALID_ADDRESS", message: "Please check your shipping address", }; } if (statusCode === 409) { return { code: "CART_CONFLICT", message: "Your cart was updated. Please review your items.", }; } return { code: "CHECKOUT_ERROR", message: "An error occurred during checkout. Please try again.", }; }
审查清单
- 是否所有 Checkout API 调用(
vtexcommercestable.com.br/api/checkout/...)都通过 BFF 路由(没有直接从前端发起调用)? - 涉及卡片数据时,是否由浏览器/应用直接发送 Send payments information 调用(
vtexpayments.com.br/api/pub/transactions/{tid}/payments),而不通过 BFF 代理? - 所有 BFF 路由处理程序和日志语句中,是否都未出现
cardNumber、holderName、validationCode/csc和dueDate? - 代码库中对
vtexpayments.com.br的所有引用是否只出现在浏览器/应用代码中,而从未出现在server/、bff/、api/或其他后端目录中? -
orderFormId是否存储在服务器端会话中,而非localStorage或sessionStorage中? - 是否从 VTEX 响应中捕获
CheckoutOrderFormOwnership和checkout.vtex.comcookie,并在后续请求中转发? - 在将所有输入转发到 VTEX 之前,是否都在服务器端进行验证?
- 下单的 3 个步骤(下单 → 付款 → 处理)是否都在 5 分钟窗口内、作为一次用户交互执行,其中步骤 1 和 3 在 BFF 中执行,步骤 2 则由浏览器直接发送到
vtexpayments.com.br? - 是否从会话中复用现有的
orderFormId,而不是每次页面加载时都创建新购物车? -
/place是否会返回orderGroup,并由浏览器在/process请求正文中发回,而不是在两次 BFF 调用之间将其存入req.session.pendingOrderGroup? - 是否在将 VTEX 错误响应发送到前端之前对其进行清理?
参考资料
- 无头购物车与结账 — 在无头商店中实现购物车和结账的完整指南
- Checkout API 参考 — 所有 Checkout 端点的完整 API 参考
- orderForm 字段 — OrderForm 数据结构的详细文档
- 从现有购物车创建常规订单 — 下单流程的分步指南
- 无头商务概览 — VTEX 无头商店的通用架构
- 添加购物车商品 — 向购物车添加商品的指南