commercetools Connect

Description

Build, test, deploy, and install production-ready commercetools Connect applications (connectors) in TS, JS or Java. Use for connect apps, API extensions, subscription/event handlers, jobs, merchant center custom applications (views), syncing to external systems (ERP, WMS, tax, email, search, CRM), and deploying/installing/certifying a connector.

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:

/plugin marketplace add commercetools/commercetools-ai-plugins
/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 Connect

Intent-driven guidance for building production-ready Connect applications. This skill teaches the decision frameworks, platform contracts, and best practices that survive a production-readiness review — not a single connector's code. It generalizes patterns (and warns against anti-patterns) found in real connectors, and grounds every platform fact in official docs.
Language scope: Connect applications can be written in JavaScript/TypeScript or Java (docs); the create-connect-app template supports JS and TS. This skill targets TypeScript/Node — the decision frameworks, platform contracts (timeouts, ack semantics, scopes, lifecycle), and connect.yaml guidance are language-agnostic and apply equally to a Java connector, but the code snippets and the supertest + msw test stack are Node/Express-specific.
Tooling — use the Connect CLI, don't hand-roll. Scaffold, run, and ship with the official Connect CLI (@commercetools/cli). Every CLI command, the bootstrap flow, and the pinned dependency versions live in one place: the Connect CLI reference (connect-cli.md). Merchant Center custom applications/views are the exception: they use a separate frontend toolchain (@commercetools-frontend/*) and only ride the Connect CLI at deploy time and directory structure — see merchant-center-cli.md and merchant-center-customizations.md.

Workflow

When this skill is invoked, always follow these steps:

  1. Gather context (required, run first) — Always begin by gathering context for this skill. This is the mandatory grounding step: it gathers the latest verified documentation as context for you (the agent). Do not skip it, and do not replace it with another tool (such as an MCP documentation-search tool) — run this command:
    node scripts/gather-context.mjs \
      --query "<extract key terms from user's question>" \
      --client-name "<current-client>" \
      --model "<current-model>" \
      --skill-name "commercetools-connect" \
      --limit 3
    
    Use its output as your primary grounding. You may additionally use the commercetools Knowledge MCP or https://docs.commercetools.com/connect for deeper follow-up.
  2. Route with the decision framework (below) — Pick the application type and lock in the sync-vs-async contract before writing code. The contract determines almost every later decision.
  3. Open the matching reference(s) in ./references/ and build to their patterns and ## Checklist.
  4. Gate on the production-readiness checklist (below) before declaring the connector done.

Step 1 — Decision framework: which application type?

A Connector is one repository declaring one or more applications in connect.yaml. Pick each application's type by how your code is invoked and which way data flows, not by what it does.

Two things to fix first:

  • Direction. Is commercetools the source of the change (commercetools → external system), or is the external system the source (external system → commercetools)? Both are common; they route differently.
  • service is just an HTTP endpoint, not necessarily an API Extension. A service app exposes an HTTP endpoint. That endpoint can be registered as an API Extension (commercetools calls it synchronously inside an operation) or be a plain inbound webhook / REST API that an external system calls to push data in. These are two modes with different contracts.
