关于 marketplace-order-hook
面向 VTEX 订单集成开发的技能,帮助系统通过 Feed 或 Hook 接收订单状态变化,并安全、幂等地处理事件。
主要功能
- 01选择 Feed 拉取或 Hook 推送
- 02配置订单状态筛选
- 03验证 Hook 来源与认证
- 04按事件键去重处理
- 05获取完整订单并异步处理
适用场景
- 01将订单状态同步到 ERP
- 02触发外部履约工作流
- 03在 Hook 中断期间用 Feed 补漏
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vtex/ skills --skill marketplace-order-hook安装后在 Claude Code 中输入/marketplace-order-hook确认已加载。
详细介绍
订单集成与 Webhook
适用场景
在构建需要响应 VTEX marketplace 中订单状态变更的集成时,使用此技能,例如将订单同步到 ERP、触发履约工作流或向外部系统发送通知。
- 配置用于订单更新的 Feed v3 或 Hook
- 在 Feed(拉取)和 Hook(推送)交付模式之间进行选择
- 验证 webhook 身份验证并以幂等方式处理事件
- 处理完整的订单状态生命周期
请勿将此技能用于:
- 商品目录或 SKU 同步(参见
marketplace-catalog-sync) - 发票和物流跟踪信息提交(参见
marketplace-fulfillment) - 通用 API 速率限制(参见
marketplace-rate-limiting)
决策规则
- 对于需要实时订单更新的高性能中间件,使用 Hook(推送)。你的端点必须在 5000ms 内返回 HTTP 200。
- 对于吞吐量有限且需要控制消费速度的 ERP 或系统,使用 Feed(拉取)。事件会一直保留在队列中,直到提交为止。
- 将 Feed 用作备用方案,与 Hook 配合使用,以捕获停机期间遗漏的事件。
- 如果只需响应订单状态变更,请使用
FromWorkflow筛选器(更简单,也是最常见的做法)。 - 如果需要使用 JSONata 表达式按任意订单属性进行筛选(例如按销售渠道筛选),请使用
FromOrders筛选器。 - 两种筛选器类型互斥。在同一配置请求中同时使用两者会返回
409 Conflict。 - 每个 appKey 只能配置一个 feed 和一个 hook。共享同一 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 字段必须与预期值匹配。
为何重要
如果不验证身份,任何人都可以向你的端点发送伪造的订单事件,从而触发未授权的履约操作、数据损坏或经济损失。
检测
如果发现 hook 端点处理程序在处理事件时未检查 Origin.Account、Origin.Key 或自定义标头 → 停止处理并添加身份验证。
正确
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 通知(至少一次投递)。如果没有幂等性,重复处理可能导致重复履约、重复开具发票或状态不一致。
检测
如果发现订单事件处理程序未检查重复的 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);
});
常见故障模式
-
使用 List Orders API 代替 Feed/Hook。
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 内响应(对耗时较长的工作使用异步处理)?
- 是否将 Feed v3 配置为备份,以捕获遗漏的 Hook 事件?
- 是否避免混用筛选器类型(FromWorkflow 和 FromOrders 互斥)?
参考资料
- Feed v3 指南 — Feed 和 Hook 配置、筛选器类型及最佳实践的完整指南
- Orders API - Feed v3 端点 — Feed 检索和提交的 API 参考
- Hook 配置 API — 创建和更新 Hook 配置的 API 参考
- Orders 概览 — VTEX Orders 模块概览
- 订单流程和状态 — 订单状态及状态转换的完整列表
- ERP 集成 - 设置订单集成 — 将订单 Feed 与后台系统集成的指南