Loading...
Loading...
Apply when implementing cart, checkout, or order placement logic proxied through a BFF for headless VTEX storefronts. Covers OrderForm lifecycle, cart creation, item management, profile/shipping/payment attachments, orderFormId management, and secure checkout flows. Use for any headless frontend that needs to proxy VTEX Checkout API calls through a server-side layer with proper session cookie handling.
npx skill4agent add vtexdocs/ai-skills headless-checkout-proxyorderFormIdCheckoutOrderFormOwnershipheadless-bff-architectureheadless-intelligent-searchheadless-caching-strategyorderFormIdlocalStoragesessionStorageCheckoutOrderFormOwnershipcheckout.vtex.comreq.bodyorderFormIdorderFormId| 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 |
/api/checkout/VtexIdclientAutCookieCheckoutOrderFormOwnershipfetchaxios/api/checkout/// 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();
}orderFormIdlocalStoragesessionStorageorderFormIdorderFormIdlocalStoragesessionStorage// 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.body// 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);
});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// 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 } };
}// 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" });
}
});// 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" });
}
});GET /api/checkout/pub/orderFormorderFormIdorderFormId// 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);
});incomplete// 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" });
}
});// 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." };
}/api/checkout/orderFormIdlocalStoragesessionStorageCheckoutOrderFormOwnershipcheckout.vtex.comorderFormId