mcpField
H
headless-checkout-proxy

headless-checkout-proxy

github.com/vtex開発471.1k 回インストール2 週間前に更新
リポジトリ

headless-checkout-proxy について

ヘッドレス VTEX ストアフロントのカートとチェックアウト実装向けスキルです。Checkout API は BFF 経由とし、カードデータは VTEX Payment Gateway へ直接送信する規則を示します。

主な機能

  1. 01Checkout API の BFF 経由プロキシ
  2. 02orderFormId のサーバーセッション管理
  3. 03OrderForm 添付データの管理
  4. 043 段階の注文フロー指針
  5. 05カードデータのゲートウェイ直送

活用シーン

  1. 01カート商品の追加・変更・削除
  2. 02顧客・配送・決済情報の添付
  3. 03注文確定と処理フローの構築
  4. 04チェックアウトデータの安全経路の確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/vtex/skills --skill headless-checkout-proxy

インストール後、Claude Code で/headless-checkout-proxyを入力して読み込まれたか確認します。

詳細

チェックアウト API プロキシと OrderForm の管理

このスキルを使用する場面

ヘッドレス VTEX ストアフロントのカートおよびチェックアウト機能を構築する際に、このスキルを使用します。カートおよびチェックアウトのすべての操作は、BFF 経由で行う必要があります。

  • カートの作成、商品の追加・更新・削除の実装
  • OrderForm へのプロフィール、配送、決済データの添付
  • 3 ステップの注文確定フロー(place → pay → process)の実装
  • orderFormId と CheckoutOrderFormOwnership Cookie のサーバーサイドでの管理

以下にはこのスキルを使用しないでください。

判断ルール

  • すべての Checkout API 呼び出し(vtexcommercestable.com.br 上の /api/checkout/...)は、BFF 経由でプロキシする必要があります。Checkout API は機密性の高い個人データ(プロフィール、住所、決済方法の選択)を扱います。
  • PCI の例外: VTEX Payment Gateway への支払い情報送信呼び出し(POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments)にカードデータが含まれる場合、ブラウザー/アプリから vtexpayments.com.br に直接送信する必要があります。加盟店の BFF をカードデータの経路に含めてはなりません。以下の専用の厳格な制約と payment-pci-security を参照してください。
  • orderFormId はサーバーサイドのセッションに保存し、localStorage や sessionStorage には保存しないでください。
  • すべてのリクエストで、BFF と VTEX 間の CheckoutOrderFormOwnership および checkout.vtex.com Cookie を取得して転送してください。
  • VTEX に転送する前に、すべての入力をサーバーサイドで検証してください。未加工の req.body を直接渡してはいけません。
  • 注文確定の 3 ステップのフロー(注文確定 → 決済送信 → 注文処理)は、5 分以内に単一の同期的なユーザー操作として実行してください。ステップ 1(注文確定)とステップ 3(ゲートウェイコールバック)は BFF で実行し、ステップ 2(vtexpayments.com.br への決済データ送信)は、上記の PCI 例外に従ってブラウザーで実行します。
  • セッションに既存の orderFormId がある場合は、必ずそれを保存して再利用してください。新しいカートを作成するのは、orderFormId が存在しない場合に限ります。
  • orderGroup などの中間フロー値は、ステップ 1(/place)とステップ 3(/process)の間で、req.session ではなくリクエストボディを通じて渡してください。本番の BFF は通常複数レプリカで構成されるため、express-session のデフォルトの MemoryStore(および Pod ごとのキャッシュ)では、2 つのリクエストが異なる Pod に到達したときに値が失われ、「処理対象の注文がありません」という誤解を招く 400 エラーが返されます。orderFormId/vtexCookies のために共有セッションストア(Redis、Memcached、データベース)がすでに必要な場合でも、ステップ間の受け渡しをリクエストボディに含めるべき理由については、以下の厳格な制約を参照してください。これにより、一部障害、スティッキールーティングの失敗、タブの再読み込み後の再生/更新時にも正しく動作します。

