mcpField
M
marketplace-order-hook

marketplace-order-hook

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

marketplace-order-hook について

VTEX の注文更新を連携する開発者向けスキルです。Feed と Hook の選択から、認証、重複排除、注文イベントの処理までを説明します。

主な機能

  1. 01Feedの取得方式とHookのプッシュ方式を選択
  2. 02注文状態フィルターを設定
  3. 03Hookの送信元と認証を検証
  4. 04イベントキーで重複を排除
  5. 05注文詳細を取得して非同期処理

活用シーン

  1. 01注文状態をERPに同期
  2. 02外部フルフィルメントのワークフローを起動
  3. 03Hook停止中のイベントをFeedで補完

インストール

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

$npx skills add https://github.com/vtex/skills --skill marketplace-order-hook

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

詳細

注文連携と Webhook

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

VTEX マーケットプレイスの注文ステータス変更に対応する必要がある連携を構築するときに、このスキルを使用します。たとえば、注文を ERP に同期する場合、フルフィルメントのワークフローを開始する場合、外部システムに通知を送信する場合などです。

  • 注文更新用の Feed v3 または Hook の設定
  • Feed(プル)と Hook(プッシュ)の配信モデルの選択
  • Webhook 認証の検証とイベントの冪等処理
  • 注文ステータスのライフサイクル全体への対応

次の場合は、このスキルを使用しないでください。

  • カタログまたは SKU の同期(marketplace-catalog-sync を参照)
  • 請求書と追跡情報の送信(marketplace-fulfillment を参照)
  • 一般的な API レート制限(marketplace-rate-limiting を参照)

判断ルール

  • リアルタイムの注文更新が必要な高性能ミドルウェアには、**Hook(プッシュ)**を使用します。エンドポイントは 5000ms 以内に HTTP 200 を返す必要があります。
  • スループットが限られている ERP やシステムで、消費ペースを制御する場合は、**Feed(プル)**を使用します。イベントはコミットされるまでキューに保持されます。
  • ダウンタイム中に取りこぼしたイベントを補足するため、Hook と併せてバックアップとして Feed を使用します。
  • 注文ステータスの変更への対応だけが必要な場合は、FromWorkflow フィルターを使用します(よりシンプルで、最も一般的です)。
  • JSONata 式を使って任意の注文プロパティ(販売チャネルなど)でフィルタリングする必要がある場合は、FromOrders フィルターを使用します。
  • 2 種類のフィルターを同時に使用することはできません。同じ設定リクエストで両方を使用すると、409 Conflict が返されます。
  • 各 appKey で設定できるのは、Feed 1 つと Hook 1 つだけです。同じ appKey を共有するユーザーは、同じ Feed/Hook にアクセスします。
FeedHook
モデルプル(アクティブ)プッシュ(リアクティブ)
スケーラビリティボリュームを制御できますどのようなボリュームにも対応する必要があります
信頼性イベントはキューに保持されます常時利用可能である必要があります
最適な用途スループットが限られた ERP高性能ミドルウェア

Hook 通知ペイロード:

{
  "Domain": "Marketplace",
  "OrderId": "v40484048naf-01",
  "State": "payment-approved",
  "LastChange": "2019-07-29T23:17:30.0617185Z",
  "Origin": {
    "Account": "accountABC",
    "Key": "vtexappkey-keyEDF"
  }
}

ペイロードに含まれるのは注文 ID とステータスだけで、注文の全データは含まれません。注文の詳細をすべて取得するには、連携側で GET /api/oms/pvt/orders/{orderId} を呼び出す必要があります。

アーキテクチャ/データフロー:

VTEX OMS                           Your Integration
   │                                       │
   │── Order status change ──────────────▶│  (Hook POST to your URL)
   │                                       │── Validate auth headers
   │                                       │── Check idempotency (orderId + State)
   │◀── GET /api/oms/pvt/orders/{id} ─────│  (Fetch full order)
   │── Full order data ──────────────────▶│
   │                                       │── Process order
   │◀── HTTP 200 ─────────────────────────│  (Must respond within 5000ms)

