commercetools Commerce Integration Layer

Description

Build and publish extensions for the commercetools Commerce Integration Layer — the project-scoped commercetools BFF. Covers the two extension types (a schema extension, i.e. an extension subgraph of GraphQL typeDefs + resolvers that shapes what clients read through the Experience API; and a Managed API Extension that approves, modifies, or blocks a commercetools write), a decision tree for where custom logic belongs (incl. porting pre-request BFF logic), and the integration-layer CLI flow to init, test, validate, and push an extension bundle. Use when building, publishing, or debugging a Commerce Integration Layer extension, or asking how to add custom logic to a Commerce Integration Layer project — even if the user just says 'integration layer' or 'CIL'. Not for building commercetools Connect connectors (see commercetools-connect), the hosted Checkout (see commercetools-checkout), or storefront clients that consume the Experience API (see commercetools-storefront).

Installation

Recommended: install the full commercetools plugin. It includes this Skill, every other commercetools Skill, our pre-tuned Subagents, and the commercetools Knowledge MCP — which gives AI live access to the commercetools docs, GraphQL/OpenAPI schemas, and query validation. You only install once; every Skill on this site becomes available in every session.
Install the plugin

In any Claude Code session, run the following commands one at a time:

  1. Add the commercetools AI marketplace:

    /plugin marketplace add commercetools/commercetools-ai-plugins
    
  2. Install the commercetools AI plugin (Skills + Subagents + MCP):

    /plugin install commercetools@commercetools
    
Reload plugins

If you've updated the plugin or installed it in another window and need the current session to pick up the latest version:

/reload-plugins
Claude Desktop
Customize -> Personal plugins -> Create plugin -> Add marketplace -> Add commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.

Instructions Included

SKILL.md

commercetools Commerce Integration Layer

The Commerce Integration Layer is a project-scoped commercetools BFF: a GraphQL edge in front of a commercetools Project that storefronts and other clients read through the Experience API. You extend it with extension bundles you author, validate, and publish yourself.
This skill covers building extensions for the Commerce Integration Layer. It is not about consuming the Experience API from a storefront, and it is not about commercetools Connect connectors — those are different mechanisms with their own skills.

Where the logic must run

Choose the mechanism from when in an API call the logic must run, not from how it feels to write. The Commerce Integration Layer sits on every client flow, so each moment below has a home:
MomentMechanism
A client reads dataSchema extension
Before commercetools processes a write (deriving or enriching the input)Usually still a Managed API Extension. See the write path
Within a write, before it is persisted (approve, modify, block)Managed API Extension
After a write is persistedcommercetools Subscription, outside the bundle

Decision tree

Work top-down and stop at the first step that fits:

  1. Can built-in commercetools features model it? Check data modeling and native features first. The requirement often disappears, and nothing is cheaper to run.
  2. Is it about what a client reads? Use a schema extension.
  3. Does it change how a commercetools write behaves? Start with a Managed API Extension, even if the old BFF did it before the request. If an API Extension cannot solve it, reach out to commercetools support with the use case. As a final step, override the existing mutation with @override. Read the write path before going past the API Extension.
  4. Is it a write to an external service provider? Add a custom mutation on the extension subgraph. This is the best way to go for that case.
Whatever you build, key it to the business action ("a line item is added to a cart"), and cover every entry point: cart creation with line items, addLineItem, adding a Shopping List. A handler for one update action leaves the other paths open.

Schema extension (an extension subgraph)

GraphQL typeDefs + resolvers that federate into the Experience API. Use it to add a query, type, or field on an entity (e.g. Product); to compute a value from data the Commerce Integration Layer owns or from fields your extensions expose (via @requires); to call an external service and return the result in the graph; or to shape and enrich what a storefront reads. For a write to an external service provider, a custom mutation on your subgraph is the best way to go. To change a commercetools write, start with a Managed API Extension instead.
It runs at the edge, so it only affects traffic that goes through the Experience API. It is not a platform-wide guarantee.

Managed API Extension

Approves, modifies, or blocks a commercetools write before it is persisted. Use it to enforce a rule on a cart, order, or customer and reject a bad write; to stamp, derive, or correct a field as part of the write; or whenever the rule must hold for every write regardless of which client made it (storefront, Merchant Center, direct API client). It builds on the commercetools API Extensions mechanism, but the Commerce Integration Layer hosts and registers the handler for you.
Because it runs inside the write, it applies to all consumers: the platform-wide guarantee a schema extension cannot give.

Building an extension