OrderForm の添付エンドポイント:

添付データエンドポイント用途
itemsPOST .../orderForm/{id}/itemsカート商品の追加、削除、更新
clientProfileDataPOST .../orderForm/{id}/attachments/clientProfileData顧客プロフィール情報
shippingDataPOST .../orderForm/{id}/attachments/shippingData住所と配送オプション
paymentDataPOST .../orderForm/{id}/attachments/paymentData決済方法の選択
marketingDataPOST .../orderForm/{id}/attachments/marketingDataクーポンと UTM データ

厳格な制約

制約:Checkout API のすべての操作は BFF 経由でなければなりません

クライアント側のコードから、vtexcommercestable.com.br/api/checkout/... 上の VTEX Checkout API エンドポイントへ直接 HTTP リクエストを送信してはなりません。カートの作成、商品の管理、プロフィールの更新、配送、支払い方法の選択(paymentData の添付)、注文の確定(/transaction)、注文処理(/gatewayCallback)など、Checkout API のすべての操作は BFF 経由でプロキシする必要があります。

このルールは Checkout API のみに適用されます。vtexpayments.com.br 上の Send payments information 呼び出しは次の制約の対象であり、PCI 上の理由から逆の方法(ブラウザーから直接呼び出す)で行います。このルールをそのエンドポイントに一般化しないでください。

重要な理由

Checkout エンドポイントは、機密性の高い個人データ(メールアドレス、住所、電話番号、支払い方法の選択)を扱います。フロントエンドから直接呼び出すと、リクエストとレスポンスのフローがブラウザーの DevTools、拡張機能、XSS 攻撃にさらされます。さらに、VtexIdclientAutCookie および CheckoutOrderFormOwnership Cookie をサーバー側で管理し、入力を検証して、カートの改ざん(価格の不正操作など)を防ぐために BFF レイヤーが必要です。

検出

クライアント側のコード(ブラウザーで実行される JavaScript、フロントエンドのソースファイル)に、vtexcommercestable.com.br/api/checkout/... への fetch または axios 呼び出しがある場合 → ただちに停止してください。Checkout API の呼び出しはすべて BFF エンドポイント経由で行う必要があります。

正しい例

// Frontend — calls BFF endpoint, never VTEX directly
async function addItemToCart(
  skuId: string,
  quantity: number,
  seller: string,
): Promise<OrderForm> {
  const response = await fetch("/api/bff/cart/items", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ skuId, quantity, seller }),
  });

  if (!response.ok) {
    throw new Error(`Failed to add item: ${response.status}`);
  }

  return response.json();
}

誤った例

