Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
vtex avatar

Headless Checkout Proxy

  • 5 installs
  • 39 repo stars
  • Updated June 16, 2026
  • vtex/ai-skills

Helps with ai & agent building tasks.

About

headless-checkout-proxy is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.

  • headless-checkout-proxy
  • AI & Agent Building
  • AI-coding skill

Headless Checkout Proxy by the numbers

  • 5 all-time installs (skills.sh)
  • +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
  • Ranked #13,065 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
  • Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vtex/ai-skills --skill headless-checkout-proxy

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs5
repo stars39
Last updatedJune 16, 2026
Repositoryvtex/ai-skills

What it does

Helps with ai & agent building tasks.

Files

SKILL.mdMarkdownGitHub ↗

Checkout API Proxy & OrderForm Management

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`)

Decision rules

  • ALL Checkout API calls (/api/checkout/... on vtexcommercestable.com.br) MUST be proxied through the BFF. The Checkout API handles sensitive personal data (profile, address, payment-method selection).
  • PCI carve-out: the Send payments information call to the VTEX Payment Gateway (POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments) MUST go directly from the browser/app to vtexpayments.com.br whenever it carries card data. The merchant BFF MUST NOT be in the card-data path. See the dedicated hard constraint below and `payment-pci-security`.
  • 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) as a single synchronous user interaction within the 5-minute window. Step 1 (place) and Step 3 (gateway callback) run in the BFF; Step 2 (send payment data to vtexpayments.com.br) runs in the browser per the PCI carve-out above.
  • Always store and reuse the existing orderFormId from the session — only create a new cart when no orderFormId exists.
  • Pass intermediate flow values such as orderGroup between Step 1 (/place) and Step 3 (/process) through the request body, not through req.session. Production BFFs are typically multi-replica; express-session's default MemoryStore (and any per-pod cache) loses the value when the two requests land on different pods, returning a misleading "no pending order to process" 400. If a shared session store (Redis, Memcached, database) is already required for orderFormId/vtexCookies, see the Hard constraint below for why the cross-step handoff still belongs in the request body — it stays correct under partial outages, sticky-routing failures, and replay/refresh after a tab reload.

OrderForm attachment endpoints:

AttachmentEndpointPurpose
itemsPOST .../orderForm/{id}/itemsAdd, remove, or update cart items
clientProfileDataPOST .../orderForm/{id}/attachments/clientProfileDataCustomer profile info
shippingDataPOST .../orderForm/{id}/attachments/shippingDataAddress and delivery option
paymentDataPOST .../orderForm/{id}/attachments/paymentDataPayment method selection
marketingDataPOST .../orderForm/{id}/attachments/marketingDataCoupons and UTM data

Hard constraints

Constraint: ALL Checkout API operations MUST go through BFF

Client-side code MUST NOT make direct HTTP requests to any VTEX Checkout API endpoint on vtexcommercestable.com.br/api/checkout/.... All Checkout API operations — cart creation, item management, profile updates, shipping, payment-method selection (paymentData attachment), order placement (/transaction), and order processing (/gatewayCallback) — must be proxied through the BFF.

This rule applies to the Checkout API only. The Send payments information call on vtexpayments.com.br is governed by the next constraint and goes the opposite way (browser-direct) for PCI reasons; do not generalize this rule to that endpoint.

Why this matters

Checkout endpoints handle sensitive personal data (email, address, phone, payment-method selection). 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 vtexcommercestable.com.br/api/checkout/... in any client-side code (browser-executed JavaScript, frontend source files) → STOP immediately. All Checkout API calls must route through BFF endpoints.

Correct

// 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

// 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: Card data MUST go directly from browser/app to vtexpayments.com.br — never through the BFF

The Send payments information call (POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments?orderId={orderGroup}) carries card data when the shopper pays with a credit, debit, or co-branded card. This call MUST originate from the shopper's browser or native app and MUST target vtexpayments.com.br directly. The BFF MUST NOT proxy this call when card data is involved, even with redaction, even with appKey/appToken on the server side, and even when only "tokenized" fields appear to be forwarded.

This is the inverse of the previous constraint. Treating it as a generic "checkout" call and routing it through the BFF — as some agents do when applying the "all checkout through BFF" rule too broadly — is a PCI DSS violation, not a security improvement.

Why this matters

The merchant operating the headless storefront is rarely PCI DSS Level 1 certified. Routing card numbers, holder names, or CVV through the merchant's BFF places the BFF and every system it touches — application logs, APM/observability tools, reverse proxies, load balancers, error trackers — inside PCI scope. Operating a non-PCI environment that handles card data violates PCI DSS Requirements 3 and 4 and can result in fines from $5,000 to over $100,000 per month from card networks, mandatory forensic investigation costs, loss of card processing ability, and legal liability.

The browser → vtexpayments.com.br path is the PCI-compliant pattern: the VTEX Payment Gateway is PCI DSS Level 1 certified and is the only environment in this flow authorized to receive raw card data. The Send payments call is authenticated by the shopper's session cookies set by the previous Place Order step — no merchant credentials are needed on the BFF for this hop.

Detection

If you find any of the following in BFF / server-side code (server/, bff/, api/, route handlers, middleware, edge functions, lambdas), STOP immediately:

  • A request from the BFF to https://*.vtexpayments.com.br/api/pub/transactions/.../payments,
  • A handler that accepts cardNumber, holderName, validationCode, csc, dueDate, or full payment fields from the browser and forwards them to any VTEX endpoint,
  • A "Payments client" / ExternalClient / axios instance that targets vtexpayments.com.br for the Send payments information endpoint.

Move that call to the browser/app. The BFF should instead return transactionId, orderGroup, and merchantName from the Place Order step so the frontend can post payment data directly to the Payment Gateway.

This rule applies even when:

  • The merchant has a VTEX appKey/appToken with payment permissions — possessing the credentials does not grant PCI authorization.
  • Only "tokenized" card fields are forwarded — token values still reference real card data and are in PCI scope.
  • The BFF code "redacts" sensitive fields before logging — the request still transits the merchant infrastructure before redaction.
  • The BFF runs on a private VPC with TLS — PCI scope is determined by what data passes through, not by how the network is configured.

Correct

async function sendPaymentDataDirect(args: {
  account: string;
  transactionId: string;
  orderGroup: string;
  merchantName: string;
  paymentInformation: PaymentField[];
}): Promise<void> {
  const {
    account,
    transactionId,
    orderGroup,
    merchantName,
    paymentInformation,
  } = args;

  const response = await fetch(
    `https://${account}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`,
    {
      method: "POST",
      credentials: "include",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(
        paymentInformation.map((p) => ({
          ...p,
          transaction: { id: transactionId, merchantName },
        })),
      ),
    },
  );

  if (!response.ok) {
    throw new Error(`Payment submission failed: ${response.status}`);
  }
}

