marketplace-order-hook について
VTEX の注文更新を連携する開発者向けスキルです。Feed と Hook の選択から、認証、重複排除、注文イベントの処理までを説明します。
主な機能
- 01Feedの取得方式とHookのプッシュ方式を選択
- 02注文状態フィルターを設定
- 03Hookの送信元と認証を検証
- 04イベントキーで重複を排除
- 05注文詳細を取得して非同期処理
活用シーン
- 01注文状態をERPに同期
- 02外部フルフィルメントのワークフローを起動
- 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 にアクセスします。
| Feed | Hook | |
|---|---|---|
| モデル | プル(アクティブ) | プッシュ(リアクティブ) |
| スケーラビリティ | ボリュームを制御できます | どのようなボリュームにも対応する必要があります |
| 信頼性 | イベントはキューに保持されます | 常時利用可能である必要があります |
| 最適な用途 | スループットが限られた 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 は相互に排他的です)?
参考資料
- Feed v3 ガイド — Feed と Hook の設定、フィルターの種類、ベストプラクティスに関する完全なガイド
- Orders API - Feed v3 エンドポイント — Feed の取得とコミットに関する API リファレンス
- Hook Configuration API — Hook 設定の作成と更新に関する API リファレンス
- 注文の概要 — VTEX Orders モジュールの概要
- 注文のフローとステータス — 注文ステータスと遷移の完全な一覧
- ERP 統合 - 注文統合の設定 — 注文 Feed をバックオフィスシステムと統合するためのガイド