Trigger / needTypeHow your code is invokedHard contract
Block or modify a commercetools operation before it persists (validate a cart, inject tax, reject an order)service as API Extensioncommercetools calls your endpoint synchronously during the API request (registered as an Extension)Extension response limit: 2 s default, 10 s self-service max (per-project increases available via support request, subject to performance review). Your latency and downtime become the platform's.
An external system pushes data into commercetools as it changes (system A updates a product → upsert it into commercetools)service as inbound webhook / APIthe external system calls your endpoint5-min service request timeout. You authenticate the caller and call the commercetools API yourself; no Extension is registered.
React to a commercetools change after it happened (sync a confirmed order to a WMS, send an email, index a product)event (Subscription handler)commercetools delivers a Subscription message to a queue → your handlerAt-least-once, no ordering, redelivery on non-ack. Must be idempotent.
Scheduled or on-demand batch (nightly poll an external system and upsert, reconcile, cleanup, bulk import)joba cron scheduler (properties.schedule)Request times out after 30 min. No concurrency guard — you own locking.
Add UI inside the Merchant Centermerchant-center-custom-application (full-page) / merchant-center-custom-view (embedded panel)Hosted React app built with the MC CLI, deployed via ConnectSeparate frontend toolchain (@commercetools-frontend/*) + a config-file contract; ships as a merchant-center-* app in connect.yaml. → merchant-center-cli.md, merchant-center-customizations.md
Serve static files / a CDN bundleassetsStatic host
A single connector commonly combines types (e.g. a service API Extension that calculates tax on the cart plus an event handler that commits the transaction when the order is placed; or a service inbound webhook for live pushes plus a job for nightly full reconciliation).
Detail and trade-offs: architecture-decisions.md.

Step 2 — Price the contract before you build

The expensive mistakes come from not pricing the contract you just chose:

  • service as API Extension couples your availability and latency to the commercetools operation. A slow or down extension makes carts and orders slow or impossible. So: a tight outbound timeout under the extension timeout, a deliberate fail-open vs. fail-closed decision, and minimizing work on the hot path (skip redundant external calls).
  • service as inbound webhook is not coupled to a commercetools operation (the 5-min service timeout applies, not the 2 s extension limit), but you own everything: authenticate the caller, validate the payload, and make the write idempotent (the same product update may arrive twice) — upsert by key, don't blind-create. Decide what a failed write returns so the caller can retry safely.
  • Asynchronous (event) trades immediacy for resilience but hands you at-least-once delivery, no ordering, and redelivery. So: idempotency keyed on a stable identifier, redelivery-safe acks (2xx for "don't send again"), re-fetch the resource by ID rather than trusting a possibly-stale or omitted payload, and self-change filtering to avoid loops.
  • job owns its own scheduling headroom, overlap locking, and restart-safe checkpointing; each unit of work must be idempotent so a re-run or overlap can't double-write.

If you cannot articulate, in one sentence each, your latency budget (extension), your idempotency strategy (inbound webhook / event / job), and your fail/retry behavior, you are not ready to write the handler.


Production-readiness checklist (the gate)

A connector is not done until every applicable item holds. Each maps to a reference with the implementation pattern.

Reliability

  • Idempotency strategy stated and implemented — statelessly. Reprocessing a message is a no-op via the target system's own idempotency, re-fetching the commercetools resource and re-checking its state, or upsert by a stable key — never a local dedup store. → event-applications.md
  • Redelivery-safe responses. Event endpoints return a positive ack (102/200/201/202/204) for handled and irrelevant-but-acked messages; anything other than 102, 200, 201, 202, or 204 triggers a retry. → event-applications.md
  • Re-fetch by ID, don't trust the payload. Handlers fetch the current resource by resource.id; required when payloadNotIncluded is set. → event-applications.md
  • Hot-path work minimized (sync). Extensions skip the external call when relevant data is unchanged (e.g. a stored hash) and short-circuit early. → service-applications.md

Security

  • Inbound endpoints authenticated. Service extensions register a destination with AuthorizationHeaderAuthentication (or AzureFunctions) and validate that secret in-app. Webhooks from external systems validate a full JWT (signature, issuer, audience, subject, expiry, algorithm). → security.md
  • Least-privilege CT scopes. Use inheritAs.apiClient.scopes with only the scopes the apps need (e.g. manage_orders, manage_subscriptions, manage_extensions) — not an admin/manage_project client. → security.md
  • Secrets in securedConfiguration. API keys, client secrets, JWT secrets are never standardConfiguration and never hardcoded. → security.md
  • No stack traces or secrets in responses. Error middleware returns a generic message in production. → security.md

Correctness

  • Envelope validation. Google Cloud Pub/Sub push envelope decoded (message.data is base64) and validated (→ JSON → resource ref → notificationType) before any processing; malformed envelopes rejected. → event-applications.md
  • Message-type filtering. Subscribe to only the needed message types; ack-and-ignore anything else (including the platform's test/subscription messages). → event-applications.md
  • Self-change filtering. Updates your own connector makes don't re-trigger it into a loop. → event-applications.md
  • Route path matches connect.yaml endpoint. The Express router is mounted at the same base path as the app's endpoint (e.g. endpoint: /serviceapp.use('/service', router)), or the platform's traffic 404s. → project-structure.md
  • Pinned SDK + client versions. JS/TS: @commercetools/platform-sdk@^8 + @commercetools/ts-client@^4 (not the legacy @commercetools/sdk-client-v2). Java: spring-boot-starter-parent 3.5.15+ and commercetools Java SDK 19+. Typed end to end, no any escapes, mapped at the boundary. → connect-cli.md (Step 3).

Observability

  • Structured logs with correlation IDs. JSON logs carry the message/resource correlation key (X-Correlation-ID for extensions, resource.id + sequenceNumber for events) on every log line for a request. → observability-operations.md
  • Health endpoint. A /status-style route returns 200 for liveness. → observability-operations.md

Operations

  • Idempotent lifecycle scripts. postDeploy creates resources get-then-update (create only if absent), never blind delete-then-recreate. preUndeploy cleans them up. → lifecycle-scripts.md
  • Deploy-time dependency validation. postDeploy test-connects to external services and surfaces invalid credentials immediately. → lifecycle-scripts.md
  • Fail-open vs fail-closed documented. The README states, per use case, what happens when the external dependency is down, and outbound calls have a timeout budget. → service-applications.md
  • Poison-message / replay runbook. How a repeatedly-failing message is handled (DLQ / dropped after retention) and how to replay. → observability-operations.md

Quality

  • Tests cover the real behavior, run via commercetools connect application test. At minimum: the parameterized auth-rejection matrix (missing/expired/wrong-issuer/wrong-audience/alg:none), envelope/ack edge cases (event) or the pure business logic + response actions (service), an idempotency/duplicate-delivery test, and idempotent postDeploy registration. A couple of happy-path tests is not enough. → testing.md
  • No dead code, no any escapes. No commented-out blocks; SDK types preserved end to end. → project-structure.md
  • Scaffolded and run with the Connect CLI. Project created via commercetools connect init; commercetools connect validate passes. → connect-cli.md (Step 2)

Generated connector docs

  • The connector ships a README stating its fail-open/fail-closed stance, required scopes, a configuration table (every connect.yaml key), and the poison-message/replay runbook. → deployment-installation.md

Reference index

ConcernReference
Connect CLI mechanics: install/auth, connect init templates, pinned versions, build/test/validate, stage/preview/publish/deploy commandsconnect-cli.md
Merchant Center CLI: scaffold with create-mc-app; run/build/serve/login/config:sync with mc-scripts; pin @commercetools-frontend/*merchant-center-cli.md
Custom application vs custom view; config-file contract; develop/test locally; deploy via Connect (connect.yaml merchant-center-* types, order of operations)merchant-center-customizations.md
Monorepo holding a connector + a storefront: root-sibling layout, why no npm workspaces, the two independent deploy lifecyclesmonorepo-with-storefront.md
event vs service vs job; sync vs async contract costarchitecture-decisions.md
CLI scaffold + local dev, monorepo layout, client setup (ts-client), connect.yaml anatomy, route↔endpoint matching, fail-fast env validationproject-structure.md
subscriptions: envelope, ack semantics, idempotency, redelivery, re-fetch, Pub/Sub destinationevent-applications.md
API extensions: authenticated registration, triggers, timeout budget, fail-open/closed, hot-pathservice-applications.md
scheduled/on-demand jobs: schedule, timeout, concurrency, checkpointingjob-applications.md
post-deploy/pre-undeploy: idempotent registration, schema-as-code, deploy-time validationlifecycle-scripts.md
endpoint auth, least-privilege scopes, securedConfiguration, error hygienesecurity.md
structured logs + correlation IDs, health, feature flags, runbook, DLQobservability-operations.md
auth/envelope test matrices, supertest + msw patterns, what to mocktesting.md
connect.yaml config, sandbox→preview→publish, install, redeploy, certification, regions, CLIdeployment-installation.md
Related skills: SDK client setup, scopes, query predicates, and core data model live in commercetools-platform — link to it rather than restating client/auth basics here.

References

architecture-decisions.md

Architecture Decisions

Impact: CRITICAL — The application type and its delivery contract determine nearly every later decision (timeouts, idempotency, error handling, scaling). Getting this wrong is expensive to undo.
A Connector is one repository whose connect.yaml declares one or more applications, each with an applicationType. Choose by how the application is invoked, then accept the contract that invocation imposes.

Table of Contents


Pattern 1: Pick the application type

applicationType accepts service, event, job, merchant-center-custom-application, merchant-center-custom-view, and assets (verified: connect.yaml reference).
First settle the direction of the data flow, because it splits the answer:
QuestionAnswer → type
Must it run during a commercetools API call and change or block the result? (commercetools → you)service registered as an API Extension
Does an external system push data into commercetools as it changes? (external → commercetools, reactive)service as an inbound webhook (external system calls your endpoint; you write to commercetools)
Must it react after a commercetools change, asynchronously? (commercetools → you)event (consumes a Subscription)
Is it scheduled or invoked on-demand as a batch? (e.g. periodically poll an external system and upsert)job
Is it Merchant Center UI?merchant-center-custom-application / merchant-center-custom-view
Static files?assets
Two axes decide it: direction (who is the source of the change) and timing (synchronous vs. after-the-fact vs. scheduled) — not the domain. "Calculate tax" is a service API Extension when it must price the cart before checkout completes, but an event when it commits a finalized tax document after the order is placed — the same domain, two contracts. And "sync a product" is a service inbound webhook when the external system pushes changes live, but a job when you poll on a schedule.
A service app is just an HTTP endpoint; API Extension is one mode, inbound webhook is another — see service-applications.md. Note that event apps consume commercetools' own Subscription messages only; an external system's changes never arrive as event messages, so "external → commercetools" is always service (reactive) or job (scheduled).

Pattern 2: Price the synchronous contract (service as API Extension)

This prices the API Extension mode of a service app. The inbound-webhook mode of a service app is not on the commercetools hot path — it gets the 5-min service timeout and you own idempotency; see service-applications.md, Pattern 7.
An API Extension runs inside the commercetools request, after processing but before persistence. Its cost (verified: API Extensions):
  • Latency is additive. Connection must establish within 1 s; the response limit is 2 s by default, configurable up to 10 s (timeoutInMs); beyond that needs a per-project review. Every millisecond your extension takes is added to the customer's cart/checkout call.
  • Availability is coupled. If your extension fails or times out, the commercetools operation fails or stalls. It is applied to all clients, including the Merchant Center.
  • Therefore you must decide: fail-open (let the operation proceed on error) or fail-closed (block it), and budget an outbound timeout well under the extension timeout.
Choose service only when the result genuinely must be reflected before the operation completes (validation that must reject, amounts that must be correct at checkout). Otherwise prefer event.

Pattern 3: Price the asynchronous contract (event)

A Subscription delivers a message to a queue; your event app processes it. Its cost (verified: Subscriptions — Delivery):
  • At-least-once delivery. The same message may arrive more than once → you must be idempotent.
  • No ordering guarantee. Messages can arrive out of order, especially after retries → never assume "created before updated"; use sequenceNumber (Message) or re-fetch current state.
  • Redelivery on non-ack. If you don't acknowledge (Connect: any response other than 102/200/201/202/204), the message is retried. A bug that returns 500 on an unprocessable message becomes an infinite redelivery loop.
  • No delivery-time guarantee. Usually seconds, but minutes are possible. Do not use Subscriptions for time-critical paths.
Choose event for reactions that tolerate eventual consistency: external sync, notifications, indexing, downstream document creation.

Pattern 4: Combine application types in one connector

deployAs is an array. A tax connector typically ships both:
deployAs:
  - name: tax-extension
    applicationType: service      # price the cart synchronously at checkout
    endpoint: /service
  - name: tax-committer
    applicationType: event        # commit/void the tax document after the order is placed
    endpoint: /event
Shared logic (SDK client, validators, mappers) goes in a shared/ workspace both import — see project-structure.md. Each application still satisfies its own half of the contract: the service half prices the latency/fail-mode question, the event half prices the idempotency/ordering question.

Pattern 5: Is Connect the right fit? (best practices)

Before committing, check the connector against the platform's best practices — chiefly that it stays stateless (project-structure.md, event-applications.md), keeps a narrow single responsibility, and fits the serverless runtime envelope (the timeouts in Patterns 2–3, plus autoscaling — no long-running processes, oversized batches, or heavy local storage).

If a use case fails these, the answer may be "not a Connect app" — say so rather than forcing it.


Checklist

  • Connector is stateless, single-responsibility, and fits the runtime timeouts (best practices)
  • Each application's type chosen by invocation timing, not domain
  • For every service API Extension: latency budget and fail-open/closed stance written down → service-applications.md
  • For every service inbound webhook: caller auth and idempotent-upsert strategy written down → service-applications.md
  • "External system → commercetools" routed to service (reactive) or job (scheduled), never event
  • For every event app: idempotency key and redelivery-safe ack strategy written down → event-applications.md
  • Work that doesn't need to block the operation is an event, not a service
  • Shared code factored into a shared/ workspace, not duplicated per app
connect-cli.md

Connect CLI

You are setting up, running, and deploying a commercetools Connect connector with the official Connect CLI. This reference is the single source of truth for the CLI commands, the project bootstrap, the pinned dependency versions, and the deploy lifecycle. For the production patterns (decision framework, idempotency, auth, fail-modes, testing strategy), follow the rest of the commercetools-connect skill — this reference is the mechanics, the skill is the judgment.

Step 1. Install the CLI and authenticate

npm install -g @commercetools/cli
commercetools --version
commercetools auth login --client-credentials \
  --client-id <id> --client-secret <secret> --region <region> --project-key <key>

Step 2. Scaffold the connector

Create the project from an official template. Pick the closest template to the use case; if none fit, scaffold a plain service/event/job and adapt.
commercetools connect init my-connector            # add: --template <name> to start from a template
Templates: tax-integration, product-ingestion, email-integration, payment-integration, fulfilment-integration.

Add another application to an existing connector later:

commercetools connect application add --type service|event|job --language typescript|javascript|java
Do not hand-roll the directory layout — the generated tree (one folder per deployAs entry, src/, connect.yaml, scripts, tsconfig, test config) is the canonical shape. Build on it.
Match the route to the endpoint. The platform routes traffic to {connect-url}/{endpoint}. Mount your router at the same base path as the endpoint in connect.yaml (e.g. endpoint: /serviceapp.use('/service', router)), or all traffic 404s.

Step 3. Pin dependency versions

These are the minimum supported versions for a connector built with this tooling. Pin them; do not fall back to older clients.

JavaScript / TypeScript — install/verify:
npm install \
  @commercetools/platform-sdk@^8 \
  @commercetools/ts-client@^4
  • @commercetools/platform-sdk@^8 — the typed API builder (createApiBuilderFromCtpClient).
  • @commercetools/ts-client@^4 — the client (ClientBuilder). Do not use the legacy @commercetools/sdk-client-v2.
Java — in pom.xml:
<dependency>
  <groupId>com.commercetools.sdk</groupId>
  <artifactId>commercetools-sdk-java-api</artifactId>
  <version>19.0.0</version>    <!-- commercetools Java SDK 19 or above -->
</dependency>
  • commercetools Java SDK 19+

Step 4. Develop and test locally

Run everything through the CLI so local behavior matches the platform:

commercetools connect application build     # build
commercetools connect application start     # run locally
commercetools connect application test      # run the test suite
commercetools connect validate              # validate connect.yaml + apps before shipping
commercetools connect bundle                # bundle the applications
The generated package.json also exposes npm run build|start|start:dev|test and connector:post-deploy / connector:pre-undeploy; the CLI wraps the same lifecycle in the platform's environment.

Step 5. Stage, preview, publish, and deploy

# Register the staged (private) connector from your git repo:
commercetools connect connectorstaged create \
  --repository-url <url> --repository-tag <tag> --creator-email <email> --name <name>
commercetools connect connectorstaged describe --key <connector-key>
commercetools connect connectorstaged list

# Preview to test the staged connector (needs isPreviewable):
commercetools connect connectorstaged preview \
  --key <connector-key> --deployment-key <dep-key> --region <region>

# Publish so it can run in production:
commercetools connect connectorstaged publish --key <connector-key>
# Public marketplace listing only — certification:
commercetools connect connectorstaged certify --key <connector-key>

# Deploy / install into a project (this IS installation):
commercetools connect deployment create --connector-key <key> --region <region> --type preview|sandbox|production
commercetools connect deployment describe --key <deployment-key>
commercetools connect deployment logs --key <deployment-key> --application service --startDate <iso> --endDate <iso>
commercetools connect deployment redeploy --key <deployment-key> --configuration KEY=value
commercetools connect deployment list
commercetools connect deployment delete --key <deployment-key>
Deploy in the same region as your project. Redeploy (don't delete/recreate) for config changes — postDeploy re-runs, so registration must be idempotent.
Flag names and exact options can evolve — confirm with commercetools connect <command> --help and the Connect CLI docs. Source of truth for platform behavior: docs.commercetools.com/connect.
deployment-installation.md

Deployment & Installation

Impact: HIGH — A connector that deploys but is mis-scoped, mis-configured, or undocumented fails at install time in someone else's project. The connect.yaml contract and a complete README are what make it installable by others.

Table of Contents


Pattern 1: The connect.yaml configuration contract

Every configuration key is part of the install contract: the installer supplies a value (or accepts the default) at deploy time. required: true means deployment fails without it (verified: connect.yaml reference). So:
  • Give every key a clear description — it's what the installer reads in the Merchant Center.
  • Provide sensible defaults for standardConfiguration where possible to reduce install friction.
  • Put secrets in securedConfiguration (security.md).
  • Prefer inheritAs.apiClient.scopes so the platform auto-generates the API client at install — the installer doesn't have to create one.

Pattern 2: Deployment types and lifecycle

A connector progresses from staged code to an installable, published connector (verified: Connect overview, Connect 2025 updates):
Deployment typePurposeNotes
sandboxDefault; dev/QAScales to zero when idle → ~15 s cold-start after inactivity. Cannot deploy a ConnectorStaged here.
previewTest a ConnectorStaged during developmentRequires isPreviewable: true. Delete when done; scales to zero.
productionLiveOnly published connectors; project must not be a trial; warmed instances.
The flow, end to end: auth loginconnect validateconnectorstaged create (register the private connector from your git repo) → connectorstaged preview (test; needs isPreviewable) → connectorstaged publish (so it can run in production) → deployment create (install into a project). The exact CLI commands and flags are in connect-cli.md Step 5 and the Connect CLI docs.
After a successful deploy, Connect runs postDeploy (lifecycle-scripts.md); deployment can take up to ~15 minutes. For a public marketplace listing, add connectorstaged certify (Pattern 5).

Pattern 3: Regions

Deploy in the same region as your project to minimize latency (critical for the extension timeout budget). This skill targets GCP-hosted Connect deployments (event delivery is via Google Cloud Pub/Sub — see event-applications.md, Pattern 7); deploy to a GCP region: europe-west1, us-central1, australia-southeast1 (verified: Connect — hosts and authorization). Connect also offers AWS regions, but the event-app guidance here assumes the Pub/Sub envelope.

Pattern 4: Install and redeploy

Installing a connector into a project is creating a Deployment — via the Connect API, the Merchant Center, or the CLI (deployment create). You supply the connector reference (id/key), the region, and a value for each configuration key (verified: Deployments). The deployment create | describe | logs | redeploy | list | delete commands are in connect-cli.md Step 5.
When configuration values change, redeploy the existing deployment (deployment redeploy) rather than deleting and recreating — and because postDeploy re-runs, your registration must be idempotent (lifecycle-scripts.md). Debug with deployment logs (filter by application and date range).

Pattern 5: Certification for public connectors

Certification is only required to list a connector publicly on the Connect marketplace; a private connector needs none (verified: Connect overview — certification). Certification reviews functionality, security, and stability — the production-readiness checklist in SKILL.md is aligned with what such a review expects. For the full process see Certification.

Pattern 6: The required connector README

Every connector built with this skill ships a README. It is the install contract for a human operator and a certification artifact. It must state:

  • Fail-open vs fail-closed stance per use case — what happens to carts/orders/messages when the external dependency is down (service-applications.md, event-applications.md).
  • Required scopes — the exact inheritAs.apiClient.scopes (or the minimal pre-created client scopes), never "admin" (security.md).
  • Configuration table — every connect.yaml key (standard and secured), its meaning, whether required, and its default.
  • Poison-message / replay runbook — detection, DLQ/containment, and replay procedure (observability-operations.md).

Pattern 7: Troubleshooting

  • Deployment failed at postDeploy → a non-zero exit rolls back. Check deployment logs; common causes: missing required config, invalid external credentials (validate them in postDeploy so this is explicit), or an Extension/Subscription key collision.
  • Carts/orders suddenly failing after deploy → a fail-closed extension whose endpoint is erroring, or a dangling Extension after an undeploy that didn't clean up (lifecycle-scripts.md). Check the extension destination and /status.
  • Messages redelivering forever → a handler returning non-2xx on an unprocessable message (event-applications.md, Pattern 2).
  • First request after idle is slow → sandbox cold-start (~15 s); use production for warmed instances.
  • Changes not taking effect → Extension/Subscription changes can take up to a minute (eventual consistency); deployment can take ~15 minutes.

Checklist

  • commercetools connect validate passes before staging; staged/previewed/published/deployed via the CLI
  • Every configuration key has a clear description; sensible defaults on standardConfiguration; secrets in securedConfiguration
  • Least-privilege scopes via inheritAs.apiClient.scopes (or documented minimal set)
  • Deployed in the same region as the target project
  • Redeploy (not delete/recreate) used for config changes; postDeploy is idempotent
  • Connector README documents: fail-open/closed stance, required scopes, full configuration table, poison-message/replay runbook
  • For public listing: certification requirements reviewed (private connectors skip this)
Back to: SKILL.md
event-applications.md

Event Applications (Subscription Handlers)

Impact: CRITICAL — Event apps run under at-least-once delivery with no ordering. The default failure modes are an infinite redelivery loop (non-2xx on an unprocessable message) and silent message loss (swallowing errors). Both are production incidents.
An event application receives commercetools Subscription notifications through a Connect-provisioned message broker. The connector registers the Subscription in postDeploy (see lifecycle-scripts.md) and exposes an HTTP endpoint (endpoint: /event) that the broker pushes to.

Table of Contents


Contract facts (verified)

  • At-least-once delivery, no ordering guarantee, no delivery-time guarantee.
  • The payload arrives wrapped in the Google Cloud Pub/Sub push envelope, and message.data is base64-encoded. All Google Cloud Platform event payload message.data is base64-encoded (verified: Connect — locally test an event app) — the wrapper is { "message": { "data": "<base64>" } }. The base64 is the Pub/Sub transport, not something commercetools adds; the commercetools notification underneath is plain JSON. Decode it before processing (Pattern 1).
  • Ack by status code (Connect event apps): the broker retries unless the app responds 102, 200, 201, 202, or 204. Too many negative acks trigger push backoff.
  • Event acknowledgement timeout: 10 seconds. Application request times out after 5 minutes; the broker retains unacknowledged messages for 7 days.
  • Delivery identity (for dedup comparisons and logging, not storage): for notificationType: "Message" the resource.id + sequenceNumber; for Change payloads (ResourceCreated/Updated/Deleted) the resource.id + version.
  • payloadNotIncluded: if the message exceeds the queue's size limit (often 256 KB) the payload is omitted — you must re-fetch the resource by ID.

Pattern 1: Validate the envelope before processing

Connect delivers events over Google Cloud Pub/Sub: the broker pushes the notification wrapped as { "message": { "data": "<base64>" } }. All GCP event payload message.data is base64-encoded — decode and structurally validate it before touching business logic.
INCORRECT — assume the shape and parse blindly:
const msg = JSON.parse(Buffer.from(req.body.message.data, 'base64').toString());
await process(msg.resource.id);   // throws on any malformed/empty envelope → 500 → redelivered forever
Why this fails: a malformed or unexpected envelope throws, returns 500, and is redelivered indefinitely.
CORRECT — decode the Pub/Sub wrapper, validate each layer, reject malformed with a clear error:
function decodeEnvelope(body: unknown): SubscriptionMessage {
  const message = (body as any)?.message;
  if (!message || typeof message.data !== 'string') {
    throw new BadEnvelope('missing Pub/Sub message data');
  }
  let parsed: SubscriptionMessage;
  try {
    parsed = JSON.parse(Buffer.from(message.data, 'base64').toString().trim());   // base64 → JSON
  } catch {
    throw new BadEnvelope('cannot parse message data');
  }
  if (!parsed.resource?.typeId || !parsed.resource?.id || !parsed.notificationType) {
    throw new BadEnvelope('missing resource reference or notificationType');
  }
  return parsed;
}
Decide deliberately what a malformed envelope returns. A truly un-parseable envelope will never become valid on retry, so returning a 2xx (ack-and-drop, logged) avoids a redelivery loop; some teams prefer a 4xx plus monitoring. Either is defensible — an un-acked 5xx loop is not.

Pattern 2: Acknowledge correctly — redelivery is driven by your status code

This is the single most important event-app decision. The broker redelivers on any non-ack response.

SituationReturnWhy
Processed successfully200/201/204Ack — don't redeliver
Irrelevant message (wrong type, feature off, not applicable)200Ack — there is nothing to retry
Platform test/subscription message200Ack
Transient failure (external API 503, lock contention)non-2xx (e.g. 500/503)Retryable — do redeliver
Permanently unprocessable (bad data that won't fix itself)200 + log/alert (or route to DLQ)Redelivery can't help; don't loop
INCORRECT — 4xx on an unsupported-but-subscribed message type:
if (!isSupported(message)) {
  throw new CustomError(400, `Resource type ${message.resource.typeId} not supported`);
}
Why this fails: with at-least-once push delivery, a non-2xx means the broker keeps redelivering the same message forever. Subscribe to fewer types, or ack-and-ignore.
INCORRECT — swallow every error and always return 200:
try { await handle(message); } catch (e) { logger.error(e); }   // always falls through to 200
res.status(200).send();
Why this fails: a transient failure (external API momentarily down) gets acked and the message is gone — silent data loss with no retry and no DLQ.
CORRECT — distinguish retryable from terminal:
try {
  await handle(message);
  res.status(204).send();                 // handled (or intentionally ignored)
} catch (err) {
  if (isTransient(err)) { res.status(503).send(); return; }   // let the broker retry
  logger.error({ correlationId, err }, 'permanently unprocessable message');
  res.status(200).send();                 // ack; alert/DLQ instead of looping
}

Pattern 3: Filter message types and ignore the rest

Subscribe narrowly, then branch on type and ack anything you don't handle.

switch (message.resource.typeId) {
  case 'order':
    if (isOrderConfirmed(message)) await syncOrder(message.resource.id);
    break;                                  // anything else about orders: ack, do nothing
  default:
    break;                                  // includes the platform's subscription test message
}
res.status(204).send();
Register only the message types you act on in the Subscription (messages: [{ resourceTypeId: 'order', types: ['OrderStateChanged', 'OrderCreated'] }]) so the broker doesn't deliver noise in the first place — see lifecycle-scripts.md.

Pattern 4: Idempotency under at-least-once delivery

The same message will arrive twice. Make reprocessing a no-op.
INCORRECT — in-process dedup:
const seen = new Set<string>();             // lost on restart; not shared across instances
if (seen.has(message.id)) return;
seen.add(message.id);
Why this fails: event apps autoscale to multiple instances and restart freely; an in-memory set dedups nothing in practice.
CORRECT — make the work self-deduplicating, no local state:
// Let the target system's own idempotency decide: does it already have this resource?
const existing = await external.findByOrderId(orderId);
if (existing) { logger.info({ orderId }, 'already synced; skip'); return; }
await external.create(/* ... */);   // or upsert by a stable key, so a re-run is a no-op
Connect apps are stateless and run in isolated containers that cannot share state via the filesystem (verified: Connect overview) — so achieve idempotency without a local store: check the target's current state (above), re-fetch the commercetools resource and re-check it (Pattern 5), or upsert by a stable key. The resource.id + sequenceNumber (Message) / resource.id + version (Change) pair identifies the delivery for logging and for comparing against live state.