// Frontend — calls VTEX Checkout API directly (SECURITY VULNERABILITY)
async function addItemToCart(
  skuId: string,
  quantity: number,
  seller: string,
): Promise<OrderForm> {
  const orderFormId = localStorage.getItem("orderFormId"); // Also wrong: see next constraint
  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}/items`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        orderItems: [{ id: skuId, quantity, seller }],
      }),
    },
  );
  return response.json();
}

制約:カードデータはブラウザー/アプリから vtexpayments.com.br に直接送信する必要があります。BFF を経由してはいけません

Send payments information 呼び出し(POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments?orderId={orderGroup})では、購入者がクレジットカード、デビットカード、または提携カードで支払う場合、カードデータが送信されます。この呼び出しは、必ず購入者のブラウザーまたはネイティブアプリから実行し、vtexpayments.com.br に直接送信してください。カードデータが関係する場合、BFF はこの呼び出しを、データをマスキングしていても、サーバー側に appKey/appToken があっても、「トークン化」されたフィールドのみを転送しているように見えても、中継してはいけません。

これは以前の制約とは逆です。この呼び出しを一般的な「チェックアウト」呼び出しとみなし、「すべてのチェックアウトを BFF 経由にする」というルールを広く適用して BFF 経由にするのは、一部のエージェントが行うように、セキュリティの向上ではなく PCI DSS 違反です。

重要な理由

ヘッドレスストアフロントを運営するマーチャントは、PCI DSS レベル 1 の認定を受けていないことがほとんどです。カード番号、名義人名、または CVV をマーチャントの BFF 経由で送信すると、BFF と、BFF が接続するすべてのシステム(アプリケーションログ、APM/オブザーバビリティツール、リバースプロキシ、ロードバランサー、エラートラッカー)が PCI の適用範囲に入ります。カードデータを取り扱う非 PCI 環境を運用すると、PCI DSS 要件 3 および 4 に違反し、カードネットワークから月額 5,000 ドルから 100,000 ドルを超える罰金を科されるほか、必須のフォレンジック調査費用、カード決済処理の停止、法的責任が発生する可能性があります。

ブラウザーから vtexpayments.com.br への経路は、PCI 準拠のパターンです。VTEX Payment Gateway は PCI DSS レベル 1 の認定を受けており、このフローで生のカードデータを受け取ることが認められた唯一の環境です。Send payments 呼び出しは、前の Place Order ステップで設定された購入者のセッション Cookie によって認証されます。この通信経路で BFF にマーチャントの認証情報は必要ありません。

検出

BFF/サーバー側のコード(server/、bff/、api/、ルートハンドラー、ミドルウェア、エッジ関数、Lambda)に次のいずれかが見つかった場合は、直ちに作業を停止してください。

  • BFF から https://*.vtexpayments.com.br/api/pub/transactions/.../payments へのリクエスト。
  • ブラウザーから cardNumber、holderName、validationCode、csc、dueDate、または決済用の fields 全体を受け取り、VTEX のいずれかのエンドポイントに転送するハンドラー。
  • Send payments information エンドポイントに対して vtexpayments.com.br を送信先とする「Payments client」/ExternalClient/axios インスタンス。

この呼び出しをブラウザー/アプリに移してください。代わりに、BFF は Place Order ステップから transactionId、orderGroup、merchantName を返し、フロントエンドが Payment Gateway に決済データを直接送信できるようにします。

このルールは、次の場合にも適用されます。

  • マーチャントが決済権限を持つ VTEX の appKey/appToken を保有している場合。認証情報を持っていても、PCI に関する認可を得たことにはなりません。
  • 「トークン化」されたカードフィールドのみを転送する場合。トークン値も実際のカードデータを参照するため、PCI の適用範囲に含まれます。
  • BFF コードでログ出力前に機密フィールドを「マスキング」している場合。マスキング前に、リクエストは引き続きマーチャントのインフラを経由します。
  • BFF が TLS を使用するプライベート VPC 上で実行されている場合。PCI の適用範囲は、ネットワークの構成ではなく、どのようなデータが経由するかによって決まります。

正しい例

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

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

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

誤った例

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

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

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

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

制約:orderFormId は必ずサーバー側で管理する必要があります

orderFormId は安全なサーバー側セッションに保存する必要があります。localStorage、sessionStorage に保存したり、VTEX API を直接呼び出せる形でフロントエンドに公開したりしてはいけません。

重要な理由

orderFormId は、顧客のショッピングカートと、その中のすべてのデータ(プロフィール情報、配送先住所、支払い情報)への鍵です。クライアント側に公開すると、攻撃者がこれを使って VTEX に直接問い合わせ、個人情報を取得したり、検証ロジックを回避して直接 API を呼び出し、カートに商品を追加・削除したりする可能性があります。

検出方法

orderFormId が localStorage または sessionStorage に保存されている場合は、直ちに作業を停止してください。BFF セッションで管理する必要があります。

正しい例

// BFF — manages orderFormId in server-side session
import { Router, Request, Response } from "express";
import { vtexCheckoutRequest } from "../vtex-api-client";

export const cartRoutes = Router();

// Get or create cart — orderFormId stays server-side
cartRoutes.get("/", async (req: Request, res: Response) => {
  try {
    let orderFormId = req.session.orderFormId;

    if (orderFormId) {
      // Retrieve existing cart
      const orderForm = await vtexCheckoutRequest({
        path: `/api/checkout/pub/orderForm/${orderFormId}`,
        method: "GET",
        cookies: req.session.vtexCookies,
      });
      return res.json(sanitizeOrderForm(orderForm));
    }

    // Create new cart
    const orderForm = await vtexCheckoutRequest({
      path: "/api/checkout/pub/orderForm",
      method: "GET",
      cookies: req.session.vtexCookies,
    });

    // Store orderFormId in session — never expose raw ID to frontend
    req.session.orderFormId = orderForm.orderFormId;
    req.session.vtexCookies = orderForm._cookies; // Store checkout cookies

    res.json(sanitizeOrderForm(orderForm));
  } catch (error) {
    console.error("Error getting cart:", error);
    res.status(500).json({ error: "Failed to get cart" });
  }
});

// Remove sensitive data before sending to frontend
function sanitizeOrderForm(
  orderForm: Record<string, unknown>,
): Record<string, unknown> {
  const sanitized = { ...orderForm };
  delete sanitized._cookies;
  return sanitized;
}

誤った例

// Frontend — stores orderFormId in localStorage (INSECURE)
async function getCart(): Promise<OrderForm> {
  let orderFormId = localStorage.getItem("orderFormId"); // EXPOSED to client

  if (!orderFormId) {
    const response = await fetch(
      "https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm",
    );
    const data = await response.json();
    orderFormId = data.orderFormId;
    localStorage.setItem("orderFormId", orderFormId!); // Stored client-side!
  }

  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}`,
  );
  return response.json();
}

