headless-checkout-proxy
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCheckout API Proxy & OrderForm Management
Checkout API 代理与OrderForm管理
When this skill applies
本技能的适用场景
Use this skill when building cart and checkout functionality for any headless VTEX storefront. Every cart and checkout operation must go through the BFF.
- Implementing cart creation, item add/update/remove operations
- Attaching profile, shipping, or payment data to an OrderForm
- Implementing the 3-step order placement flow (place → pay → process)
- Managing and
orderFormIdcookies server-sideCheckoutOrderFormOwnership
Do not use this skill for:
- General BFF architecture and API routing (use )
headless-bff-architecture - Search API integration (use )
headless-intelligent-search - Caching strategy decisions (use )
headless-caching-strategy
当为任意无头VTEX店面构建购物车和结账功能时,可使用本技能。所有购物车和结账操作都必须通过BFF完成。
- 实现购物车创建、商品添加/更新/删除操作
- 为OrderForm附加资料、配送或支付数据
- 实现三步下单流程(下单→支付→处理)
- 在服务端管理和
orderFormIdcookieCheckoutOrderFormOwnership
请勿在以下场景使用本技能:
- 通用BFF架构和API路由(使用)
headless-bff-architecture - 搜索API集成(使用)
headless-intelligent-search - 缓存策略决策(使用)
headless-caching-strategy
Decision rules
决策规则
- ALL Checkout API calls MUST be proxied through the BFF — no exceptions. The Checkout API handles sensitive personal data (profile, address, payment).
- Store in a server-side session, never in
orderFormIdorlocalStorage.sessionStorage - Capture and forward and
CheckoutOrderFormOwnershipcookies between the BFF and VTEX on every request.checkout.vtex.com - Validate all inputs server-side before forwarding to VTEX — never pass raw directly.
req.body - Execute the 3-step order placement flow (place order → send payment → process order) in a single synchronous BFF handler to stay within the 5-minute window.
- Always store and reuse the existing from the session — only create a new cart when no
orderFormIdexists.orderFormId
OrderForm attachment endpoints:
| Attachment | Endpoint | Purpose |
|---|---|---|
| items | | Add, remove, or update cart items |
| clientProfileData | | Customer profile info |
| shippingData | | Address and delivery option |
| paymentData | | Payment method selection |
| marketingData | | Coupons and UTM data |
- 所有Checkout API调用必须通过BFF代理——无例外。Checkout API处理敏感个人数据(资料、地址、支付信息)。
- 将存储在服务端会话中,绝不要存在
orderFormId或localStorage里。sessionStorage - 在BFF与VTEX的每次请求之间,捕获并转发和
CheckoutOrderFormOwnershipcookie。checkout.vtex.com - 在转发给VTEX之前,必须在服务端验证所有输入——绝不要直接传递原始。
req.body - 在单个同步BFF处理器中执行三步下单流程(下单→支付→处理),以确保在5分钟窗口内完成。
- 始终从会话中存储并复用现有的——仅当不存在
orderFormId时才创建新购物车。orderFormId
OrderForm附加信息端点:
| 附加信息 | 端点 | 用途 |
|---|---|---|
| items | | 添加、删除或更新购物车商品 |
| clientProfileData | | 客户资料信息 |
| shippingData | | 地址与配送选项 |
| paymentData | | 支付方式选择 |
| marketingData | | 优惠券与UTM数据 |
Hard constraints
硬性约束
Constraint: ALL checkout operations MUST go through BFF
约束:所有结账操作必须通过BFF完成
Client-side code MUST NOT make direct HTTP requests to any VTEX Checkout API endpoint (). All checkout operations — cart creation, item management, profile updates, shipping, payment, and order placement — must be proxied through the BFF layer.
/api/checkout/Why this matters
Checkout endpoints handle sensitive personal data (email, address, phone, payment details). Direct frontend calls expose the request/response flow to browser DevTools, extensions, and XSS attacks. Additionally, the BFF layer is needed to manage and cookies server-side, validate inputs, and prevent cart manipulation (e.g., price tampering).
VtexIdclientAutCookieCheckoutOrderFormOwnershipDetection
If you see or calls to in any client-side code (browser-executed JavaScript, frontend source files) → STOP immediately. All checkout calls must route through BFF endpoints.
fetchaxios/api/checkout/Correct
typescript
// 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();
}Wrong
typescript
// 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();
}客户端代码绝不能直接向任何VTEX Checkout API端点()发起HTTP请求。所有结账操作——购物车创建、商品管理、资料更新、配送、支付以及下单——都必须通过BFF层代理。
/api/checkout/为什么这很重要
结账端点处理敏感个人数据(邮箱、地址、电话、支付详情)。直接前端调用会将请求/响应流程暴露给浏览器开发者工具、扩展程序和XSS攻击。此外,需要BFF层在服务端管理和 cookie,验证输入,并防止购物车篡改(如价格篡改)。
VtexIdclientAutCookieCheckoutOrderFormOwnership检测方式
如果在任何客户端代码(浏览器执行的JavaScript、前端源文件)中看到或调用→立即停止。所有结账调用必须通过BFF端点路由。
fetchaxios/api/checkout/正确示例
typescript
// 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();
}错误示例
typescript
// 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();
}Constraint: orderFormId MUST be managed server-side
约束:orderFormId必须在服务端管理
The MUST be stored in a secure server-side session. It SHOULD NOT be stored in , , or exposed to the frontend in a way that allows direct VTEX API calls.
orderFormIdlocalStoragesessionStorageWhy this matters
The is the key to a customer's shopping cart and all data within it — profile information, shipping address, payment details. If exposed client-side, an attacker could use it to query VTEX directly and retrieve personal data, or manipulate the cart by adding/removing items through direct API calls bypassing any validation logic.
orderFormIdDetection
If you see stored in or → STOP immediately. It should be managed in the BFF session.
orderFormIdlocalStoragesessionStorageCorrect
typescript
// 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;
}Wrong
typescript
// 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();
}orderFormIdlocalStoragesessionStorage为什么这很重要
orderFormId检测方式
如果看到存储在或中→立即停止。它应该在BFF会话中管理。
orderFormIdlocalStoragesessionStorage正确示例
typescript
// 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;
}错误示例
typescript
// 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();
}Constraint: MUST validate all inputs server-side before forwarding to VTEX
约束:必须在服务端验证所有输入后再转发给VTEX
The BFF MUST validate all input data before forwarding requests to the VTEX Checkout API. This includes validating SKU IDs, quantities, email formats, address fields, and coupon codes.
Why this matters
Without server-side validation, malicious users can send crafted requests through the BFF to VTEX with invalid or manipulative data — negative quantities, SQL injection in text fields, or spoofed seller IDs. While VTEX has its own validation, defense-in-depth requires validating at the BFF layer to catch issues early and provide clear error messages.
Detection
If BFF route handlers pass directly to VTEX API calls without any validation or sanitization → STOP immediately. All inputs must be validated before proxying.
req.bodyCorrect
typescript
// 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" });
}
});Wrong
typescript
// 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必须在将请求转发给VTEX Checkout API之前验证所有输入数据。这包括验证SKU ID、数量、邮箱格式、地址字段和优惠券代码。
为什么这很重要
如果没有服务端验证,恶意用户可以通过BFF向VTEX发送精心构造的请求,包含无效或操纵性数据——如负数量、文本字段中的SQL注入,或伪造的卖家ID。虽然VTEX有自己的验证,但纵深防御要求在BFF层验证,以便尽早发现问题并提供清晰的错误信息。
检测方式
如果BFF路由处理器直接将传递给VTEX API调用而没有任何验证或清理→立即停止。所有输入在代理前必须验证。
req.body正确示例
typescript
// 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" });
}
});错误示例
typescript
// 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);
});Preferred pattern
推荐模式
Request flow through the BFF for checkout operations:
text
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 APIVTEX Checkout API client with cookie management:
typescript
// 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 } };
}Cart management BFF routes:
typescript
// 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" });
}
});Order placement — all 3 steps in a single handler to respect the 5-minute window:
typescript
// server/routes/order.ts
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";
export const orderRoutes = Router();
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_ENVIRONMENT = process.env.VTEX_ENVIRONMENT || "vtexcommercestable";
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;
// POST /api/bff/order/place — place order from existing cart
// CRITICAL: All 3 steps must complete within 5 minutes or the order is canceled
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 {
// Step 1: Place order — starts the 5-minute timer
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 || orders.length === 0) {
return res.status(500).json({ error: "Order placement returned no orders" });
}
const orderId = orders[0].orderId;
const transactionId =
orders[0].transactionData.merchantTransactions[0]?.transactionId;
// Step 2: Send payment — immediately after placement
const { paymentData } = req.body as {
paymentData: {
paymentSystem: number;
installments: number;
value: number;
referenceValue: number;
};
};
if (!paymentData) {
return res.status(400).json({ error: "Payment data is required" });
}
const paymentUrl = `https://${VTEX_ACCOUNT}.${VTEX_ENVIRONMENT}.com.br/api/payments/transactions/${transactionId}/payments`;
const paymentResponse = await fetch(paymentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-VTEX-API-AppKey": VTEX_APP_KEY,
"X-VTEX-API-AppToken": VTEX_APP_TOKEN,
},
body: JSON.stringify([
{
paymentSystem: paymentData.paymentSystem,
installments: paymentData.installments,
currencyCode: "BRL",
value: paymentData.value,
installmentsInterestRate: 0,
installmentsValue: paymentData.value,
referenceValue: paymentData.referenceValue,
fields: {},
transaction: { id: transactionId, merchantName: VTEX_ACCOUNT },
},
]),
});
if (!paymentResponse.ok) {
return res.status(500).json({ error: "Payment submission failed" });
}
// Step 3: Process order — immediately after payment
await vtexCheckout<unknown>({
path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
method: "POST",
cookies: req.session.vtexCookies || {},
});
// Clear cart session after successful order
delete req.session.orderFormId;
delete req.session.vtexCookies;
res.json({
orderId,
orderGroup,
transactionId,
status: "placed",
});
} catch (error) {
console.error("Error placing order:", error);
res.status(500).json({ error: "Failed to place order" });
}
});结账操作通过BFF的请求流程:
text
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客户端:
typescript
// 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路由:
typescript
// 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" });
}
});下单——所有3个步骤在单个处理器中完成以遵守5分钟窗口:
typescript
// server/routes/order.ts
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";
export const orderRoutes = Router();
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_ENVIRONMENT = process.env.VTEX_ENVIRONMENT || "vtexcommercestable";
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;
// POST /api/bff/order/place — place order from existing cart
// CRITICAL: All 3 steps must complete within 5 minutes or the order is canceled
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 {
// Step 1: Place order — starts the 5-minute timer
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 || orders.length === 0) {
return res.status(500).json({ error: "Order placement returned no orders" });
}
const orderId = orders[0].orderId;
const transactionId =
orders[0].transactionData.merchantTransactions[0]?.transactionId;
// Step 2: Send payment — immediately after placement
const { paymentData } = req.body as {
paymentData: {
paymentSystem: number;
installments: number;
value: number;
referenceValue: number;
};
};
if (!paymentData) {
return res.status(400).json({ error: "Payment data is required" });
}
const paymentUrl = `https://${VTEX_ACCOUNT}.${VTEX_ENVIRONMENT}.com.br/api/payments/transactions/${transactionId}/payments`;
const paymentResponse = await fetch(paymentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-VTEX-API-AppKey": VTEX_APP_KEY,
"X-VTEX-API-AppToken": VTEX_APP_TOKEN,
},
body: JSON.stringify([
{
paymentSystem: paymentData.paymentSystem,
installments: paymentData.installments,
currencyCode: "BRL",
value: paymentData.value,
installmentsInterestRate: 0,
installmentsValue: paymentData.value,
referenceValue: paymentData.referenceValue,
fields: {},
transaction: { id: transactionId, merchantName: VTEX_ACCOUNT },
},
]),
});
if (!paymentResponse.ok) {
return res.status(500).json({ error: "Payment submission failed" });
}
// Step 3: Process order — immediately after payment
await vtexCheckout<unknown>({
path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
method: "POST",
cookies: req.session.vtexCookies || {},
});
// Clear cart session after successful order
delete req.session.orderFormId;
delete req.session.vtexCookies;
res.json({
orderId,
orderGroup,
transactionId,
status: "placed",
});
} catch (error) {
console.error("Error placing order:", error);
res.status(500).json({ error: "Failed to place order" });
}
});Common failure modes
常见失败模式
-
Creating a new cart on every page load: Callingwithout an
GET /api/checkout/pub/orderFormon every page load creates a new empty cart each time, abandoning the previous one. Always store and reuse theorderFormIdfrom the server-side session.orderFormIdtypescript// 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); }); -
Ignoring the 5-minute order processing window: Placing an order (step 1) but delaying payment or processing beyond 5 minutes causes VTEX to automatically cancel the order as. Execute all three steps (place order → send payment → process order) sequentially and immediately in a single BFF request handler. Never split these across multiple independent frontend calls.
incompletetypescript// Execute all 3 steps in a single, synchronous flow orderRoutes.post("/place", async (req: Request, res: Response) => { try { // Step 1: Place order — starts the 5-minute timer const placeResult = await vtexCheckout<PlaceOrderResponse>({ path: `/api/checkout/pub/orderForm/${req.session.orderFormId}/transaction`, method: "POST", body: { referenceId: req.session.orderFormId }, cookies: req.session.vtexCookies || {}, }); // Step 2: Send payment — immediately after placement await sendPayment(placeResult.data); // Step 3: Process order — immediately after payment await processOrder(placeResult.data.orderGroup); res.json({ success: true, orderId: placeResult.data.orders[0].orderId }); } catch (error) { console.error("Order placement failed:", error); res.status(500).json({ error: "Order placement failed" }); } }); -
Exposing raw VTEX error messages to the frontend: Forwarding VTEX API error responses directly to the frontend leaks internal details (account names, API paths, data structures). Map VTEX errors to user-friendly messages in the BFF and log the full error server-side.typescript
// 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." }; }
-
每次页面加载都创建新购物车:每次页面加载时,在没有的情况下调用
orderFormId,会每次创建新的空购物车,丢弃之前的购物车。始终从服务端会话中存储并复用GET /api/checkout/pub/orderForm。orderFormIdtypescript// 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自动将订单标记为并取消。在单个BFF请求处理器中连续且立即执行所有三个步骤(下单→支付→处理)。绝不要将这些步骤拆分为多个独立的前端调用。
incompletetypescript// Execute all 3 steps in a single, synchronous flow orderRoutes.post("/place", async (req: Request, res: Response) => { try { // Step 1: Place order — starts the 5-minute timer const placeResult = await vtexCheckout<PlaceOrderResponse>({ path: `/api/checkout/pub/orderForm/${req.session.orderFormId}/transaction`, method: "POST", body: { referenceId: req.session.orderFormId }, cookies: req.session.vtexCookies || {}, }); // Step 2: Send payment — immediately after placement await sendPayment(placeResult.data); // Step 3: Process order — immediately after payment await processOrder(placeResult.data.orderGroup); res.json({ success: true, orderId: placeResult.data.orders[0].orderId }); } catch (error) { console.error("Order placement failed:", error); res.status(500).json({ error: "Order placement failed" }); } }); -
向前端暴露原始VTEX错误消息:直接将VTEX API错误响应转发给前端会泄露内部细节(账户名称、API路径、数据结构)。在BFF中将VTEX错误映射为用户友好的消息,并在服务端记录完整错误。typescript
// 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." }; }
Review checklist
审核清单
- Are ALL checkout API calls routed through the BFF (no direct frontend calls to )?
/api/checkout/ - Is stored in a server-side session, not in
orderFormIdorlocalStorage?sessionStorage - Are and
CheckoutOrderFormOwnershipcookies captured from VTEX responses and forwarded on subsequent requests?checkout.vtex.com - Are all inputs validated server-side before forwarding to VTEX?
- Does the order placement handler execute all 3 steps (place → pay → process) in a single synchronous flow within the 5-minute window?
- Is the existing reused from the session rather than creating a new cart on every page load?
orderFormId - Are VTEX error responses sanitized before being sent to the frontend?
- 所有结账API调用是否都通过BFF路由(没有直接前端调用)?
/api/checkout/ - 是否存储在服务端会话中,而不是
orderFormId或localStorage?sessionStorage - 是否从VTEX响应中捕获和
CheckoutOrderFormOwnershipcookie并在后续请求中转发?checkout.vtex.com - 所有输入是否在服务端验证后再转发给VTEX?
- 下单处理器是否在单个同步流程中执行所有3个步骤(下单→支付→处理),以遵守5分钟窗口?
- 是否复用会话中的现有,而不是每次页面加载都创建新购物车?
orderFormId - VTEX错误响应是否在发送给前端前进行了清理?
Reference
参考资料
- Headless cart and checkout — Complete guide to implementing cart and checkout in headless stores
- Checkout API reference — Full API reference for all Checkout endpoints
- orderForm fields — Detailed documentation of the OrderForm data structure
- Creating a regular order from an existing cart — Step-by-step guide to the order placement flow
- Headless commerce overview — General architecture for headless VTEX stores
- Add cart items — Guide to adding products to a shopping cart
- Headless cart and checkout — 无头店面购物车和结账实现完整指南
- Checkout API reference — 所有结账端点的完整API参考
- orderForm fields — OrderForm数据结构的详细文档
- Creating a regular order from an existing cart — 从现有购物车创建常规订单的分步指南
- Headless commerce overview — 无头VTEX店面的通用架构
- Add cart items — 向购物车添加商品的指南