Wrong

const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;

paymentRoutes.post("/", async (req: Request, res: Response) => {
  const { transactionId, orderGroup, paymentInformation } = req.body as {
    transactionId: string;
    orderGroup: string;
    paymentInformation: Array<{
      paymentSystem: number;
      installments: number;
      value: number;
      fields: {
        cardNumber: string;
        holderName: string;
        validationCode: string;
        dueDate: string;
      };
    }>;
  };

  const url = `https://${process.env.VTEX_ACCOUNT}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`;
  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-VTEX-API-AppKey": VTEX_APP_KEY,
      "X-VTEX-API-AppToken": VTEX_APP_TOKEN,
    },
    body: JSON.stringify(paymentInformation),
  });

  res.json({ status: response.status });
});

---

Constraint: orderFormId MUST be managed server-side

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

// 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

// 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: Multi-step order flow handoffs MUST travel in the request body, not in req.session

Values that link /place (Step 1) and /process (Step 3) — most importantly orderGroup, but also any per-attempt transactionId or merchant context the frontend needs to echo back — MUST be returned by /place to the browser and sent back by the browser in the /process request body. The handler MUST NOT rely on req.session.pendingOrderGroup (or any per-pod cache) as the only source of truth for the in-flight order.

Why this matters

Production BFFs run multiple replicas (Kubernetes deployments, ECS services, lambdas behind an ALB, edge runtimes). express-session defaults to MemoryStore, which is per-pod, so a session value written on one pod is invisible to every other pod. Even with a shared session store (Redis, DynamoDB, Memcached), Step 1 and Step 3 fire as fast as the user can roundtrip through vtexpayments.com.br, so any partial Redis outage, write replication lag, sticky-routing failure, tab refresh, or session regeneration on login surfaces as 400 — no pending order to process. From the shopper's point of view the order is paid (their card was charged in Step 2) but the storefront reports failure, and support has to reconcile by hand. The 5-minute order processing window leaves no time for retries.