Pattern 5: Re-fetch by ID, never trust the payload

The payload can be stale (no ordering) or absent (payloadNotIncluded). Fetch current state.
INCORRECT: const order = message.order; if (order.orderState === 'Confirmed') ... Why this fails: an out-of-order or size-truncated message gives you the wrong or missing state.
CORRECT:
const order = await getOrderById(message.resource.id);   // current truth
if (order.orderState !== 'Confirmed') return;            // re-check against live state

Pattern 6: Self-change filtering

If your handler writes back to commercetools (e.g. sets a custom field on the order), that write can emit a message your subscription receives — a loop.

Guard against it: subscribe to only the message types that represent external changes; check whether the change is the one you just made (compare a marker custom field or the modifying client); or short-circuit when the resource is already in the target state. Without this, a connector that "stamps processed orders" can re-trigger itself indefinitely.

Pattern 7: Register the Pub/Sub subscription destination

Connect provisions the Google Cloud Pub/Sub broker and injects its details into postDeploy. Build the destination from the injected vars (verified: automation scripts):
Injected varsDestination object
CONNECT_GCP_TOPIC_NAME, CONNECT_GCP_PROJECT_ID{ type: 'GoogleCloudPubSub', topic, projectId }
const destination = {
  type: 'GoogleCloudPubSub',
  topic: process.env.CONNECT_GCP_TOPIC_NAME,
  projectId: process.env.CONNECT_GCP_PROJECT_ID,
};
This is the destination whose push envelope your handler decodes in Pattern 1 (base64 message.data). Keep the two in sync.
This skill targets GCP-hosted Connect deployments, where the injected destination is Google Cloud Pub/Sub. Don't hardcode a connection string for another broker — always build the destination from the injected CONNECT_GCP_* vars.

