Разработка кастомных API Extensions для commercetools
API Extensions — механизм синхронных хуков в commercetools. При определённых операциях с ресурсами платформа делает HTTP-запрос к вашему сервису до или после сохранения, ожидая ответ в течение 2 секунд. Это единственный способ добавить бизнес-логику прямо в lifecycle ресурсов без форка платформы.
Типы триггеров
| Trigger | Ресурс | Когда срабатывает |
|---|---|---|
Create |
Cart, Order, Customer, Payment | При POST |
Update |
Cart, Order, Customer, Payment | При POST /ID |
Delete |
Customer, Shopping List | При DELETE /ID |
Наиболее востребованные: Cart-Update (пересчёт цен, валидация промокодов), Order-Create (отправка в ERP), Payment-Update (синхронизация статусов оплаты).
Регистрация Extension
const extension = await apiRoot.extensions().post({
body: {
key: "cart-validation-extension",
destination: {
type: "HTTP",
url: "https://extensions.your-service.com/cart-validate",
authentication: {
type: "AuthorizationHeader",
headerValue: `Bearer ${process.env.EXTENSION_SECRET}`,
},
},
triggers: [
{
resourceTypeId: "cart",
actions: ["Create", "Update"],
},
],
timeoutInMs: 2000,
},
}).execute();
Extension с типом AWSLambda или GoogleCloudFunction вызывается напрямую без HTTP — платформа подписывает запрос через IAM.
Структура Extension сервиса
// extensions/src/cart-handler.ts
import express from "express";
import { ExtensionInput, CartUpdateAction } from "@commercetools/platform-sdk";
const app = express();
app.use(express.json());
app.post("/cart-validate", async (req, res) => {
const input: ExtensionInput = req.body;
const { action, resource } = input;
const cart = resource.obj; // полный объект корзины
const actions: CartUpdateAction[] = [];
const errors: ExtensionError[] = [];
// Пример: минимальная сумма заказа
if (action === "Update" && cart.totalPrice.centAmount < 50000) {
errors.push({
code: "InvalidInput",
message: "Минимальная сумма заказа — 500 ₽",
extensionExtraInfo: { field: "totalPrice" },
});
}
// Пример: автоматически применить скидку для VIP
if (cart.customerGroup?.id === "vip-group-id") {
const alreadyHasVipDiscount = cart.discountCodes?.some(
(dc) => dc.discountCode.id === "vip-discount-id"
);
if (!alreadyHasVipDiscount) {
actions.push({
action: "addDiscountCode",
code: "VIP10",
});
}
}
if (errors.length > 0) {
return res.status(400).json({ errors });
}
res.json({ actions });
});
Ответ с actions — commercetools применит их атомарно к ресурсу. Ответ с errors — операция отклонится, клиент получит 400.
Extension для пересчёта цен
Сценарий: цены берутся из внешней ERP, не из commercetools Price.
app.post("/cart-reprice", async (req, res) => {
const { resource } = req.body as ExtensionInput;
const cart = resource.obj;
const actions: CartUpdateAction[] = [];
// Получить актуальные цены из ERP
const skus = cart.lineItems.map((li) => li.variant.sku).filter(Boolean);
const erpPrices = await fetchErpPrices(skus, cart.customerId);
for (const lineItem of cart.lineItems) {
const erpPrice = erpPrices[lineItem.variant.sku!];
if (!erpPrice) continue;
const currentCentAmount = lineItem.price.value.centAmount;
const erpCentAmount = Math.round(erpPrice * 100);
if (currentCentAmount !== erpCentAmount) {
actions.push({
action: "setLineItemPrice",
lineItemId: lineItem.id,
externalPrice: {
value: {
centAmount: erpCentAmount,
currencyCode: cart.totalPrice.currencyCode,
},
},
});
}
}
res.json({ actions });
});
Extension для Order-Create: отправка в ERP
app.post("/order-created", async (req, res) => {
const { resource } = req.body as ExtensionInput;
const order = resource.obj;
try {
const erpOrderId = await sendToErp({
commercetoolsOrderId: order.id,
orderNumber: order.orderNumber,
customer: order.customerEmail,
lines: order.lineItems.map((li) => ({
sku: li.variant.sku,
quantity: li.quantity,
price: li.price.value.centAmount,
})),
shippingAddress: order.shippingAddress,
});
// Сохранить ERP ID как custom field заказа
res.json({
actions: [
{
action: "setCustomField",
name: "erpOrderId",
value: erpOrderId,
},
],
});
} catch (err) {
// Не отклоняем создание заказа при ошибке ERP —
// лучше записать в очередь и обработать асинхронно
await enqueueForRetry({ orderId: order.id, error: err });
res.json({ actions: [] });
}
});
Деплой Extension-сервиса
Extension должен отвечать за 2000 мс. Рекомендуемые варианты деплоя:
- AWS Lambda + API Gateway — холодный старт ≤ 200мс при provisioned concurrency
- Google Cloud Run — min-instances=1 исключает холодный старт
- Kubernetes — если уже есть кластер
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist/ ./dist/
EXPOSE 3000
CMD ["node", "dist/server.js"]
Мониторинг и отладка
commercetools логирует все Extension-вызовы в Merchant Center → Developer → Extension Logs (хранит 7 дней). Каждый лог содержит:
- Тело запроса (payload)
- HTTP-статус ответа Extension
- Тело ответа
- Время выполнения
При timeout или 5xx от Extension операция отклоняется — это жёсткое поведение, которое нельзя изменить. Поэтому Extension-сервис должен быть стабильнее основного API.
Сроки разработки
| Extension | Сложность | Срок |
|---|---|---|
| Валидация корзины | Низкая | 1–2 дня |
| Пересчёт цен из ERP | Высокая | 3–5 дней |
| Order → ERP интеграция | Средняя | 2–4 дня |
| Payment статусы синхронизация | Средняя | 2–3 дня |
| Кастомные скидки + промокоды | Высокая | 4–6 дней |







