headless-checkout-proxy

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Checkout 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
    orderFormId
    and
    CheckoutOrderFormOwnership
    cookies server-side
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附加资料、配送或支付数据
  • 实现三步下单流程(下单→支付→处理)
  • 在服务端管理
    orderFormId
    CheckoutOrderFormOwnership
    cookie
请勿在以下场景使用本技能:
  • 通用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
    orderFormId
    in a server-side session, never in
    localStorage
    or
    sessionStorage
    .
  • Capture and forward
    CheckoutOrderFormOwnership
    and
    checkout.vtex.com
    cookies between the BFF and VTEX on every request.
  • Validate all inputs server-side before forwarding to VTEX — never pass raw
    req.body
    directly.
  • 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
    orderFormId
    from the session — only create a new cart when no
    orderFormId
    exists.
OrderForm attachment endpoints:
AttachmentEndpointPurpose
items
POST .../orderForm/{id}/items
Add, remove, or update cart items
clientProfileData
POST .../orderForm/{id}/attachments/clientProfileData
Customer profile info
shippingData
POST .../orderForm/{id}/attachments/shippingData
Address and delivery option
paymentData
POST .../orderForm/{id}/attachments/paymentData
Payment method selection
marketingData
POST .../orderForm/{id}/attachments/marketingData
Coupons and UTM data
  • 所有Checkout API调用必须通过BFF代理——无例外。Checkout API处理敏感个人数据(资料、地址、支付信息)。
  • orderFormId
    存储在服务端会话中,绝不要存在
    localStorage
    sessionStorage
    里。
  • 在BFF与VTEX的每次请求之间,捕获并转发
    CheckoutOrderFormOwnership
    checkout.vtex.com
    cookie。
  • 在转发给VTEX之前,必须在服务端验证所有输入——绝不要直接传递原始
    req.body
  • 在单个同步BFF处理器中执行三步下单流程(下单→支付→处理),以确保在5分钟窗口内完成。
  • 始终从会话中存储并复用现有的
    orderFormId
    ——仅当不存在
    orderFormId
    时才创建新购物车。
OrderForm附加信息端点:
附加信息端点用途
items
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数据

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 (
/api/checkout/
). All checkout operations — cart creation, item management, profile updates, shipping, payment, and order placement — must be proxied through the BFF layer.
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
VtexIdclientAutCookie
and
CheckoutOrderFormOwnership
cookies server-side, validate inputs, and prevent cart manipulation (e.g., price tampering).
Detection
If you see
fetch
or
axios
calls to
/api/checkout/
in any client-side code (browser-executed JavaScript, frontend source files) → STOP immediately. All checkout calls must route through BFF endpoints.
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端点(
/api/checkout/
)发起HTTP请求。所有结账操作——购物车创建、商品管理、资料更新、配送、支付以及下单——都必须通过BFF层代理。
为什么这很重要
结账端点处理敏感个人数据(邮箱、地址、电话、支付详情)。直接前端调用会将请求/响应流程暴露给浏览器开发者工具、扩展程序和XSS攻击。此外,需要BFF层在服务端管理
VtexIdclientAutCookie
CheckoutOrderFormOwnership
cookie,验证输入,并防止购物车篡改(如价格篡改)。
检测方式
如果在任何客户端代码(浏览器执行的JavaScript、前端源文件)中看到
fetch
axios
调用
/api/checkout/
→立即停止。所有结账调用必须通过BFF端点路由。
正确示例
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
orderFormId
MUST be stored in a secure server-side session. It SHOULD NOT be stored in
localStorage
,
sessionStorage
, or exposed to the frontend in a way that allows direct VTEX API calls.
Why this matters
The
orderFormId
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.
Detection
If you see
orderFormId
stored in
localStorage
or
sessionStorage
→ STOP immediately. It should be managed in the BFF session.
Correct
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();
}

orderFormId
必须存储在安全的服务端会话中。绝不能存储在
localStorage
sessionStorage
中,也不能以允许直接调用VTEX API的方式暴露给前端。
为什么这很重要
orderFormId
是访问客户购物车及其中所有数据的关键——包括资料信息、配送地址、支付详情。如果在客户端暴露,攻击者可以直接使用它查询VTEX并获取个人数据,或者通过直接API调用绕过任何验证逻辑来篡改购物车(如添加/删除商品)。
检测方式
如果看到
orderFormId
存储在
localStorage
sessionStorage
中→立即停止。它应该在BFF会话中管理。
正确示例
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
req.body
directly to VTEX API calls without any validation or sanitization → STOP immediately. All inputs must be validated before proxying.
Correct
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路由处理器直接将
req.body
传递给VTEX API调用而没有任何验证或清理→立即停止。所有输入在代理前必须验证。
正确示例
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 API
VTEX 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: Calling
    GET /api/checkout/pub/orderForm
    without an
    orderFormId
    on every page load creates a new empty cart each time, abandoning the previous one. Always store and reuse the
    orderFormId
    from the server-side session.
    typescript
    // 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
    incomplete
    . 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.
    typescript
    // 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
    ,会每次创建新的空购物车,丢弃之前的购物车。始终从服务端会话中存储并复用
    orderFormId
    typescript
    // 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
    并取消。在单个BFF请求处理器中连续且立即执行所有三个步骤(下单→支付→处理)。绝不要将这些步骤拆分为多个独立的前端调用。
    typescript
    // 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
    orderFormId
    stored in a server-side session, not in
    localStorage
    or
    sessionStorage
    ?
  • Are
    CheckoutOrderFormOwnership
    and
    checkout.vtex.com
    cookies captured from VTEX responses and forwarded on subsequent requests?
  • 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
    orderFormId
    reused from the session rather than creating a new cart on every page load?
  • Are VTEX error responses sanitized before being sent to the frontend?
  • 所有结账API调用是否都通过BFF路由(没有直接前端调用
    /api/checkout/
    )?
  • orderFormId
    是否存储在服务端会话中,而不是
    localStorage
    sessionStorage
  • 是否从VTEX响应中捕获
    CheckoutOrderFormOwnership
    checkout.vtex.com
    cookie并在后续请求中转发?
  • 所有输入是否在服务端验证后再转发给VTEX?
  • 下单处理器是否在单个同步流程中执行所有3个步骤(下单→支付→处理),以遵守5分钟窗口?
  • 是否复用会话中的现有
    orderFormId
    ,而不是每次页面加载都创建新购物车?
  • VTEX错误响应是否在发送给前端前进行了清理?

Reference

参考资料