制約:複数ステップの注文フローの引き継ぎ値は、req.session ではなくリクエストボディで受け渡す必要があります

/place(ステップ 1)と /process(ステップ 3)を結び付ける値(最も重要なのは orderGroup ですが、フロントエンドが受け取って返す必要のある試行ごとの transactionId やマーチャントコンテキストも含みます)は、/place からブラウザーに返し、ブラウザーから /process リクエストのボディで送り返す必要があります。ハンドラーは、処理中の注文に関する唯一の信頼できる情報源として req.session.pendingOrderGroup(またはポッドごとのキャッシュ)に依存してはいけません。

重要な理由

本番環境の BFF は、複数のレプリカ(Kubernetes デプロイメント、ECS サービス、ALB 配下のラムダ、エッジランタイム)で動作します。express-session のデフォルトは MemoryStore で、ポッドごとに管理されます。そのため、あるポッドに書き込まれたセッション値は、ほかのすべてのポッドから見えません。共有セッションストア(Redis、DynamoDB、Memcached)を使っていても、ステップ 1 とステップ 3 は、ユーザーが vtexpayments.com.br を経由して往復できるほどの速さで実行されます。そのため、Redis の一時的な障害、書き込みのレプリケーション遅延、スティッキールーティングの失敗、タブの再読み込み、ログイン時のセッション再生成があると、「処理する保留中の注文がありません」という 400 エラーが発生します。購入者から見ると、ステップ 2 でカードの請求が完了して注文の支払いも済んでいるのに、ストアフロントでは失敗と表示され、サポート担当者が手作業で照合しなければなりません。注文の処理に使える時間は 5 分しかなく、再試行する余裕はありません。

orderGroup は秘密情報ではありません。ステップ 1 で既にブラウザーに返され、ステップ 2 でブラウザーから vtexpayments.com.br を直接呼び出す際にも必要です。ステップ 3 でこれを返す方法は、セッションに状態を保持する方法よりも攻撃対象領域が狭くなります。認証と所有権の確認は、引き続きセッションに紐付いた vtexCookies、orderFormId、および(存在する場合は)vtexAuthToken を通じてサーバー側で行います。BFF が orderGroup だけを信頼することはありません。

検出方法