A Project must be enrolled and activated for the Commerce Integration Layer before extensions can be published. The flow, from the Get started guide; the commerce-integration-layer-extensions repo holds standalone example extensions, one per pattern:
# Install the CLI and the integration-layer topic (once), then log in
npm install -g @commercetools/cli
commercetools plugins install @commercetools/cli-topic-integration-layer
commercetools auth login --project-key <your-project-key>

# Start a real workspace (extensions merge into the one bundle a Project deploys)
commercetools integration-layer init

# Or start from the template closest to what you want, then edit src/extension.ts
pnpm test         # unit tests for resolvers and handlers; offline
pnpm dev          # local gateway over the merged bundle + deployed Experience API; hot-reloads
pnpm validate     # sandbox, shape, composition, and breaking-change checks; no upload
pnpm push         # validates, replaces the Project's bundle, waits until it is running
Before pushing, test the whole merged bundle in layers, including API Extension handlers fired offline and against real writes: see verify and push.
A bundle may export schema extensions (typeDefs + resolvers), Managed API Extensions (apiExtensions), or both.

Workflow

  1. Confirm the mechanism. Walk the decision tree with the user's actual requirement. Don't skip its first step (native modeling), because a data-model change often beats any extension. For anything past a Managed API Extension, read the write path.
  2. Read before writing. Open the Commerce Integration Layer documentation for the chosen type (schema extensions or Managed API Extensions), then the closest example in the commerce-integration-layer-extensions repo.
  3. Start from a template or integration-layer init. Edit src/extension.ts; keep one concern per extension so it merges cleanly into the Project bundle.
  4. Test the merged bundle, validate, then push. Follow verify and push: unit tests, handlers fired offline, the composed schema, and real writes for every entry point of the business action. Then pnpm validate, and pnpm push, which only counts as done once it reports the new revision as running.

References

verify-and-push.md

Testing, validating, and pushing a Commerce Integration Layer bundle