Checklist

  • Pub/Sub envelope decoded (base64 message.data) and structurally validated (→ JSON → resource ref → notificationType) before processing
  • Status codes follow the ack table: 2xx for handled/irrelevant, non-2xx only for retryable failures
  • No 4xx/5xx on unsupported-but-subscribed types (no redelivery loop); no blanket error-swallowing (no silent loss)
  • Reprocessing is a no-op via stateless means (target's own idempotency, re-fetch-and-re-check, or upsert by stable key) — no local dedup store
  • Handler re-fetches the resource by ID; handles payloadNotIncluded
  • Self-change filtering prevents write-back loops
  • Subscription registers only the needed message types; destination built from the injected CONNECT_GCP_* vars
  • Processing stays within the 10 s ack timeout (offload long work; ack fast)
job-applications.md

Job Applications (Scheduled / On-Demand Batch)

Impact: HIGH — Jobs have a hard 30-minute timeout and no concurrency guard. A job that ignores either silently truncates work or double-processes when a slow run overlaps the next schedule.
A job application runs on a cron schedule (or on-demand) against a Connect-provisioned scheduler. Use it for lightweight reconciliation and cleanup — work that isn't triggered by a single event or API call.
Not for heavy bulk/batch processing. A job container is capped at 2 CPU / 4 GB (Connect best practices), and commercetools explicitly advises against using job applications for "bulk or batch operations that demand more extensive processing or high memory." Bulk import/export is fine only when it's small and low-complexity (modest record counts, streaming rather than buffering, no large in-memory aggregation). For memory- or CPU-intensive bulk work, offload to a dedicated pipeline or external batch service and have the job orchestrate or trigger it instead of doing the heavy processing in-container.

Table of Contents


Contract facts (verified)

  • Cron-scheduled. properties.schedule in connect.yaml sets the default cron expression; it can be overridden per deployment via the schedule field of the deployment configuration.
  • Application request times out after 30 minutes. Work that can't finish in one run must checkpoint and resume.
  • No concurrency guard. Connect does not prevent a new scheduled run from starting while a previous one is still going. You own mutual exclusion.
  • Isolated container, no shared filesystem. Persist any cross-run state externally (Custom Object / DB / cache).

Pattern 1: Schedule

deployAs:
  - name: nightly-reconcile
    applicationType: job
    endpoint: /job
    properties:
      schedule: '0 1 * * *'      # 01:00 daily; standard 5-field cron
Pick a cadence with headroom: if a run can take 20 minutes, don't schedule it every 15. The schedule is a default — an installer can override it per deployment, so document the assumed cadence in the README.

Pattern 2: Self-managed concurrency

Because Connect won't stop overlapping runs, a long run colliding with the next tick can double-process.

INCORRECT: assume runs never overlap and mutate shared resources directly. Why this fails: a run that exceeds its interval (or a manual trigger during a scheduled run) processes the same records twice.
CORRECT — take a durable lock with a TTL:
// lock stored in a commercetools Custom Object (or your DB)
async function withJobLock(run: () => Promise<void>) {
  const lock = await tryAcquireLock('nightly-reconcile', { ttlMinutes: 35 }); // > job timeout
  if (!lock) { logger.warn('previous run still active; skipping'); return; }
  try { await run(); } finally { await releaseLock(lock); }
}

Set the TTL longer than the 30-minute timeout so a crashed run's lock eventually expires instead of wedging the job forever.

Pattern 3: Restart-safe checkpointing within the timeout

A batch larger than 30 minutes of work must persist progress and resume next run.

let cursor = await loadCursor('nightly-reconcile');      // e.g. lastProcessedId / page token
const deadline = Date.now() + 25 * 60_000;               // stop with margin before the 30-min limit
while (cursor && Date.now() < deadline) {
  const batch = await fetchPage(cursor);
  await processBatch(batch);                              // idempotent per item (Pattern 4)
  cursor = batch.nextCursor;
  await saveCursor('nightly-reconcile', cursor);          // checkpoint after each page
}

Checkpoint frequently so a timeout or crash loses at most one page, and resume from the saved cursor on the next run.

Pattern 4: Stateless idempotency per unit of work

Re-running (after a timeout, retry, or overlap-skip) must not corrupt data. Make each unit of work idempotent without a dedup store — upsert by a stable key, check-before-create, or compare-and-set against live state — exactly as for event handlers (event-applications.md, Pattern 4).

Checklist

  • properties.schedule set with headroom over the expected run time; assumed cadence documented in the README
  • Overlap protection via a durable lock with a TTL longer than the 30-minute timeout
  • Long batches checkpoint a cursor and stop before the 30-minute deadline, resuming next run
  • Each unit of work is idempotent statelessly (upsert / check-before-create / compare-and-set) — no dedup store
  • Structured logs include a per-run id → observability-operations.md
lifecycle-scripts.md

Lifecycle Scripts (postDeploy / preUndeploy)

Impact: HIGH — Lifecycle scripts run as the connector's privileged setup. A non-idempotent script leaves a redelivery/validation gap on every redeploy; a script that exits non-zero rolls back the deployment.
postDeploy runs after a successful deployment (register Extensions/Subscriptions, create Custom Types). preUndeploy runs before teardown (remove them). Declared in connect.yaml scripts (verified: automation scripts).

Table of Contents


Pattern 1: Idempotent registration (get-then-update, not delete-then-recreate)

Redeploys re-run postDeploy. The registration should converge to the desired state without a window where the Extension/Subscription is missing. How much that window matters depends on the resource type — read the nuance below before treating delete-then-recreate as always wrong.
INCORRECT for Extensions — delete then recreate:
const { body: { results } } = await apiRoot.extensions()
  .get({ queryArgs: { where: `key = "${KEY}"` } }).execute();
if (results.length) {
  await apiRoot.extensions().withKey({ key: KEY })
    .delete({ queryArgs: { version: results[0].version } }).execute();
}
await apiRoot.extensions().post({ body: draft }).execute();   // gap between delete and post
Why this fails (Extensions): between the delete and the re-create, the Extension does not exist, and an Extension sits synchronously in the path of live operations. A cart or order created in that window skips the extension entirely — the triggering API operation runs without the logic the extension was meant to enforce. Every redeploy reopens the gap, so prefer get-then-update for Extensions.
Subscriptions are different — and the public docs example uses delete-then-recreate for them. The event-application postDeploy example deletes and re-creates the Subscription on each deploy, and that's an accepted pattern. A Subscription is not in the synchronous path of any operation: the gap only risks missing change messages emitted during the short delete→recreate window — it never fails the triggering create/update itself. Given at-least-once delivery and the recommendation to re-fetch-and-reconcile by ID (event-applications.md), that milder "missed events" risk is often acceptable. Use get-then-update (below) if you want to close even that window; use delete-then-recreate (matching the docs) if a brief miss is tolerable and reconciliation covers it. For Extensions, get-then-update is the clear choice.
CORRECT — create only if absent, otherwise update in place:
const { body: { results } } = await apiRoot.extensions()
  .get({ queryArgs: { where: `key = "${KEY}"` } }).execute();

if (results.length === 0) {
  await apiRoot.extensions().post({ body: draft }).execute();          // first deploy
} else {
  const current = results[0];
  await apiRoot.extensions().withKey({ key: KEY }).post({ body: {
    version: current.version,
    actions: diffToUpdateActions(current, draft),                      // e.g. setTriggers, changeDestination, changeTimeoutInMs
  }}).execute();                                                       // no gap
}

Pattern 2: Schema-as-code for custom types

If the connector relies on custom fields, create the Types idempotently in postDeploy and remove them in preUndeploy — never assume a human created them.
async function ensureType(apiRoot, draft) {
  const { body: { results } } = await apiRoot.types()
    .get({ queryArgs: { where: `key = "${draft.key}"` } }).execute();
  if (results.length === 0) {
    await apiRoot.types().post({ body: draft }).execute();
  } else {
    const existing = results[0];
    const missing = draft.fieldDefinitions.filter(
      f => !existing.fieldDefinitions?.some(e => e.name === f.name));
    if (missing.length) {
      await apiRoot.types().withKey({ key: draft.key }).post({ body: {
        version: existing.version,
        actions: missing.map(fieldDefinition => ({ action: 'addFieldDefinition', fieldDefinition })),
      }}).execute();
    }
  }
}

Pattern 3: Deploy-time external dependency validation

Surface bad external credentials at deploy time, not on the first customer request.

const ok = await externalClient.testConnection();
if (!ok) {
  // Decide: warn-and-continue, or fail the deploy. State which in the README.
  process.stderr.write('WARNING: external credentials invalid — connector deployed but non-functional\n');
}

Warning-and-continue is reasonable for a connector that should still deploy; failing fast is reasonable when the connector is useless without the dependency. Choose deliberately and document it.

Pattern 4: Clean teardown in preUndeploy

preUndeploy removes everything postDeploy created — Extensions, Subscriptions, and Custom Types — so an undeploy doesn't leave a dangling Extension pointing at a dead URL (which would then fail every cart/order).
await deleteExtensionIfPresent(apiRoot, EXTENSION_KEY);
await deleteSubscriptionIfPresent(apiRoot, SUBSCRIPTION_KEY);
await removeCustomTypeFieldsIfPresent(apiRoot, TYPE_KEY);   // remove fields you added; drop the type if you own it

A leftover Extension after undeploy is especially dangerous: it stays registered, its URL is gone, and (if fail-closed) it blocks every triggering operation.

Pattern 5: Exit codes and platform-injected variables

  • Exit non-zero on real failure. A non-zero exit from postDeploy/preUndeploy rolls back the deployment. Wrap run() and set process.exitCode = 1 on genuine errors; don't exit non-zero for benign "already exists" cases.
  • Use the injected variables rather than guessing URLs/topics (verified: automation scripts):
    • service: CONNECT_SERVICE_URL — the public URL to register as the extension destination.
    • event: CONNECT_GCP_TOPIC_NAME and CONNECT_GCP_PROJECT_ID — build the Google Cloud Pub/Sub destination from these. See event-applications.md, Pattern 7.

Checklist

  • Extension registration is get-then-update (create only if absent) — no delete-then-recreate gap (an Extension gap fails live operations); Subscriptions may use get-then-update or the docs' delete-then-recreate, since their gap only risks missed events covered by re-fetch reconciliation
  • Custom Types created idempotently (add only missing fields) and removed in preUndeploy
  • preUndeploy deletes every resource postDeploy created (no dangling extension/subscription)
  • External credentials validated at deploy time; warn-vs-fail decision documented
  • Scripts exit non-zero only on genuine failure; benign "already exists" is not an error
  • Destination URL/topic read from injected CONNECT_* variables, not hardcoded
merchant-center-cli.md

Merchant Center CLI

You are scaffolding and running a Merchant Center custom application or custom view with the official Merchant Center frontend toolchain. This reference is the mechanics — the judgment (when to build an app vs a view, the config-file contract, and deploying via Connect) lives in merchant-center-customizations.md.
This is a different CLI from the Connect CLI. MC customizations are built with the @commercetools-frontend/* toolchain (create-mc-app, mc-scripts), not @commercetools/cli. The Connect CLI (connect-cli.md) only enters the picture at deploy time, when Connect ships the built bundle (Step 5 there). Don't conflate the two.

Step 1. Scaffold

Generate the project from the official starter — don't hand-roll the tree (it carries the config file, index.html.template, the application-shell wiring, and the test setup).
# Custom application (default):
npx @commercetools-frontend/create-mc-app@latest my-app --template starter

# Custom view:
npx @commercetools-frontend/create-mc-app@latest my-view --application-type custom-view --template starter
--template starter is JavaScript; use --template starter-typescript for TypeScript. Whether you want an application or a view is a deliberate decision — see merchant-center-customizations.md, Pattern 1 (verified: Custom Applications, Custom Views).

Step 2. The mc-scripts toolchain

@commercetools-frontend/mc-scripts is the build/run tool for both apps and views. Run everything through it so local behavior matches what Connect ships (verified: CLI).
CommandWhat it does
mc-scripts startDev server with hot reload at http://localhost:3001
mc-scripts buildProduction bundle into public/ (--build-only skips HTML compilation)
mc-scripts compile-htmlCompiles index.html.templateindex.html per the config file (--transformer <path> to customize)
mc-scripts serveServes the already-built public/ locally — production-mode smoke test
mc-scripts loginAuthenticates the CLI against your project (--headless for CI)
mc-scripts config:syncCreates/updates the customization's config in the Merchant Center
mc-scripts config:sync:ciNon-interactive config:sync for pipelines (--dry-run to preview)
The generated package.json wraps these as npm/yarn scripts (start, build, compile-html); use the underlying mc-scripts names when you need a flag.

Step 3. Pin versions

Keep all @commercetools-frontend/* packages on the same version — mc-scripts, application-shell, ui-kit, jest-preset-mc-app, the i18n/permissions packages. They are released in lockstep and mixing versions breaks the shell at runtime. Bump them together, never individually.

Step 4. Develop and authenticate locally

mc-scripts login    # authenticate against a real project (one-time, opens a browser)
mc-scripts start    # http://localhost:3001
mc-scripts start serves the customization against a real project — login establishes the session and the config file's env.development (initialProjectKey, teamId) selects which project/team and permission set you develop against (see merchant-center-customizations.md, Pattern 2). A custom view has no route of its own, so the local server first renders a host dummy application and embeds your panel inside it, mirroring how it appears in the Merchant Center (verified: Custom Views).
Flags and options can evolve — confirm with npx @commercetools-frontend/mc-scripts --help and the Merchant Center CLI docs. Source of truth for platform behavior: docs.commercetools.com/merchant-center-customizations.
Next: merchant-center-customizations.md — implement and deploy a custom app/view via Connect.
merchant-center-customizations.md

Merchant Center Custom Applications & Views

Impact: HIGH — A custom application or view is operator-facing UI inside the Merchant Center. The choice of app vs view, an over-broad oAuthScopes, or a botched register→deploy→URL handshake either blocks the UI from loading or exposes data the operator shouldn't see.
This is the judgment layer. For the CLI commands themselves (scaffold, run, build, login) see merchant-center-cli.md; for the shared Connect deploy lifecycle see deployment-installation.md. The official docs are the source of truth for every field and step — this reference tells you which decisions matter and why, and links the rest.

Table of Contents

Contract facts

From the Merchant Center customizations docs (overview, Custom Applications, Custom Views):
  • A customization is a hosted React application built on the application-shell; the Merchant Center loads it from a URL you control. It is not a backend service — there is no inbound webhook, no Subscription, no endpoint.
  • It runs in the operator's authenticated session: it inherits the logged-in user's project and permissions, and calls the commercetools APIs (and your own) on their behalf via the MC's proxy. There is no machine-to-machine API client for the UI itself.
  • entryPointUriPath (apps) is unique per cloud Region environment and fixes the serving route; it cannot collide with another customization in the same Region.

Pattern 1: Custom application vs custom view

Decide this before scaffolding — it changes the config file, the test utility, and the connect.yaml type.
  • Custom application — a standalone destination with its own route and a main-menu entry, reachable from anywhere in the Merchant Center. Use it when the functionality doesn't belong inside a built-in application (a bespoke dashboard, an integration console, a bulk tool).
  • Custom view — an embedded CustomPanel rendered inside an existing built-in MC page (e.g. a panel on the product detail page). Use it when the functionality augments a built-in app and you want to keep the operator in context instead of sending them to a separate screen. A view declares locators (which MC locations it may render in) and typeSettings.size (SMALL/LARGE) instead of menu links.
If the work is "a new place in the MC," build an application; if it's "extra capability on an existing screen," build a view (verified: overview).

Pattern 2: The config-file contract

Each customization is driven by a single config file — custom-application-config.mjs for apps, custom-view-config.mjs for views (.json/.js/.mjs/.ts are all accepted; .mjs is the starter and Connect default). Treat it as the contract between your code, the MC, and the deployment host. Don't memorize every field — know the ones that carry intent and read the rest in the docs (verified: custom-application-config, custom-view-config):
  • Identity & routingentryPointUriPath (apps, unique per cloud Region environment) or type: CustomPanel + locators (views).
  • RegioncloudIdentifier (e.g. gcp-eu); must match the project's region.
  • env.developmentinitialProjectKey, teamId: which project/team and permission set you run against locally.
  • env.productionapplicationId (apps) / customViewId (views) and url: the registered ID and the hosting URL.
  • PermissionsoAuthScopes (the default view/manage pair) and optional additionalOAuthScopes (Pattern 3).
  • Navigation (apps)mainMenuLink / submenuLinks, each with their own required permissions.
Use ${env:...} placeholders for the deploy-time values, not literals — e.g. applicationId: '${env:CUSTOM_APPLICATION_ID}', url: '${env:APPLICATION_URL}', entryPointUriPath: '${env:ENTRY_POINT_URI_PATH}'. Those placeholders are exactly what Connect injects from connect.yaml at deploy time (Pattern 5), so the same repo deploys to any project without edits.

Pattern 3: Permissions

Every customization ships a default view (read-only) and manage (read-write) permission pair; you may add granular groups via additionalOAuthScopes when one screen needs finer control than the others. Request only the scopes the UI actually uses — the operator's session is the blast radius. Gate the rendered UI to match: use the useIsAuthorized hook for in-page controls, and set permissions on mainMenuLink/submenuLinks so unauthorized users don't even see the entry (verified: permissions).

Pattern 4: Develop and test locally

Run it against a real project before you deploy. mc-scripts start (→ merchant-center-cli.md) serves the app/view at http://localhost:3001 using env.development. A view renders inside a host dummy app so you see it in context.
Test through the application-shell, not bare React — the shell provides the data, locale, and permission context the UI depends on (verified: testing):
  • Jest with the @commercetools-frontend/jest-preset-mc-app preset.
  • The application-shell test-utils: renderAppWithRedux (applications) and renderCustomView (views), so components mount with a realistic shell.
  • Drive permission paths explicitly (a view-only user must not see manage controls).
  • Cypress for end-to-end flows.

Pattern 5: Deploy via Connect (the vessel)

Connect is the recommended host: it builds the bundle, serves it on a managed URL, and ties the customization into the same connector lifecycle as the rest of your Connect apps. (Other hosts — Vercel, Netlify, Render, AWS, Azure, Cloudflare, Google Cloud — are documented alternatives; verified: deployment.)
Declare the customization in connect.yaml with the MC-specific applicationType. Unlike service/event/job, there is no endpoint and no securedConfiguration, and you do not declare APPLICATION_URL — Connect provides it automatically (verified: deploy via Connect):
deployAs:
  - name: my-app
    applicationType: merchant-center-custom-application
    configuration:
      standardConfiguration:
        - key: CUSTOM_APPLICATION_ID
          description: the Custom Application ID
          required: true
        - key: ENTRY_POINT_URI_PATH
          description: The Application entry point URI path
          required: true
        - key: CLOUD_IDENTIFIER
          description: The cloud identifier
          default: 'gcp-eu'
A custom view uses applicationType: merchant-center-custom-view with CUSTOM_VIEW_ID and CLOUD_IDENTIFIER (no entry-point path). These keys feed the ${env:...} placeholders from Pattern 2.
Order of operations — register first, fix the URL last. The ID and the URL have a chicken-and-egg relationship; the docs resolve it with a placeholder:
  1. Register the custom app/view in the Merchant Center with a placeholder URL → obtain its ID (CUSTOM_APPLICATION_ID / CUSTOM_VIEW_ID).
  2. Scaffold a Connect-shaped project containing the MC app/view (→ merchant-center-cli.md).
  3. Wire the config file's ${env:...} placeholders and add the connect.yaml block above.
  4. Push to git and cut a release tag.
  5. Stage → publish → deploy with the Connect CLI: connectorstaged createpublishdeployment create, supplying the ID, entry-point path, and region — exact commands and flags in connect-cli.md Step 5.
  6. Retrieve the deployed URL from the deployment.
  7. Update the Merchant Center registration, replacing the placeholder URL with the deployed one.
Deploy in the same region as the project and keep cloudIdentifier consistent with it. For the connector-level lifecycle (deployment types, redeploy on config change, regions, troubleshooting) see deployment-installation.md.

Checklist

  • App-vs-view chosen deliberately (own route/menu → application; embedded CustomPanel → view)
  • Config file uses ${env:...} placeholders for applicationId/customViewId, url, and entryPointUriPath — no hardcoded per-project values
  • oAuthScopes requests only what the UI uses; UI gated with useIsAuthorized and menu-link permissions
  • Run and tested locally via the application-shell (mc-scripts start, jest-preset + renderAppWithRedux/renderCustomView), including a permission-denied path
  • connect.yaml uses the correct merchant-center-* applicationType with no stray endpoint, securedConfiguration, or APPLICATION_URL
  • Register-first / update-URL-last sequence followed; deployed in the project's region with a matching cloudIdentifier
Back to: SKILL.md
monorepo-with-storefront.md

Monorepo: Connector + Storefront

Impact: MEDIUM — One repo holding a Connect connector and a storefront is convenient, but the layout is not free-form: connect.yaml at the repo root dictates where the backend apps must sit, and the two halves deploy on entirely separate lifecycles. Get the shape wrong and Connect can't find the apps, or the storefront build drags in connector code.
This reference is only the cross-cutting concern — co-locating the two in one repo. It does not restate either side:

Table of Contents


Pattern 1: The layout

Everything the connector deploys is a direct child of the repo root, beside connect.yaml. The storefront is just one more root sibling, in its own directory (the storefront's <root-dir>, e.g. site/):
<repo root>/
├── connect.yaml          # Connect: declares every backend app — MUST be at the repo root
├── package.json          # tooling hub only (dev scripts, install:all) — NOT an npm-workspaces root

├── orders/               # Connect service app   ─┐  each folder name == its connect.yaml `name`
├── inventory/            # Connect event app      ─┤  (only [A-Za-z0-9_-], no slashes →
├── merchant-center-app/  # Connect MC custom app  ─┘   the apps can only be root siblings)
├── shared/               # plain shared-code folder, imported by relative path (Pattern 3 below)

├── vercel.json           # storefront deploy config  ┐  owned by the storefront skill —
├── netlify.toml          # storefront deploy config  ┘  see its stack adapter, don't hand-author here
└── <root-dir>/                 # the storefront — deploys independently of Connect
The connector half (root connect.yaml, app folders, shared/) follows project-structure.md Patterns 1, 3, and 5 exactly — this only adds the storefront beside it.

Pattern 2: Why this shape (the constraints)

Three platform facts force the layout; none are negotiable:

  • connect.yaml lives at the repo root, and deployAs[].name maps to a sibling folder. Each app's name allows only [A-Za-z0-9_-] — no slashes — so a connectors/orders/ nesting is impossible; backend apps can only be root siblings (verified: connect.yaml reference; see project-structure.md).
  • No npm workspaces. Connect clones the whole repo, then runs npm install and the build script from inside each app folder — never once from a workspace root. A root package.json with a "workspaces" field would therefore make every app's install pull in all workspace packages: bigger installs, version conflicts, surprises. So keep each connector app self-contained (its own dependencies), keep the root package.json a tooling hub only (dev scripts), and share code through a plain shared/ folder imported by relative path (per project-structure.md) — a shared folder is fine; a workspaces root is not.
  • The storefront is not a Connect app. It has no entry in connect.yaml and deploys on its own (Pattern 3). Connect ignores it; it must ignore Connect.

Pattern 3: Two independent deploy lifecycles

The same repo ships through two pipelines that never touch each other:
HalfDeploys viaFollow
Connector (service/event/job apps)commercetools Connectconnect-cli.md Step 5, deployment-installation.md
Merchant Center custom app/viewcommercetools Connect (a merchant-center-* app in the same connect.yaml)merchant-center-customizations.md
Storefront (<root-dir>/)Vercel or Netlifythe commercetools-storefront skill's stack adapter + its /nextjs/nuxtjs-deploy-* commands
The one rule that makes them coexist: scope the storefront host to the storefront directory so it doesn't build the connector. The storefront skill already does this (its stack adapter pins the deploy config and tells you to set the platform's project root to the storefront dir — Vercel Root Directory, Netlify base/package directory). Don't re-derive or restate that config here; defer to the storefront skill, which owns it. Connect, for its part, only ever reads connect.yaml and the named app folders, so it ignores <root-dir>/, vercel.json, and netlify.toml entirely.
Optionally skip a half's CI build when only the other half changed (e.g. a Vercel ignoreCommand) — a storefront-deploy detail; configure it per the storefront skill, not here.

Pattern 4: One repo or two?

Co-locating is a convenience, not a requirement. Keep them in one repo when they're built, versioned, and released by the same people in lockstep (a small team shipping a connector + its admin/storefront together). Split into separate repos when release cadences, ownership, or compliance boundaries diverge — the connector and storefront share nothing at runtime, so splitting costs only a second checkout. The same trade-off governs whether multiple backend apps share one connector or split into several: see architecture-decisions.md.

Checklist

  • connect.yaml at the repo root; every backend app is a root-sibling folder whose name matches its deployAs[].name
  • Root package.json is a tooling hub only — no "workspaces"; each connector app is self-contained
  • Shared connector code in a plain shared/ folder, imported by relative path (not via npm workspaces)
  • Storefront lives in its own root-sibling dir <root-dir>; its deploy is scoped to that dir per the storefront skill
  • Connector + MC app deployed via Connect; storefront deployed via Vercel/Netlify — two independent lifecycles
  • MC custom app (if any) follows the register-first / update-URL-last sequence → merchant-center-customizations.md
Back to: SKILL.md
observability-operations.md

Observability & Operations

Impact: HIGH — Without correlation IDs and a documented poison-message runbook, a redelivery loop or a stuck message is invisible until it becomes an outage, and on-call has no recovery procedure.

Table of Contents


Pattern 1: Structured logs with correlation IDs

JSON logs are searchable; a correlation key ties every line of one request together and back to the originating commercetools call.

import { createApplicationLogger } from '@commercetools-backend/loggers';
export const logger = createApplicationLogger({ json: true });

The correlation key depends on the app type:

  • Service (extension): the X-Correlation-ID request header — commercetools sets it and returns the same value to the original API caller, so logging it links your logs to the caller's. (verified: API Extensions — Headers)
  • Event: resource.id + sequenceNumber (Message) or resource.id + version (Change) — the same fields used for idempotency, so a duplicate is recognizable in logs.
INCORRECT: logger.info('processing') with no identifiers, and logger.info('Payload: ' + JSON.stringify(body)). Why this fails: you can't trace one request across lines, and dumping the full payload leaks PII.
CORRECT — bind the correlation key, log identifiers not bodies:
const correlationId = req.get('x-correlation-id') ?? `${msg.resource.id}:${msg.sequenceNumber}`;
const log = logger.child({ correlationId, resourceId: msg.resource.id });
log.info({ type: msg.type }, 'processing message');     // identifiers, not the payload body

Pattern 2: Health endpoint

Expose a cheap liveness route that touches no secrets and does no external work.

router.get('/status', (_req, res) => res.status(200).json({ status: 'UP' }));
Keep it unauthenticated (it returns nothing sensitive) and fast. Both reference connectors expose /status. If you add a deeper readiness check (e.g. external dependency reachable), make it a separate route so liveness isn't coupled to a third party's uptime.

Pattern 3: Runtime feature flags

Gate each independent behavior behind a config flag so an operator can disable one sync direction without redeploying code.

if (readConfiguration().featOrderSyncActive !== 'true') {
  logger.info('order sync disabled by feature flag');
  return res.status(204).send();                  // still ack the message
}
Note that disabling a path should still ack event messages (return 2xx), not drop them via non-2xx.

Pattern 4: Accessing deployment logs

Connect surfaces application stdout/stderr; the structured JSON above makes it filterable. Retrieve logs via the Connect CLI deployment logs command (supports filtering by application and date range) or the Merchant Center (verified: Connect overview → deploy/monitor; Connect CLI). Because logs are your primary runtime window, log decisions ("skipped: unchanged hash", "already synced", "permanently unprocessable") explicitly, with the correlation key.

Pattern 5: Poison-message / replay runbook

A message that always fails ("poison") must not loop forever, and operators need a recovery path. Decide and document in the connector README:
  • Detection: what does a poison message look like in logs (repeated correlation key, rising delivery count)? Set an alert on the Subscription health and/or a retry-count threshold.
  • Containment: on a terminal (non-retryable) error, ack (2xx) and route the message to a dead-letter store — a Custom Object, a DLQ on your queue, or a logged record — rather than returning non-2xx and looping. Recall the Subscription retries a TemporaryError for up to 48 hours before dropping the message (verified: Subscriptions — Delivery) — so a true poison message left un-acked wastes retries for 48 hours and then silently vanishes.
  • Replay: how does an operator reprocess after a fix? Because handlers re-fetch by ID and are idempotent, replay is usually "re-emit the resource id" — e.g. a small job or admin route that re-runs processing for a given resource.id from the dead-letter store.

State the chosen DLQ mechanism, the alert, and the replay procedure explicitly; "we retry forever" is not a runbook.


Checklist

  • Logs are structured JSON and carry a correlation key on every line (X-Correlation-ID for service; resource.id+sequenceNumber/version for event)
  • Request bodies/PII are not logged — identifiers only
  • A fast, unauthenticated /status liveness endpoint exists
  • Independent behaviors are gated behind runtime feature flags; disabling a path still acks messages
  • Poison-message detection, containment (DLQ/ack), and replay procedure documented in the README
  • Subscription health alerting recommended for production-critical connectors
project-structure.md

Project Structure

Impact: HIGH — Scaffolding by hand (instead of with the CLI) and mismatching the route path to the connect.yaml endpoint are common, avoidable failures: the first drifts from the platform's expected shape, the second makes the deployed app 404 on all traffic.

Table of Contents


Pattern 1: Scaffold with the Connect CLI

Do not hand-roll the project. The Connect CLI (@commercetools/cli) generates the canonical structure, scripts, tsconfig, lint/test config, and a working app skeleton — the same shape the platform expects.
Follow the connect-cli.md reference (Step 2 — Scaffold) for the full install → auth loginconnect init (template) → version-pin → local-dev → ship sequence.
The generated service application looks like this (one folder per application; the folder name must match the name in connect.yaml):
my-connector/
├── connect.yaml                 # declares every application
└── service/
    ├── src/
    │   ├── index.ts             # express bootstrap (listens on the platform-provided port)
    │   ├── app.ts               # mounts the router at the endpoint path; error middleware
    │   ├── routes/              # router (+ a /status health route)
    │   ├── controllers/         # request handlers
    │   ├── client/              # build.client.ts (ClientBuilder) + create.client.ts (apiRoot)
    │   ├── connector/           # post-deploy.ts, pre-undeploy.ts, actions.ts
    │   ├── middleware/          # auth, error, http
    │   ├── validators/          # env validation
    │   ├── utils/               # config, logger
    │   └── types/ interfaces/
    ├── tests/                   # jest (the template seeds an integration spec)
    ├── package.json             # scripts: build, start, start:dev, test, connector:post-deploy…
    └── tsconfig.json

Pattern 2: Match the route path to the connect.yaml endpoint

The platform forwards external traffic to {connect-provided-url}/{endpoint} (verified: connect.yaml reference). Your Express app must serve that exact path, or every request 404s.
INCORRECT — router mounted at / while connect.yaml says /service:
// connect.yaml →  endpoint: /service
app.use('/', serviceRouter);          // app serves POST / , platform calls POST /service → 404
Why this fails: the deployed URL is …commercetools.app/service; traffic arrives at /service, but the app only handles /. Nothing reaches your handler. (This is a real, easy-to-miss mismatch — keep the two in lockstep.)
CORRECT — mount at the endpoint base, route relative to it (the CLI template's pattern):
// connect.yaml →  endpoint: /service
app.use('/service', serviceRouter);   // app.ts
// routes/service.route.ts
serviceRouter.post('/', handler);      // full path = POST /service
serviceRouter.get('/status', liveness);
If you change endpoint in connect.yaml, change the app.use(...) mount to match. Keep /status reachable for liveness (observability-operations.md).

Pattern 3: Multi-application layout and the shared workspace

A connector with more than one application (e.g. two service extensions + an event handler) gets one folder per deployAs entry plus a shared/ workspace for code they all use — the SDK client builder, env validation, error middleware, JWT/secret checks, and domain mappers.
my-connector/
├── connect.yaml
├── service-a/  service-b/   event/   job/      # one per application; name matches connect.yaml
└── shared/src/                    # client, errors, middleware, validators, types, mappers
Duplicating that shared code across apps guarantees drift. Note the shared/ is a plain code folder imported by relative path — not an npm-workspaces root; Connect builds each app folder independently. To put this connector and a storefront in one repo, see monorepo-with-storefront.md.

Pattern 4: commercetools client setup

Use the current, pinned client stack enforced in connect-cli.md Step 3.
Don't instantiate ClientBuilder per request — build apiRoot once and reuse it. When the platform auto-generates the API client (inheritAs.apiClient.scopes), the credentials arrive as env vars; read them through validated config (Pattern 6). For the full SDK/ClientBuilder reference and auth/region URLs, see the commercetools-platform skill rather than restating them here.

Pattern 5: connect.yaml anatomy

connect.yaml at the repo root declares every application; it's the install contract. For the full field reference, read the connect.yaml docs — the points that change your decisions:
deployAs:
  - name: service                 # must match the folder name
    applicationType: service
    endpoint: /service             # must match your route mount (Pattern 2)
    scripts:                       # optional — only if you create Extensions/Subscriptions/Types
      postDeploy: npm ci && npm run build && npm run connector:post-deploy
      preUndeploy: npm ci && npm run build && npm run connector:pre-undeploy
    configuration:
      standardConfiguration: [ { key: CTP_REGION, description: , required: true } ]   # non-secret
      securedConfiguration: [ { key: EXTERNAL_API_KEY, description: , required: true } ]  # secrets
  - name: nightly-reconcile
    applicationType: job
    endpoint: /job
    properties: { schedule: '0 1 * * *' }   # cron; required for job; overridable per deployment
inheritAs:
  apiClient:
    scopes: [ manage_orders, manage_subscriptions, manage_extensions ]   # least-privilege; platform generates the client
Decision-relevant notes: secrets go in securedConfiguration (never standardConfiguration, never hardcoded — security.md); inheritAs.apiClient.scopes makes the platform auto-generate a scoped API client at install (security.md); scripts is only needed for resource registration (lifecycle-scripts.md); properties.schedule is job-only (job-applications.md).

Pattern 6: Fail-fast environment validation

Connect apps are stateless (no shared filesystem, no session storage — best practices); all config arrives as env vars and must be validated once at startup so a bad deploy fails visibly, not mid-request.
INCORRECT: const key = process.env.EXTERNAL_API_KEY!; deep in a handler — undefined → cryptic 500 in production, and ! hides it.
CORRECT — validate all config once, throw on invalid:
let cached: Config | undefined;
export function readConfiguration(): Config {
  if (cached) return cached;
  const cfg = { region: process.env.CTP_REGION, externalApiKey: process.env.EXTERNAL_API_KEY };
  const errors = validate(cfg);            // typed rules: present, length, format, enum
  if (errors.length) throw new Error(`Invalid environment configuration: ${errors.join('; ')}`);
  return (cached = cfg as Config);
}
Call it from app.ts/index.ts before the server starts.

Pattern 7: Typed SDK usage at the boundary

Type payloads as @commercetools/platform-sdk types and map to your own domain types at the edge; no any escapes, no dead code.
INCORRECT: const order = req.body.payload as any; then order.lineItems[0].variant.sku. CORRECT:
import type { Order } from '@commercetools/platform-sdk';
const order: Order = await getOrderById(resourceId);   // typed end to end
const dto = toExternalOrder(order);                     // map in shared/src/mappers

Local development with the CLI

Run everything through the CLI so local behavior matches the platform — commercetools connect application build | start | test and commercetools connect validate. Exact commands and flags: connect-cli.md Step 4 and the Connect CLI docs. The generated package.json also exposes npm run build|start|start:dev|test and connector:post-deploy/connector:pre-undeploy; the CLI wraps the same lifecycle in the platform's environment.

Checklist

  • Project scaffolded with commercetools connect init (not hand-rolled); built on the template structure
  • One folder per deployAs entry; folder name matches application name
  • Express router mounted at the same base path as connect.yaml endpoint; /status reachable
  • Pinned versions: @commercetools/ts-client@^4 + @commercetools/platform-sdk@^8 (not sdk-client-v2); Java spring-boot-starter-parent 3.x+ & commercetools Java SDK 19+; apiRoot built once and reused
  • Shared code in a single shared/ workspace (multi-app connectors); imported, not duplicated
  • Secrets only in securedConfiguration; least-privilege inheritAs.apiClient.scopes
  • readConfiguration() validates all env vars once at startup and throws on invalid; app is stateless
  • SDK types end to end; no any escapes; no dead code
  • commercetools connect validate passes; commercetools connect application test runs the suite
security.md

Security

Impact: CRITICAL — Connect endpoints are internet-reachable and connectors hold privileged API credentials. An unauthenticated endpoint, an over-scoped client, or a leaked secret turns a connector into an attack surface.

Table of Contents


Pattern 1: Authenticate every inbound endpoint

Two kinds of inbound endpoint, both must be authenticated:

  1. API extension endpoint — called by commercetools. Register destination auth and verify it in-app (see service-applications.md, Pattern 1). commercetools sends the Authorization header (or x-functions-key) you configured.
  2. External webhook endpoint — called by a third-party system pushing events to your connector. Authenticate with a full JWT or a shared secret the external system signs.
INCORRECT — an "internal" route left open:
serviceRouter.post('/', handleExtension);          // no auth middleware
serviceRouter.use(['/admin'], verifyJWT);          // auth only on a different route
Why this fails: the highest-value route — the one that drives external calls and update actions — is reachable by anyone. Auth must cover the endpoint that actually does the work.
CORRECT — authenticate the work endpoint; leave only /status open:
router.get('/status', statusHandler);              // liveness only, no secrets
router.post('/', verifyInbound, handler);          // every processing route authenticated

Pattern 2: Validate JWTs fully

For webhook endpoints secured by JWT, verify every claim — a partial check is a bypass.
INCORRECT — decode without verifying:
const { payload } = jwt.decode(token, { complete: true });   // decode ≠ verify; signature unchecked
if (payload.iss === expectedIssuer) next();                   // trivially forged
Why this fails: decode does not check the signature; an attacker forges any payload. Accepting alg: none or an unverified signature is a full auth bypass.
CORRECT — verify signature, issuer, audience, subject, expiry, and pin the algorithm:
import { verify } from 'jsonwebtoken';
const payload = verify(token, secret, {
  algorithms: ['HS256'],        // pin; never allow 'none' or caller-chosen alg
  issuer: cfg.jwtIssuer,
  audience: cfg.jwtAudience,
  subject: cfg.jwtSubject,
  ignoreExpiration: false,
});

Pattern 3: Least-privilege commercetools scopes

Grant only the scopes the apps use. The modern mechanism is platform-generated API clients via inheritAs.apiClient.scopes (verified: modify connector):
inheritAs:
  apiClient:
    scopes:
      - manage_orders
      - manage_subscriptions   # only if an event app uses Subscriptions
      - manage_extensions      # only if a service app uses API Extensions
At install time the platform generates an API client scoped to exactly these and injects CTP_CLIENT_ID/CTP_CLIENT_SECRET/CTP_SCOPE/CTP_PROJECT_KEY/CTP_API_URL/CTP_AUTH_URL — "no more and no less" than needed. Do not also declare those CTP credential keys in configuration when using auto-generation; they're provided at runtime.
INCORRECT: instruct installers to create an admin / manage_project API client. Why this fails: a leaked or misused connector credential then has full project access. Scope to the specific resources.
If you must accept pre-created credentials instead of auto-generation, still document the minimal scope set the connector needs (e.g. manage_orders view_products), never "admin".

Pattern 4: Secrets in securedConfiguration

Anything sensitive goes in securedConfiguration (write-only, not echoed back), never standardConfiguration, never hardcoded.
ValueWhere
External API keys, passwords, connection stringssecuredConfiguration
JWT shared secretsecuredConfiguration
Pre-created CTP_CLIENT_ID/CTP_CLIENT_SECRET/CTP_SCOPE (if not auto-generated)securedConfiguration
Region, project key, feature flags, non-secret defaultsstandardConfiguration
Secrets are encrypted at rest by the platform and surfaced as env vars; read them through validated config (project-structure.md, Pattern 3). Never log secret values.

Pattern 5: Error hygiene

Error responses and logs must not leak stack traces, secrets, or internals to callers.

CORRECT — generic message in production, detail only in development:
export const errorMiddleware = (err, _req, res, _next) => {
  const dev = process.env.NODE_ENV === 'development';
  if (err instanceof CustomError) {
    return res.status(err.statusCode).json({ message: err.message, ...(dev && { stack: err.stack }) });
  }
  res.status(500).json({ message: dev ? String(err) : 'Internal server error' });
};

Checklist

  • Every processing endpoint authenticated (extension destination auth + in-app check; webhooks via full JWT/secret); only /status is open
  • JWT validation checks signature, issuer, audience, subject, expiry, and pins the algorithm (no alg: none)
  • Scopes are least-privilege via inheritAs.apiClient.scopes (or a documented minimal set) — never admin/manage_project
  • All secrets in securedConfiguration; none hardcoded or in standardConfiguration; secrets never logged
  • Error responses hide stack traces and internals in production
  • Request bodies/PII not logged; only identifiers and correlation keys
service-applications.md

Service Applications (HTTP Endpoints)

Impact: CRITICAL — A service app is a public HTTP endpoint. In its API-Extension mode its latency is added to every cart/checkout call and its downtime can block them. In its inbound-webhook mode it writes to commercetools on a caller's behalf. Either way, an unauthenticated endpoint is a security hole.
A service application is an HTTP endpoint Connect exposes (5-minute request timeout, autoscaled). It runs in one of two modes — decide which before building:
  • API Extension (commercetools → you): registered as an API Extension in postDeploy, commercetools calls it synchronously after processing a create/update but before persistence; it can validate (reject) or return up to 100 update actions. This mode carries the strict 2 s/10 s response limit. → Patterns 1–6.
  • Inbound webhook / API (external system → commercetools): an external system calls it to push data into commercetools; you authenticate the caller, validate the payload, and write to commercetools via the SDK yourself. No Extension is registered, and the 2 s/10 s limit does not apply — the 5-min service timeout does. → Pattern 7.

Table of Contents


Contract facts (verified)

Patterns 1–6 below are the API Extension mode. For the inbound webhook mode, jump to Pattern 7 — the timeout and response-format facts here are extension-specific and do not apply to it.
  • Timeouts: connection limit 1 s; response limit 2 s default, configurable via timeoutInMs up to 10 s (higher needs a per-project performance review). Aim to respond fast — ~50 ms for simple validation.
  • Coupling: "If it fails or takes a second longer to return, the whole API call fails or takes a second longer." Applied to all clients, including the Merchant Center.
  • Extensible resources: carts, orders, payments, payment-methods, customers, customer-groups, quote-requests, staged-quotes, quotes, business-units, shopping-lists. Max 25 extensions per project.
  • Response: HTTP destination returns 200/201 for success (empty body or update actions), 400 with an errors array for validation failure. Any other status = failure to respond.
  • Headers in: X-Correlation-ID is provided and echoed to the original API caller — log it. Authorization / x-functions-key set if you configured destination auth.
  • additionalContext.includeOldResource: true adds oldResource to Update payloads (not Create) — use it to diff what changed.
Note: the Connect service request timeout (5 minutes) and autoscaling are separate platform facts; the binding constraint for an extension is the 2 s / 10 s extension response limit, not 5 minutes.

Pattern 1: Authenticate the extension destination

The endpoint is publicly reachable. It must be authenticated both at registration and validated in-app.
INCORRECT — open HTTP destination, no in-app check:
await apiRoot.extensions().post({ body: {
  key, destination: { type: 'HTTP', url: serviceUrl },   // no authentication block
  triggers: [...],
}}).execute();
Why this fails: anyone who learns the URL can POST forged carts/orders and drive your external calls or update actions. The endpoint is open to the internet.
CORRECT — set destination authentication and verify it in the handler:
// registration (post-deploy)
await apiRoot.extensions().post({ body: {
  key,
  destination: {
    type: 'HTTP',
    url: serviceUrl,
    authentication: { type: 'AuthorizationHeader', headerValue: `Bearer ${sharedSecret}` },
  },
  triggers: [...],
}}).execute();
// handler: reject anything without the expected secret
function assertAuthorized(req: Request) {
  if (req.get('authorization') !== `Bearer ${readConfiguration().extensionSecret}`) {
    throw new Unauthorized();
  }
}
For Azure Functions destinations use { type: 'AzureFunctions', key } (sets x-functions-key); for Google Cloud Functions prefer the dedicated GoogleCloudFunction destination with IAM (verified: API Extensions — destinations). Store the secret in securedConfiguration — see security.md.

Pattern 2: Trigger conditions — don't fire when you can't act

A trigger condition (a query predicate) keeps the extension from being invoked on resources it can't process yet — saving latency on every skipped call.
triggers: [{
  resourceTypeId: 'cart',
  actions: ['Create', 'Update'],
  condition: 'shippingAddress is defined AND lineItems is not empty',
}]

Pattern 3: Timeout budget

Your outbound calls must finish inside the extension response limit, with margin.

INCORRECT: call the external API with no timeout and hope it returns within 2 s. Why this fails: a slow third party blows the response limit; commercetools times the extension out and the cart/checkout call fails regardless of your fail-mode intent.
CORRECT — budget explicitly and abort:
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 1500);   // < the 2s extension limit, leaving margin
try {
  const res = await fetch(externalUrl, { signal: controller.signal });
  // ...
} finally { clearTimeout(t); }
If your real work can't fit in ~1.5 s, raise timeoutInMs (up to 10 s) deliberately — but a longer extension timeout means a slower checkout for every customer. Consider moving the work to an event app instead.

Pattern 4: Fail-open vs fail-closed

When the external dependency is down or times out, you must have decided what happens — and documented it.
  • Fail-open: on error, return success with no update actions so the cart/order proceeds (possibly without your enrichment). Right when the operation must not be blocked (e.g. optional enrichment, non-blocking validation).
  • Fail-closed: on error, return a 400 so the operation is rejected. Right only when proceeding would be incorrect or unsafe (e.g. compliance validation that must hold).
INCORRECT — fail-closed by accident:
catch (error) { return { statusCode: 400, error: error.message }; }   // any outage blocks ALL carts
Why this fails: a third-party tax outage blocks every cart update and checkout, with no deliberate decision and no documentation. Whatever you choose, choose it on purpose.
CORRECT — explicit, logged decision:
catch (err) {
  logger.error({ correlationId, err }, 'external dependency failed');
  if (FAIL_OPEN) return res.status(200).end();          // proceed without enrichment
  return res.status(400).json({ errors: [{ code: 'General', message: 'validation unavailable' }] });
}
Record the stance in the connector README (see deployment-installation.md).

Pattern 5: Minimize work on the hot path

The extension fires on every matching create/update. Skip the expensive external call when nothing relevant changed.

const hash = hashTaxRelevantFields(cart);                 // address, line items, quantities…
if (hash === cart.custom?.fields?.lastHash && cart.taxedPrice) {
  return res.status(200).end();                           // nothing changed → no external call, no actions
}
const actions = await computeAndBuildActions(cart);
actions.push(setHashAction(hash));                        // store the new hash for next time
return res.status(200).json({ actions });

Pattern 6: Response format

  • Success, no changes: 200/201, empty body (or empty actions).
  • Updates: 200/201 with { "actions": [ ... ] } — up to 100 actions, each a valid update action for that resource type. Return well-formed, domain-correct actions (e.g. for external tax on a cart: changeTaxModeExternalAmount, then setLineItemTaxAmount / setCartTotalTax.
  • Validation failure: 400 with { "errors": [{ "code": "InvalidInput", "message": "..." }] }code must be a known error code; optional localizedMessage, extensionExtraInfo.

Pattern 7: Inbound webhook mode (external system → commercetools)

Use this mode when an external system pushes data into commercetools as it changes (e.g. "a product is updated in system A → upsert it into commercetools"). The service app is a plain HTTP endpoint the external system calls; you do not register an API Extension, and the 2 s/10 s extension limit does not apply (the 5-min Connect service timeout does). For scheduled sync (poll system A on a timer) use a job instead — see job-applications.md.

The discipline is different from an extension — you own the whole write:

  1. Authenticate the caller. The endpoint is public; validate a shared secret or a full JWT on every request (see security.md). This is not optional just because commercetools isn't the caller.
  2. Validate the payload before trusting it; reject malformed input with a 4xx.
  3. Write idempotently. The same update may be delivered twice (most senders retry). Upsert by a stable key, don't blind-create.
  4. Return a status the caller can act on — 2xx on success, 4xx on bad input, 5xx on a transient failure so the sender retries.
INCORRECT — blind create on every call, no idempotency:
router.post('/products', async (req, res) => {
  await apiRoot.products().post({ body: toProductDraft(req.body) }).execute();  // duplicates on retry
  res.status(201).end();
});
Why this fails: the sender retries on timeout/5xx, and a second delivery creates a duplicate product (or 409s on a duplicate key with no recovery).
CORRECT — authenticate, then upsert by key:
router.post('/products', verifyInbound, async (req, res) => {
  const draft = toProductDraft(validatePayload(req.body));   // 400 on invalid
  try {
    const existing = await getProductByKey(draft.key);       // stable external key
    if (existing) {
      await apiRoot.products().withKey({ key: draft.key })
        .post({ body: { version: existing.version, actions: diffToActions(existing, draft) } }).execute();
    } else {
      await apiRoot.products().post({ body: draft }).execute();
    }
    res.status(200).json({ key: draft.key });
  } catch (err) {
    if (isVersionConflict(err)) return res.status(409).end();   // sender may retry; you re-read & re-apply
    logger.error({ correlationId, err }, 'inbound upsert failed');
    res.status(503).end();                                       // transient → let the sender retry
  }
}
Map the external model to the commercetools draft in shared/src/mappers (project-structure.md). Use the external system's stable identifier as the commercetools key so upserts are deterministic. Consider the Import API for high-volume bulk loads instead of one call per item.

Checklist

API Extension mode (Patterns 1–6):
  • Destination registered with AuthorizationHeader (or AzureFunctions) auth, and the secret validated in-app
  • Trigger condition set so the extension only fires when it can actually act
  • Outbound calls have an explicit timeout under the extension response limit; timeoutInMs set deliberately if >2 s
  • Fail-open vs fail-closed decided per use case and documented in the README
  • Hot-path work skipped when relevant inputs are unchanged (hash/signature compare)
  • Responses use the correct format: 200/201 (+ actions) or 400 (+ errors with valid codes)
Inbound webhook mode (Pattern 7):
  • Caller authenticated (shared secret or full JWT) on every request
  • Payload validated; malformed input rejected with 4xx
  • Write is idempotent — upsert by a stable key, never blind-create; version conflicts handled
  • Status codes let the sender retry safely (2xx / 4xx / 5xx); Import API considered for bulk
Both modes:
testing.md

Testing

Impact: HIGH — The two failure modes that bite hardest in production (auth bypass and redelivery/loss from wrong status codes) are exactly the ones a router-level test suite catches cheaply. Skipping them ships the bug.
Run the suite with commercetools connect application test (the CLI runs your tests locally; the generated package.json also exposes npm test / jest). See connect-cli.md Step 4 for the local build/test/start commands. The CLI template seeds a tests/integration/ spec — grow it, don't delete it.
Test at the router level: drive the Express app with supertest, mock outbound HTTP (commercetools SDK calls, external APIs) with msw, and assert on status code and side effects. This exercises middleware (auth, error handling) and controllers together — where the production-critical behavior lives. A couple of happy-path tests is not enough — cover the auth matrix, the envelope/ack edge cases (event) or pure logic + returned actions (service), an idempotency/duplicate test, and idempotent registration (below).

Checklist

  • Parameterized auth rejection matrix covering missing/malformed/alg:none/wrong-signature/wrong-issuer/wrong-audience/wrong-subject/expired, plus a valid-token accept case
  • Envelope tests decode the Pub/Sub wrapper (base64 message.data) and reject malformed input per the chosen contract → event-applications.md, Pattern 1
  • Ack-contract tests: 2xx for handled/irrelevant, non-2xx for transient failure
  • Idempotency test: same message twice → one side effect
  • Router-level tests use supertest + msw with onUnhandledRequest: 'error'
  • Hot-path skip asserted (external call not made when inputs unchanged)
  • Lifecycle scripts tested for idempotency (no delete-then-recreate)