BFF またはサーバー側のチェックアウトコードに次のいずれかがある場合は、直ちに作業を停止してください。

  • /place で書き込まれ、/process で読み取られる req.session.pendingOrderGroup、req.session.orderGroup、req.session.lastTransactionId、または同様のフローごとの値を読み書きしている。
  • req.body.orderGroup によるフォールバックや検証がなく、req.session だけから orderGroup を取得している /process(または /gatewayCallback)ハンドラー。
  • BFF が注文を「記憶」していることを前提に、ボディも orderGroup も含めずに /api/bff/order/process を呼び出すフロントエンドの placeOrder フロー。
  • 「orderGroup の復元はセッションに依存する」といったコメント、または「整理」のために orderGroup をリクエストボディからセッションに移すコミット。

正しい例

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

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

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

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

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

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

/place が orderGroup を返し、ブラウザーが /process のボディでそれを返す対応するブラウザーフローは、以下の推奨パターンに示されています。

誤った例

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

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

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

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

制約:VTEX に転送する前に、すべての入力を必ずサーバー側で検証する

BFF は、リクエストを VTEX Checkout API に転送する前に、すべての入力データを必ず検証する必要があります。これには、SKU ID、数量、メールアドレスの形式、住所の各フィールド、クーポンコードの検証が含まれます。

重要な理由

サーバー側で検証しないと、悪意のあるユーザーが不正な数量、テキストフィールドへの SQL インジェクション、偽装した販売者 ID など、不正または操作を意図したデータを含むリクエストを BFF 経由で VTEX に送信できます。VTEX にも独自の検証機能はありますが、多層防御のため、問題を早期に検出してわかりやすいエラーメッセージを提供できるよう、BFF 層でも検証する必要があります。

検出

BFF のルートハンドラーが、検証やサニタイズを行わずに req.body を VTEX API 呼び出しに直接渡している場合 → ただちに中止してください。プロキシする前に、すべての入力を検証する必要があります。

正しい例

// BFF — validates inputs before forwarding to VTEX
import { Router, Request, Response } from "express";
import { vtexCheckoutRequest } from "../vtex-api-client";

export const cartItemsRoutes = Router();

interface AddItemRequest {
  skuId: string;
  quantity: number;
  seller: string;
}

function validateAddItemInput(body: unknown): body is AddItemRequest {
  if (typeof body !== "object" || body === null) return false;
  const b = body as Record<string, unknown>;

  return (
    typeof b.skuId === "string" &&
    /^\d+$/.test(b.skuId) &&
    typeof b.quantity === "number" &&
    Number.isInteger(b.quantity) &&
    b.quantity > 0 &&
    b.quantity <= 100 &&
    typeof b.seller === "string" &&
    /^[a-zA-Z0-9]+$/.test(b.seller)
  );
}

cartItemsRoutes.post("/", async (req: Request, res: Response) => {
  if (!validateAddItemInput(req.body)) {
    return res.status(400).json({
      error: "Invalid input",
      details:
        "skuId must be numeric, quantity must be 1-100, seller must be alphanumeric",
    });
  }

  const { skuId, quantity, seller } = req.body;
  const orderFormId = req.session.orderFormId;

  if (!orderFormId) {
    return res.status(400).json({ error: "No active cart" });
  }

  try {
    const orderForm = await vtexCheckoutRequest({
      path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
      method: "POST",
      body: {
        orderItems: [{ id: skuId, quantity, seller }],
      },
      cookies: req.session.vtexCookies,
    });

    res.json(sanitizeOrderForm(orderForm));
  } catch (error) {
    console.error("Error adding item:", error);
    res.status(500).json({ error: "Failed to add item to cart" });
  }
});

誤った例

// BFF — passes raw input to VTEX without validation (UNSAFE)
cartRoutes.post("/items", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;

  // No validation — attacker can send any payload
  const orderForm = await vtexCheckoutRequest({
    path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
    method: "POST",
    body: req.body, // Raw, unvalidated input passed directly!
    cookies: req.session.vtexCookies,
  });

  res.json(orderForm);
});

