Declare, configure, test, and publish Managed API Extension handlers.
This page covers how to author Managed API Extension handlers: declaring them, the handler contract, configuration, conditional triggers, payload options, chaining, local testing, and publishing. Managed API Extensions are authored in the same bundle and sandbox as schema extensions; see Write schema extensions for the shared sandbox and configuration model. You can use the
cart-sku-blocker template in the Commerce Integration Layer extension examples repository to get started.Declare a handler
Each handler declares which resource and actions it intercepts and provides a
handler function. The received payload is the commercetools SDK's own ExtensionInput, so you narrow on input.resource.typeId to get a fully typed resource:import type { Cart, ExtensionInput } from '@commercetools/platform-sdk';
export const apiExtensions = [
{
key: 'cart-sku-blocker', // becomes the commercetools extension key
resourceTypeId: 'cart', // cart | order | customer | payment | quote | business-unit | shopping-list | …
actions: ['Create', 'Update'],
handler: (input: ExtensionInput, ctx: { config: Record<string, string> }) => {
const blockedSku = ctx.config.BLOCKED_SKU || 'BLOCKED-SKU';
const cart: Cart | undefined =
input.resource.typeId === 'cart' ? input.resource.obj : undefined;
return (cart?.lineItems ?? []).some((li) => li.variant.sku === blockedSku)
? { errors: [{ code: 'InvalidInput', message: `SKU "${blockedSku}" cannot be added to the cart.` }] }
: {};
},
},
];
Handler inputs include
input (the resource being written, before it is persisted) and ctx (containing config). Handlers lack access to the session, shopper identity, or presentment context, as writes may originate from clients without a shopper context (for example, Merchant Center or imports).The handler contract
A handler returns a plain object describing its decision:
| Return value | Effect |
|---|---|
{} | Approves the write unchanged. |
{ actions: [...] } | Modifies the write by applying update actions before it is saved. |
{ errors: [...] } | Blocks the write and returns a validation error to the caller. |
Unhandled exceptions cause the write to fail. Return explicit validation errors rather than throwing exceptions.
Configuration fields
| Field | Description |
|---|---|
key | Identifies the handler; becomes the commercetools extension key. |
resourceTypeId | The resource to intercept: cart, order, customer, payment, quote, business-unit, shopping-list, and others. |
actions | The write actions to intercept, such as Create and Update. |
condition | Optional query predicate. commercetools evaluates it on each matching write and calls the handler only when it is true. Omit to run on every create or update for the resource. |
timeoutInMs | The maximum time, in milliseconds, that commercetools waits for this handler. Omit to use the default of 2 seconds. |
includeOldResource | Optional. Set to true to also receive the state of the resource before the update. See Previous state of the resource. |
expansionPaths | Optional list of expansion paths that commercetools resolves before it delivers the payload. See Expand references in the payload. |
dependsOn | Optional list of handler keys that must complete before this handler runs. See Chain handlers. |
handler | The function called with (input, ctx). |
Conditional triggers
Without a
condition, commercetools calls the handler on every create or update operation for the configured resourceTypeId. Set condition when the handler should run only for specific writes (for example, to skip cart updates that do not alter the resource state, such as setting a line item quantity to its existing value). The platform evaluates the predicate and skips the callback when the expression evaluates to false, preventing unrelated write operations from making unnecessary platform requests.{
key: 'cart-quantity-cap',
resourceTypeId: 'cart',
actions: ['Create', 'Update'],
condition: 'lineItems is not empty',
handler: (input, ctx) => { /* … */ },
}
The
cart-quantity-cap template in the extension examples repository uses condition together with a { actions } response to cap line quantities. For predicate syntax and evaluation rules, see Conditional triggers in the HTTP API reference.If a predicate references optional fields, use the
is defined operator so a missing field does not cause evaluation to fail. See the commercetools HTTP reference for details.Previous state of the resource
By default, the payload carries only the resource as it is after the update, which is not enough for a handler that must know what changed, such as whether a Line Item quantity went up or down. Set
includeOldResource to true and commercetools adds an oldResource field holding the state before the update:{
key: 'cart-quantity-delta',
resourceTypeId: 'cart',
actions: ['Update'],
includeOldResource: true,
handler: (input, ctx) => {
const before = input.oldResource?.obj;
const after = input.resource.obj;
/* compare the two */
},
}
commercetools sends
oldResource for Update actions only, so it is never present on a Create, and it is absent entirely when the handler does not ask for it. The Commerce Integration Layer registers the flag as the Extension's additionalContext; for the underlying behavior, see Previous state of the updated resource in the HTTP API reference.Expand references in the payload
Payloads include references (such as resource IDs) to related resources rather than full resource objects by default. If a handler requires details from a referenced resource, it must send a separate API request to the platform, consuming time against the callback deadline. To avoid additional API calls, define
expansionPaths so commercetools resolves and embeds the referenced resources directly into the payload before delivery.{
key: 'cart-customer-group-pricing',
resourceTypeId: 'cart',
actions: ['Create', 'Update'],
expansionPaths: ['customerGroup'],
handler: (input, ctx) => { /* input.resource.obj.customerGroup.obj is populated */ },
}
You can declare up to
3 paths per handler, and they apply to oldResource as well when includeOldResource is set. For the path syntax and the payload-size trade-off, see Reference Expansion in API Extensions.Chain handlers
By default, handlers that match the same write run concurrently, and each sees the resource before those handlers run. If a handler must build on another handler's update actions, declare that order with
dependsOn:export const apiExtensions = [
{
key: 'cart-validate',
resourceTypeId: 'cart',
actions: ['Update'],
handler: (input, ctx) => { /* … */ },
},
{
key: 'cart-enrich',
resourceTypeId: 'cart',
actions: ['Update'],
dependsOn: ['cart-validate'],
handler: (input, ctx) => { /* sees cart-validate's actions already applied */ },
},
];
dependsOn names key values, not the generated commercetools Extension keys; the Commerce Integration Layer resolves keys declared in the bundle and passes undeclared keys through as-is for Project-owned API Extensions. Each referenced Extension must exist and be applicable to every trigger and action of this handler, or registration fails. A handler cannot depend on itself.Platform dependency rules apply: commercetools enforces a maximum of
5 direct dependencies per Extension and a maximum chain depth of 3, and it rejects circular dependencies. Chained handlers run sequentially, so their timeouts add up against the request budget. See Extension Chaining for the execution model and the performance considerations.Configuration and the sandbox
Handlers run in the same sandbox as resolvers. No ambient authority exists, and outbound calls only use the allowlist-gated global
fetch. Read per-project settings from ctx.config, exactly like a resolver, and secrets are decrypted host-side. For more information, see Configuration.A single bundle can export both
apiExtensions and typeDefs/resolvers. The cart-sku-blocker example does exactly this: it blocks certain SKUs from Carts and exposes a Query.blockedSkus field returning those same SKUs, both reading one shared ctx.config value.Handler deadline
commercetools sets the deadline for the callback. If a handler runs past it, commercetools fails the write itself, so keep handlers fast.
If a handler declares no
timeoutInMs, the deadline is your Project's own timeout limit, which is 2 seconds by default. See Time limits for API Extensions.A handler that needs longer, for example because it calls a vendor API, can declare its own timeout:
{
key: 'cart-vendor-pricing',
resourceTypeId: 'cart',
actions: ['Create', 'Update'],
timeoutInMs: 5000,
handler: (input, ctx) => { /* … */ },
}
Each declaration registers its own commercetools Extension, so handlers can carry different timeouts. Values above your Project's maximum are rejected.
Develop locally
Try testing Managed API Extension handlers offline, without credentials or a deployment.
First, scaffold a realistic commercetools callback payload with
create-api-extension-input. Then send it to your bundle's handlers with invoke-api-extension:commercetools integration-layer extension create-api-extension-input --resource-type cart --action Create --out ./payloads/cart-create.json
commercetools integration-layer extension invoke-api-extension --input ./payloads/cart-create.json
commercetools integration-layer extension invoke-api-extension --input ./payloads/cart-update.json --config BLOCKED_SKU=NO-SELL
commercetools integration-layer extension invoke-api-extension --input ./payloads/order-create.json --key order-tagger
invoke-api-extension prints each handler's decision, which can be APPROVE, MODIFY with update actions, or blocking errors. A handler runs only when its resourceTypeId and actions match the payload. Non-matching handlers are reported as skipped. The local invoke does not evaluate condition, instead, commercetools applies that predicate in production. Use --key (repeatable) to invoke only specific handlers. In a monorepo, add --all to invoke the merged bundle under extensions/.ctx.config comes from EXTENSION_CONFIG_* in a project .env file or the shell and --config KEY=VALUE overrides a given key. For the full flag reference for local development, see Develop locally in Write schema extensions.Then
validate and push as usual. A Managed-API-Extensions-only bundle skips the GraphQL composition check, since there's no schema to compose. For the full publish flow, see Validate and publish.