厳守すべき制約

制約:Webhook 認証を検証する

イベントを処理する前に、Hook エンドポイントでは VTEX から送信された認証ヘッダーを必ず検証する必要があります。ペイロード内の Origin.Account と Origin.Key の値が、想定している値と一致していなければなりません。

重要な理由

認証を検証しないと、誰でもエンドポイントに偽の注文イベントを送信でき、不正なフルフィルメント処理、データ破損、金銭的損失につながる可能性があります。

検出方法

Origin.Account、Origin.Key、またはカスタムヘッダーを確認せずにイベントを処理する Hook エンドポイントのハンドラーを見つけた場合は、処理を止めて認証検証を追加してください。

正しい例

import { RequestHandler } from "express";

interface HookPayload {
  Domain: string;
  OrderId: string;
  State: string;
  LastChange: string;
  Origin: {
    Account: string;
    Key: string;
  };
}

interface HookConfig {
  expectedAccount: string;
  expectedAppKey: string;
  customHeaderKey: string;
  customHeaderValue: string;
}

function createHookHandler(config: HookConfig): RequestHandler {
  return async (req, res) => {
    const payload: HookPayload = req.body;

    // Handle VTEX ping during hook configuration
    if (payload && "hookConfig" in payload) {
      res.status(200).json({ success: true });
      return;
    }

    // Validate Origin credentials
    if (
      payload.Origin?.Account !== config.expectedAccount ||
      payload.Origin?.Key !== config.expectedAppKey
    ) {
      console.error("Unauthorized hook event", {
        receivedAccount: payload.Origin?.Account,
        receivedKey: payload.Origin?.Key,
      });
      res.status(401).json({ error: "Unauthorized" });
      return;
    }

    // Validate custom header (configured during hook setup)
    if (req.headers[config.customHeaderKey.toLowerCase()] !== config.customHeaderValue) {
      console.error("Invalid custom header");
      res.status(401).json({ error: "Unauthorized" });
      return;
    }

    // Process the event
    await processOrderEvent(payload);
    res.status(200).json({ success: true });
  };
}

async function processOrderEvent(payload: HookPayload): Promise<void> {
  console.log(`Processing order ${payload.OrderId} in state ${payload.State}`);
}

誤った例

// WRONG: No authentication validation — accepts events from anyone
const unsafeHookHandler: RequestHandler = async (req, res) => {
  const payload: HookPayload = req.body;

  // Directly processing without checking Origin or headers
  // Any actor can POST fake events and trigger unauthorized actions
  await processOrderEvent(payload);
  res.status(200).json({ success: true });
};

制約:イベントを冪等に処理する

連携では、注文イベントを必ず冪等に処理する必要があります。重複処理を防ぐため、OrderId + State + LastChange の組み合わせを重複排除キーとして使用してください。

重要な理由

VTEX は同じ Hook 通知を複数回配信する場合があります(少なくとも 1 回の配信)。冪等性がないと、二重のフルフィルメント処理、請求書の重複発行、状態の不整合が発生する可能性があります。

検出方法

orderId の重複チェックや重複排除の仕組みがない注文イベントハンドラーを見つけた場合は、冪等性について警告してください。イベントがすでに処理済みかどうかを確認せずに、ハンドラーが状態を直接変更している場合も警告してください。

正しい例

interface ProcessedEvent {
  orderId: string;
  state: string;
  lastChange: string;
  processedAt: Date;
}

// In-memory store for example — use Redis or database in production
const processedEvents = new Map<string, ProcessedEvent>();

function buildDeduplicationKey(payload: HookPayload): string {
  return `${payload.OrderId}:${payload.State}:${payload.LastChange}`;
}