orderGroup is not a secret. It already returns to the browser from Step 1 and is required by the direct browser → vtexpayments.com.br call in Step 2. Echoing it back in Step 3 is strictly less surface area than session-backed state. Authentication and ownership are still enforced server-side through the session-bound vtexCookies, orderFormId, and (when present) vtexAuthToken — the BFF never trusts orderGroup alone.

Detection

If you find any of the following in BFF / server-side checkout code, STOP immediately:

  • Any read or write of req.session.pendingOrderGroup, req.session.orderGroup, req.session.lastTransactionId, or similar per-flow values written in /place and read in /process.
  • A /process (or /gatewayCallback) handler whose only source of orderGroup is req.session, with no req.body.orderGroup fallback or validation.
  • A frontend placeOrder flow that calls /api/bff/order/process with no body and no orderGroup, relying on the BFF to "remember" the order.
  • Comments like "// rely on session to recover orderGroup" or commits that move orderGroup from request bodies into the session for "tidiness".

Correct

orderRoutes.post("/place", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  if (!orderFormId) return res.status(400).json({ error: "No active cart" });

  const { data } = await vtexCheckout<PlaceOrderResponse>({
    path: `/api/checkout/pub/orderForm/${orderFormId}/transaction`,
    method: "POST",
    body: { referenceId: orderFormId },
    cookies: req.session.vtexCookies || {},
    userToken: req.session.vtexAuthToken,
  });

  res.json({
    account: process.env.VTEX_ACCOUNT,
    orderId: data.orders[0].orderId,
    orderGroup: data.orderGroup,
    transactionId: data.orders[0].transactionData.merchantTransactions[0].transactionId,
    merchantName: data.orders[0].transactionData.merchantTransactions[0].merchantName,
  });
});

orderRoutes.post("/process", async (req: Request, res: Response) => {
  const orderGroup =
    typeof req.body?.orderGroup === "string" ? req.body.orderGroup : undefined;
  if (!orderGroup || !/^[A-Za-z0-9-]+$/.test(orderGroup)) {
    return res.status(400).json({ error: "Missing or invalid orderGroup" });
  }
  if (!req.session.orderFormId) {
    return res.status(400).json({ error: "No active checkout session" });
  }

  await vtexCheckout({
    path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
    method: "POST",
    cookies: req.session.vtexCookies || {},
  });

  delete req.session.orderFormId;
  delete req.session.vtexCookies;
  res.json({ orderGroup, status: "processed" });
});

The matching browser flow — /place returns orderGroup and the browser echoes it back in the /process body — is shown in the Preferred pattern below.

Wrong

orderRoutes.post("/place", async (req: Request, res: Response) => {
  // ... call /transaction ...
  req.session.pendingOrderGroup = orderGroup;
  res.json({ account, orderId, orderGroup, transactionId, merchantName });
});