推奨パターン

チェックアウト処理における BFF 経由のリクエストフロー:

Frontend
    │
    └── POST /api/bff/cart/items/add  {skuId, quantity, seller}
            │
            BFF Layer
            │ 1. Validates input (skuId format, quantity > 0, seller exists)
            │ 2. Reads orderFormId from server-side session
            │ 3. Forwards CheckoutOrderFormOwnership cookie
            │ 4. Calls VTEX: POST /api/checkout/pub/orderForm/{id}/items
            │ 5. Updates session with new orderFormId if changed
            │ 6. Returns sanitized orderForm to frontend
            │
            VTEX Checkout API

Cookie 管理を備えた VTEX Checkout API クライアント:

// server/vtex-checkout-client.ts
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_ENVIRONMENT = process.env.VTEX_ENVIRONMENT || "vtexcommercestable";
const BASE_URL = `https://${VTEX_ACCOUNT}.${VTEX_ENVIRONMENT}.com.br`;

interface CheckoutRequestOptions {
  path: string;
  method?: string;
  body?: unknown;
  cookies?: Record<string, string>;
  userToken?: string;
}

interface CheckoutResponse<T = unknown> {
  data: T;
  cookies: Record<string, string>;
}

export async function vtexCheckout<T>(
  options: CheckoutRequestOptions,
): Promise<CheckoutResponse<T>> {
  const { path, method = "GET", body, cookies = {}, userToken } = options;

  const headers: Record<string, string> = {
    Accept: "application/json",
    "Content-Type": "application/json",
  };

  // Build cookie header from stored cookies
  const cookieParts: string[] = [];
  if (cookies["checkout.vtex.com"]) {
    cookieParts.push(`checkout.vtex.com=${cookies["checkout.vtex.com"]}`);
  }
  if (cookies["CheckoutOrderFormOwnership"]) {
    cookieParts.push(
      `CheckoutOrderFormOwnership=${cookies["CheckoutOrderFormOwnership"]}`,
    );
  }
  if (userToken) {
    cookieParts.push(`VtexIdclientAutCookie=${userToken}`);
  }
  if (cookieParts.length > 0) {
    headers["Cookie"] = cookieParts.join("; ");
  }

  const response = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(
      `Checkout API error: ${response.status} for ${method} ${path}: ${errorBody}`,
    );
  }

  // Extract cookies from response for session storage
  const responseCookies: Record<string, string> = {};
  const setCookieHeaders = response.headers.getSetCookie?.() ?? [];
  for (const setCookie of setCookieHeaders) {
    const [nameValue] = setCookie.split(";");
    const [name, value] = nameValue.split("=");
    if (name && value) {
      responseCookies[name.trim()] = value.trim();
    }
  }

  const data = (await response.json()) as T;
  return { data, cookies: { ...cookies, ...responseCookies } };
}

カート管理用 BFF ルート:

// server/routes/cart.ts
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";

export const cartRoutes = Router();

// GET /api/bff/cart — get or create cart
cartRoutes.get("/", async (req: Request, res: Response) => {
  try {
    const result = await vtexCheckout<OrderForm>({
      path: req.session.orderFormId
        ? `/api/checkout/pub/orderForm/${req.session.orderFormId}`
        : "/api/checkout/pub/orderForm",
      cookies: req.session.vtexCookies || {},
      userToken: req.session.vtexAuthToken,
    });

    req.session.orderFormId = result.data.orderFormId;
    req.session.vtexCookies = result.cookies;

    res.json(result.data);
  } catch (error) {
    console.error("Error getting cart:", error);
    res.status(500).json({ error: "Failed to get cart" });
  }
});