async function idempotentProcessEvent(payload: HookPayload): Promise<boolean> {
  const deduplicationKey = buildDeduplicationKey(payload);

  // Check if this exact event was already processed
  if (processedEvents.has(deduplicationKey)) {
    console.log(`Event already processed: ${deduplicationKey}`);
    return false; // Skip — already handled
  }

  // Mark as processing (with TTL in production)
  processedEvents.set(deduplicationKey, {
    orderId: payload.OrderId,
    state: payload.State,
    lastChange: payload.LastChange,
    processedAt: new Date(),
  });

  try {
    await handleOrderStateChange(payload.OrderId, payload.State);
    return true;
  } catch (error) {
    // Remove from processed set so it can be retried
    processedEvents.delete(deduplicationKey);
    throw error;
  }
}

async function handleOrderStateChange(orderId: string, state: string): Promise<void> {
  switch (state) {
    case "ready-for-handling":
      await startOrderFulfillment(orderId);
      break;
    case "handling":
      await updateOrderInERP(orderId, "in_progress");
      break;
    case "invoiced":
      await confirmOrderShipped(orderId);
      break;
    case "cancel":
      await cancelOrderInERP(orderId);
      break;
    default:
      console.log(`Unhandled state: ${state} for order ${orderId}`);
  }
}

async function startOrderFulfillment(orderId: string): Promise<void> {
  console.log(`Starting fulfillment for ${orderId}`);
}

async function updateOrderInERP(orderId: string, status: string): Promise<void> {
  console.log(`Updating ERP: ${orderId} → ${status}`);
}

async function confirmOrderShipped(orderId: string): Promise<void> {
  console.log(`Confirming shipment for ${orderId}`);
}

async function cancelOrderInERP(orderId: string): Promise<void> {
  console.log(`Canceling order ${orderId} in ERP`);
}

誤った例

// WRONG: No deduplication — processes every event even if already handled
async function processWithoutIdempotency(payload: HookPayload): Promise<void> {
  // If VTEX sends the same event twice, this creates duplicate records
  await database.insert("fulfillment_tasks", {
    orderId: payload.OrderId,
    state: payload.State,
    createdAt: new Date(),
  });

  // Duplicate fulfillment task created — items may ship twice
  await triggerFulfillment(payload.OrderId);
}

async function triggerFulfillment(orderId: string): Promise<void> {
  console.log(`Fulfilling ${orderId}`);
}

const database = {
  insert: async (table: string, data: Record<string, unknown>) => {
    console.log(`Inserting into ${table}:`, data);
  },
};

制約:すべての注文ステータスに対応する

統合では、Status Null を含む、考えられるすべての注文ステータスに対応する必要があります。認識できないステータスは記録し、統合がクラッシュしないようにしてください。

重要な理由

VTEX のドキュメントでは、Status Null が識別されず、別のステータスとしてマッピングされる可能性があり、その結果、エラーにつながることがあると警告されています。ハンドラーでステータスが抜けていると、注文が処理待ちのままになったり、失われたりする可能性があります。

検出

ステータスハンドラーが 2〜3 個のステータスしか処理せず、デフォルト/フォールバックケースもない場合は、不完全なステータス処理について警告してください。

正しい例

type OrderStatus =
  | "order-created"
  | "order-completed"
  | "on-order-completed"
  | "payment-pending"
  | "waiting-for-order-authorization"
  | "approve-payment"
  | "payment-approved"
  | "payment-denied"
  | "request-cancel"
  | "waiting-for-seller-decision"
  | "authorize-fulfillment"
  | "order-create-error"
  | "order-creation-error"
  | "window-to-cancel"
  | "ready-for-handling"
  | "start-handling"
  | "handling"
  | "invoice-after-cancellation-deny"
  | "order-accepted"
  | "invoiced"
  | "cancel"
  | "canceled";

async function handleAllStatuses(orderId: string, state: string): Promise<void> {
  switch (state) {
    case "ready-for-handling":
    case "start-handling":
      await notifyWarehouse(orderId, "prepare");
      break;

    case "handling":
      await updateFulfillmentStatus(orderId, "in_progress");
      break;

    case "invoiced":
      await markAsShipped(orderId);
      break;

    case "cancel":
    case "canceled":
    case "request-cancel":
      await handleCancellation(orderId, state);
      break;

    case "payment-approved":
      await confirmPaymentReceived(orderId);
      break;

    case "payment-denied":
      await handlePaymentFailure(orderId);
      break;

    default:
      // CRITICAL: Log unknown statuses instead of crashing
      console.warn(`Unknown or unhandled order status: "${state}" for order ${orderId}`);
      await logUnhandledStatus(orderId, state);
      break;
  }
}