orderRoutes.post("/process", async (req: Request, res: Response) => {
  // BUG: works in dev with one Node process and MemoryStore, returns
  // `400 — no pending order to process` the moment the load balancer routes
  // /place and /process to different replicas.
  const orderGroup = req.session.pendingOrderGroup;
  if (!orderGroup) {
    return res.status(400).json({ error: "No pending order to process" });
  }

  await vtexCheckout({
    path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
    method: "POST",
    cookies: req.session.vtexCookies || {},
  });

  delete req.session.pendingOrderGroup;
  res.json({ status: "processed" });
});

---

Constraint: MUST validate all inputs server-side before forwarding to 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

// 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

// 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:

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:

// 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:

// 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 — Steps 1 and 3 run in the BFF; Step 2 runs in the browser per the PCI carve-out. All 3 steps must complete within the 5-minute window.

// server/routes/order.ts — BFF handles Step 1 (place) and Step 3 (process) only.
// Step 2 (send payment data) runs in the browser; see the card-data carve-out constraint.
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";

export const orderRoutes = Router();

const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;

// POST /api/bff/order/place — Step 1: place order from existing cart.
// Returns the data the browser needs to call vtexpayments.com.br directly in
// Step 2 AND to call /api/bff/order/process in Step 3. The browser is the
// authority for `orderGroup` between Step 1 and Step 3 — the BFF does NOT
// stash it in `req.session`, because that would silently break in any
// multi-replica deployment whose session store is in-memory or sticky-routed.
orderRoutes.post("/place", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  if (!orderFormId) {
    return res.status(400).json({ error: "No active cart" });
  }

  try {
    const placeResult = await vtexCheckout<PlaceOrderResponse>({
      path: `/api/checkout/pub/orderForm/${orderFormId}/transaction`,
      method: "POST",
      body: { referenceId: orderFormId },
      cookies: req.session.vtexCookies || {},
      userToken: req.session.vtexAuthToken,
    });

    const { orders, orderGroup } = placeResult.data;
    if (!orders?.length) {
      return res
        .status(500)
        .json({ error: "Order placement returned no orders" });
    }

    const orderId = orders[0].orderId;
    const merchantTransaction =
      orders[0].transactionData.merchantTransactions[0];
    const transactionId = merchantTransaction?.transactionId;
    const merchantName = merchantTransaction?.merchantName ?? VTEX_ACCOUNT;

    res.json({
      account: VTEX_ACCOUNT,
      orderId,
      orderGroup,
      transactionId,
      merchantName,
    });
  } catch (error) {
    console.error("Error placing order:", error);
    res.status(500).json({ error: "Failed to place order" });
  }
});