// POST /api/bff/cart/items — add items to cart
cartRoutes.post("/items", async (req: Request, res: Response) => {
  const { items } = req.body as {
    items: Array<{ id: string; quantity: number; seller: string }>;
  };

  if (!Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: "Items array is required" });
  }

  for (const item of items) {
    if (
      !item.id ||
      typeof item.quantity !== "number" ||
      item.quantity < 1 ||
      !item.seller
    ) {
      return res
        .status(400)
        .json({ error: "Each item must have id, quantity (>0), and seller" });
    }
  }

  const orderFormId = req.session.orderFormId;
  if (!orderFormId) {
    return res
      .status(400)
      .json({ error: "No active cart. Call GET /api/bff/cart first." });
  }

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

    req.session.vtexCookies = result.cookies;
    res.json(result.data);
  } catch (error) {
    console.error("Error adding items:", error);
    res.status(500).json({ error: "Failed to add items to cart" });
  }
});

注文の確定 — ステップ 1 と 3 は BFF で実行し、ステップ 2 は PCI の例外規定に従ってブラウザーで実行します。3 つのステップすべてを 5 分以内に完了する必要があります。

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

export const orderRoutes = Router();

const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;

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

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

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

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

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

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

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

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

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

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

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

よくある失敗パターン

  • ページが読み込まれるたびに新しいカートを作成する:ページが読み込まれるたびに orderFormId を指定せず GET /api/checkout/pub/orderForm を呼び出すと、そのたびに空のカートが新しく作成され、前のカートは放棄されます。サーバー側のセッションにある orderFormId を常に保存して再利用してください。

    // Always check for existing orderFormId first
    cartRoutes.get("/", async (req: Request, res: Response) => {
      const orderFormId = req.session.orderFormId;
    
      const path = orderFormId
        ? `/api/checkout/pub/orderForm/${orderFormId}` // Retrieve existing cart
        : "/api/checkout/pub/orderForm"; // Create new cart only if none exists
    
      const result = await vtexCheckout<OrderForm>({
        path,
        cookies: req.session.vtexCookies || {},
        userToken: req.session.vtexAuthToken,
      });
    
      req.session.orderFormId = result.data.orderFormId;
      req.session.vtexCookies = result.cookies;
      res.json(result.data);
    });
    
  • 5 分間の注文処理ウィンドウを無視する:注文を確定(ステップ 1)した後、支払いや処理を 5 分以上遅らせると、VTEX は注文を incomplete として自動的にキャンセルします。ステップ 1 → 2 → 3 を、1 回のユーザー操作として順番どおりにすぐ実行してください。ステップ 1(確定)とステップ 3(ゲートウェイコールバック)は BFF で実行し、ステップ 2(支払いデータの送信)は上記のカードデータの例外規定に従ってブラウザーで実行します。ステップの合間に UI で追加の操作を求めないでください。

    // Frontend orchestrates Place (BFF) → Pay (browser → vtexpayments.com.br) → Process (BFF)
    // on a single click, well within the 5-minute window.
    async function onPlaceOrderClick(card: CardPaymentInformation) {
      const { account, orderGroup, transactionId, merchantName } =
        await fetchJson("/api/bff/order/place", { method: "POST" });
    
      await sendPaymentDirectToGateway({
        account,
        transactionId,
        orderGroup,
        merchantName,
        card,
      });
    
      await fetchJson("/api/bff/order/process", {
        method: "POST",
        body: { orderGroup },
      });
    }
    
  • 「一貫性を保つため」に vtexpayments.com.br への支払い送信を BFF 経由でプロキシする:支払い情報の送信呼び出しを BFF 経由にすると、ほかの BFF の方針と対称的に見えます。また、一部のエージェントは「すべての VTEX 呼び出しを一元化する」ためにこれを追加します。しかし、カードデータを扱う場合、これはリファクタリングではなく PCI DSS 違反です。ステップ 2 はブラウザーで実行してください。BFF では /api/bff/order/place(transactionId/orderGroup/merchantName を返す)と /api/bff/order/process(/gatewayCallback を呼び出す)を公開し、カードフロー用の /api/bff/order/payment は決して公開しないでください。

  • /place と /process の間で orderGroup を req.session に保存する:ステップ 1 で req.session.pendingOrderGroup に書き込み、ステップ 3 で読み込む方法は、すっきりしていて、単一プロセスのローカル開発環境では動作します。しかし、express-session のデフォルトの MemoryStore(およびポッドごとのキャッシュ)では、2 つのリクエストが異なるレプリカに届いた瞬間に、水平スケーリングされた BFF が値を失います。その結果、ステップ 2 ですでに購入者のカードに請求が発生しているにもかかわらず、/process は 400 — 処理対象の注文がありません を返します。/place からブラウザーに orderGroup を返し、専用のハード制約の説明に従って、ブラウザーから /process の本文に含めて送り返すようにしてください。orderFormId/vtexCookies 用の共有セッションストア(Redis、Memcached、データベース)を使用している場合でも、部分的な障害、スティッキールーティングの失敗、レプリケーション遅延、タブの再読み込み時にもフローが正しく動作するよう、ステップ間の引き継ぎには引き続きリクエスト本文を使用してください。

  • VTEX の生のエラーメッセージをフロントエンドに公開する:VTEX API のエラーレスポンスをそのままフロントエンドに転送すると、アカウント名、API パス、データ構造などの内部情報が漏えいします。BFF で VTEX のエラーをユーザー向けのメッセージに変換し、エラーの完全な内容はサーバー側でログに記録してください。

    // Map VTEX errors to safe, user-friendly messages
    function mapCheckoutError(
      vtexError: string,
      statusCode: number,
    ): { code: string; message: string } {
      if (statusCode === 400 && vtexError.includes("item")) {
        return {
          code: "INVALID_ITEM",
          message: "One or more items are unavailable",
        };
      }
      if (statusCode === 400 && vtexError.includes("address")) {
        return {
          code: "INVALID_ADDRESS",
          message: "Please check your shipping address",
        };
      }
      if (statusCode === 409) {
        return {
          code: "CART_CONFLICT",
          message: "Your cart was updated. Please review your items.",
        };
      }
      return {
        code: "CHECKOUT_ERROR",
        message: "An error occurred during checkout. Please try again.",
      };
    }
    