async function notifyWarehouse(orderId: string, action: string): Promise<void> {
  console.log(`Warehouse notification: ${orderId} → ${action}`);
}
async function updateFulfillmentStatus(orderId: string, status: string): Promise<void> {
  console.log(`Fulfillment status: ${orderId} → ${status}`);
}
async function markAsShipped(orderId: string): Promise<void> {
  console.log(`Shipped: ${orderId}`);
}
async function handleCancellation(orderId: string, state: string): Promise<void> {
  console.log(`Cancellation: ${orderId} (${state})`);
}
async function confirmPaymentReceived(orderId: string): Promise<void> {
  console.log(`Payment received: ${orderId}`);
}
async function handlePaymentFailure(orderId: string): Promise<void> {
  console.log(`Payment failed: ${orderId}`);
}
async function logUnhandledStatus(orderId: string, state: string): Promise<void> {
  console.log(`UNHANDLED: ${orderId} → ${state}`);
}

誤った例

// WRONG: Only handles 2 statuses, no fallback for unknown statuses
async function incompleteHandler(orderId: string, state: string): Promise<void> {
  if (state === "ready-for-handling") {
    await startOrderFulfillment(orderId);
  } else if (state === "invoiced") {
    await confirmOrderShipped(orderId);
  }
  // All other statuses silently ignored — orders get lost
  // "cancel" events never processed — canceled orders still ship
  // "Status Null" could be misinterpreted
}

推奨されるパターン

Hook を設定する

適切なフィルターとエンドポイント URL を指定して Hook を設定します。

import axios, { AxiosInstance } from "axios";

interface HookSetupConfig {
  accountName: string;
  appKey: string;
  appToken: string;
  hookUrl: string;
  hookHeaderKey: string;
  hookHeaderValue: string;
  filterStatuses: string[];
}

async function configureOrderHook(config: HookSetupConfig): Promise<void> {
  const client: AxiosInstance = axios.create({
    baseURL: `https://${config.accountName}.vtexcommercestable.com.br`,
    headers: {
      "Content-Type": "application/json",
      "X-VTEX-API-AppKey": config.appKey,
      "X-VTEX-API-AppToken": config.appToken,
    },
  });

  const hookConfig = {
    filter: {
      type: "FromWorkflow",
      status: config.filterStatuses,
    },
    hook: {
      url: config.hookUrl,
      headers: {
        [config.hookHeaderKey]: config.hookHeaderValue,
      },
    },
  };

  await client.post("/api/orders/hook/config", hookConfig);
  console.log("Hook configured successfully");
}

// Example usage:
await configureOrderHook({
  accountName: "mymarketplace",
  appKey: process.env.VTEX_APP_KEY!,
  appToken: process.env.VTEX_APP_TOKEN!,
  hookUrl: "https://my-integration.example.com/vtex/order-hook",
  hookHeaderKey: "X-Integration-Secret",
  hookHeaderValue: process.env.HOOK_SECRET!,
  filterStatuses: [
    "ready-for-handling",
    "start-handling",
    "handling",
    "invoiced",
    "cancel",
  ],
});
認証と冪等性を備えた Hook エンドポイントを構築する
import express from "express";

const app = express();
app.use(express.json());

const hookConfig: HookConfig = {
  expectedAccount: process.env.VTEX_ACCOUNT_NAME!,
  expectedAppKey: process.env.VTEX_APP_KEY!,
  customHeaderKey: "X-Integration-Secret",
  customHeaderValue: process.env.HOOK_SECRET!,
};

app.post("/vtex/order-hook", createHookHandler(hookConfig));

// The createHookHandler and idempotentProcessEvent functions
// from the Hard constraints section above handle auth + deduplication
注文データ全体を取得して処理する