// POST /api/bff/order/process — Step 3: process gateway callback after the
// browser has submitted payment data directly to vtexpayments.com.br in Step 2.
// Carries no card data; safe to run server-side. The browser passes back the
// same `orderGroup` it received from /place so the handler is stateless across
// replicas. The BFF still validates ownership through the session-bound
// orderFormId + checkout cookies before calling VTEX.
orderRoutes.post("/process", async (req: Request, res: Response) => {
  const orderGroup =
    typeof req.body?.orderGroup === "string" ? req.body.orderGroup : undefined;
  if (!orderGroup || !/^[A-Za-z0-9-]+$/.test(orderGroup)) {
    return res.status(400).json({ error: "Missing or invalid orderGroup" });
  }
  if (!req.session.orderFormId) {
    return res.status(400).json({ error: "No active checkout session" });
  }

  try {
    await vtexCheckout<unknown>({
      path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
      method: "POST",
      cookies: req.session.vtexCookies || {},
    });

    delete req.session.orderFormId;
    delete req.session.vtexCookies;

    res.json({ orderGroup, status: "processed" });
  } catch (error) {
    console.error("Error processing order:", error);
    res.status(500).json({ error: "Failed to process order" });
  }
});
// frontend/checkout/placeOrder.ts — Step 2 runs in the browser.
// Card fields stay on the device; the BFF never sees them.
// Step 3 echoes `orderGroup` back to the BFF in the request body so it works
// the same way whether the BFF runs on one pod or fifty.
async function placeOrderWithCard(card: CardPaymentInformation) {
  const placeResp = await fetch("/api/bff/order/place", {
    method: "POST",
    credentials: "include",
  });
  const { account, orderGroup, transactionId, merchantName } =
    await placeResp.json();

  await fetch(
    `https://${account}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`,
    {
      method: "POST",
      credentials: "include",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify([
        { ...card, transaction: { id: transactionId, merchantName } },
      ]),
    },
  );

  await fetch("/api/bff/order/process", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ orderGroup }),
  });
}

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.
  // 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 Steps 1 → 2 → 3 sequentially and immediately as a single user interaction. Step 1 (place) and Step 3 (gateway callback) run in the BFF; Step 2 (send payment data) runs in the browser per the card-data carve-out above. Do not pause for additional UI between steps.
  // Frontend orchestrates Place (BFF) → Pay (browser → vtexpayments.com.br) → Process (BFF)
  // on a single click, well within the 5-minute window.
  async function onPlaceOrderClick(card: CardPaymentInformation) {
    const { account, orderGroup, transactionId, merchantName } =
      await fetchJson("/api/bff/order/place", { method: "POST" });

    await sendPaymentDirectToGateway({
      account,
      transactionId,
      orderGroup,
      merchantName,
      card,
    });

    await fetchJson("/api/bff/order/process", {
      method: "POST",
      body: { orderGroup },
    });
  }
  • Proxying `vtexpayments.com.br` payment submission through the BFF "for consistency": Routing the Send payments information call through the BFF feels symmetrical with the rest of the BFF mandate, and some agents add it to "centralize all VTEX calls". When card data is involved this is a PCI DSS violation, not a refactor. Keep Step 2 in the browser; the BFF should expose /api/bff/order/place (returns transactionId/orderGroup/merchantName) and /api/bff/order/process (calls /gatewayCallback) but never /api/bff/order/payment for card flows.
  • Storing `orderGroup` in `req.session` between `/place` and `/process`: Writing req.session.pendingOrderGroup in Step 1 and reading it in Step 3 looks tidy and works in single-process local development. With express-session's default MemoryStore (and any per-pod cache) a horizontally scaled BFF loses the value the moment the two requests land on different replicas, so /process returns 400 — no pending order to process while the shopper's card has already been charged in Step 2. Return orderGroup from /place to the browser and require the browser to send it back in the /process body — see the dedicated hard constraint above. Even with a shared session store (Redis, Memcached, database) in place for orderFormId/vtexCookies, the cross-step handoff still belongs in the request body so the flow stays correct under partial outages, sticky-routing failures, replication lag, and tab refresh.
  • 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.
  // 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 (vtexcommercestable.com.br/api/checkout/...) routed through the BFF (no direct frontend calls)?
  • [ ] Is the Send payments information call (vtexpayments.com.br/api/pub/transactions/{tid}/payments) sent from the browser/app directly, NOT proxied through the BFF, when card data is involved?
  • [ ] Are cardNumber, holderName, validationCode/csc, and dueDate absent from every BFF route handler and log statement?
  • [ ] Does any reference to vtexpayments.com.br in the codebase appear only in browser/app code, never in server/, bff/, api/, or other backend directories?
  • [ ] 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?
  • [ ] Do all 3 order-placement steps (place → pay → process) execute as a single user interaction within the 5-minute window, with Steps 1 and 3 in the BFF and Step 2 in the browser direct to vtexpayments.com.br?
  • [ ] Is the existing orderFormId reused from the session rather than creating a new cart on every page load?
  • [ ] Is orderGroup returned by /place and sent back by the browser in the /process request body, instead of being stashed in req.session.pendingOrderGroup between the two BFF calls?
  • [ ] Are VTEX error responses sanitized before being sent to the frontend?

Reference

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.