Developing Custom API Extensions for commercetools
API Extensions are a mechanism of synchronous hooks in commercetools. On certain resource operations, the platform makes an HTTP request to your service before or after saving, waiting for a response for up to 2 seconds. This is the only way to add business logic directly to resource lifecycles without forking the platform.
Types of Triggers
| Trigger | Resource | When it fires |
|---|---|---|
Create |
Cart, Order, Customer, Payment | On POST |
Update |
Cart, Order, Customer, Payment | On POST /ID |
Delete |
Customer, Shopping List | On DELETE /ID |
Most popular: Cart-Update (price recalculation, promo code validation), Order-Create (send to ERP), Payment-Update (sync payment statuses).
Registering an 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 with type AWSLambda or GoogleCloudFunction is invoked directly without HTTP — the platform signs the request via IAM.
Extension Service Structure
// 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; // full cart object
const actions: CartUpdateAction[] = [];
const errors: ExtensionError[] = [];
// Example: minimum order amount
if (action === "Update" && cart.totalPrice.centAmount < 50000) {
errors.push({
code: "InvalidInput",
message: "Минимальная сумма заказа — 500 ₽",
extensionExtraInfo: { field: "totalPrice" },
});
}
// Example: automatically apply VIP discount
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 });
});
Response with actions — commercetools will apply them atomically to the resource. Response with errors — the operation is rejected, client receives 400.
Extension for Price Recalculation
Scenario: prices come from external ERP, not from commercetools Price.
app.post("/cart-reprice", async (req, res) => {
const { resource } = req.body as ExtensionInput;
const cart = resource.obj;
const actions: CartUpdateAction[] = [];
// Get current prices from 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 for Order-Create: Send to 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,
});
// Save ERP ID as custom field of order
res.json({
actions: [
{
action: "setCustomField",
name: "erpOrderId",
value: erpOrderId,
},
],
});
} catch (err) {
// Don't reject order creation on ERP error —
// better enqueue for retry and process asynchronously
await enqueueForRetry({ orderId: order.id, error: err });
res.json({ actions: [] });
}
});
Deploying Extension Service
Extension must respond within 2000 ms. Recommended deployment options:
- AWS Lambda + API Gateway — cold start ≤ 200ms with provisioned concurrency
- Google Cloud Run — min-instances=1 eliminates cold start
- Kubernetes — if you already have a cluster
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist/ ./dist/
EXPOSE 3000
CMD ["node", "dist/server.js"]
Monitoring and Debugging
commercetools logs all Extension calls in Merchant Center → Developer → Extension Logs (stores 7 days). Each log contains:
- Request body (payload)
- Extension HTTP response status
- Response body
- Execution time
On timeout or 5xx from Extension, the operation is rejected — this is strict behavior that cannot be changed. Therefore, Extension service must be more stable than the main API.
Development Timeframes
| Extension | Complexity | Timeframe |
|---|---|---|
| Cart validation | Low | 1–2 days |
| Price recalculation from ERP | High | 3–5 days |
| Order → ERP integration | Medium | 2–4 days |
| Payment status synchronization | Medium | 2–3 days |
| Custom discounts + promo codes | High | 4–6 days |