Hook 通知を受信したら、処理のために注文データ全体を取得します。

interface VtexOrder {
  orderId: string;
  status: string;
  items: Array<{
    id: string;
    productId: string;
    name: string;
    quantity: number;
    price: number;
    sellingPrice: number;
  }>;
  clientProfileData: {
    email: string;
    firstName: string;
    lastName: string;
    document: string;
  };
  shippingData: {
    address: {
      postalCode: string;
      city: string;
      state: string;
      country: string;
      street: string;
      number: string;
    };
    logisticsInfo: Array<{
      itemIndex: number;
      selectedSla: string;
      shippingEstimate: string;
    }>;
  };
  totals: Array<{
    id: string;
    name: string;
    value: number;
  }>;
  value: number;
}

async function fetchAndProcessOrder(
  client: AxiosInstance,
  orderId: string,
  state: string
): Promise<void> {
  const response = await client.get<VtexOrder>(
    `/api/oms/pvt/orders/${orderId}`
  );
  const order = response.data;

  switch (state) {
    case "ready-for-handling":
      await createFulfillmentTask({
        orderId: order.orderId,
        items: order.items.map((item) => ({
          skuId: item.id,
          name: item.name,
          quantity: item.quantity,
        })),
        shippingAddress: order.shippingData.address,
        estimatedDelivery: order.shippingData.logisticsInfo[0]?.shippingEstimate,
      });
      break;

    case "cancel":
      await cancelFulfillmentTask(order.orderId);
      break;

    default:
      console.log(`Order ${orderId}: state=${state}, no action needed`);
  }
}

async function createFulfillmentTask(task: Record<string, unknown>): Promise<void> {
  console.log("Creating fulfillment task:", task);
}

async function cancelFulfillmentTask(orderId: string): Promise<void> {
  console.log("Canceling fulfillment task:", orderId);
}
フォールバックとして Feed を実装する

Feed v3 をバックアップとして使用し、ダウンタイム中に Hook が取りこぼしたイベントを検出します。

async function pollFeedAsBackup(client: AxiosInstance): Promise<void> {
  const feedResponse = await client.get<Array<{
    eventId: string;
    handle: string;
    domain: string;
    state: string;
    orderId: string;
    lastChange: string;
  }>>("/api/orders/feed");

  const events = feedResponse.data;

  if (events.length === 0) {
    return; // No events in queue
  }

  const handlesToCommit: string[] = [];

  for (const event of events) {
    try {
      await fetchAndProcessOrder(client, event.orderId, event.state);
      handlesToCommit.push(event.handle);
    } catch (error) {
      console.error(`Failed to process feed event for ${event.orderId}:`, error);
      // Don't commit failed events — they'll return to the queue after visibility timeout
    }
  }

  // Commit successfully processed events
  if (handlesToCommit.length > 0) {
    await client.post("/api/orders/feed", {
      handles: handlesToCommit,
    });
  }
}

// Run feed polling on a schedule (e.g., every 2 minutes)
setInterval(async () => {
  try {
    const client = createVtexClient();
    await pollFeedAsBackup(client);
  } catch (error) {
    console.error("Feed polling error:", error);
  }
}, 120000); // 2 minutes

function createVtexClient(): AxiosInstance {
  return axios.create({
    baseURL: `https://${process.env.VTEX_ACCOUNT_NAME}.vtexcommercestable.com.br`,
    headers: {
      "X-VTEX-API-AppKey": process.env.VTEX_APP_KEY!,
      "X-VTEX-API-AppToken": process.env.VTEX_APP_TOKEN!,
    },
  });
}
完全な例
import express from "express";
import axios, { AxiosInstance } from "axios";

// 1. Configure hook
async function setupIntegration(): Promise<void> {
  await configureOrderHook({
    accountName: process.env.VTEX_ACCOUNT_NAME!,
    appKey: process.env.VTEX_APP_KEY!,
    appToken: process.env.VTEX_APP_TOKEN!,
    hookUrl: `${process.env.BASE_URL}/vtex/order-hook`,
    hookHeaderKey: "X-Integration-Secret",
    hookHeaderValue: process.env.HOOK_SECRET!,
    filterStatuses: [
      "ready-for-handling",
      "handling",
      "invoiced",
      "cancel",
    ],
  });
}