Impact: HIGH — a Project runs exactly one bundle, and a push replaces all of it. A handler that never fires, or a schema change that breaks a client, reaches every consumer at once.
Test the merged bundle, because that is what deploys: the root scripts of an integration-layer init workspace run with --all, which merges the typeDefs, resolvers, and apiExtensions of every package under extensions/* into one subgraph and one artifact. Work through the layers below in order. Each one catches something the previous one cannot, and the first three need no deployment.
LayerCommand (from the workspace root)CatchesNeeds
1. Unit testspnpm testResolver and handler logic, config handlingNothing
2. Handlers, offlineinvoke-api-extension --allWhich handlers fire for a write, and their verdictNothing
3. Schema, composedpnpm devMerged schema against the deployed Experience API, @requires, entity fields, @overrideLogin
4. Real writesserve-api-extension --allcommercetools calling your handlers on real writes, condition triggersLogin with manage_project, a tunnel, a sandbox Project
5. Publish gatepnpm validateSandbox violations, shape, composition, breaking changesLogin
6. Push and confirmpnpm push, then explore --deployedThe revision actually loads and servesLogin
Commands for developing, publishing, and checking an extension are documented in Get started and Configure and deploy; --help lists every flag. The CLI commands below are shortened: each one runs as commercetools integration-layer extension <command>.

1. Unit-test resolvers and handlers

Resolvers and API Extension handlers are plain functions, so call them directly with a minimal fake for the host context (now() and config). init scaffolds a Vitest file next to each src/extension.ts, and pnpm test at the root runs every package's suite with no login and no network.
Test a handler with a hand-built ExtensionInput. The handler receives the resource as it will be persisted, after the request's update actions are applied, not the update actions themselves. Assert on the resulting cart or order, and cover Create and Update:
import { describe, expect, it } from "vitest";
import type { ExtensionInput } from "@commercetools/platform-sdk";
import { apiExtensions } from "./extension.js";

const ctx = (config: Record<string, string> = {}) => ({ now: () => Date.now(), config });
const { handler } = apiExtensions.find((e) => e.key === "cart-sku-blocker")!;

const cartWith = (action: "Create" | "Update", skus: string[]) =>
  ({
    action,
    resource: {
      typeId: "cart",
      id: "cart-1",
      obj: { id: "cart-1", lineItems: skus.map((sku, i) => ({ id: `li-${i}`, quantity: 1, variant: { sku } })) },
    },
  }) as unknown as ExtensionInput;

describe("cart-sku-blocker", () => {
  it("approves a cart without blocked SKUs", () => {
    expect(handler(cartWith("Create", ["OK-1"]), ctx())).toEqual({});
  });

  it.each(["Create", "Update"] as const)("blocks a blocked SKU on %s", (action) => {
    expect(handler(cartWith(action, ["BLOCKED-SKU"]), ctx())).toMatchObject({
      errors: [expect.objectContaining({ code: "InvalidInput" })],
    });
  });

  it("reads the blocked list from config", () => {
    expect(handler(cartWith("Create", ["X"]), ctx({ BLOCKED_SKU: "X,Y" }))).toHaveProperty("errors");
  });
});
Because the handler sees the resulting resource, a check on lineItems holds for every entry point that adds a line item (cart creation, addLineItem, adding a Shopping List) without knowing which one was used. Prefer that over inspecting the request.
Unit tests run in Node, where process, Buffer, and node:* imports exist. The sandbox withholds them, so a passing unit test does not prove the bundle loads. Step 5 catches that.

2. Fire the handlers at the merged bundle, offline

invoke-api-extension runs the bundle's handlers in-process against a JSON ExtensionInput and prints each handler's verdict: approve, modify with the actions, or block with the errors. With --all it runs the merged bundle and reports every package's handlers for the same write.
# Scaffold realistic payloads once, edit them, and keep them in the repo as fixtures
commercetools integration-layer extension create-api-extension-input --resource-type cart --action Create --out payloads/cart-create.json
commercetools integration-layer extension create-api-extension-input --resource-type cart --action Update --out payloads/cart-update.json

commercetools integration-layer extension invoke-api-extension --all --input payloads/cart-create.json
commercetools integration-layer extension invoke-api-extension --all --input payloads/cart-update.json --config MAX_LINE_QUANTITY=10
A handler fires locally only when its resourceTypeId and actions match the payload, and the rest are reported as skipped. Check the skipped list: a handler that should have fired but was skipped has the wrong trigger. Local matching uses resourceTypeId and actions only, so test a commercetools condition with real writes in step 4.
ctx.config comes from EXTENSION_CONFIG_<KEY> variables in the shell or a .env file, and --config KEY=VALUE overrides a single key.

3. Query the merged schema through a gateway

The workspace's root pnpm dev runs serve --all: a local federated gateway over your merged extension subgraph and the Project's deployed Experience API. This is the production topology in miniature, so query core fields and your fields together in one operation:
{ product(id: "…") { name loyaltyPoints(price: 1000) } }
name resolves upstream and loyaltyPoints locally. The server hot-reloads on every save of an extension or the .env. The server exposes three routes:
  • /graphql is the gateway.
  • /_extension is the raw merged subgraph.
  • /composed shows the merged schema.

If an edit does not compose, the log lists the collisions and the server keeps serving the last good schema, so watch the log, not only the query result.

  • Fields that use @requires or @override only show their real behavior in the composed graph. Test them here, not in a standalone server.
  • External services: when you are logged in, resolver fetch goes through the same Project host allowlist as production. Add the provider's host with allowlist add <host> before testing. Without a login, fetch is unrestricted locally, so an allowlist gap only shows up once you log in.
  • Customer-scoped fields: commercetools integration-layer explore --as <email>, run from an extension directory, composes your working tree against the Project and runs operations as that customer.
  • One extension in isolation: pnpm --filter @extensions/<name> dev serves just that subgraph, offline. Exercise entity fields through the _entities query.

4. Run real writes through your local handlers

serve-api-extension serves the merged handlers over HTTP and registers a commercetools API Extension pointing at a public tunnel, so real writes in the Project call the code on your machine. Attach a debugger, and edits hot-reload.
ngrok http 4000   # or any HTTPS tunnel
commercetools integration-layer extension serve-api-extension --all --public-url https://<tunnel-host>
Use a dedicated sandbox Project and a manage_project login. The command refuses to start if the Project already has an API Extension on the same resource and action, which can include the one your deployed bundle registered. It deletes what it registered on exit, and --cleanup removes leftovers after a crash.

This is the only layer where commercetools itself validates the request before your handler runs. Use it to:

  • send every entry point of the business action through the API: a cart created with lineItems, addLineItem, adding a Shopping List;
  • confirm that a condition triggers on the writes it should, and only those;
  • confirm the case from the write path: a value the request needs to pass validation has to be on the request itself, because your handler never sees a request that fails validation.

5. Validate the merged bundle

pnpm validate runs the publish gate against the logged-in Project without uploading anything:
CheckWhereBypass with --force
Static analysis: no withheld globals (process, node:*, eval, …)LocalNever
Shape: non-empty typeDefs, a resolver for every declared field, well-formed apiExtensionsLocalNever
Composition against the Project's live core subgraphRemoteYes
Breaking changes against the currently published schemaRemoteYes
Fix every local failure. A bundle that fails them does not load. A Project's first push has no published schema to compare against, and a bundle with only API Extensions has no schema to compose, so those checks do not apply there. commercetools integration-layer schema fetch prints the core subgraph SDL your bundle composes against, which helps when a composition error comes from a change on the core side.

6. Push and confirm the revision is running

pnpm push builds, validates, and uploads the merged bundle, replacing the Project's current one. A push stores the bundle. The runtime then loads it, and each revision moves from pending to running (it serves) or failed (with a reason). push waits for that verdict by default and exits non-zero on failed. Keep the wait, and raise --wait-timeout instead of using --no-wait if loading takes long.
  • A failed revision drops out of service. The Project keeps serving the newest revision that did not fail. When a pushed change does not appear, check whether the push reported running.
  • extension status shows the stored bundle (version, size, upload time, recorded source revision), not whether it loaded. The push's wait is where the load verdict is reported.
  • Rolling back means pushing the previous code again. There is no unpublish command, and the recorded source revision (from git, or EXTENSION_SOURCE_REVISION in CI) tells you which code is deployed.
  • --force skips only the remote checks, for a coordinated breaking change. Through a package script, forward it explicitly: pnpm push -- --force.
  • Configuration set with config set <KEY> <VALUE> (add --secret for credentials) takes effect without a new push.
After running, check the live edge:
commercetools integration-layer explore --deployed        # the composed schema the router actually serves
commercetools integration-layer extension invoke-api-extension --deployed --input payloads/cart-create.json   # the deployed handlers; nothing is persisted
In CI, log in with a manage_project client and push with the wait on:
EXTENSION_SOURCE_REVISION="$BUILD_ID" \
  commercetools integration-layer extension push --all --wait-timeout 300
write-path.md

The write path in the Commerce Integration Layer

Impact: HIGH — logic tied to one client or one update action silently lapses when another client or a new update action reaches the same data. Choose the earliest option that is correct for every path.
First decide whose write it is. A write to commercetools data follows the order below. A write to an external service provider gets a custom mutation.

Changing commercetools write behavior

The Commerce Integration Layer replaces the customer's BFF, so logic the BFF ran around a commercetools write needs a home inside it. Walk the steps in order and stop at the first that fits. For the wider migration (BFF code the Commerce Integration Layer makes redundant, and workloads that belong in Connect), see Migrate an existing BFF.

1. Start with a Managed API Extension

"The BFF set it before the request, so it must be set before the request" is an assumption, not a fact. An API Extension runs after the request is processed and before it is persisted, and it can validate the write or return update actions. That covers most BFF write logic: enforcing a rule, stamping a computed custom field, attaching a loyalty, tax, or pricing attribute, correcting a value. It also applies to every client, not only traffic through the Experience API.

Two conditions put a requirement beyond an API Extension:

  • The field is immutable once set. An extension firing after the fact cannot change it, so the value must be right on the incoming draft or action.
  • The request must be valid on arrival. commercetools validates a request before any extension runs, so a value the request needs in order to pass validation has to be on it already.
Example of the second: every Price is scoped to a distribution channel with no fallback Price, and the channel for a buyer lives in a Custom Field on their Business Unit. The shopper's client does not know the channel. Price selection needs the channel on the addLineItem itself, so it must be present on the way in.

If neither condition holds, build a Managed API Extension and stop here.

2. Reach out to commercetools support

If an API Extension cannot solve it, contact commercetools support with the use case before building anything else. Describe the business requirement, not only the field you want to set: the right answer is often a modeling option or feature that makes custom logic unnecessary.

3. Final step: override the existing mutation with @override

When the steps above do not solve it, the extension subgraph can take over an existing core mutation with the federation @override directive. Clients keep calling the same mutation, and your resolver runs in its place: resolve the missing input, then perform the commercetools write.

It is the final step because your resolver now owns the whole operation:

  • It must keep up with the core mutation. Fields and behavior the core mutation gains later are not inherited automatically. Re-check the override when you update the bundle.
  • It only covers traffic through the Experience API. Writes from the Merchant Center or direct API clients do not pass through it, unlike an API Extension.

Writes to an external service provider

When the write goes to an external service provider, not to commercetools data, add a custom mutation on the extension subgraph. This is the best way to go: the operation is yours, it does not change any core commercetools mutation, and the client calls it explicitly. Keep the provider call and its error mapping in the resolver, and return a result type shaped for the client.

Scope the logic to the business action, not the update action

Whatever you build on the commercetools write path, key the logic to the business action ("a line item is being added to a cart"), not to one low-level API action. The same business action has many entry points: creating a Cart with lineItems in the draft, addLineItem, adding a Shopping List to a Cart, a gift line item added by a Cart Discount. A handler that only recognizes addLineItem leaves the other paths open, and the rule it was meant to hold silently lapses the day a client uses one of them. This matters most for an @override, which covers only the one mutation it replaces.

Before you ship, list every entry point for the business action and check that each one is covered.