レビューチェックリスト

  • すべての Checkout API 呼び出し(vtexcommercestable.com.br/api/checkout/...)は BFF 経由でルーティングされていますか(フロントエンドからの直接呼び出しはありませんか)?
  • カードデータが含まれる場合、Send payments information 呼び出し(vtexpayments.com.br/api/pub/transactions/{tid}/payments)は BFF を経由せず、ブラウザー/アプリから直接送信されていますか?
  • すべての BFF ルートハンドラーおよびログ出力に、cardNumber、holderName、validationCode/csc、dueDate が含まれていませんか?
  • コードベース内の vtexpayments.com.br への参照は、ブラウザー/アプリのコードのみにあり、server/、bff/、api/、その他のバックエンドディレクトリーにはありませんか?
  • orderFormId は localStorage や sessionStorage ではなく、サーバー側のセッションに保存されていますか?
  • VTEX のレスポンスから CheckoutOrderFormOwnership と checkout.vtex.com の Cookie を取得し、後続のリクエストに転送していますか?
  • VTEX に転送する前に、すべての入力をサーバー側で検証していますか?
  • 注文確定の 3 つのステップ(place → pay → process)はすべて、5 分以内の単一のユーザー操作として実行されますか?その際、Step 1 と Step 3 は BFF で実行し、Step 2 はブラウザーから vtexpayments.com.br に直接送信していますか?
  • ページを読み込むたびに新しいカートを作成するのではなく、セッションにある既存の orderFormId を再利用していますか?
  • /place が返した orderGroup は、2 つの BFF 呼び出しの間に req.session.pendingOrderGroup に保存するのではなく、ブラウザーから /process リクエストの本文に含めて返送していますか?
  • VTEX のエラーレスポンスは、フロントエンドに送信する前にサニタイズされていますか?

参考資料

コメント