// 2. Start webhook server
const app = express();
app.use(express.json());

const hookHandler = createHookHandler({
  expectedAccount: process.env.VTEX_ACCOUNT_NAME!,
  expectedAppKey: process.env.VTEX_APP_KEY!,
  customHeaderKey: "X-Integration-Secret",
  customHeaderValue: process.env.HOOK_SECRET!,
});

app.post("/vtex/order-hook", hookHandler);

// 3. Health check for VTEX ping
app.get("/health", (_req, res) => res.status(200).json({ status: "ok" }));

// 4. Start feed polling as backup
setInterval(async () => {
  try {
    const client = createVtexClient();
    await pollFeedAsBackup(client);
  } catch (error) {
    console.error("Feed backup polling error:", error);
  }
}, 120000);

app.listen(3000, () => {
  console.log("Order integration running on port 3000");
  setupIntegration().catch(console.error);
});

よくある失敗パターン

  • Feed/Hook ではなく List Orders API を使用する。 GET /api/oms/pvt/orders エンドポイントはインデックスに依存しているため、リアルタイム更新より遅れることがあります。ポーリングを頻繁に行うと、処理速度が遅く、信頼性も低下し、レート制限にも達しやすくなります。Feed v3 はインデックス作成前に実行され、インデックスに依存しません。注文変更の検出には Feed v3 または Hook を使用し、List Orders はアドホックなクエリにのみ使用してください。

  • 処理に時間がかかり、Hook の応答が遅れる。 VTEX では、Hook エンドポイントが 5000ms 以内に HTTP 200 を返す必要があります。処理に時間がかかる場合(ERP 同期や複雑なデータベース書き込みなど)、VTEX は配信失敗と見なし、遅延を段階的に増やしながら再試行します。失敗が繰り返されると、Hook が無効化される可能性があります。イベントをすぐに受け付けてから、キューを介して非同期で処理してください。

import { RequestHandler } from "express";

// Correct: Acknowledge immediately, process async
const asyncHookHandler: RequestHandler = async (req, res) => {
  const payload: HookPayload = req.body;

  // Validate auth (fast operation)
  if (!validateAuth(payload, req.headers)) {
    res.status(401).json({ error: "Unauthorized" });
    return;
  }

  // Enqueue for async processing (fast operation)
  await enqueueOrderEvent(payload);

  // Respond immediately — well within 5000ms
  res.status(200).json({ received: true });
};

function validateAuth(
  payload: HookPayload,
  headers: Record<string, unknown>
): boolean {
  return (
    payload.Origin?.Account === process.env.VTEX_ACCOUNT_NAME &&
    headers["x-integration-secret"] === process.env.HOOK_SECRET
  );
}

async function enqueueOrderEvent(payload: HookPayload): Promise<void> {
  // Use a message queue (SQS, RabbitMQ, Redis, etc.)
  console.log(`Enqueued order event: ${payload.OrderId}`);
}

レビューチェックリスト

  • 適切な配信モデルを選択していますか(スループットを制御する場合は Feed、リアルタイムの場合は Hook)?
  • Hook エンドポイントで Origin.Account、Origin.Key、カスタムヘッダーを検証していますか?
  • 重複排除キーに OrderId + State + LastChange を使用して、イベント処理を冪等にしていますか?
  • ステータスハンドラーは、デフォルト/フォールバックケースを含め、すべての注文ステータスに対応していますか?
  • Hook エンドポイントは 5000ms 以内に応答しますか(負荷の高い処理には非同期処理を使用していますか)?
  • Hook が取りこぼしたイベントを検出するためのバックアップとして、Feed v3 を設定していますか?
  • フィルターの種類を混在させていませんか(FromWorkflow と FromOrders は相互に排他的です)?

参考資料

コメント