commercetools Connect

Description

Build, test, deploy, install, and certify production-ready commercetools Connect applications — service/API-extension, event/subscription, job, and merchant-center custom apps — in TypeScript, JavaScript, or Java, and integrate a deployed connector into a custom storefront. Covers the connect.yaml contract, least-privilege scopes, lifecycle scripts, sync-vs-async idempotency/ack, testing, and deployment. Includes connector sub-areas for payment (Stripe, Adyen, PayPal), tax (Avalara, Vertex, TaxJar), PIM (Akeneo), CRM (Salesforce, HubSpot), order-management/OMS, gift cards, transactional email (SendGrid, Mailgun), marketplace (Mirakl), promotion and loyalty (Talon.One, Voucherify), analytics export to a warehouse/CDP (BigQuery, Snowflake, Segment), and search/product discovery (Algolia). Use when building, configuring, forking, or debugging a commercetools Connect connector, or syncing commercetools data to or from an external system. Not for the hosted Checkout widget (see commercetools-checkout).

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. Docs search (required, run first) — Always begin by searching docs 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) This script optimizes for tuned search results — run this command:
    node scripts/docs-search.mjs \
      --query "<extract key terms from user's question>" \
      --app-name "<current-app ex: claude, copilot, codex>" \
      --model "<current-model>" \
      --skill-name "commercetools-connect" \
      --limit 10
    
    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.

Optional scripts

Fetch GraphQL schema — Run this when you need context about a commercetools GraphQL query or mutation — for example, to inspect a resource's fields, types, and available operations before writing a query, or to verify a GraphQL query/mutation you have just generated against the real schema. It fetches the partial GraphQL SDL for a single commercetools resource:
node scripts/graphql-schemata.mjs \
  --resource-name "<commercetools resource, e.g. Cart, Product, Order>" \
  --app-name "<current-app, e.g. claude, copilot, cursor, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect"
The output is the GraphQL SDL for that resource. If the resource name is not recognized, the script prints the list of valid resource names — pick the correct one and re-run. Note: the SDL may contain stubbed types — referenced resources rendered as stubs, with their real type name given in a comment. Fetch any you need separately by re-running this script with that type name as --resource-name.
Fetch OpenAPI (REST) schema — Run this when you need context about a commercetools REST endpoint, request/response payload, or update action — for example, to inspect a resource's REST operations before constructing a request, or to verify a REST request/payload you have just generated against the real specification. It fetches the partial OpenAPI specification for a single commercetools resource:
node scripts/openApi-schemata.mjs \
  --resource-name "<commercetools resource, e.g. api-Cart-write, api-Customer-read, checkout-Application>" \
  --app-name "<current-app, e.g. claude, copilot, cursor, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect"
The output is the OpenAPI specification (YAML) for that resource. REST resources use a read/write-split naming form (e.g. api-Cart-read, api-Cart-write). If the resource name is not recognized, the script prints the list of valid resource names — pick the correct one and re-run. Note: the spec does not include reference-expansion schemas — fetch a referenced resource's schema separately by re-running this script with that resource as --resource-name.

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.

Connector-type integration sub-areas

The build-side guidance in this skill is connector-type-agnostic (any service/event/job). Some connector types also have a focused, end-to-end sub-area that owns the whole job for that type — from "is there a connector already?" through configuring, forking, or building one, to the application backend around it:
Connector typeCoversGo to
Payment (e.g Stripe, Adyen, Mollie, PayPal, ...etc)The full payment lifecycle for a custom storefront: decide whether a certified/public connector fits → configure it, or fork it, or spin up a new one from the payment-integration template → build the backend (session BFF, Order after authorization, capture/refund/cancel via the processor, webhook reconciliation); plus debugging the round tripintegrations/payment/overview.md
Tax (e.g Avalara, Vertex, TaxJar, ...etc)The full tax integration: decide whether a certified connector fits (Avalara/Vertex have them; TaxJar does not) → configure it, fork it, or build from the tax-integration template → the two apps (a cart API Extension that calculates tax in ExternalAmount mode + an OrderCreated Subscription that records/commits the transaction); plus the sandbox-doesn't-persist and no-nexus-means-zero trapsintegrations/tax/overview.md
CRM (e.g Salesforce, HubSpot, Dynamics 365, Zoho, ...etc)The full customer-relationship integration: decide whether a public connector fits (classic CRMs usually have none → build) → configure it, fork it, or build for a CRM you define → pick direction + source of truth first, then the customer-sync apps it implies (event syncers out, an inbound webhook/poll in, a one-time migration job), all linked by externalId; plus the duplicate-contact, sync-loop, and PII/deletion trapsintegrations/crm/overview.md
PIM (e.g Akeneo, inriver, Bluestone, Pimcore, …)The full product-data sync job: decide whether a public PIM connector fits → configure it, or fork it, or build one → map the PIM model onto Product Types/attributes/categories/media, keep price & inventory separate, and pick the sync architecture (Import API vs HTTP API, event webhook vs job)integrations/pim/overview.md
Order management (OMS, e.g Fluent Commerce, kbrw, OneStock, NewStore, Pipe17)Connect commercetools to an OMS: decide whether to install a public connector → configure it, or fork/customize one, or build a new one for a bespoke order-management service (scaffold from the fulfilment-integration template) → design the sync (order export on OrderCreated, status/shipment/fulfillment inbound webhook, inventory sync, reconcile job). No fixed connector contract; composes the type-agnostic event/service/job build-sideintegrations/order-management/overview.md
Gift card (e.g Voucherify, in-house store credit, ...etc)The full gift card integration: decide whether to use a public connector directly (Voucherify), customize/fork one, or build a new one from the gift-card template for a gift card system you define → the two apps (an enabler UI + a processor that checks balance, redeems value, and owns the Payment via session-authenticated balance/redeem and Payment Intents refund/reverse); plus the must-pair-with-a-fallback and sample-only-simulates trapsintegrations/giftcard/overview.md
Email (e.g SendGrid, Mailgun, AWS SES, Postmark, ...etc)The full transactional email integration: decide whether a ready-made connector fits (email is template-first — most ESPs have none) → configure it, fork/customize it, or build the one event app from the transactional email template → the app (a Subscription on Customer/Order Messages → send via the ESP); the central at-most-once vs at-least-once decision for a non-idempotent send; plus the token-email, order-state-filtering, and localization trapsintegrations/email/overview.md
Marketplace — multi-vendor, or selling on an external marketplace (e.g Marketplacer, Mirakl, Convictional, channel managers, ...etc)The full marketplace integration: fix the role (operator vs selling on someone else's marketplace) and direction per domain → ask the user whether to use a public connector directly, customise/fork one, or build for a service they define (most marketplace listings are partner integrations, and there is no marketplace template) → model sellers and offers (Channel/Store/CustomObject per seller, per-seller prices + inventory, one Product for a shared SKU) → build the sync apps (seller + offer sync, order import or per-seller routing with syncInfo, fulfilment status, reconciliation); plus the channel-less-price, aggregated-availability, and un-deletable-Channel trapsintegrations/marketplace/overview.md
Promotion / loyalty (e.g Talon.One, Voucherify, Dovetech, Eagle Eye, NULogic, ...etc)The full promotion integration: first rule out native Cart Discounts/Discount Codes/Discount Groups (rung 0) → then ask the user whether to use a public connector as-is, customise/fork one, or build one for a promotion service they define (there is no promotion template) → the two apps (a cart API Extension that applies the engine's discounts via setDirectDiscounts + an OrderCreated Subscription that redeems and awards points); plus the Direct-Discounts-make-Discount-Codes-inert rule and the double-redemption and abandoned-cart trapsintegrations/promotion/overview.md
Analytics — export to a data warehouse / CDP / product-analytics tool (e.g BigQuery, Snowflake, Redshift, Databricks, Segment, mParticle, ...etc)The full analytics egress: there is no turnkey analytics connector and no Export API, so (after the live registry check) build from the product-export template → a directional egress pipeline of two primitives, an event streamer on Subscriptions/Messages (near-real-time) and/or a job querying the API with lastModifiedAt windowing + cursor pagination (batch/backfill) → the event→row transform and destination-side dedup on resource.id+sequenceNumber; plus the disambiguation from Platform Insights (APM) and Change History (governance), the client-side-tracking boundary, and the duplicate-row/missing-event/payloadNotIncluded trapsintegrations/analytics/overview.md
Search / product discovery (e.g Algolia, Constructor, Bloomreach, Coveo, Elasticsearch, Typesense, ...etc)The full search integration (outbound, backend-only — no API Extension): first rule out native Product Search / Product Projection Search (rung 0) → then use a public connector, fork one, or scaffold from the product-export template → map a Product Projection onto a flat search document (price-context, locales, category denormalization, Store assortment) → the two apps (a full-ingestion service/job that atomically reindexes the catalog + an incremental-updater event on ProductPublished/ProductUnpublished/store-selection Subscriptions); plus the ghost-record, half-empty-rebuild, and eventual-consistency traps, and the vendor-hosted-integration-is-not-a-connector ruleintegrations/search/overview.md
Start at the matching overview.md for any payment-, tax-, CRM-, PIM-, order-management-, gift-card-, email-, marketplace-, promotion-, analytics-, or search-connector task — integrating a deployed one or building/forking one. Each decision ladder routes you: rung 1 configure, rung 2 config-closes-the-gap, rung 3 fork, rung 4 build-from-template (provider gotchas live in the provider file — payment/stripe.md, tax/avalara.md, email/providers.md; the CRM and PIM sub-areas are vendor-neutral — look the connector up live; the OMS sub-area has no fixed connector contract and composes the build-side directly; the gift-card sub-area has one public connector (Voucherify) plus a build-from-template path for an in-house system; the marketplace sub-area is vendor-neutral and has no template — assess any fork candidate from its current repo; the promotion sub-area has two public MIT integrations and no template — promotion/public-connectors.md names which artifact is actually the production one; the analytics sub-area has no turnkey connector and no Export API — it still forces a live registry check, then builds a directional egress pipeline from the product-export template — analytics/destinations.md routes the warehouse/CDP/product-analytics/BI decision; the search sub-area is vendor-neutral, gates on native Product Search first, and scaffolds the outbound build from the product-export template). It hands back to the build-side workflow and references above only for the deep, type-agnostic publish/certify lifecycle and the production-readiness gate.
The promotion and search sub-areas each have a rung 0. commercetools ships its own discount engine (Cart Discounts, Discount Codes, Discount Groups) and its own search (Product Search / Product Projection Search), so "should this be a connector at all?" is a real question in those two in a way it isn't for payment, tax, or the rest — rule the native capability out explicitly before recommending a connector.
Each sub-area lives under references/integrations/<type>/ with its own overview.md. Adding another connector type later (e.g. shipping) means adding a sibling references/integrations/<type>/ tree and one row here — the build-side guidance does not change.

Marketplace listings are not all Connect connectors — verify before recommending

  • The marketplace is fine as a discovery source, but it lists integrations that are not necessarily commercetools Connect connectors — partner-operated services, SaaS products, and iPaaS middleware appear alongside deployable Connect applications. It can also be out of sync with the actual Connect connector registry (a listing may exist for something not deployable via Connect, or the version may differ), and any specific vendor (Akeneo, Stripe, …) may or may not be listed at any given time — never assume a named connector exists.
  • Double-check that a candidate is actually a commercetools Connect connector before recommending it as install/configure/fork: look for a Connect affordance (a public connector repo / connect.yaml / a Connect deploy action), and treat the Connect CLI / connector registry as authoritative over the marketing listing.
  • Then ask the user what to do — don't silently pick. Present the fit and whether it's Connect-deployable.
  • If the user wants to use a non–Connect integration, warn that this skill does not cover using non–Connect connectors — its build/configure/deploy patterns (connect.yaml, the Connect CLI, lifecycle scripts, the Connect deployment model) don't apply. Point them to the vendor/partner's own onboarding, and offer the in-skill alternative: build or fork a Connect connector instead.

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

Integrating a deployed payment connector (sub-area)

Start at the overview; it routes to the rest (integrate, configure, fork, or build a new one). See also the Connector-type integration sub-areas section above.
ConcernReference
Start here — the backend-focused workflow: requirements → is-a-certified-connector-enough → config → BFF/Order/capture-refund/webhookintegrations/payment/overview.md
Is a certified connector enough? fit-check a use case vs public connectors using live marketplace/docs dataintegrations/payment/connector-selection.md
Requirements → connect.yaml config mapping, worked exampleintegrations/payment/config-from-requirements.md
The backend: session/BFF, Order after payment, capture/refund/cancel via the processor, webhook reconciliation, who owns the Paymentintegrations/payment/backend-integration.md
Test-drive the backend test-first: assert-vs-mock per piece, invariants as regression testsintegrations/payment/backend-tdd.md
Full-flow integration test against a real deployed connector + test cardintegrations/payment/integration-test.md
Provider-agnostic frontend contract: session body, enabler load, processor routes + auth, pitfall catalogintegrations/payment/connector-contract.md
Stripe specifics: exact connect.yaml keys + defaults, enabler bundle, test cards, webhook setupintegrations/payment/stripe.md
Deploy a public payment connector (CLI auth, scopes, deployment create, not connectorstaged)integrations/payment/deploy-public-connector.md
Deploy a forked/custom payment connector (connectorstaged → publish → deployment create)integrations/payment/deploy-custom-connector.md
Verify the round trip; throwaway harness to prove a deployed connectorintegrations/payment/verification.md, integrations/payment/test-harness.md

Integrating or building a tax connector (sub-area)

Start at the overview; it routes to the rest (configure a certified connector, fork one, or build both apps from the template). See also the Connector-type integration sub-areas section above.
ConcernReference
Start here — the two-app workflow: requirements → is-a-certified-connector-enough → config → calculate + recordintegrations/tax/overview.md
Is a certified connector enough? per engine (Avalara/Vertex certified; TaxJar build-from-template), via live marketplace dataintegrations/tax/connector-selection.md
Requirements → connect.yaml: tax mode (ExternalAmount vs External), nexus, tax-code source, exemptions, scopes; worked exampleintegrations/tax/config-from-requirements.md
The two-app contract: the calculator API Extension (all four tax actions, 200-not-202, fail modes, call reduction) + the order-syncer Subscription (commit/void/refund, idempotency); full pitfall catalogintegrations/tax/tax-contract.md
Avalara ground truth (from the certified open-source connector): exact keys, AvaTax createTransaction quote-vs-commit, tax-code/entity-use mapping, MC config app — plus TaxJar as the build-from-template contrastintegrations/tax/avalara.md
Verify the round trip: taxedPrice on the cart, transaction recorded; the sandbox-doesn't-persist and no-nexus-means-zero trapsintegrations/tax/verification.md

Integrating or building a CRM connector (sub-area)

Start at the overview; it routes to the rest (configure a public connector, fork one, or build for a CRM you define). See also the Connector-type integration sub-areas section above.
ConcernReference
Start here — the sync workflow: requirements → direction + source of truth → is-a-public-connector-enough → config → build the sync appsintegrations/crm/overview.md
Is a public connector enough? why classic CRMs (Salesforce/HubSpot/Dynamics/Zoho) are usually build-from-scratch; live-marketplace check; the ladderintegrations/crm/connector-selection.md
Requirements → connect.yaml: direction → app composition, source of truth, externalId/Custom-Field linking, least-privilege scopes, secured config; worked exampleintegrations/crm/config-from-requirements.md
The sync contract: outbound event syncer, inbound webhook/poll, migration job; idempotent upsert by externalId, re-fetch by id, ack semantics, self-change/loop filtering, deletion/PII; full pitfall catalogintegrations/crm/crm-contract.md
Verify the round trip: record linked by externalId, delta propagates once, deletion propagates; the loop / rate-limit / sandbox trapsintegrations/crm/verification.md

Syncing a PIM into commercetools (sub-area)

Start at the overview; it routes to the rest (use a public connector, configure, fork, or build one). See also the Connector-type integration sub-areas section above.
ConcernReference
Start here — the sync-focused workflow: requirements → is a public connector enough? → configure/fork/build → data mapping → verifyintegrations/pim/overview.md
Is a public PIM connector enough? live marketplace check, named connectors, fit dimensions, the configure/fork/build ladderintegrations/pim/connector-selection.md
Data mapping (the substance): Product Type strategy (never 1:1 with PIM families), attribute mapping, localization, categories, media, price/inventory separation, keys & idempotencyintegrations/pim/data-mapping.md
Build or fork a connector: Import API vs HTTP API, service webhook vs job, full vs incremental, idempotent upsert, dependency resolution, delete handlingintegrations/pim/build-connector.md
Testing & safely running a sync: mapping unit tests, then a bounded sandbox-only live run with a pre-flight item count, large-catalog gate, and idempotency re-run (never production credentials)integrations/pim/testing.md

Order-management (OMS) connector (sub-area)

Start at the overview; it routes to the rest (use a public connector, customize/fork one, or build a new one for a bespoke OMS) and applies the sync design to all paths. See also the Connector-type integration sub-areas section above.
ConcernReference
Start here — direction & source of truth, the requirements → use/configure/fork/build ladder, and the export/inbound/reconcile workflowintegrations/order-management/overview.md
Is a public OMS connector enough? live fit-check vs marketplace connectors; installable-Connect-connector vs vendor-hosted-integration distinctionintegrations/order-management/connector-selection.md
Sync design: export (event on OrderCreated), inbound (service webhook), reconcile (job); which Messages to subscribe to; OMS-status → CT-state mapping; idempotency per flowintegrations/order-management/sync-architecture.md
Build a new connector for a user-defined OMS (rung 4): scaffold, which applications to declare, connecting to the OMS API, what to reuse from templatesintegrations/order-management/build-oms-connector.md
OMS connectors have no fixed runtime contract, so the build side composes the type-agnostic event/service/job references above (scaffolding from the fulfilment-integration CLI template); deploy uses deployment-installation.md, not a sub-area-specific flow.

Integrating or building a gift card connector (sub-area)

Start at the overview; it routes to the rest (use a public connector directly, customize/fork one, or build a new one from the gift-card template). See also the Connector-type integration sub-areas section above.
ConcernReference
Start here — the two-app workflow: requirements → use/customize/build → config → balance + redeem + refundintegrations/giftcard/overview.md
Use / customize / build? the ladder (Voucherify public; in-house build-from-template), the sample connector for PoC, via live marketplace dataintegrations/giftcard/connector-selection.md
Requirements → connect.yaml: CT connection block + JWKS/issuer, currency, gift-card-system credentials, least-privilege scopes; worked exampleintegrations/giftcard/config-from-requirements.md
The two-app contract: enabler (session-driven UI) + processor (balance/redeem session-auth, Payment Intents modifyPayment refund/reverse), partial/multiple cards, idempotency, always-pair-with-a-fallback; full pitfall catalogintegrations/giftcard/giftcard-contract.md
Verify the round trip: balance → redeem → Payment transaction → fallback remainder → refund/reverse; the sample-only-simulates and no-fallback trapsintegrations/giftcard/verification.md

Integrating or building an email connector (sub-area)

Start at the overview; it routes to the rest (configure a ready-made connector, fork/customize one, or build the one event app from the transactional email template). See also the Connector-type integration sub-areas section above. This is a pure event app, so it builds on event-applications.md.
ConcernReference
Start here — the one-app workflow: requirements → is-a-ready-made-connector-enough → config → send + verifyintegrations/email/overview.md
Is a ready-made connector enough? configure vs fork/customize vs build-from-template; why email is template-first; live-marketplace checkintegrations/email/connector-selection.md
Requirements → connect.yaml: which Messages, ESP key + per-email template IDs, sender, least-privilege scopes scoped to the emails in use; worked exampleintegrations/email/config-from-requirements.md
The one-app contract: Subscription registration + message→email routing, the at-most-once vs at-least-once decision for a non-idempotent send, the token-email (≤60 min) gotcha, order-state filtering, localization, PII; full pitfall catalogintegrations/email/email-contract.md
ESP specifics: SendGrid / Mailgun / AWS SES / Postmark send-call shape, ESP-hosted templates, idempotency keys; provider comparisonintegrations/email/providers.md
Verify the round trip: per-event checks; the no-subscription and sandbox-doesn't-deliver traps; duplicate/silent-drop symptomsintegrations/email/verification.md

Integrating or building a marketplace connector (sub-area)

Start at the overview; it routes to the rest (use a public connector directly, customise/fork one, or build for a marketplace service the user defines). See also the Connector-type integration sub-areas section above. Marketplace listings especially often aren't Connect connectors — apply Marketplace listings are not all Connect connectors before recommending one.
ConcernReference
Start here — the workflow: disambiguate "marketplace" → role + direction per domain → which path → seller/offer modeling → build the sync appsintegrations/marketplace/overview.md
Which path — ask the user: use a public connector as-is, customise/fork, or build for their service; live listing check, and how to assess a fork candidate from its current repo (connect.yaml, handlers, mapping) against the production gateintegrations/marketplace/connector-selection.md
Seller + offer modeling and connect.yaml: Channel/Store/CustomObject per seller, offer keying, per-seller price/stock scoping, Order Import + syncInfo, the Project limits that constrain the design, scopes; worked exampleintegrations/marketplace/config-from-requirements.md
The sync contract: seller sync, offer/inventory/price sync, order import vs per-seller routing, fulfilment status, reconciliation job; idempotent upsert by marketplace id, split multi-seller orders; full pitfall catalogintegrations/marketplace/marketplace-contract.md
Verify the round trip: seller usable, offer sellable per seller, order imported/routed exactly once, multi-seller split; the channel-less-price, aggregated-availability, throttling, and un-deletable-Channel trapsintegrations/marketplace/verification.md

Integrating or building a promotion / loyalty connector (sub-area)

Start at the overview; it routes to the rest (rule out native discounts first, then use a public connector, customise/fork one, or build one for your own engine). See also the Connector-type integration sub-areas section above.
ConcernReference
Start here — the workflow: requirements → native-or-connector → use/customise/build → config → evaluate + redeemintegrations/promotion/overview.md
Native, use, customise, or build? the rung-0 native check (Cart Discounts/Discount Codes/Discount Groups), the live-marketplace procedure, the per-engine landscape, and why there is no promotion templateintegrations/promotion/connector-selection.md
Requirements → connect.yaml: how discounts land on the cart (setDirectDiscounts vs negative custom line items vs engine-managed codes), coupon-code custom field, scopes; worked exampleintegrations/promotion/config-from-requirements.md
The two-app contract: the evaluator (effect→action mapping, permyriad, coupon rejection without failing the cart, 200-not-202, fail-open, call reduction, extension chaining with tax) + the redemption-syncer (redeem/rollback, idempotency, session identity); full pitfall catalogintegrations/promotion/promotion-contract.md
Which public integration to actually use — Talon.One's Connect connector is a third party's while the vendor's own repo is a PoC accelerator; Voucherify's is a port, not an install — plus the commercetools-side fixes to apply when forkingintegrations/promotion/public-connectors.md
Verify the round trip: directDiscounts on the cart, redemption in the engine; the inert-discount-codes, zero-discount, fail-open-self-heal and cart-merge trapsintegrations/promotion/verification.md

Integrating or building an analytics connector (sub-area)

Start at the overview; it routes to the rest (run the live registry check, then build a directional egress pipeline from the product-export template). See also the Connector-type integration sub-areas section above. There is no turnkey analytics connector and no Export API, so this composes the type-agnostic event/job build-side (event-applications.md, job-applications.md).
ConcernReference
Start here — the egress workflow: requirements → is-a-connector-enough (live check anyway) → pipeline design → build test-first; disambiguates commerce analytics from Platform Insights + Change History; draws the client-side boundaryintegrations/analytics/overview.md
Is a connector enough? the forced live registry/marketplace check even though build is expected; how a CDP/ELT loader changes the answer; the configure/fork/build-from-product-export-template ladderintegrations/analytics/connector-selection.md
Requirements → connect.yaml: which Messages / job schedule, least-privilege read scopes, destination creds in securedConfiguration; worked exampleintegrations/analytics/config-from-requirements.md
The pipeline (the substance): event streamer (subscribe→decode→re-fetch→transform→deliver) + batch/backfill job (lastModifiedAt window + cursor pagination + checkpoint); event→row transform, destination-side dedup (resource.id+sequenceNumber/version), payloadNotIncluded re-fetch, PII/GDPR, the limits; full pitfall catalogintegrations/analytics/pipeline-architecture.md
Destinations: warehouse vs CDP vs product-analytics vs BI — stream-vs-batch, what data flows, PII implication; the Subscription brokers; the client-side-first honesty caveat for GA4/Mixpanelintegrations/analytics/destinations.md
Verify the round trip: one change → one row (no duplicate), batch window loads idempotently; the no-subscription / duplicate-row / payloadNotIncluded / query-off-by-default trapsintegrations/analytics/verification.md

Integrating or building a search connector (sub-area)

Start at the overview; it routes to the rest (rule out native Product Search first, then use a public connector, fork one, or scaffold from the product-export template for your engine). It is outbound and backend-only — no API Extension. See also the Connector-type integration sub-areas section above. An engine's own dashboard-configured integration (e.g. "Algolia for commercetools") often isn't a Connect connector — apply Marketplace listings are not all Connect connectors before recommending one.
ConcernReference
Start here — the workflow: rung-0 native gate → requirements → use/fork/build → data mapping → the two apps → verifyintegrations/search/overview.md
Native, use, fork, or build? the rung-0 native-search gate (Product Search / Product Projection Search), the live-marketplace check, the vendor-hosted-integration trap, and scaffolding from the product-export templateintegrations/search/connector-selection.md
Requirements → the search document + connect.yaml: the two apps (full ingestion + incremental updater), index/engine keys, read-only least-privilege scopes, secured config; worked exampleintegrations/search/config-from-requirements.md
Data mapping (the heart): Product Projection → flat document, objectID keying, record granularity, the price-context explosion, localization, category denormalization, Store assortment, the availability boundaryintegrations/search/data-mapping.md
The two-app contract: full ingestion (cursor pagination, atomic/blue-green reindex, count check) + incremental updater (idempotent upsert, deletion propagation, staleness guard); full pitfall catalogintegrations/search/search-contract.md
Verify the sync: publish appears, unpublish/delete disappears, full load counts match, re-run idempotent, per-Store scope; the eventual-consistency, availability-drift, and non-atomic-rebuild trapsintegrations/search/verification.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. Tax modes (Platform/External/ExternalAmount/Disabled), discount stacking order, sortOrder semantics, and Direct-Discounts-blocking-Discount-Codes as domain concepts are in commercetools-commerce-patterns; these sub-areas cover the connectors that drive them.

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.
The transactional email sub-area (integrations/email/overview.md) is a worked, end-to-end event app built on the patterns below — including the at-most-once vs at-least-once decision for a non-idempotent ESP send.

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)
integrations/analytics/config-from-requirements.md

Requirements → analytics connector config

This turns the Step 1 requirements (overview.md) into concrete connect.yaml values. For a public connector these are its documented keys; for a build (the common case) these are the keys and apps you define.

The requirement → config map

Requirement (Step 1)Config / decisionWhy
Destination + credentialssecuredConfiguration: destination API key / service-account JSON / connection stringSecrets never in standardConfiguration, never hardcoded — often PII-adjacent
Latency (stream / batch / both)App composition (see below)Direction is fixed (egress); latency decides which apps exist
Which data domainsThe Messages the streamer subscribes to and the read scopesOnly subscribe to / read what you export
Historical backfillA separate job with a schedule (or on-demand)Backfill and delta need different tooling — keep them separate
Destination schema / grainTransform module + dedup/merge keyEvent-row vs upserted current-state decides the transform
Region + projectstandardConfiguration: CTP_REGION; scopes via inheritAsHost + client provisioning are region/project specific
Volume / throughputBatch page size + backoff toggles; Subscription budgetThe 50-Subscription soft limit and destination rate limits constrain design

Latency → app composition

Direction is always commercetools → destination; latency decides the apps (build only what you need — see overview.md):
  • Streaming (near-real-time): an event app. Register a MessageSubscription to the specific Messages you export (e.g. OrderCreated, OrderStateChanged, CustomerCreated), or a ChangeSubscription on a resource to catch every change. Subscribe to the minimum set — the 50-Subscription-per-Project limit is a soft limit, so don't burn it with one Subscription per message type when a ChangeSubscription covers the resource.
  • Batch / backfill: a job (properties.schedule) that queries the API with a lastModifiedAt window + cursor pagination and loads the delta. Also the vehicle for the one-time historical load.
  • Optional full-export service: a service endpoint that triggers an on-demand full export (the template's full-export app). Mention it; build it only if the user needs on-demand full loads.

Scopes and secrets

  • Least-privilege, read-only. Egress reads commercetools and writes the destination — so the commercetools scopes are read scopes for the domains you export plus manage_subscriptions for the streamer's registration. Never an admin/manage_project client. → parent security.md.
  • Destination credentials in securedConfiguration — API key / service-account JSON / connection string — never standardConfiguration, never hardcoded. Analytics data includes customer PII; treat destination creds accordingly.

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the docs. Use only documented envelope keys (deployAs / applicationType / endpoint / scripts / properties / configuration; inheritAs), and place the file at the repository root — a nested connect.yaml silently fails to deploy.

Native client provisioning (prefer this)

Declare scopes and let Connect mint a least-privilege API client rather than hand-supplying CTP_CLIENT_ID/SECRET:
inheritAs:
  apiClient:
    scopes:
      - manage_subscriptions   # streamer: postDeploy registers the Subscriptions
      - view_orders            # export orders / order Messages
      - view_customers         # export customers (PII — only if in scope)
      - view_published_products # export catalog (use the read scope your domains need)
      # add view_payments / view_stock etc. only for the domains you actually export
  configuration:
    standardConfiguration:
      - key: DESTINATION_DATASET
        description: Warehouse dataset / table (or CDP source id)
    securedConfiguration:
      - key: DESTINATION_CREDENTIALS
        description: Destination API key / service-account JSON / connection string
Note: view_subscriptions is not a valid standalone scope — manage_subscriptions covers read + write. Declaring non-existent view scopes fails client creation. Grant only the view_* scopes for the domains you export — nothing more.

Per-app config

deployAs:
  - name: analytics-streamer        # near-real-time
    applicationType: event
    endpoint: /analyticsStreamer
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the Subscriptions
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "Injected Pub/Sub on Connect (GoogleCloudPubSub)"
  - name: analytics-backfill        # scheduled batch + one-time history
    applicationType: job
    endpoint: /analyticsBackfill
    properties:
      schedule: "0 2 * * *"         # 02:00 daily; or run on-demand for the one-time load

Worked example (Snowflake, build, orders + customers, stream + nightly backfill)

Requirements: Snowflake warehouse; export orders + customers; near-real-time stream for freshness plus a nightly backfill to repair gaps and load history; one row per event, deduped on the warehouse side; europe-west1.gcp.

Derived config:

inheritAs:
  apiClient:
    scopes: [manage_subscriptions, view_orders, view_customers]
  configuration:
    standardConfiguration:
      - key: SNOWFLAKE_ACCOUNT
        description: "Snowflake account + database/schema/table"
    securedConfiguration:
      - key: SNOWFLAKE_CREDENTIALS
        description: Snowflake key-pair / PAT for the loader role
deployAs:
  - name: analytics-streamer
    applicationType: event
    endpoint: /analyticsStreamer
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub (injected on Connect)"
  - name: analytics-backfill
    applicationType: job
    endpoint: /analyticsBackfill
    properties:
      schedule: "0 2 * * *"
Rationale to hand the user: one event streamer registering a MessageSubscription on order (OrderCreated, OrderStateChanged) and a ChangeSubscription on customer — re-fetch by id, transform to the Snowflake row, deliver, and emit resource.id + sequenceNumber as the dedup key so a MERGE on the warehouse side is idempotent; one job windowing on lastModifiedAt + cursor pagination for the nightly gap-repair and the one-time history load; scopes are exactly the two read scopes + manage_subscriptions the apps need; Snowflake creds are securedConfiguration; customer PII is minimized to the columns analytics needs and never logged (pipeline-architecture.md).
integrations/analytics/connector-selection.md

Is a public connector enough? (analytics)

This answers Step 1.5 of overview.md. Unlike tax (a certified connector usually exists), for analytics the answer is almost always build — there is no turnkey commercetools "analytics connector", no Export API, and no analytics template other than the general-purpose Product export template. That expectation is not a licence to skip the live check.

Do the live check anyway — don't answer from memory

Even though we expect "build", run the check so you don't miss a destination-specific integration that has appeared:
  1. Search the Connect marketplace and the integration docs (via docs-search / the Knowledge MCP) for the user's destination (e.g. "Segment", "Snowflake", "BigQuery") — not for "analytics".
  2. Apply the marketplace-listing rule. Analytics/CDP listings are especially likely to be partner services, SaaS products, or iPaaS/ELT middleware (Fivetran/Airbyte-style loaders, a CDP's own commercetools source) rather than a deployable Connect connector. Confirm a Connect affordance (public repo / connect.yaml / a Connect deploy action) before calling anything install/configure — full rule: Marketplace listings are not all Connect connectors.
  3. Name what you checked (connector/product + version) or record "none exists", and confirm the path with the user before building.

The landscape (verify, but this is the shape)

What you may find on/around the marketplaceWhat it actually isDefault rung
A CDP's own commercetools source/integration (Segment, mParticle, RudderStack, Tealium)Often the CDP's product, configured on their side — may not be a Connect connectorUse it if it covers the domains — but verify it's Connect-deployable; else it's out of this skill's scope
An ELT/data-loader (Fivetran/Airbyte-style) reading the commercetools APIThird-party pipeline tooling, not a Connect connectorValid alternative — but not built/deployed via Connect (say so)
A warehouse listing (BigQuery/Snowflake/Redshift)Almost never a turnkey commercetools connector4 (build from template)
Nothing for the destinationThe common case4 (build from template)
The practical consequence: "just install the analytics connector" is usually not available. Say this plainly and early — it changes the effort estimate. If the user already runs a CDP or an ELT loader that can pull the commercetools API, that may be the cheapest path and not a Connect build at all — surface it, and warn that this skill's build/deploy patterns (connect.yaml, the Connect CLI, lifecycle scripts) apply only if they choose a Connect connector.

The ladder (stop at the first rung that fits)

Rung 1 — Configure a public connector / native destination integration

If a Connect-deployable connector or a destination-native integration exists and covers the domains, configure it — cheapest and most maintainable. Installation (CLI auth, scopes, deployment create) is the parent skill's deployment-installation.md. Hand it the config from config-from-requirements.md.

Rung 2 — A gap that config can close

Which Messages/domains flow, field-to-column mapping, and destination table/dataset are usually configuration, not code. Re-check the apparent gap against the connector's configuration surface before forking.

Rung 3 — Fork/extend a public connector (only if open source)

A genuine gap config can't close and the connector is open source → fork it, add only the delta, deploy as an Organization connector. Hand off to commercetools-connect for the build/stage/publish lifecycle. A partner-hosted CDP integration can't be forked — that's a vendor conversation.

Rung 4 — Build from the Product export template (the common case)

  • a full-export application (an API endpoint that exports all resources of a Store to an external system) — your backfill/full-load base;
  • an incremental updater — an event app that subscribes to Messages and pushes each change to the external system — your streamer base.
Scaffold it with the Connect CLI (commercetools connect init, template product-export), then adapt: change which resources/Messages you subscribe to (orders/customers/payments, not just products), rewrite the transform to your destination's schema, and replace the delivery call with your destination's ingestion API. What you write is the transform + the destination client + the dedup key; the Connect plumbing (envelope handling, subscription registration, lifecycle) is scaffolded. Contract and gotchas: pipeline-architecture.md.
The full build/stage/publish/certify lifecycle for rungs 3–4 is the parent commercetools-connect skill; return to this analytics flow once deployed.

Recording the decision

In the requirements block, note: destination · rung · what you checked live (or "none exists") · path. Example:
Destination: Snowflake · rung 4 (build) · checked marketplace 2026-08 — no Connect-deployable Snowflake connector; found only ELT loaders (out of Connect scope) · building an event streamer + a nightly backfill job from the product-export template, deduped on resource.id+sequenceNumber.
integrations/analytics/destinations.md

Analytics destinations — pick the mechanism, don't catalog vendors

This decides how data reaches the destination and what flows there, by category. It is not a vendor spec sheet — for any specific destination's ingestion API, field names, and limits, read that vendor's own docs (they evolve and are outside commercetools' docs). Use this to route the design; use pipeline-architecture.md to build it.

The transport underneath (same for every destination)

A commercetools Subscription delivers to one of a fixed set of message brokers (Destination types): AWS SQS / SNS / EventBridge, Azure Service Bus / Event Grid, Google Cloud Pub/Sub, and Confluent Cloud (Kafka). Your connector reads from the broker and delivers onward to the destination's ingestion API. On Connect the injected broker is Google Cloud Pub/Sub — you don't choose it; build the destination from the injected CONNECT_GCP_* vars (event-applications.md, Pattern 7). Batch loads bypass the broker entirely and query the HTTP/GraphQL API (pipeline-architecture.md).

Categories (route by these, not by brand)

Data warehouses — BigQuery, Snowflake, Redshift, Databricks

  • Decision: the default and best-fit analytics destination. Both mechanisms apply — stream events for freshness, batch for history/gap-repair. Land raw event rows into staging keyed on resource.id + sequenceNumber, then model/MERGE downstream.
  • What flows: all transactional/state truth — orders, line items, customers, payments, inventory, catalog.
  • PII: the warehouse becomes a PII store — minimize columns, and propagate erasure (pipeline-architecture.md → PII).

CDPs — Segment, mParticle, RudderStack, Tealium

  • Decision: stream server-side commerce events into the CDP so it can unify profiles and fan out to downstream tools. Check first whether the CDP has its own commercetools source / integration — if it does and it fits, that may be a configure path (rung 1), not a Connect build (connector-selection.md).
  • What flows: customer + order events keyed to a stable user id; usually a curated event set, not the full catalog.
  • PII: CDPs are identity-centric — consent and identifier mapping matter; carry only permitted fields.

Product / behavioral analytics — Amplitude, Mixpanel, GA4, Snowplow

  • Decision: server-side ingestion of commerce events only (e.g. purchase/refund from OrderCreated/order-state Messages). Honesty caveat: GA4 and Mixpanel are client-side-first — their server-side ingestion (e.g. GA4 Measurement Protocol) is limited and event-shaped, not a natural full-data-export target. Don't present them as a warehouse substitute; if the user wants complete history and modeling, route to a warehouse. Snowplow/Amplitude have more first-class server-side ingestion.
  • What flows: a small, well-defined set of conversion/behavioral events — not orders-as-rows.
  • PII: keep to hashed/consented identifiers per the tool's model.

BI tools — Looker, Tableau, Power BI

  • Decision: do not integrate BI with commercetools directly. BI sits on the warehouse, not on commercetools — point the connector at a warehouse (above) and let BI read from there. If the user asks to "connect Tableau to commercetools," redirect: build the warehouse egress, then BI reads the warehouse.
  • What flows: nothing directly from commercetools — it reads modeled tables in the warehouse.

Restating the boundaries (so the design doesn't drift)

  • Server-side egress only. Client-side behavioral/pixel tracking (GA4 gtag, Google Tag Manager, Segment.js) belongs in the storefront (e.g. commercetools Frontend) with a tag manager — not a Connect connector. A connector can feed server-side ingestion of the same tools, but page-view/click tracking is a storefront concern.
  • Not Platform Insights. If the "analytics" the user wants is API latency / error rates / request logs, that's Platform Insights (an Add-On forwarding telemetry to New Relic / Datadog / OpenTelemetry / Dynatrace) — operational APM, not commerce data. Route there, not to a connector.
  • Not Change History. The Audit Log / Change History is a governance change log on a separate, rate-limited host — never an analytics feed.

Choosing, in one line

Warehouse for complete history + modeling (stream + batch); CDP for profile unification and downstream fan-out (stream, and check for a native source first); product-analytics for a curated server-side event set (stream, mind the client-side-first caveat); BI always via the warehouse, never direct.
integrations/analytics/overview.md

Analytics connector — export commercetools data to an analytics destination

This is the analytics integration sub-area of commercetools-connect: you need to get commercetools commerce data — orders, carts, customers, payments, inventory, catalog — into an analytics destination (a data warehouse, CDP, or product/behavioral-analytics tool) so it can be reported on and modeled. The type-agnostic build/publish/certify lifecycle and the production-readiness gate are the parent skill's; this sub-area owns the analytics-specific shape end to end.
Analytics is a directional egress pipeline with no fixed connector contract: commercetools is always the source, the destination is downstream, and nothing runs synchronously on the cart hot path. commercetools has no dedicated analytics connector and no Export API (Import and export: "commercetools does not provide a dedicated Export API. To export resources, use the Merchant Center or query resources with the HTTP API."), so an analytics integration is a build-it-yourself pipeline assembled from two primitives:
  • Streaming (near-real-time): a Connect event app on Subscriptions / Messages — the resource changes, a Message is delivered, you transform and deliver a row.
  • Batch (scheduled backfill / periodic load): a Connect job app that queries the HTTP/GraphQL API with a lastModifiedAt window + cursor pagination and loads the delta.
The closest first-party precedent is the Connect Product export template — a full-export service app plus an incremental event app on Messages — which is exactly this two-primitive shape and your build base (rung 4). Structurally this reads like the order-management sub-area (directional data-sync, no contract); the file layout mirrors CRM.
The mistake to internalize first: delivery is at-least-once, so dedup on the destination side — and never build analytics off Change History. A Subscription delivers each Message at least once with no ordering (delivery guarantees); without a dedup key on the warehouse side (resource.id + sequenceNumber) you get duplicate rows. And the Change History / Audit Log is not an analytics feed: it lives on a separate host, is token-rate-limited (429 + Retry-After), and the docs explicitly say to "avoid making API calls in response to an event stream or message subscription" — it is a governance/compliance log, not a high-throughput data source.

Disambiguate "analytics" before designing (three different things)

Fix which one the user means — they route completely differently:

  1. Commerce/business analytics (this sub-area). Transactional/state truth (orders, customers, inventory, catalog) flowing out to a warehouse/CDP/analytics tool, server-side. This is what you build here.
  2. Platform Insights — an Add-On that forwards commercetools API metrics and server-side logs to an APM (New Relic, Datadog, OpenTelemetry, Dynatrace). This is operational/APM telemetry, not commerce data — if the user wants request latency / error rates, route them here, not to a connector.
  3. Change History / Audit Log — a governance/compliance change log (who changed what). Separate host, rate-limited — not an analytics feed (see the blockquote).

Server-side egress only — client-side tracking is out of scope

This sub-area covers server-side export of transactional/state truth. Client-side behavioral/pixel tracking (GA4 via gtag, Google Tag Manager, Segment.js, …) is a storefront concern — implement it in the storefront (e.g. commercetools Frontend) with a tag manager, not in a Connect connector. A connector can feed server-side ingestion of the same tools, but it is not where page-view/click tracking belongs. Keep the boundary explicit with the user.

Workflow

Follow these steps in order. The heart is Step 1 → Step 1.5 → Step 2 → Step 3 (requirements → is a connector enough? → pipeline design → build test-first).

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with analytics-focused terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<analytics terms from the user's request, e.g. 'export orders data warehouse subscription messages product export template lastModifiedAt query pagination'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root, where scripts/docs-search.mjs lives.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or https://docs.commercetools.com for deeper follow-up.

Step 1 — Extract requirements (before any config or code)

The pipeline design is downstream of these; the wrong default silently produces duplicate rows, missing events, or leaked PII. Ask the user (don't assume) — each maps to a config key in Step 2 or a rule in pipeline-architecture.md:
  1. Which destination, and what kind? A data warehouse (BigQuery, Snowflake, Redshift, Databricks), a CDP (Segment, mParticle, RudderStack, Tealium), a product/behavioral-analytics tool (Amplitude, Mixpanel, GA4, Snowplow), or a BI tool (Looker/Tableau/Power BI — which sits on the warehouse, not on commercetools). The category decides the mechanism — see destinations.md.
  2. Which data domains? Orders, carts, customers, payments, inventory, catalog (products/prices) — and which fields. Customer data is PII: name it explicitly so GDPR handling is designed in, not bolted on.
  3. Latency — streaming, batch, or both? Near-real-time (event app on Messages) vs periodic load (job querying the API). Most real pipelines need both: a stream for freshness + a batch backfill for history and gap-repair.
  4. Historical backfill needed? A one-time (or periodic full) load of existing data is a separate job from the ongoing stream — like a migration.
  5. Destination schema / grain. One row per event, or an upserted current-state table? This decides the transform and the dedup/merge key.
  6. Volume & throughput. Order/event volume shapes batch page size, backoff, and whether the ~50-Subscription budget is a constraint.
  7. Anything special or non-standard? (always ask — open-ended) Multi-project/multi-region consolidation, data residency, real-time personalization needs, existing warehouse loader/ELT tooling (Fivetran/Airbyte-style) the user already runs, retention/erasure policy. Capture each as its own line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before deriving config. Sane default if nothing special surfaces: an event streamer on the relevant Messages for freshness plus a job backfill for history, delivering to a warehouse, deduped on resource.id + sequenceNumber, read-only least-privilege scopes, destination creds in securedConfiguration — and say so explicitly.

Step 1.5 — Is a public connector enough? (a hard, ordered gate — do the live check anyway)

We already expect the answer to be build — there is no turnkey analytics connector. That is not a licence to skip the live check. Run this gate in order:
  1. Check live data. Search the Connect marketplace and the integration docs (via docs-search / Knowledge MCP) for anything targeting the user's destination. Don't answer from memory — the marketplace changes.
  2. Apply the marketplace-listing rule. A listing may be a partner/iPaaS/SaaS product, not a deployable Connect connector — see Marketplace listings are not all Connect connectors.
  3. Confirm with the user, and only then conclude the rung.
Then walk the ladder (details + how a CDP/ELT tool changes the answer: connector-selection.md):
  1. A public connector / native destination integration covers it → install + configure. Installation is the parent skill's deployment-installation.md.
  2. A gap looks like config → prove it (which Messages, field mapping, destination table) before forking → back to rung 1.
  3. An open-source connector with a real gapfork/extend it; hand off to commercetools-connect for the lifecycle.
  4. No connector for the destination (the common case)build from the Product export template and adapt it to your destination. This is the default landing rung. → connector-selection.md, pipeline-architecture.md.

Record the decision, rung, and connector name + version (or "none exists") in the requirements block.

Step 2 — Design the pipeline (the core deliverable)

Whichever rung, pin the pipeline design: which apps exist (event streamer / batch job / optional full-export service), which Messages the streamer subscribes to, the event→row transform, the dedup/merge key, and PII handling. This is where the analytics value and the expensive mistakes (duplicate rows, missing events, re-fetch-on-payloadNotIncluded) live. Read pipeline-architecture.md and pick the destination mechanism from destinations.md; produce, for the user: the app list, the message-subscription list, the transform/schema mapping, and the dedup strategy.

Step 3 — Build (rungs 3–4), test-first

Analytics maps directly onto the parent skill's application types — there is no analytics-specific runtime contract to learn — so build on those references and their checklists:
  • Event streamer = an event app subscribing to the relevant Messages → event-applications.md. At-least-once, no ordering: decode the Pub/Sub envelope, re-fetch by id (required on payloadNotIncluded), ack correctly, and emit a stable dedup key.
  • Batch/backfill = a job querying the API with lastModifiedAt + cursor pagination → job-applications.md. Checkpoint the window; each unit idempotent.
  • Optional full-export service (like the template's full export, or an in-Merchant-Center dashboard via a custom application) = a service app → service-applications.md. Mentioned as an extension, not required.
  • Registration of Subscriptions in idempotent postDeploy / preUndeploylifecycle-scripts.md.
Build test-first (parent skill's Quality gate): the rules that make analytics correct — dedup key emitted, re-fetch on payloadNotIncluded, idempotent batch window — are invisible at the call site. Mock the destination and the commercetools API and assert on what your code decided to write. → testing.md.

Step 4 — Deploy

Deploy is type-agnostic — use the parent skill's deployment-installation.md. Destination credentials go in securedConfiguration, never in code.

Step 5 — Verify the round trip

Don't declare done until data flows end to end and proves idempotent: a resource change produces exactly one row in the destination (no duplicate on redelivery), and a batch run over a window loads it idempotently (a re-run doesn't double-load). See verification.md, which also covers the analytics traps (Subscription not registered → no data; duplicate rows from missing dedup key; re-fetch needed on payloadNotIncluded; Messages query API off by default).

References

NeedReference
Is a connector enough?: the live registry/marketplace check even though we expect build; CDP/ELT-changes-the-answer; the configure/fork/build-from-template ladderconnector-selection.md
Requirements → config: which Messages / job schedule, least-privilege read scopes, destination creds in securedConfiguration, worked exampleconfig-from-requirements.md
Pipeline architecture (the substance): event streamer + batch/backfill job, event→row transform, warehouse-side dedup keys, payloadNotIncluded re-fetch, PII/GDPR, the limits; full pitfall catalogpipeline-architecture.md
Destinations: warehouse vs CDP vs product-analytics vs BI — the stream-vs-batch decision, what data flows, PII implication, the client-side honesty caveatdestinations.md
Verify the round trip: one change → one row, idempotent batch window; the no-subscription / duplicate-row / re-fetch / query-off-by-default trapsverification.md
Build/publish/certify lifecycle, deploy, scopes, production-readiness gate (type-agnostic)commercetools-connect

Adding another destination later reuses this same tree — the two primitives, the dedup model, and the flow don't change; only the destination's delivery API does.

Checklist

Requirements

  • Destination named + categorized (warehouse / CDP / product-analytics / BI); data domains + fields listed
  • "analytics" disambiguated (commerce data vs Platform Insights vs Change History); client-side tracking flagged out of scope
  • Latency decided (stream / batch / both); historical backfill in or out of scope
  • Destination schema/grain (event rows vs upserted current-state) and dedup/merge key decided
  • PII data domains flagged; retention/erasure policy captured
  • Open-ended "anything special?" asked; each special requirement its own line
  • Requirements block written and confirmed

Connector fit (decide before building)

  • Ran the live marketplace/registry check even though build is expected; applied the listing-isn't-a-connector rule; confirmed with the user
  • Ladder rung chosen and recorded: configure (1) · config-closes-gap (2) · fork (3) · build-from-template (4, the default)

Pipeline design (the deliverable)

  • Apps chosen: event streamer (event) and/or batch job (job); optional full-export service noted
  • Streamer subscribes to only the needed Messages; transform is a pure, tested function
  • Dedup key on the destination side (resource.id + sequenceNumber); payloadNotIncluded re-fetch handled
  • Batch job windows on lastModifiedAt + cursor pagination + checkpoint
  • PII minimized; not logged; least-privilege read scopes; destination creds in securedConfiguration

Build & verify (test-first)

  • Built test-first on the parent event/job/service references and their checklists
  • Subscriptions registered idempotently in postDeploy; cleaned up in preUndeploy
  • Round trip verified: one change → one row (no duplicate); batch window loads idempotently
integrations/analytics/pipeline-architecture.md

The analytics egress pipeline

Everything each app must do, and the pitfalls that silently break an analytics feed. Which apps you build follows from latency (overview.md); the destination mechanism is in destinations.md. These build on the parent skill's contracts — event-applications.md, job-applications.md, security.md — and add only the analytics-specific substance. Don't re-teach envelope/ack/idempotency, job scheduling/checkpointing, or scopes here — link to those.

The one rule that spans the pipeline: dedup on the destination side

Subscription delivery is at-least-once with no ordering (delivery guarantees) and a batch backfill window can overlap the stream — so the same change reaches the destination more than once. You cannot dedup inside a stateless Connect app; make the destination absorb duplicates with a stable dedup/merge key:
  • For notificationType: "Message"resource.id + sequenceNumber (monotonic per resource; higher wins).
  • For Change payloads (ResourceCreated/Updated/Deleted) → resource.id + version (note version is not sequential, but is comparable per resource).
Land raw event rows into a staging table keyed on this pair (an append is naturally idempotent if the key is unique), or MERGE/upsert current-state on resource.id keeping the row with the highest sequenceNumber/version. This is the analytics analogue of CRM's upsert-by-externalId and OMS's orderNumber idempotency — same principle, warehouse side.

App 1 — the event streamer (event, near-real-time)

Build on event-applications.md — it owns the envelope/ack/idempotency/Pub-Sub contract. The streamer's job is the classic five steps:
subscribe → decode the Pub/Sub envelope → re-fetch by id → transform to the destination schema → deliver (with the dedup key).
  • Subscribe to the minimum Messages for the domains you export — register in idempotent postDeploy (lifecycle-scripts.md). A MessageSubscription for specific message types (OrderCreated, OrderStateChanged, CustomerCreated, …) when you want typed, targeted events; a ChangeSubscription on a resource to capture all changes in one Subscription (spends less of the 50-Subscription budget). Message catalogs: Cart & Order, Customer, all Messages. On Connect the injected broker is Google Cloud Pub/Sub — build the destination from the injected CONNECT_GCP_* vars, don't hardcode a broker (event-applications.md, Pattern 7).
  • Decode + validate the envelope (base64 message.data → JSON → resource ref → notificationType), and ack correctly (2xx for handled/irrelevant; non-2xx only for a transient delivery/destination failure you want redelivered). Don't 4xx a subscribed-but-unhandled type into a redelivery loop; don't swallow a transient destination outage into silent data loss. → event-applications.md, Patterns 1–3.
  • Re-fetch the resource by resource.id — never transform from the Message payload. It can be stale (no ordering) or absent: if a Message exceeds the queue size limit it is delivered with payloadNotIncluded set and no resource data, so re-fetch is mandatory, not optional (event-applications.md, Pattern 5). Fetch current state, then transform.
  • Transform to the destination schema (see below) — a pure function, no network, unit-tested without a deployment.
  • Deliver + emit the dedup key. Send to the destination's ingestion API with resource.id + sequenceNumber (or version) attached so the destination deduplicates.

App 2 — the batch / backfill job (job, scheduled + one-time history)

Build on job-applications.md — it owns the schedule, the 30-min timeout, overlap locking, and checkpointing. The analytics-specific part is how you window the query, because there is no Export API (Import and export) — you page the normal HTTP/GraphQL API:
  • Window on lastModifiedAt. Query only resources changed since the last checkpoint: a where predicate like lastModifiedAt >= :cursor (the documented integration best practice for "query for changes using timestamps"). Far cheaper than re-scanning the whole dataset.
  • Cursor-based pagination, not offset. Offset degrades with depth and is capped at 10,000 records; cursor pagination (a stable sort + lastId/timestamp cursor) is consistent at any depth. Place the most restrictive predicate first (performance considerations).
  • Checkpoint the window (e.g. the last processed lastModifiedAt + id in a Custom Object) so a restart resumes and a re-run loads only the delta. Each unit idempotent via the same destination dedup key as the stream.
  • Two jobs, one shape: a one-time historical backfill (wide window, from the beginning) and an ongoing gap-repair run (narrow window since the last checkpoint). Keep the backfill separate from the stream.
  • Not for heavy bulk. Job containers are capped (2 CPU / 4 GB) and the docs advise against jobs for memory-intensive bulk work (job-applications.md). For a large one-time history load, stream page-by-page to the destination (don't buffer the whole dataset), or orchestrate an external batch loader from the job rather than doing the heavy lift in-container.

The event→row transform (the real work)

Mapping a commercetools resource onto a destination schema is where the value is; keep it pure and tested:

  • Localized strings, money, nested arrays. commercetools localized strings ({ "en": … }), centAmount/currencyCode money, and nested line-item/address arrays rarely map 1:1 to a flat warehouse column. Decide per field: flatten to columns, keep as a JSON/VARIANT column, or explode line items into a child table.
  • Grain. One row per event (append-only fact table, dedup on the key) or one current-state row per resource (MERGE/upsert on resource.id)? This is a destination-schema decision — pin it in Step 1.
  • Carry the dedup key as columns (resource_id, sequence_number / version, last_modified_at) so warehouse-side dedup/merge and late-arrival handling are possible.

PII / GDPR (customer data)

Orders and customers carry PII. Treat the destination as a data processor:
  • Minimize — export only the fields analytics needs; don't ship full customer profiles by default.
  • Never log PII or destination credentials; structured logs carry only identifiers/correlation keys (security.md).
  • Propagate erasure. If a CustomerDeleted / anonymization must reach the warehouse (right-to-be-forgotten), subscribe to it and delete/anonymize the destination rows — an analytics warehouse is a common place orphaned PII hides.
  • Destination creds in securedConfiguration; least-privilege read scopes only (config-from-requirements.md).

Platform limits & realities to design around

  • No Export API — you build the pipeline from Subscriptions + API queries; there is no bulk "dump" endpoint (Import and export).
  • ~50 Subscriptions per Project — a soft limit, increasable on request. Prefer a ChangeSubscription per resource over many per-message-type Subscriptions to stay within budget.
  • payloadNotIncluded (payload omitted above the queue size limit, often around 256 KB) — re-fetch by id always; never rely on the payload being present (event-applications.md).
  • Messages query API is off by default — Messages are not persisted for querying unless you enable the feature in Merchant Center Developer Settings (enable querying Messages). Subscriptions deliver regardless; don't design a poller against the Messages API assuming it's queryable.
  • Change History is not the source — separate host, token-rate-limited (429), explicitly not for event-driven/high-throughput use (see overview.md, and Audit Log overview).

Pitfall catalog

PitfallSymptomFix
No destination dedup keyDuplicate rows after redelivery / batch-stream overlapEmit resource.id + sequenceNumber (or version); dedup/MERGE on the warehouse side
Transforming from the payloadWrong/missing data; empty rows on payloadNotIncludedRe-fetch the resource by resource.id; transform from current state
4xx/5xx on an unhandled typeRedelivery loop flooding the destinationAck (2xx) irrelevant messages; subscribe narrowly
Swallowing a destination outageSilent gaps (events acked but never landed)Non-2xx on transient destination failure so it redelivers; DLQ terminal failures
offset pagination for backfillBatch stalls / caps at 10,000 recordsCursor pagination + lastModifiedAt window
No checkpoint on the batch windowRe-run reloads everything / restart loses progressCheckpoint the window; resume from it
One Subscription per message typeBurns the ~50-Subscription budgetChangeSubscription per resource where you need all changes
Polling the Messages APIEmpty results — querying is off by defaultUse Subscriptions; only query Messages if the feature is enabled
Building off Change History429s; not event-driven; missing API-origin changes on BasicUse Subscriptions + API queries, not the Audit Log
PII in the warehouse / logsCompliance exposure; erasure gapsMinimize fields; never log PII; propagate deletion/anonymization
Route ≠ connect.yaml endpointPlatform traffic 404sMount the router at the app's endpoint base path
Legacy SDKFails the parent skill's pinned-version gate@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4

Test-first checklist (mirror in the suite)

Event streamer

  • Decodes the base64 envelope; validates type; acks irrelevant messages (no loop)
  • Re-fetches the resource by id (never transforms from the payload); handles payloadNotIncluded
  • Transform is a pure, unit-tested function (localized strings / money / arrays handled)
  • Emits the dedup key (resource.id + sequenceNumber / version); duplicate delivery lands one row
  • Transient destination failure → non-2xx (redelivered); terminal → ack + DLQ/alert
  • No PII in logged output; least-privilege read scopes

Batch / backfill job

  • Windows on lastModifiedAt; cursor pagination (not offset)
  • Checkpoints the window; a re-run loads only the delta and is idempotent (same dedup key)
  • Streams pages to the destination (no whole-dataset buffering); overlap lock + timeout headroom
  • Boundary mocked (commercetools API + destination); suite runs with no deployment/secrets
integrations/analytics/verification.md

Verify the analytics round trip

Don't declare done until data flows end to end and proves idempotent. The two checks below are the minimum; the traps after them regularly look like bugs when they're correct, or look fine while silently dropping/duplicating data.

Check 1 — one change produces exactly one row (the stream)

Change one resource on the source side (place an Order, edit a Customer), let the stream run — or, locally without Pub/Sub, POST the base64 message envelope to the streamer directly (Test an event application locally) — then:
  • The row appears in the destination with the mapped fields correct (localized strings, money, addresses).
  • The dedup key is present (resource.id + sequenceNumber, or version) on the row — this is what makes redelivery a no-op.
  • Redeliver the same envelope (or force a redelivery) and confirm the destination still has one row, not two. A second row is the tell that the warehouse-side dedup/merge key is missing (pipeline-architecture.md).

Check 2 — a batch window loads idempotently

Run the backfill/gap-repair job over a known lastModifiedAt window, then run it again over the same window:
  • The first run loads the delta; the second run adds no new rows (same dedup key → upsert/no-op).
  • The checkpoint advanced, and a run started from the checkpoint fetches only changes since it — not the whole dataset.
  • Cursor pagination reached the end of the window (no silent truncation at 10,000 rows — the sign someone used offset).

The traps (behavior that looks like a bug — or hides one)

Trap 1 — no data at all → the Subscription isn't registered

The most common "nothing is arriving": the postDeploy Subscription registration didn't run or failed, so no Messages are delivered. Confirm the Subscription exists (query Subscriptions), that it targets the right resource/message types, and that its destination is the injected Pub/Sub. A Subscription change takes up to a minute to take effect (Subscriptions).

Trap 2 — duplicate rows are expected without a dedup key

At-least-once delivery and a batch window overlapping the stream both produce the same change twice. Duplicates are not a delivery bug — they're the absence of a destination dedup/merge key. Verify by asserting the dedup key and re-running Check 1's redelivery.

Trap 3 — empty/partial rows → payloadNotIncluded, re-fetch missing

Rows that arrive with missing fields (or only for small resources) mean the handler transformed from the Message payload, which is omitted when the Message exceeds the queue size limit (payloadNotIncluded). Fix: always re-fetch by resource.id and transform from current state (event-applications.md).

Trap 4 — the Messages query API returns nothing → it's off by default

If a batch design polls the Messages API and gets empty results, that's because Messages are not persisted for querying unless the feature is enabled in Merchant Center Developer Settings (enable querying Messages). Subscriptions deliver regardless — prefer the stream, or window on the resources' lastModifiedAt, rather than polling Messages.

Trap 5 — acked but never landed → silent gap

A handler that returns 2xx even when the destination delivery failed acks the Message away with no retry — events vanish. Confirm transient destination failures return non-2xx (redelivered) and terminal ones go to a DLQ/alert, not a blanket 200 (event-applications.md).

Verification checklist

  • One source change → exactly one destination row; dedup key present
  • Redelivering the same envelope adds no second row (idempotent)
  • Batch job over a window is idempotent on re-run; checkpoint advances; cursor pagination (no 10,000 cap)
  • Subscription confirmed registered (right resource/types, Pub/Sub destination) when nothing arrives
  • payloadNotIncluded handled by re-fetch (no empty/partial rows)
  • Transient destination failure redelivers; terminal failure DLQ'd — not acked into a gap
  • No PII or destination credentials in logs; erasure propagates to the destination (if in scope)
integrations/crm/config-from-requirements.md

Requirements → CRM connector config

This turns the Step 1 requirements (overview.md) into concrete connect.yaml values and a linking model. For a public connector these are its documented keys; for a build these are the keys and apps you define.

The requirement → config map

Requirement (Step 1)Config / decisionWhy
Which CRM + credentialssecuredConfiguration: CRM API token / OAuth client id+secretSecrets never in standardConfiguration, never hardcoded; this is PII-adjacent
Direction (out / in / both)App composition (see below)Direction is the architecture — it decides which apps exist
Source of truthField-level read/write ownership; read-only Custom Fields on the mastered sidePrevents the losing side from overwriting the master
Which entities/objectsMapping module: Customer→Contact, Order→DealThe core of the build; keep it a pure function
Which events sync (out)Subscription message types registered in postDeployOnly subscribe to what you sync
Deletion / consentCustomerDeleted subscription (out) and/or erasure endpoint; consent field mappingGDPR: deletion must propagate; consent must not be lost
Region + projectstandardConfiguration: CTP_REGION; scopes via inheritAsHost + client provisioning are region/project specific
Volume / latencyevent/webhook (real-time) vs job (batch) + page size / backoff togglesBatch vs broadcast is the documented trade-off

Direction → app composition

Direction decides which apps you deploy. Build only what the direction needs (see the table in overview.md):
  • commercetools → CRM (outbound): one or more event apps. To catch every customer change, register a ChangeSubscription on the customer resource (delivers ResourceCreated/ResourceUpdated/ResourceDeleted); to sync only specific changes, register MessageSubscriptions to the Customer messages you care about (CustomerCreated, CustomerEmailChanged, CustomerAddressAdded, CustomerDeleted, …). Add OrderCreated if syncing orders. This is the broadcasting events pattern.
  • CRM → commercetools (inbound): a service inbound webhook (CRM pushes changes; 5-min timeout, you authenticate the caller) or a job that polls the CRM for deltas on a schedule. Pick webhook when the CRM can push and you need low latency; poll when it can't or when batch is fine.
  • Initial migration: a job for the one-time bulk backfill — kept separate from the ongoing sync, as the docs recommend, because backfill and delta need different pagination/throughput handling.

Source of truth and the linking model

The single most consequential choice after direction. Decide who masters customer data, then wire the link:
  • Link every synced pair by a stable key. Store the CRM record id in the Customer's externalId (the field commercetools provides for exactly this — external-system references), or in a Custom Field if externalId is already used. This key is your upsert / idempotency key in both directions — never blind-create.
  • When the CRM masters customer data, keep a Customer in commercetools anyway (it owns permissions, Cart/Order ownership, and promotions), and hold CRM-only attributes in Custom Fields marked read-only so storefront/MC edits can't diverge from the master.
  • When commercetools masters, the CRM record is downstream; write to it, don't read authoritative fields back.
  • Bi-directional is discouraged. If unavoidable, you must assign field-level ownership (which side wins per field) and add self-change filtering to break loops — see crm-contract.md.

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the docs. Use only documented envelope keys (deployAs / applicationType / endpoint / scripts / configuration; inheritAs), and place the file at the repository root — a nested connect.yaml silently fails to deploy.

Native client provisioning (prefer this)

Declare the connector's scopes and let Connect mint a least-privilege API client, rather than hand-supplying CTP_CLIENT_ID/SECRET. Scopes depend on direction:
inheritAs:
  apiClient:
    scopes:
      - manage_subscriptions   # outbound: postDeploy registers the Subscriptions
      - view_customers         # outbound: re-fetch the Customer to build the CRM payload
      - view_orders            # outbound: re-fetch the Order (if syncing orders)
      # inbound instead needs:
      # - manage_customers     # upsert Customers coming from the CRM
      # - manage_types         # only if postDeploy creates the Custom Type for CRM fields
  configuration:
    standardConfiguration:
      - key: CRM_BASE_URL
        description: CRM API base URL (or sandbox vs prod toggle)
    securedConfiguration:
      - key: CRM_API_TOKEN
        description: CRM API token / OAuth client secret
Note: view_subscriptions is not a valid standalone scope — manage_subscriptions covers read + write. Declaring non-existent view scopes fails client creation. Grant manage_customers only on the inbound app that actually writes Customers.

Per-app config

deployAs:
  - name: customer-syncer          # outbound example
    applicationType: event
    endpoint: /customerSyncer
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the Subscriptions
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub or SNS"
  - name: crm-migration            # one-time backfill
    applicationType: job
    endpoint: /crmMigration
    properties:
      schedule: "0 3 * * *"        # or run on-demand

Worked example (HubSpot, build, commercetools → CRM one-way)

Requirements: HubSpot; CRM is master for marketing attributes but commercetools masters the account record; sync every customer change + OrderCreated out; one-time migration of existing customers; propagate deletion; near-real-time; europe-west1.gcp.

Derived config:

inheritAs:
  apiClient:
    scopes: [manage_subscriptions, view_customers, view_orders]
  configuration:
    standardConfiguration:
      - key: CRM_BASE_URL
        description: "HubSpot API base (sandbox vs prod)"
    securedConfiguration:
      - key: CRM_API_TOKEN
        description: HubSpot private-app token
deployAs:
  - name: customer-syncer
    applicationType: event
    endpoint: /customerSyncer
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub or SNS"
  - name: crm-migration
    applicationType: job
    endpoint: /crmMigration
Rationale to hand the user: one event syncer registering a ChangeSubscription on customer (covering create/update/delete in one) plus an OrderCreated MessageSubscription; a job for the one-time backfill; scopes are exactly what the postDeploy registration and the resource re-fetches need — nothing more; the HubSpot token is securedConfiguration; each contact is upserted by externalId = HubSpot contact id, written back to the Customer on first sync. Marketing attributes that HubSpot masters are held in read-only Custom Fields so they aren't clobbered from commercetools (crm-contract.md).
integrations/crm/connector-selection.md

Is a public CRM connector enough?

This answers Step 1.5 of overview.md: given the requirements, do you configure an existing connector, fork one, or build for the CRM the user defines? Unlike tax — where the answer is engine-specific but a certified connector usually exists — for CRM the answer is most often build, because classic CRMs generally have no certified commercetools connector.

Check live data first — don't answer from memory

The marketplace changes. Before deciding:

  1. Search the Connect marketplace (marketplace.commercetools.com/connectors) and the integration docs via the docs-search script or the Knowledge MCP.
  2. Compare the requirements CRM-by-capability (which entities/objects, direction, field mapping, deletion/consent, real-time vs batch).
  3. Name the connector and version you checked — or record that none exists — in the requirements block.

The CRM landscape (verify, but this is the shape)

The marketplace skews toward marketing / customer-data-platform / personalization tools, not classic sales CRMs:
CategoryExamples on the marketplaceTypical default rung
Marketing / CDP / personalizationKlaviyo, Bloomreach, Mailchimp, Dynamic Yield, Relewise (verify live)1 (configure) if it covers the use case
Classic CRM (Salesforce, HubSpot, Dynamics 365, Zoho)Typically no certified connector4 (build)
Anything else the user definesCheck the marketplaceLikely 4 unless a listing exists
The practical consequence: "just use the certified connector" is often not available for a classic CRM. A request to "integrate Salesforce/HubSpot with commercetools" is usually a build job — say this plainly to the user early, because it changes the effort estimate. If the real need is marketing automation or a CDP (segments, campaigns, personalization) rather than a system-of-record CRM, a public connector may well fit rung 1 — clarify which they actually mean.
There is also no crm-integration Connect template (templates list: payment-integration, product-export, tax-integration, transactional-emails). So a build starts from plain apps, not a CRM-specific scaffold — see rung 4.

The ladder (stop at the first rung that fits)

Rung 1 — Configure a public connector

If a public connector exists and covers the requirements, install and configure it — cheapest and most maintainable. Installation (CLI auth, scopes, deployment create --connector-key, or Merchant Center install) is the parent skill's deployment-installation.md. Hand it the config you derive in config-from-requirements.md. Most CRM/CDP "customization" is field mapping and event selection — configuration, not code.

Rung 2 — A gap that config can close

A "missing" behavior is often a setting: which events sync, how fields map, whether consent flags carry, list/segment targeting. Re-check the apparent gap against the connector's configuration surface before forking. Details in config-from-requirements.md.

Rung 3 — Fork/extend a public connector (only if open source)

If there's a genuine gap config can't close and the connector is open source, fork it, add only the delta, and deploy as an Organization connector. Don't rebuild a working connector. Hand off to commercetools-connect for the fork's build/stage/publish lifecycle. A partner-private connector can't be forked — a genuine gap there means working with the vendor or building custom.

Rung 4 — Build for the CRM the user defines (the common case)

No public connector for the CRM → build it. Because there is no CRM template, scaffold plain apps with the Connect CLI (commercetools connect init then connect application add --type event|service|job), or start from the closest template and adapt. The nearest outbound shapes (react to a commercetools event → call an external API) are the transactional-emails and product-export templates; there is no inbound template, so build the CRM → commercetools direction as a plain service (webhook) or job (poll). Either way the Connect plumbing (lifecycle scripts, subscription/extension registration, envelope handling) is scaffolded; the CRM API calls and the mapping are what you write.
What you actually build on rung 4 (only the apps your direction needs — see overview.md):
  • Outbound syncer (event): commercetools message → CRM object upsert, idempotent by externalId (see crm-contract.md).
  • Inbound app (service webhook or job poll): CRM record → Customer upsert by externalId, read-only mapped fields.
  • Migration job (job): one-time bulk backfill, checkpointed.
  • Config + scopes (config-from-requirements.md).
The full build/stage/publish/certify lifecycle for rungs 3–4 is the parent commercetools-connect skill; return to this CRM flow once the connector is deployed. The CRM-specific correctness rules and gotchas (upsert-not-create, loop avoidance, ack semantics, deletion/PII) are in crm-contract.md.

Recording the decision

In the requirements block, note: CRM · rung · connector name + version checked (or "none exists") · why. Example:
CRM: HubSpot · rung 4 (build) · checked marketplace 2026-07 — no public HubSpot connector; marketplace has marketing/CDP tools only · building an outbound event syncer + a migration job, CRM-as-master one-way, linked by externalId.
integrations/crm/crm-contract.md

The CRM sync contract

Everything each app must do, and the pitfalls that silently break it. Which apps you build follows from direction (overview.md); the rules below are per app. These build on the parent skill's async contracts — event-applications.md, service-applications.md, job-applications.md, security.md — and add the CRM-specific rules.

The one rule that spans every app: upsert by externalId, never blind-create

Every sync write, in either direction, is an upsert keyed on a stable external reference — the commercetools Customer's externalId holds the CRM record id (and/or the CRM holds the commercetools id). At-least-once delivery means every message can arrive twice; a create-on-every-message design produces duplicate contacts and duplicate Customers. Look up by the key, update if present, create (and write the key back) if absent. This is the CRM analogue of tax's stable transaction_id.

App 1 — the outbound syncer (commercetools → CRM, event)

What triggers it

A Connect event application: Connect provisions the queue/destination and delivers each message as an HTTP POST to the app's endpoint (port 8080). Register the Subscription in postDeploy (idempotent get-then-create):
  • ChangeSubscription on customer — fires on ResourceCreated/ResourceUpdated/ResourceDeleted for any customer change. Simplest way to keep a full profile in sync.
  • MessageSubscriptions to specific Customer messages (CustomerCreated, CustomerEmailChanged, CustomerAddressAdded, CustomerDeleted, …) when only certain changes should sync, or when you need the message's typed fields.
  • Add OrderCreated (a MessageSubscription) if orders map to CRM deals/sales.
Message catalogs: Customer messages, Subscriptions. Don't manage the transport — Connect abstracts Pub/Sub vs SNS.

The delivery envelope

The payload shape depends on config, so don't hardcode one form (same as any event app):

  • Transport wrapper (GCP): { "message": { "data": "<base64>", ... } }message.data is base64-encoded JSON; decode first.
  • Message format: PlatformFormat ({ notificationType, type, resource: { typeId, id }, ... }) or CloudEventsFormat ({ type: "com.commercetools.…", data: { … } }). Read type and resource.id from whichever you get, and validate the type before acting (ack-and-ignore the platform's test/probe messages). See Test an event application locally.

What it must do

  • Re-fetch the Customer/Order by id from resource.id — don't trust the payload. At-least-once delivery with no ordering means an older ResourceUpdated can arrive after a newer one; re-fetching the current resource makes the sync converge to the latest state instead of replaying stale deltas.
  • Map the resource to the CRM's object model (Customer→Contact/Lead, Order→Deal). Keep the mapping a pure function — no network — so it's unit-testable without a deployment or token.
  • Upsert by externalId. If the Customer has no CRM id yet, create the CRM record and write its id back to the Customer's externalId (or Custom Field) with setExternalId/setCustomField. If it has one, update that CRM record. Idempotent on redelivery.
  • Ack correctly. Reply with a positive ack (200/2xx; the Connect event contract treats 102/200/201/202/204 as "don't redeliver") for handled and irrelevant-but-acked messages. Return non-2xx only for transient failures you want redelivered.

Self-change filtering (only if bi-directional)

If an inbound app also writes Customers, an inbound write raises a ResourceUpdated that this syncer would push straight back to the CRM — an infinite loop. Break it: mark connector-originated writes (e.g. a syncSource Custom Field, or compare against the last-synced hash/version) and skip re-syncing your own changes. This is the single nastiest CRM bug; a one-way design avoids it entirely, which is why the integration-patterns guidance discourages bi-directional sync.

Deletion & PII (GDPR)

  • If deletion is in scope, handle CustomerDeleted / ResourceDeleted by deleting or anonymizing the CRM record — an erasure request must propagate, not leave orphaned PII downstream.
  • Customer data is PII: sync only the fields you need, keep credentials in securedConfiguration, and never log PII or the CRM token (parent security.md). Carry marketing-consent flags through the mapping so a "do not contact" preference isn't lost.

App 2 — the inbound app (CRM → commercetools)

Two forms; pick per the CRM's capability and your latency need.

Form A — service inbound webhook (CRM pushes)

  • Not an API Extension — no Extension is registered; the CRM calls your endpoint directly. The 5-min service timeout applies (not the 2 s extension limit).
  • Authenticate the caller — the CRM calls you, so validate its proof (webhook signature / shared secret / JWT) in-app before writing (parent security.md). Never trust an unauthenticated inbound write to Customers. (AuthorizationHeaderAuthentication is the separate mechanism for the reverse direction — commercetools calling your endpoint as an Extension destination — not inbound-caller auth.)
  • Upsert the Customer by externalId — look up by the CRM id, update or create. Use manage_customers scope.
  • Idempotent — the same webhook may arrive twice; the upsert must be a no-op the second time.
  • Read-only mapped fields — when the CRM masters these fields, store them in Custom Fields you treat as read-only elsewhere, so storefront/MC edits don't fight the master.

Form B — job poll (you pull deltas)

  • A scheduled job (properties.schedule) that queries the CRM for records changed since the last run, pages through them, and upserts each by externalId.
  • Checkpoint the last-synced timestamp/cursor (e.g. in a CustomObject) so a restart resumes and you fetch only deltas, not the whole CRM each run.
  • Owns its own overlap locking and 30-min timeout headroom (parent job-applications.md).

App 3 — the migration job (one-time backfill)

Keep the initial bulk load separate from ongoing sync (the integration-patterns guidance recommends separating migration from ongoing integration — different throughput/pagination needs). A job that pages the source (CRM or commercetools) in batches, upserts by externalId, and checkpoints progress so a failure resumes mid-run rather than restarting. Cleanse/validate records on the way (the migration guidance calls out cleansing Customer data). Respect CRM rate limits — batch and back off; a naive tight loop will get throttled or banned.

Cross-cutting: mapping and rate limits

  • Mapping is the real work. commercetools localized strings, addresses (array), customer groups, and Custom Fields rarely map 1:1 to a CRM's flat contact schema. Decide per field; keep it pure and tested.
  • Rate limits & backoff. CRMs rate-limit hard. Give outbound calls a timeout, retry transient 429/5xx with exponential backoff, and prefer the CRM's batch endpoints for migration.

Pitfall catalog

PitfallSymptomFix
Create-on-every-messageDuplicate contacts / duplicate Customers after redeliveryUpsert by externalId; write the id back on first sync
Trusting the payloadStale/missing data synced; deltas replayed out of orderRe-fetch the resource by resource.id
No self-change filter (bi-directional)Infinite sync loop, runaway API callsMark connector writes; skip your own changes — or go one-way
Envelope not decodedHandler sees base64 garbage / crashesDecode message.data (base64→JSON) before use
No message-type filterActing on unrelated/test messagesValidate type; ack-and-ignore the rest
Wrong ackHandled message redelivered forever, or failures silently dropped2xx for handled/irrelevant; non-2xx only for retryable failures
Deletion not propagatedOrphaned PII in the CRM after erasureHandle CustomerDeleted/ResourceDeleted → delete/anonymize
PII / token in logsCompliance incidentStructured logs without PII; token in securedConfiguration
Unauthenticated inbound webhookAnyone can write CustomersValidate signature/secret/JWT; least-privilege manage_customers
Migration mixed into ongoing syncThrottling, restarts reload everythingSeparate migration job; checkpoint; batch + backoff
Route ≠ connect.yaml endpointPlatform traffic 404sMount the router at the app's endpoint base path
Legacy SDKFails the parent skill's pinned-version gate@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4

Test-first checklist (mirror in the suite)

Outbound syncer

  • Decodes the base64 envelope; validates type; acks irrelevant messages
  • Re-fetches the resource by id (doesn't map from the payload)
  • Upserts by externalId; creates-then-writes-back when absent; update when present
  • Duplicate delivery is a no-op (idempotent)
  • Self-change filtering asserted if bi-directional
  • Deletion → delete/anonymize (if in scope); no PII in logged output

Inbound (webhook or job)

  • Webhook: rejects unauthenticated/invalid-signature calls (auth matrix)
  • Upserts the Customer by externalId; idempotent on repeat
  • CRM-mastered fields written as read-only Custom Fields
  • Job: checkpoint advances; a re-run fetches only deltas

Migration job

  • Pages + checkpoints; resumes mid-run after a simulated failure
  • Upsert (not create) so a re-run doesn't duplicate
  • Boundary mocked; suite runs with no deployment/secrets
integrations/crm/overview.md

CRM connector — integrate an external CRM (customer-sync-focused)

This is the CRM integration sub-area of commercetools-connect: you need to keep customer (and often order) data in sync between commercetools and an external CRM, and you'll do it with a Connect connector. For the deep, type-agnostic build/publish/certify lifecycle and the production-readiness gate, that's the parent connect skill; this sub-area owns the CRM-specific shape end to end — from "is there a connector already?" through configuring, forking, or building one for a CRM you define.
A CRM integration is not a fixed set of apps the way tax or payment is. Its shape falls out of two decisions you must make first — direction and source of truth — and it is fundamentally asynchronous: syncing a customer profile must never block or fail a registration or checkout. Unlike a tax calculator, nothing here runs synchronously on the cart hot path.
The mistake to internalize first: pick direction and source of truth before anything else. Almost every CRM-integration failure — duplicated contacts, overwritten edits, infinite sync loops — traces back to not having decided who masters customer data and which way it flows. commercetools' own integration guidance is explicit: pick a single source of truth per data domain, and avoid bi-directional syncs — they carry real conflict and loop risk.

The three shapes (by direction)

DirectionSource of truthConnect app(s)Trigger
commercetools → CRM (push customers/orders out)commercetools mastersevent app(s) — the broadcasting events patterna ChangeSubscription on customer (all changes) or specific Customer messages; OrderCreated; …
CRM → commercetools (pull profiles/segments in)CRM mastersservice inbound webhook or job pollCRM pushes a webhook, or a schedule polls the CRM for deltas
Initial migration (one-time bulk load)eitherjobOn-demand / scheduled; separate from the ongoing sync
Most real integrations combine an ongoing shape (event or webhook/poll) with a one-time migration job — the docs recommend separating them, because bulk backfill and delta sync need different tools. When the CRM is the master, the canonical setup is one-way CRM → commercetools, with a Customer created in commercetools anyway (it owns permissions, Cart/Order ownership, and promotions) and linked back to the CRM record. See config-from-requirements.md.

Workflow

When integrating a CRM, follow these steps in order. The heart is Step 1 → Step 1.5 → Step 2 → Step 4 (requirements → is a public connector enough? → config → build the sync apps).

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with CRM-focused terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<CRM terms from the user's request, e.g. 'CRM customer sync subscription CustomerCreated externalId integration patterns'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or the integration planning and patterns guide for deeper follow-up.

Step 1 — Extract requirements (before any config or code)

CRM behavior is downstream of business facts, and the wrong default silently produces duplicated contacts, stale data, or a sync loop. Extract these first; each maps to a config key in Step 2, a rung in Step 1.5, or a rule in the contract. Ask the user (don't assume):
  1. Which CRM, and do they have API access? Salesforce, HubSpot, Dynamics 365, Zoho, or another. Account, API credentials/OAuth app, sandbox vs production, and its rate limits.
  2. Direction and source of truth? commercetools → CRM, CRM → commercetools, or (discouraged) both. Who masters customer data? This is the single most consequential answer — it decides the app shape and which side's write wins on conflict.
  3. Which entities, and mapped to which CRM objects? commercetools Customer → CRM Contact/Lead/Person; Order → CRM Deal/Opportunity/Sales record; Cart → (rarely). Which fields on each side, and how localized names/addresses map.
  4. Ongoing sync, initial migration, or both? A one-time backfill of existing customers is a job; ongoing delta sync is an event or webhook/poll — usually both, built separately.
  5. Which events trigger an outbound sync? Creation only, or every customer change and OrderCreated too? This maps to the two Subscription flavors: a ChangeSubscription on the customer resource fires on all changes (ResourceCreated/ResourceUpdated/ResourceDeleted); MessageSubscriptions target specific Customer messages (CustomerCreated, CustomerEmailChanged, CustomerAddressAdded, CustomerFirstNameSet, CustomerDeleted, …) when only certain changes matter. → decides which Subscriptions the connector registers.
  6. Deletion / GDPR / consent? Must a CustomerDeleted (or anonymize/erasure request) propagate to delete or anonymize the CRM record? Are there marketing-consent flags to carry? Customer data is PII — this is not optional to think about.
  7. Volume and latency? Near-real-time (event/webhook) vs batch (nightly job); expected record counts (drives rate-limit and pagination handling). The docs frame the batch-vs-broadcast choice on exactly these axes.
  8. Anything special or non-standard? (always ask — open-ended) Multi-brand/multi-store contact separation, B2B accounts/company hierarchies, loyalty tiers or segments flowing in, double-opt-in, region/data-residency. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before deriving config. If the user surfaces nothing special, a sane default is: CRM as master where it exists, one-way sync, Customer↔CRM-record linked by externalId, ongoing delta via events (or webhook), a separate migration job, deletion propagated — and say so explicitly.

Step 1.5 — Is a public connector enough? (decide before wiring or building)

With the requirements in hand, answer the question the rest of the flow assumes: does a connector that already does this exist for this CRM? Don't answer from memory — the marketplace changes. Check live data (the Connect marketplace + the integration docs, via the docs-search script / Knowledge MCP), and name the connector + version you checked.
The CRM landscape differs sharply from tax: classic CRMs (Salesforce, HubSpot, Dynamics, Zoho) generally have no certified commercetools connector — the marketplace leans toward marketing/CDP/personalization platforms (Klaviyo, Bloomreach, Mailchimp, …). So a request to "integrate Salesforce/HubSpot" is usually a build job, not a marketplace install. There is also no crm-integration template — you scaffold plain apps and adapt. See connector-selection.md.
Then walk the ladder — stop at the first rung that fits, because each later one is more to build and maintain:
  1. Public connector covers everything → install + configure (Step 2). Don't build. Installing it (CLI auth, scopes, deployment create) is the parent skill's deployment-installation.md; it is not the connectorstaged flow.
  2. A public connector exists, gap looks like a capability → prove it isn't config first. Field mapping, which events sync, and consent handling are often connector settings → back to rung 1. See config-from-requirements.md.
  3. A public connector exists with a genuine gap config can't close, and it's open sourcefork/extend it; add only the delta and deploy as an Organization connector. Don't rebuild a working connector. Hand off to commercetools-connect for the build/publish lifecycle.
  4. No public connector for the CRM (the common case — Salesforce, HubSpot, Dynamics, Zoho, or any CRM the user defines)build it. There is no CRM template, so scaffold plain event/service/job apps (or start from the closest outbound template — transactional-emails or product-export — and adapt; there is no inbound template). You implement the CRM API calls and the mapping. The exact contract and gotchas are in crm-contract.md.

Record the decision, the rung, and the version in the requirements block.

Step 2 — Derive the config from the requirements

Translate the Step 1 answers into concrete connect.yaml values (for the chosen connector or your own), with a one-line why for each. The full mapping is in config-from-requirements.md. Key decisions that live here:
  • App composition from direction — which event/service/job apps you deploy, per the table above.
  • Least-privilege API-client scopes via inheritAs.apiClient.scopes — outbound needs read scopes (view_customers, view_orders) + manage_subscriptions; inbound needs manage_customers. Don't hand-supply a manage_project admin client.
  • Secrets in securedConfiguration — the CRM API token / OAuth client secret is securedConfiguration, never standardConfiguration, never hardcoded. This is customer-PII-adjacent; treat it accordingly.
  • The linking model — store the CRM record id in the Customer's externalId (or a Custom Field), and hold CRM-only attributes in Custom Fields marked read-only when the CRM is master.

Step 3 — Price the async contract (reference)

CRM sync inherits the parent skill's async contracts. Restate them in one sentence each before coding: idempotency (upsert by a stable externalId, never blind-create), at-least-once with no ordering (re-fetch the resource by id; don't apply deltas from a possibly-stale payload), and loop avoidance if bi-directional (a CRM-originated write must not re-trigger an outbound sync). Full contract: crm-contract.md.

Step 4 — Build/verify the sync apps (the main body of work), test-first

Tests come before implementation. The rules that make a CRM integration correct — upsert-not-create keyed on externalId, re-fetch by id, ack semantics on the event endpoint, self-change filtering, deletion propagation — are invisible at the call site and tedious to reproduce by hand. Each is one cheap assertion. Write the test first.
Read crm-contract.md and build, in order — test first for each — only the apps your direction requires:
  1. Outbound syncer(s) (event) — on a customer change (ChangeSubscription ResourceUpdated/ResourceCreated, or specific Customer messages) or OrderCreated, re-fetch the resource by id, map it to the CRM's object model, upsert by externalId (idempotent), write the CRM id back to the Customer, ack correctly.
  2. Inbound app (service webhook or job poll) — authenticate the caller (webhook) or page the CRM (job); upsert the Customer by externalId; set CRM-mastered fields read-only; be idempotent.
  3. Migration job (job) — page the source in bulk, upsert deltas, checkpoint so a restart resumes; keep each unit idempotent.
Mock the outbound boundary (the CRM API, the CT APIs) and assert on what your code decided — which CRM object, what body, upsert-vs-create, what it wrote back. The suite must run with zero deployment and zero secrets. What to assert/mock per app is in crm-contract.md.

Step 5 — Verify the round trip

Don't declare done until a real customer flows end to end. Create a Customer in commercetools (or the CRM), confirm the counterpart record appears linked by externalId, update a field and confirm the delta propagates once (no loop), and — if in scope — delete/anonymize and confirm it propagates. See verification.md, which also covers the traps that look like bugs but aren't (a sync loop from missing self-change filtering, CRM rate-limit throttling, sandbox data quirks).

References

NeedReference
Is a public connector enough?: live-marketplace check; why classic CRMs are usually build-from-scratch; the ladderconnector-selection.md
Requirements → config mapping: direction → app composition, source of truth, externalId/Custom Fields linking, scopes, secured config; the connect.yaml envelope; worked exampleconfig-from-requirements.md
The sync contract: outbound syncer, inbound webhook/poll, migration job; idempotent upsert by externalId, re-fetch by id, ack semantics, self-change/loop filtering, deletion/PII, mapping; full pitfall catalogcrm-contract.md
Verify the round trip: record linked by externalId, delta propagates once, deletion propagates; the loop / rate-limit / sandbox trapsverification.md
Build/publish/certify lifecycle, deploy, scopes, production-readiness gate (type-agnostic)commercetools-connect

Adding another CRM later means reusing this same tree — the direction-driven app shapes, the linking model, and the flow do not change; only the CRM's object model and API calls do.

Checklist

Requirements

  • CRM chosen + API access/credentials (sandbox vs prod, rate limits) known
  • Direction and source of truth decided (one-way preferred; bi-directional only with a stated reason)
  • Entity → CRM-object mapping (Customer→Contact, Order→Deal) and field mapping identified
  • Ongoing sync vs initial migration (usually both) decided; trigger events listed
  • Deletion/GDPR/consent handling decided; PII scope minimized
  • Asked the open-ended "anything special?" question; each special requirement its own line
  • Requirements block written and confirmed; specials fed into the Step 1.5 fit-check

Connector fit (decide before wiring/building)

  • Checked live marketplace + integration docs (not memory); named the connector + version (or confirmed none exists)
  • Ladder rung chosen: configure (1) · config-closes-gap (2) · fork/extend (3) · build (4)
  • For a classic CRM with no connector, recognized this is a build, not a marketplace install

Config (the deliverable)

  • App composition matches the direction (event / webhook / poll / migration job)
  • Only documented connect.yaml envelope fields; file at the repo root
  • inheritAs.apiClient.scopes least-privilege (read + manage_subscriptions outbound; manage_customers inbound)
  • CRM credentials in securedConfiguration; toggles/region in standardConfiguration
  • Linking model chosen: externalId (or Custom Field) as the stable key; CRM-only fields read-only when CRM is master

The sync apps (build test-first — do not write a function body before its red test)

  • Outbound syncer re-fetches by id, upserts by externalId, writes the CRM id back, acks with 200/2xx
  • Inbound app authenticates the caller (webhook) / pages the CRM (job); upserts the Customer by externalId; idempotent
  • Self-change filtering in place if bi-directional (no loop)
  • Deletion/anonymization propagated if in scope
  • Boundary mocked; suite runs with no deployment/secrets

Verification

  • Counterpart record appears, linked by externalId
  • A field update propagates exactly once (no loop)
  • Deletion/anonymization propagates (if in scope); no PII in logs
integrations/crm/verification.md

Verify the CRM round trip

Don't declare done until a customer flows end to end and you've proven it doesn't loop. The three checks below are the minimum; the traps after them regularly look broken when they're actually correct (or look fine when they're actually looping).

Check 1 — the counterpart record appears, linked by externalId

Create a Customer in commercetools (outbound) or in the CRM (inbound), let the sync run (or, locally without Pub/Sub, POST the base64 message envelope to the syncer directly), then:

  • The counterpart record exists in the destination (a Contact in the CRM, or a Customer in commercetools).
  • The link is set: the commercetools Customer's externalId (or Custom Field) holds the CRM record id — this is what makes the next change an update, not a duplicate. A record that appears with no externalId written back is the tell that the upsert-and-link step is missing; the next sync will create a duplicate.
  • The mapped fields match (localized names, address, consent flags).

Check 2 — a delta propagates exactly once (no loop)

Update one field (e.g. last name) on the mastering side and watch the other side:

  • The change appears on the counterpart — once.
  • No duplicate record is created (proves upsert-by-externalId, not create).
  • Watch for a loop. In a bi-directional setup, a missing self-change filter shows up as a burst of writes ping-ponging between the systems (rising API call counts, version numbers climbing on their own). One update should produce one write per direction and then stop. If it doesn't, the self-change filter is missing (crm-contract.md).

Check 3 — deletion / anonymization propagates (if in scope)

Delete or anonymize the Customer and confirm the CRM record is deleted or anonymized (no orphaned PII). Confirm the syncer acked the CustomerDeleted/ResourceDeleted event.

The traps (behavior that looks like a bug — or hides one)

Trap 1 — the sync loop (looks fine at first, then floods)

A bi-directional sync with no self-change filter passes Check 1 and seems to work, then floods both systems with writes because each side's write re-triggers the other. Verify by making one change and confirming the write count settles. The durable fix is one-way sync; if bi-directional is required, assert the self-change filter with a test, not just by eyeballing.

Trap 2 — rate-limit throttling looks like "sync stopped"

CRMs rate-limit aggressively. A migration or a burst of events that suddenly stops landing records is usually 429 throttling, not a logic bug — check for backoff/retry and batch endpoints (crm-contract.md), and confirm the job resumes from its checkpoint rather than restarting.

Trap 3 — sandbox data quirks

CRM sandboxes may cap records, expire data, or return canned responses. Verify the contract (upsert, idempotency, mapping, ack) against the sandbox; verify real persistence and visibility against a controlled production/full-sandbox account, and clean up test records afterward so they don't pollute the CRM.

Verification checklist

  • Counterpart record created and externalId written back (link established)
  • A field update propagates once; no duplicate record created
  • One change settles to one write per direction (no loop) — asserted, not just observed
  • Deletion/anonymization propagates; no orphaned PII (if in scope)
  • No PII or CRM token in logs
  • Migration resumes from checkpoint; respects rate limits (batch + backoff)
integrations/email/config-from-requirements.md

Requirements → email connector config

This turns the Step 1 requirements (overview.md) into concrete connect.yaml values. For a ready-made connector these are its documented keys; for a from-template build these are the keys you define. The template's own key names are called out below.

The requirement → config map

Requirement (Step 1)Config / decisionWhy
Which ESP + credentialssecuredConfiguration: EMAIL_PROVIDER_API_KEY (or the ESP's user/pass/region)Secrets never in standardConfiguration, never hardcoded
Sender identitysecuredConfiguration: SENDER_EMAIL_ADDRESS (must be a verified domain/sender in the ESP)Unverified senders are rejected or land in spam
Which emailsThe Subscription message types (in code/postDeploy) and one template id per emailThe handler routes by message type to a template
ESP-hosted templatessecuredConfiguration: one *_TEMPLATE_ID per email typePoints each email at its ESP template
LocalizationLanguage source (customer.locale / order / store) → per-locale template id or a locale field passed to the ESPRight language per recipient (template hardcodes en-US — a gap)
Order-state target statesConfig or code list of the states that trigger a sendOrderStateChanged fires on every transition; gate it
Region + projectstandardConfiguration: CTP_REGION; scopes via inheritAsHost + client provisioning are region/project specific
Token emails in scopemanage_customers scope (mint token is a write); token-validity ≤ 60 min if you want the value in the MessageSee email-contract.md

Scopes — least-privilege depends on which emails you send

The connector needs exactly the scopes its postDeploy and handlers use — no more. Build the set from the emails in scope:
CapabilityScopeNeeded when
Register the Subscription in postDeploymanage_subscriptionsalways
Re-fetch the Order to build order emailsview_ordersany order email (confirmation, state/shipment, refund)
Re-fetch the Customer to build customer emailsview_customersregistration / any email that reads customer data
Mint an email/password token in the handlermanage_customersverification / password-reset emails (supersedes view_customers)
Why token emails need write access. The token value is only present in the CustomerEmailTokenCreated / CustomerPasswordTokenCreated Message when the token's validity is ≤ 60 minutes (customer password reset). For longer-lived tokens the value is omitted, so the connector must create the token itself (a POST .../password-token write) — which is what the official template does. If your reset tokens are short-lived and you read the value straight from the Message, view_customers is enough; if you mint in the handler, you need manage_customers.

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the docs. Use only documented envelope keys, and place the file at the repository root — a nested connect.yaml silently fails to deploy.

Native client provisioning (prefer this)

Declare scopes and let Connect mint a least-privilege API client, rather than hand-supplying CTP_CLIENT_ID/SECRET:
inheritAs:
  apiClient:
    scopes:
      - manage_subscriptions   # postDeploy registers the email Subscription
      - view_orders            # handlers re-fetch the Order for order emails
      - manage_customers       # only if minting verification/password tokens; else view_customers
The official template hand-declares CTP_CLIENT_ID/SECRET/SCOPE as secured config; migrating to inheritAs.apiClient.scopes is the more native, lower-maintenance form and is worth doing on a from-template build.

The event app

deployAs:
  - name: mail-sender
    applicationType: event
    endpoint: /mailSender
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy    # registers the Subscription
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy  # deletes it
    configuration:
      standardConfiguration:
        - key: CTP_REGION
          description: commercetools Composable Commerce API region
      securedConfiguration:
        - key: EMAIL_PROVIDER_API_KEY
          description: API key for the email service provider
        - key: SENDER_EMAIL_ADDRESS
          description: Verified sender address shown in the email
        - key: ORDER_CONFIRMATION_TEMPLATE_ID
          description: ESP template id for order confirmation
        # …one template id per email type in scope
Subscription destination is injected, not declared. An event app's queue/topic is provisioned by Connect, which injects CONNECT_SUBSCRIPTION_DESTINATION and CONNECT_GCP_TOPIC_NAME / CONNECT_GCP_PROJECT_ID (or CONNECT_AWS_TOPIC_ARN for SNS) at deploy time. Build the Subscription destination from those in postDeploy — don't add them to connect.yaml and don't hardcode a broker (event-applications.md).

Worked example (SendGrid, from-template build)

Requirements: SendGrid; order confirmation + shipment + password reset; ESP-hosted dynamic templates; English + German by customer.locale; europe-west1.gcp; short-lived reset tokens minted in the connector.

Derived config:

inheritAs:
  apiClient:
    scopes: [manage_subscriptions, view_orders, manage_customers]  # manage_customers: mints the reset token
  configuration:
    standardConfiguration:
      - key: CTP_REGION
        description: commercetools Composable Commerce API region
    securedConfiguration:
      - key: EMAIL_PROVIDER_API_KEY
        description: SendGrid API key
      - key: SENDER_EMAIL_ADDRESS
        description: Verified sender (e.g. no-reply@shop.example)
      - key: ORDER_CONFIRMATION_TEMPLATE_ID_EN
        description: SendGrid dynamic template id — order confirmation (en)
      - key: ORDER_CONFIRMATION_TEMPLATE_ID_DE
        description: SendGrid dynamic template id — order confirmation (de)
      - key: ORDER_SHIPMENT_TEMPLATE_ID_EN
        description: SendGrid dynamic template id — shipment (en)
      - key: ORDER_SHIPMENT_TEMPLATE_ID_DE
        description: SendGrid dynamic template id — shipment (de)
      - key: PASSWORD_RESET_TEMPLATE_ID_EN
        description: SendGrid dynamic template id — password reset (en)
      - key: PASSWORD_RESET_TEMPLATE_ID_DE
        description: SendGrid dynamic template id — password reset (de)
deployAs:
  - name: mail-sender
    applicationType: event
    endpoint: /mailSender
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
Rationale to hand the user: manage_subscriptions for the postDeploy registration, view_orders to re-fetch orders for confirmation/shipment, manage_customers because the reset email mints a short-lived token in the handler — nothing more. Two template ids per email keep localization explicit; the handler picks _EN/_DE from customer.locale with EN as the fallback. Shipment emails must be gated on the shipment reaching Shipped (not fired on every OrderStateChanged) — see email-contract.md. Provider-exact send-call shape (dynamic templates, dynamic_template_data, idempotency header): providers.md.
integrations/email/connector-selection.md

Is a ready-made email connector enough?

This answers Steps 1–2 of overview.md, the mandatory ordered gate: first list the public marketplace connectors, then confirm with the user whether to use one as-is, modify/fork one, or create a new one — before gathering ESP details or writing anything. For email the answer skews toward create/modify-from-template, because the connector landscape is ESP-specific and thin — unlike tax, where Avalara/Vertex ship certified connectors.

Do this in order — don't skip, don't answer from memory

Marketplace listings change. Run these before any ESP/requirements questions:
  1. List the connectors from the live Connect marketplace (marketplace.commercetools.com/connectors) + the email docs via the docs-search script or the Knowledge MCP — the email / messaging / marketing listings.
  2. Present them to the user: name · vendor · service · certification/status, and flag whether any is a transactional email connector or only marketing/CRM platforms.
  3. Confirm the approach with the user — use as-is (rung 1) · config-closes-gap (rung 2) · modify/fork (rung 3) · create from template (rung 4). Do not presume the rung.
  4. Record platform/ESP · rung · connector + version checked · why.
Only after this gate do you gather ESP-specific requirements (overview.md Step 3). Match the requirements against the listings ESP-by-capability (which emails/messages, ESP-hosted vs in-connector templates, localization, attachments, multi-store).

The email landscape (verify, but this is the shape)

commercetools ships an official, ESP-agnostic transactional email integration template (commercetools/connect-email-integration-template). It wires the Connect plumbing (the event app, the Subscription registration, message routing to per-type handlers, config) and leaves one thing stubbed: the actual call to the ESP (GenericHandler.sendMail). It is a template, not a marketplace install — you deploy your own customization of it.
Whether a ready-made marketplace connector exists depends entirely on the ESP:
SituationDefault rung
A marketplace connector exists for the exact ESP and covers the emails needed1 (configure)
A marketplace connector exists but source-available and has a real gap3 (fork/customize)
No marketplace connector for the ESP (the common case)4 (build from the official template)
The practical consequence: "just install a connector" is often not available for email. A request to "send order emails via SendGrid/Mailgun/SES" is usually a build-from-template job — start from the official template and implement the provider call. State this to the user early; it changes the effort estimate. And because the template already carries the skeleton, rung 3 and rung 4 are nearly the same work — "customize the code" and "build a new one for a defined ESP" both mean edit the template and implement sendMail.

The ladder (stop at the first rung that fits)

Rung 1 — Configure a ready-made connector

If a marketplace connector exists for the ESP and covers the requirements, install and configure it — cheapest and most maintainable. Installation (CLI auth, scopes, deployment create --connector-key, or Merchant Center install) is the parent skill's deployment-installation.md. Hand it the config you derive in config-from-requirements.md (API key, sender, template IDs).

Rung 2 — A gap that config can close

Most "missing" email behavior is configuration: which emails are sent, which ESP template ID maps to each, the sender address, the region. Re-check the apparent gap against the connector's config surface before forking. Mapping: config-from-requirements.md.

Rung 3 — Fork/customize (the "customize the code" path)

A genuine gap config can't close — add message types (e.g. OrderShipmentStateChanged, a custom message), localize by customer.locale, attach a PDF invoice, gate order-state emails on specific target states, or swap the ESP — and the source is available (the official template always is). Fork it, add only the delta, deploy as an Organization connector. Don't rebuild the plumbing. Hand off to commercetools-connect for the fork's build/stage/publish lifecycle. The per-email contract and pitfalls to preserve are in email-contract.md.

Rung 4 — Build from the template (the "create a new one" path)

No connector for the ESP → build from the transactional email template. The template ships the mail-sender event app with the Connect plumbing done — lifecycle scripts, Subscription registration, envelope decode, message→handler routing, per-email personalization mapping — but the ESP call is a stub you implement (sendMail), plus retry/recovery is yours.

What you actually write on rung 4:

  • The ESP send call in sendMail: template id + to/from + personalization data → the provider's transactional-send API (providers.md).
  • Your delivery-semantics choice (ack-first vs ack-after-success + dedupe) and any retry (email-contract.md).
  • Localization, state-filtering on order-state emails, and token-email handling if in scope.
  • Config + least-privilege scopes (config-from-requirements.md).
The full build/stage/publish/certify lifecycle for rungs 3–4 is the parent commercetools-connect skill; return to this email flow once the connector is deployed.

Recording the decision

In the requirements block, note: ESP · rung · connector name + version checked · why. Example:
Email: SendGrid · rung 4 (build from template) · checked marketplace 2026-07 — no dedicated SendGrid email connector listed; using the official transactional email template and implementing the SendGrid dynamic-templates send call · emails: order confirmation + shipment + password reset.
integrations/email/email-contract.md

The one-app email contract

Everything the mail-sender event app must do, and the pitfalls that silently break each. Grounded in the official transactional email template. This is a pure event app, so read it on top of event-applications.md (envelope decode, ack table, idempotency, re-fetch, self-change filtering) — this file adds only the email-specific layer. Provider send-call shapes are in providers.md.

What triggers it — one Subscription, several message types

Register a Subscription in postDeploy (idempotently — the template deletes-by-key then recreates), keyed on a stable subscription key, with the destination built from the injected CONNECT_GCP_* vars (event-applications.md). Subscribe to only the message types you send email for — the broker shouldn't deliver noise you'll just ack-and-ignore.

The canonical message set (grounded in the template) and what each email is:

EmailresourceTypeIdMessage type
Registration / welcomecustomerCustomerCreated
Email verification (double opt-in)customer-email-tokenCustomerEmailTokenCreated
Password resetcustomer-password-tokenCustomerPasswordTokenCreated
Order confirmationorderOrderCreated (and OrderImported if you email on imports)
Order state / cancellationorderOrderStateChanged
ShipmentorderOrderShipmentStateChanged
Refund / returnsorderReturnInfoAdded, ReturnInfoSet
Register these as messages: [{ resourceTypeId, types: [...] }]. Message reference: customer messages, cart & order messages.

What the handler must do

  1. Decode & validate the envelope, then branch on Message type to the right email (the template uses a handler factory). Ack anything you don't handle (see the delivery-semantics section, and event-applications.md).
  2. Re-fetch the resource by idgetOrderById(message.resource.id), getCustomerById(order.customerId). Don't trust the payload: it can be stale (no ordering) or omitted (payloadNotIncluded). The template does this correctly. Token emails are the exception (below).
  3. Build the personalization data (recipient, name, order lines, totals) and pick the template id for the email type (and locale).
  4. Send via the ESP (providers.md) behind a tight timeout — the event ack timeout is 10 s; a hung ESP call must abort, not stall the handler.
Keep the map from resource → ESP request a pure function (no network), so the whole mapping is unit-testable without a deployment, a token, or a real send. Assert: the right email type is chosen, the recipient/template/data are correct, money and dates format correctly, and missing optional fields don't throw.

The central decision: delivery semantics for a non-idempotent send

An ESP send is not idempotent — two calls send two emails. Event delivery is at-least-once, so the same Message will occasionally be redelivered. Your acknowledgement choice decides the failure mode. There is no free lunch; pick per email type.
A 2xx ack for an event app means "don't redeliver" — including 202. (This is the opposite of an API Extension, where 202 fails the operation. Same number, different contract, because event ack semantics differ from extension response semantics. The tax calculator is an Extension; this email app is an event — don't carry the 202 rule across.)

Option A — ack first, then send (at-most-once; the template's default)

The template sends 202 at the top of the handler, before validation and before the ESP call:
response.status(HTTP_STATUS_SUCCESS_ACCEPTED).send();   // 202, immediately
// …then decode, route, re-fetch, sendMail — errors only get logged
  • Guarantees: never double-sends on redelivery (the message is already acked).
  • Cost: a transient ESP failure (or a throw) silently drops the email — the platform will not redeliver. Fire-and-forget.
  • Use when a duplicate is worse than a miss, or you add your own retry/DLQ around the send.

Option B — send, then ack on success (at-least-once + dedupe)

Ack only after the ESP confirms; return non-2xx on a transient failure so the broker redelivers:

try {
  await sendMail(...);           // confirmed accepted by the ESP
  res.status(204).send();        // ack — safe to stop
} catch (err) {
  if (isTransient(err)) { res.status(503).send(); return; }  // redeliver
  res.status(200).send();        // permanent: ack + alert, don't loop
}
  • Guarantees: transient failures retry — the email eventually goes out.
  • Cost: redelivery will re-send unless you dedupe. Email sends aren't idempotent at the platform, so make them so:
    • ESP idempotency key — pass a stable key (e.g. resource.id + sequenceNumber, or the message id) so the ESP collapses duplicates (providers.md — SendGrid, others support this).
    • or a sent-marker — record "sent" on a stable key the target can check before re-sending (a Custom Field/Custom Object), re-checking live state — never an in-process set (event-applications.md).
Recommendation: default confirmations to Option A (a rare dropped confirmation is tolerable; a double confirmation annoys). Use Option B with dedupe for drop-intolerant emails — password reset and email verification, where a lost email blocks the user. State the choice per email type in the README.

Token emails (verification & password reset) — the value isn't always in the Message

The token value rides the CustomerEmailTokenCreated / CustomerPasswordTokenCreated Message only when the token's validity is ≤ 60 minutes (customer password reset). Otherwise it's omitted. Two designs:
  • Read from the Message — create tokens with ≤ 60-min validity so the value is present; view_customers is enough. Simplest, and the emailed token is the one the user's action created.
  • Mint in the handler — call POST .../password-token (or email-token) yourself and email that value (what the template does). Works for any validity, but needs manage_customers (a write), and the emailed token differs from the triggering one. Under at-least-once this also means a redelivery mints another token — dedupe, or accept that older tokens stay valid until used (creating a token doesn't invalidate older ones by default).

Never log the token value (PII/secret) — see hygiene below.

Order-state emails must be gated on the target state

OrderStateChanged and OrderShipmentStateChanged fire on every transition. The template routes all of them to one handler and emails unconditionally — so a shopper gets an email on every internal state change. After re-fetching, gate on the specific target state you mean:
const order = await getOrderById(id);
if (order.shipmentState !== 'Shipped') return ack();     // only the shipment email
// or: if (order.orderState !== 'Cancelled') return ack();
Ack (don't error) the transitions you don't email on. Make the target states configurable where they vary by project.

Localization

The template hardcodes DEFAULT_LOCALE = 'en-US' for line-item names and picks one template id per email — so every email is English. For multi-language:
  • Read the language from customer.locale (fallback: order/store locale, then a default).
  • Resolve localized strings from LocalizedString fields (lineItem.name[locale]) with a fallback, and pick a locale-specific template id (or pass the locale to the ESP if the template branches internally).
  • Don't log PII or tokens. The template logs full message bodies and email addresses; scrub recipient addresses, names, and any token value from logs (log the resource.id/sequenceNumber correlation key instead). → security.md, observability-operations.md.
  • Keep it transactional. Transactional emails (order/account/token) generally don't require marketing opt-in; marketing/promotional email does and belongs in a marketing platform, not this connector. Don't quietly turn a transactional connector into a marketing sender.
  • Sender must be verified. SENDER_EMAIL_ADDRESS must be a verified sender/domain in the ESP or mail is rejected or spam-filed.
  • Bounces/complaints are the ESP's to report. If you need them reflected back into commercetools, that's a separate inbound-webhook service app consuming the ESP's event webhook — out of scope for the sender.

Pitfall catalog

PitfallSymptomFix
Ack-first + failed sendEmail silently never arrives; no retryOption B with dedupe for drop-intolerant emails, or add your own retry/DLQ
At-least-once without dedupeCustomer gets 2+ copiesESP idempotency key or a sent-marker on a stable key
Emailing on every OrderStateChangedShopper spammed on internal transitionsGate on the target state after re-fetch
Trusting the payloadWrong/missing data; throws on payloadNotIncludedRe-fetch the Order/Customer by resource.id
Token value read from a >60-min MessageEmpty reset linkUse ≤60-min validity, or mint the token in the handler (manage_customers)
Hardcoded en-USWrong-language emailsLocalize by customer.locale + locale-specific template id
Subscribing to whole resourcesBroker delivers noise; every message hits a handlerRegister only the exact message types
Non-idempotent postDeployDuplicate/failed Subscription on redeployDelete-by-key then create, or get-then-skip
Logging recipient/tokenPII & secret leakageLog the correlation id only; scrub addresses and token values
Unverified senderSends rejected / spam-filedVerify the sender domain in the ESP
Legacy SDKFails the parent skill's pinned-version gate@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4

Test-first checklist (mirror in the suite)

  • Decodes the base64 envelope; validates & branches on message type; acks unhandled types
  • Delivery semantics asserted — ack-first or ack-after-success + dedupe; the failure path proven (no silent drop / no double-send for the chosen mode)
  • Re-fetches Order/Customer by id; handles payloadNotIncluded
  • Order-state/shipment emails gated on the target state (asserted)
  • Correct template id + recipient + personalization data per email type; money/date formatting
  • Localization picks the right template/strings from customer.locale with fallback
  • Token email: value sourced correctly (Message ≤60 min, or minted) and never logged
  • postDeploy registers only the needed message types, idempotently; boundary mocked; suite runs with no deployment/secrets
integrations/email/overview.md

Email connector — integrate a transactional email service (event-driven)

This is the email integration sub-area of commercetools-connect: you want commercetools events (a customer registers, an order is placed or ships, a password reset is requested) to trigger transactional emails through an external Email Service Provider (ESP). You'll do it with a Connect connector. For the deep, type-agnostic build/publish/certify lifecycle and the production-readiness gate, that's the parent connect skill; this sub-area owns the email-specific shape end to end — from "is there a connector already?" through configuring, forking, or building one.
Unlike the tax sub-area (a synchronous calculator plus an asynchronous recorder), an email integration is one job and one application:
  • mail-sender (an event app driven by a Subscription on Customer/Order Messages) — commercetools delivers a Message to the app's queue; the handler picks the email type from the Message type, re-fetches the resource, builds the personalization data, and calls the ESP to send. Email is always asynchronous: sending must never block or fail a checkout, so there is no API Extension here.
This one-app shape is what the official transactional email integration template ships (its app is literally named mail-sender, applicationType: event, endpoint: /mailSender) and what the email integration tutorial documents. Because it's a pure event app, everything in event-applications.md applies directly — this sub-area layers the email-specific decisions (which Messages, delivery semantics for un-idempotent sends, templating, localization, PII) on top.
The mistake to internalize first: delivery semantics. An ESP send is not idempotent — call it twice and the customer gets two emails. Event delivery is at-least-once, so the same Message will sometimes arrive twice. How you acknowledge decides everything: ack before sending (at-most-once — never double-sends, but a transient ESP failure silently drops the email) vs. ack after a confirmed send and dedupe on redelivery (at-least-once — retries failures, but you must dedupe or customers get duplicates). The official template acks first (fire-and-forget). Pick deliberately per email type — see email-contract.md.

Workflow

Follow these steps in order. The connector fit-check is a hard, ordered gate — never skip it or jump ahead to ESP/provider details: (1) list the public marketplace connectors, (2) confirm with the user whether to use a public one, modify/fork one, or create a new one, and only then (3) gather the detailed requirements. The heart is Step 1 → Step 2 → Step 3 → Step 4 (list marketplace → use/modify/create → requirements → config).

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with email-focused terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<email terms from the user's request, e.g. 'transactional email connector subscription messages order confirmation customer registration'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or https://docs.commercetools.com/tutorials/connect-email-integration for deeper follow-up.

Step 1 — List the publicly available connectors (required — do this first, before ESP or requirements)

Before asking anything about the ESP or which emails, find out what already exists. Don't answer from memory — the marketplace changes. Check live data (the Connect marketplace at marketplace.commercetools.com/connectors + the email docs, via the docs-search script / Knowledge MCP) and present the user a concrete list of the available email / messaging / marketing connectors — each with its name, vendor, the service it integrates, and its certification/status. Call out explicitly whether any is a transactional email connector or whether the listings are only marketing/CRM platforms (as of writing they are marketing-oriented; the classic transactional ESPs — SendGrid, Mailgun, AWS SES, Postmark — have no dedicated connector and are build-from-template). How to check and the current landscape: connector-selection.md.

Step 2 — Confirm the approach: use, modify, or create (required — do not skip, do not assume)

With the list in front of the user, explicitly ask which path they want. This decision drives everything after it, so make it before gathering ESP/build details:
  1. Use a public connector as-is (rung 1) → install + configure it; the emails are authored and sent inside that platform. Installation (CLI auth, scopes, deployment create) is the parent skill's deployment-installation.md.
  2. Modify / fork an existing connector (rung 3) → the "customize the code" path: fork a source-available connector or the official template, add only the delta (message types, localization, attachments, a different ESP), deploy as an Organization connector. (First rule out rung 2 — a gap that config can close.)
  3. Create a new one from scratch (rung 4) → build from the transactional email template, implementing the stubbed ESP call for the service they define.
Walk the ladder (stop at the first rung that fits) and record: platform/ESP · rung · connector + version checked · why. Full ladder incl. the "config closes the gap" middle rung: connector-selection.md.
Email is template-first: unlike tax (where Avalara/Vertex ship certified connectors), most ESPs have no dedicated transactional connector, so "create new" or "modify the template" is the common outcome — but you still run Steps 1–2 and let the user decide; never skip the fit-check or presume the rung.

Step 3 — Extract requirements (after the approach is chosen)

Which emails, on which events, in which language, is downstream of business facts. Each maps to a config key in Step 4 or a decision in the contract. Ask the user (don't assume):

  1. Which ESP, and why? SendGrid, Mailgun, AWS SES, Postmark, Brevo, Mailchimp/Mandrill, … Do they already have an account + API key + a verified sender domain? (Deliverability, template model, and pricing differ; see providers.md.)
  2. Which emails do they need? Each maps to a commercetools Message — the canonical set the template covers: registration (CustomerCreated), email verification (CustomerEmailTokenCreated), password reset (CustomerPasswordTokenCreated), order confirmation (OrderCreated), order state / shipment (OrderStateChanged, OrderShipmentStateChanged), refund/returns (ReturnInfoAdded, ReturnInfoSet). See email-contract.md.
  3. For order-state emails, which target states trigger a send? OrderStateChanged fires on every transition — you only want to email on specific ones (e.g. Confirmed, Cancelled, shipmentState Shipped). Without a state gate you spam customers on every internal state change.
  4. Delivery guarantee per email: is a duplicate email acceptable, or is a dropped email worse? Token/reset emails are high-stakes (a dropped reset email blocks the user); marketing-ish confirmations tolerate at-most-once. → drives the ack strategy (email-contract.md).
  5. Templating & localization. Are templates authored in the ESP (dynamic/stored templates, referenced by ID — the template's model) or rendered in the connector? Multiple languages? What's the language source — customer.locale, the order/store locale, or a single default? (The template hardcodes en-US — a gap to close.)
  6. Region and project? e.g. europe-west1.gcp, project my-project.
  7. Token-email validity. For verification/reset emails: the token value only rides the Message when the token's validity is ≤ 60 minutes; otherwise the connector must mint the token itself (a write). → scope + design impact, email-contract.md.
  8. Anything special? (always ask — open-ended) Multi-store/brand (different sender/template per store), B2B/business-unit recipients, attachments (PDF invoice), unsubscribe/consent handling, bounce/complaint feedback back into commercetools, a batch/digest email (a separate job app). Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before deriving config. If the user surfaces nothing special, a sane default is: ESP chosen → the emails they name → ESP-hosted templates by ID → language from customer.locale with an en fallback → at-most-once for confirmations, and prioritized retry for token emails → and say so explicitly.
The rung was set in Step 2 — if it's rung 1 (use as-is), the configuration below is the installed connector's settings and Steps 5–6 are owned by that connector (skip to Step 7 to verify); for rungs 3–4 it's your own connect.yaml and app.

Step 4 — Derive the config from the requirements

Translate the answers into connect.yaml values, with a one-line why each. Full mapping and provider key names: config-from-requirements.md. Key decisions here:
  • Least-privilege scopes via inheritAs.apiClient.scopes (not hand-supplied CTP_CLIENT_ID/SECRET). Which scopes depends on which emails: always manage_subscriptions (postDeploy registers the Subscription); view_orders/view_customers to re-fetch for order/registration emails; manage_customers if token emails mint a token.
  • Secrets in securedConfiguration: ESP API key, and (per template) the per-email template IDs; region and toggles in standardConfiguration.

Step 5 — The Subscription & message routing (reference)

The Subscription is what makes the connector fire. Register it in postDeploy on the exact resourceTypeId + message types you send email for — nothing more (the broker shouldn't deliver noise). Then the handler branches on the Message type to the right email. Full registration shape and routing: email-contract.md.

Step 6 — Build the one app (the main body of work), test-first

Tests come before implementation. The rules that make an email integration correct — acking so a failed send isn't silently lost (or a redelivery isn't double-sent), gating order-state emails on the target state, re-fetching by id, localization, not logging PII/tokens — are invisible at the call site. Each is one cheap assertion. Write the test first.
Read email-contract.md and providers.md, then build, test-first:
  1. Subscription registration (postDeploy) — idempotent (delete-then-create by a stable key, or get-then-skip); the exact message types; destination from the injected CONNECT_GCP_* vars.
  2. The handler — decode the base64 envelope; validate & branch on Message type; ack per your chosen delivery semantics; re-fetch the Order/Customer by id; map to the ESP's send request (template id + personalization data); call the ESP behind a tight timeout.
Mock the outbound boundary (the ESP, the CT APIs) and assert on what your code decided — which email type, which template, what recipient/data, what it did on failure. The suite must run with zero deployment and zero secrets. What to assert/mock is in email-contract.md.

Step 7 — Verify the round trip

Don't declare done until a real event produces a real email. Trigger each event (register a customer, place an order), confirm the ESP's activity feed shows the send to the right recipient with the right template and data, and check the two traps that look like bugs: the Subscription wasn't registered (no email fires at all) and the ESP is in sandbox/test mode (accepts the call but doesn't deliver). See verification.md.

References

NeedReference
Is a ready-made connector enough?: configure vs fork vs build-from-template; the template-first reality; live-marketplace checkconnector-selection.md
Requirements → config mapping: which messages, ESP + template IDs, sender, least-privilege scopes; the connect.yaml envelope; worked exampleconfig-from-requirements.md
The one-app contract: subscription registration, message→email routing, the at-most-once vs at-least-once decision, token-email gotcha, state filtering, localization, PII; full pitfall catalogemail-contract.md
ESP specifics: SendGrid / Mailgun / AWS SES / Postmark send-call shape, ESP-hosted templates, idempotency keys; provider comparisonproviders.md
Verify the round trip: per-event checks; the no-subscription and sandbox-doesn't-deliver traps; duplicate/silent-drop symptomsverification.md
Generic event-app contract (envelope, ack table, idempotency, re-fetch) — this sub-area builds on itevent-applications.md
Build/publish/certify lifecycle, deploy, scopes, production-readiness gate (type-agnostic)commercetools-connect
Adding another ESP later means adding notes to providers.md — the one-app architecture, the contract, and the flow do not change.

Checklist

Connector fit-check (do this FIRST — do not skip or reorder)

  • Listed the live marketplace connectors (not from memory) and presented them to the user with name · vendor · service · status
  • Flagged whether any is a transactional email connector or only marketing/CRM platforms
  • Confirmed the approach with the user: use as-is (1) · config-closes-gap (2) · modify/fork (3) · create from template (4) — before gathering ESP/build details
  • Recorded platform/ESP · rung · connector + version checked · why

Requirements (after the approach is chosen)

  • ESP/platform chosen + API key + verified sender domain; region + project
  • The exact emails/Messages listed; for order-state emails, the target states that trigger a send named
  • Delivery guarantee decided per email (duplicate-tolerant vs drop-intolerant) — for use-as-is, owned by the platform
  • Templating model (ESP-hosted by ID vs in-connector) and localization source identified
  • Token-email validity (≤ 60 min → value in Message; else mint in connector) understood
  • Asked the open-ended "anything special?" question; each special its own line
  • Requirements block written and confirmed

Config (the deliverable)

  • inheritAs.apiClient.scopes least-privilege for the emails in scope (manage_subscriptions + the read/write the handlers need)
  • ESP key + template IDs in securedConfiguration; region/toggles in standardConfiguration
  • connect.yaml at the repo root; only documented envelope fields

The one app (build test-first)

  • Subscription registered idempotently on only the needed message types; destination from injected CONNECT_GCP_* vars
  • Ack strategy implemented and asserted (no silent drop; no double-send)
  • Order-state emails gated on the target state; handlers re-fetch by id
  • Boundary mocked; suite runs with no deployment/secrets

Verification

  • Each event produces an email to the right recipient with the right template + data
  • Understood: no Subscription → no email; ESP sandbox/test mode may not deliver
integrations/email/providers.md

Email service provider specifics

The official template leaves exactly one thing unimplemented: GenericHandler.sendMail(sender, recipient, templateId, data) — the call to the ESP. This file is the shape of that call per provider. The commercetools side is identical regardless of ESP; only this outbound call changes. Verify each provider's exact API against its own docs (linked) — ESP APIs evolve and are outside commercetools' docs.

The shape (ESP-agnostic)

Every transactional ESP send is the same four things:

  1. Auth — the API key from EMAIL_PROVIDER_API_KEY (secured config), typically a Bearer header.
  2. From/ToSENDER_EMAIL_ADDRESS (a verified sender) → the recipient (order.customerEmail / customer.email).
  3. A template reference — the ESP-hosted template id for this email type (+ locale), from secured config.
  4. Personalization data — the key/value object your handler built (order number, name, line items, totals, token/link) merged into the template by the ESP.
Prefer ESP-hosted templates referenced by id over rendering HTML in the connector: marketers can edit copy without a redeploy, and the connector stays a thin data-mapper. Render in-connector only if the ESP has no template feature or you need full control.
Pass an idempotency key wherever the ESP supports one — it's how Option B (at-least-once + dedupe, email-contract.md) avoids duplicate emails. Use a stable key: resource.id + sequenceNumber, or the message id.

Providers

SendGrid (dynamic templates)

// sendMail body sketch
{
  from: { email: senderEmailAddress },
  personalizations: [{ to: [{ email: recipient }], dynamic_template_data: data }],
  template_id: templateId,
}

Mailgun (stored templates)

AWS SES (templated email)

  • Send: SendTemplatedEmail / SendBulkTemplatedEmail (SDK v3) or the SESv2 SendEmail with a Template.
  • Template: Template name + TemplateData (JSON string); auth via the app's AWS credentials (secured config).
  • Docs: SES send templated email.

Postmark (templated, transactional-first)

Cross-provider summary

DimensionSendGridMailgunAWS SESPostmark
Template reftemplate_id (d-…)template nameTemplate nameTemplateId/TemplateAlias
Data fielddynamic_template_dataMailgun variablesTemplateDataTemplateModel
AuthBearer keybasic api:<key>AWS credsserver token header
PayloadJSONform-encodedSDKJSON
Localizationone template id per locale, or a locale in the datasamesamesame
All four fit the same sendMail(sender, recipient, templateId, data) seam — swapping ESP is a change to this one function, not the connector. Keep the mapping (resource → data) provider-independent and unit-tested; keep only the HTTP/SDK call provider-specific.

Checklist

  • sendMail implemented against the chosen ESP's transactional-send API; key from EMAIL_PROVIDER_API_KEY
  • Sender is a verified domain/sender in the ESP
  • ESP-hosted template referenced by id (per email type, per locale) unless rendering in-connector is justified
  • Personalization data mapping is a pure, unit-tested function; only the HTTP/SDK call is provider-specific
  • Idempotency key passed on the send when using at-least-once delivery (dedupe — email-contract.md)
  • Outbound call has a tight timeout under the 10 s event ack budget
integrations/email/verification.md

Verify the email round trip

Don't declare done until a real commercetools event produces a real email in the ESP. Because sending is fire-and-forget-ish and asynchronous, "no error in the logs" is not evidence it worked — verify at the ESP.

Check 1 — the Subscription exists and points at the connector

No Subscription → no message → no email fires at all, silently. Before anything else:
  • Query Subscriptions and confirm one exists for your key with the expected messages (resourceTypeId + types) and a destination pointing at the deployed app.
  • If it's missing, postDeploy didn't run or failed — check the deployment logs. This is the number-one "nothing happens" cause.

Check 2 — each event produces the right email

For every email in scope, trigger its event and confirm the send in the ESP's activity/logs feed (not just your connector logs):
EmailTriggerConfirm
RegistrationCreate a CustomerESP shows a send to the customer's email with the registration template
Email verificationCreate an email token (≤60 min to get the value in the Message)Send contains a working verification link/token
Password resetCreate a password tokenSend contains a working reset link/token
Order confirmationPlace an order (convert a cart)Send with the order number, line items, totals
ShipmentTransition the order's shipmentState to ShippedSend fires only on the target state, not other transitions
Refund/returnAdd/set return infoSend fires; other order changes don't
Locally (without a real broker) you can POST the base64 OrderCreated/CustomerCreated envelope straight to the app's endpoint and assert the ESP call — see test an event application locally.
Check the details, not just "an email was sent": right recipient, right template, right language, and data (order number, name) actually rendered — not empty placeholders.

The traps (correct-looking behavior that is a bug, or vice-versa)

Trap 1 — ESP sandbox / test mode accepts but doesn't deliver

Most ESPs have a sandbox/test mode (or SES sandbox, which can only send to verified recipients). The API returns 202/200, your connector logs success — but nothing is delivered. An empty inbox after a "successful" send is expected in sandbox. Verify the contract (accepted, right payload) in sandbox; verify delivery on a live key sending to a real inbox (then clean up).

Trap 2 — duplicate emails (at-least-once without dedupe)

Two identical emails for one order means you're on at-least-once delivery (Option B) without dedupe, and the message was redelivered. Add an ESP idempotency key or a sent-marker (email-contract.md). This is a real defect, not the platform misbehaving — redelivery is guaranteed.

Trap 3 — silent drops (ack-first + a failing send)

With ack-first (Option A, the template default) a transient ESP failure is acked and never retried — the email just doesn't arrive, and there's no redelivery to save it. If drop-intolerant emails (reset/verification) go missing intermittently, this is why. Move those to Option B with dedupe, or add your own retry/DLQ.

Trap 4 — an email on every state change

If shoppers get an email on internal transitions, the order-state handler isn't gated on the target state. Re-fetch and check the specific orderState/shipmentState before sending; ack the rest (email-contract.md).
A blank token in the email means the token value wasn't in the Message (validity > 60 min) and you read from the payload instead of minting it. Use ≤60-min validity or mint the token in the handler (manage_customers) — email-contract.md.

Verification checklist

  • Subscription registered with the expected message types and destination (else: no email at all)
  • Each in-scope event produces a send visible in the ESP feed to the right recipient
  • Right template, right language, real data rendered (no empty placeholders)
  • Order-state/shipment emails fire only on the target state
  • Reset/verification links actually work (token present and valid)
  • Delivery confirmed on a live key to a real inbox (sandbox/test mode may not deliver)
  • No duplicates under redelivery; no silent drops on transient ESP failure
  • No PII/token values in logs
integrations/giftcard/config-from-requirements.md

Requirements → gift card connector config

The requirement → config map

Requirement (Step 1)Config / decisionWhy
Which gift card system + credentialssecuredConfiguration: system API secret/token (+ any standardConfiguration application/program id, base URL)Secrets never in standardConfiguration, never hardcoded
Region + projectstandardConfiguration: CTP_PROJECT_KEY, CTP_AUTH_URL, CTP_API_URL, CTP_SESSION_URL, CTP_JWKS_URL, CTP_JWT_ISSUERHosts + token validation are region/project specific
Currency scopestandardConfiguration: a currency key (template: MOCK_CONNECTOR_CURRENCY; Voucherify: VOUCHERIFY_CURRENCY)One deployment is typically scoped to one currency; multi-currency ⇒ multiple deployments or a converting system
Fallback payment method(Merchant Center Payment Integration config, not connect.yaml)The gift card integration is configured alongside a PSP integration — a Checkout Application setting
Balance / redeemBoth are core processor routes (always built)The minimum gift-card contract
Refund / reverse on cancel-returnImplement the Payment Intents modifyPayment operationsPost-order lifecycle goes through the Payment Intents API, not the enabler
Partial + multiple cardsRedeem logic + remainder handling (code, not a single toggle)The card may not cover the total; the remainder goes to the fallback method

The commercetools connection block

The processor validates two kinds of caller — Checkout Sessions (balance/redeem) and Merchant Center JWTs (Payment Intents operations) — so the CT block carries both the session URL and the JWKS/issuer:
standardConfiguration:
  - key: CTP_PROJECT_KEY
    description: commercetools project key
    required: true
  - key: CTP_AUTH_URL
    description: commercetools Auth URL
    required: true
    default: https://auth.europe-west1.gcp.commercetools.com
  - key: CTP_API_URL
    description: commercetools API URL
    required: true
    default: https://api.europe-west1.gcp.commercetools.com
  - key: CTP_SESSION_URL
    description: Session API URL
    required: true
    default: https://session.europe-west1.gcp.commercetools.com
  - key: CTP_CLIENT_ID
    description: commercetools client ID (scopes below)
    required: true
  - key: CTP_JWKS_URL
    description: JWKs URL for JWT validation
    required: true
    default: https://mc-api.europe-west1.gcp.commercetools.com/.well-known/jwks.json
  - key: CTP_JWT_ISSUER
    description: JWT issuer for JWT validation
    required: true
    default: https://mc-api.europe-west1.gcp.commercetools.com
securedConfiguration:
  - key: CTP_CLIENT_SECRET
    description: commercetools client secret
    required: true
Match every host to the project's region. The defaults above are europe-west1.gcp; a project in another region needs the matching auth/api/session/mc-api hosts, or session validation and JWKS lookup fail. This is the most common misconfiguration.

Scopes

manage_payments  manage_orders  view_sessions  view_api_clients  manage_checkout_payment_intents  introspect_oauth_tokens
  • manage_payments — the processor creates and updates the Payment (redeem transactions).
  • manage_orders — associate the Payment with the Order / read order context.
  • view_sessions + introspect_oauth_tokens — validate the Checkout Session on /balance and /redeem.
  • view_api_clients — resolve the calling client during session/JWT validation.
  • manage_checkout_payment_intents — accept POST /payment-intents/:id calls from the Payment Intents API (refund/reverse). Automated reversals additionally require the connector to support the reversePayment action.
Prefer declaring scopes so Connect provisions a least-privilege client over hand-supplying CTP_CLIENT_ID/SECRET. Grant only the scopes the routes above use — nothing broader like manage_project.

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the docs. Use only documented envelope keys (deployAs / applicationType / endpoint / scripts / configuration), and place the file at the repository root — a nested connect.yaml silently fails to deploy. The two apps:
deployAs:
  - name: enabler
    applicationType: assets
  - name: processor
    applicationType: service
    endpoint: /
    configuration:
      standardConfiguration: [ ... CT block + currency + system config ... ]
      securedConfiguration:  [ ... CT client secret + system secret ... ]
The enabler is assets (a static bundle, no endpoint); the processor is service with endpoint: / (routes are mounted at the root — /status, /balance, /redeem, /payment-intents/:id). Keep the router mounted at / to match, or Checkout's calls 404.

Worked example (build from template, in-house gift card ledger)

Requirements: in-house store-credit ledger with a REST API; single currency EUR; balance + redeem + refund; partial + multiple cards; paired with an existing Stripe PSP integration; europe-west1.gcp.

Derived processor config (CT block from above, plus):

standardConfiguration:
  - key: GIFTCARD_CURRENCY
    description: Currency this deployment handles (EUR)
    required: true
  - key: GIFTCARD_API_URL
    description: Base URL of the store-credit ledger API
    required: true
securedConfiguration:
  - key: GIFTCARD_API_KEY
    description: Store-credit ledger API key
    required: true
Rationale to hand the user: one deployment for EUR (add a second deployment for another currency if needed); the ledger secret in securedConfiguration, its base URL in standardConfiguration; scopes exactly the six the routes need. The fallback pairing (Stripe) is configured in the Checkout Application's Payment Integrations, not here — flag that the gift card integration must not be shipped alone (overview.md). For Voucherify's exact keys instead (VOUCHERIFY_APPLICATION_ID, VOUCHERIFY_API_URL, VOUCHERIFY_CURRENCY, VOUCHERIFY_SECRET_KEY), see its connect.yaml.
integrations/giftcard/connector-selection.md

Use it, customize it, or build it?

This answers Step 1.5 of overview.md: given the requirements, do you use an existing connector directly, customize/fork one, or build a new one from the template? The answer is system-specific — it depends entirely on whether that gift card management system has a public connector.

Check live data first — don't answer from memory

Supported systems and connectors change. Before deciding:

  1. Search the Connect marketplace (via the Merchant Center Connect view) and the gift-card docs via the docs-search script or the Knowledge MCP. Filter for Public Connectors of type Gift Cards.
  2. Compare the requirements system-by-capability (balance, redeem, partial redemption, multiple cards, refund/reverse, currency, region).
  3. Name the connector and version you checked, and record it in the requirements block.

The gift card landscape (verify, but this is the shape)

SystemPublic connector?Source available?Default rung
Sample / mock (commercetools)✅ Yes — for test/PoC onlyn/a (simulation)Use for PoC; never production
Voucherify✅ Yes (commercetools/connect-giftcard-integration-voucherify)Open source1 (use) — or 3 (fork) since the source is open
In-house / store-credit / other platform❌ Usually none— (only the generic template)4 (build from template)
The practical consequence: "just use a connector" works for Voucherify; most other systems are a build-from-template job. A request to integrate an in-house or niche gift-card system is a build, not a marketplace install — there is nothing to install. State this plainly to the user early, because it changes the effort estimate.

The ladder (stop at the first rung that fits)

Rung 1 — Use a public connector directly (Voucherify; sample for PoC)

If a Public Connector of type Gift Cards exists and covers the requirements, install and configure it (install an Organization/Public Connector). This is the cheapest, most maintainable path. Hand it the config you derive in config-from-requirements.md. Installation mechanics (CLI auth, scopes, deployment create) are the parent skill's deployment-installation.md; it is not the connectorstaged flow.
The sample gift card connector is a special case of rung 1: install it to validate the checkout wiring (the Payment Integration renders, the enabler loads, balance/redeem round-trips) before a real system exists. It simulates only — codes like Valid-10000-EUR (success), Valid-0010000-EUR (forced failure), Valid-0-EUR (no balance) drive the outcome and no payment is made (docs). Never ship it as the production integration.

Rung 2 — A gap that config can close

A "missing" behavior is often a config value or a Merchant Center Payment Integration setting: the currency, which operations are enabled, the fallback pairing, display/labels. Re-check the apparent gap against the connector's configuration surface before forking. Details in config-from-requirements.md.

Rung 3 — Customize/fork the public connector (Voucherify)

If there's a genuine gap config can't close and the connector is open source (Voucherify's is), fork it, add only the delta, and deploy as an Organization connector. Don't rebuild — you'd throw away a working codebase (its session/JWT handling, balance/redeem flow, Payment lifecycle, and enabler are substantial). Hand off to commercetools-connect for the fork's build/stage/publish lifecycle, then return to this flow once deployed.

Rung 4 — Build a new one from the gift card template (the common case)

No public connector for the system → build from the gift card integration template. The template (TypeScript, Fastify, @commercetools/connect-payment-sdk) ships both apps with the Connect plumbing done — session/JWT authentication, the commercetools client, the route skeleton, the Payment lifecycle wiring — but the calls to your gift card service are stubs you implement (the template ships a mock in their place).

What you actually write on rung 4:

  • The processor balance/redeem logic: code → your system's balance/redeem API, response → the commercetools Payment transaction (see giftcard-contract.md).
  • The processor post-order operations (modifyPayment): refund/reverse against your system, if in scope.
  • The enabler UI for capturing the gift card code (and PIN, if the system needs one).
  • Config + scopes (config-from-requirements.md).
Because rung 4 is the most work, it's where the template's own contract bites (session-vs-JWT auth per route, the "always pair with a fallback" rule, partial-redemption remainder handling, idempotent redeem). Those are catalogued in giftcard-contract.md.
The full build/stage/publish/certify lifecycle for rungs 3–4 is the parent commercetools-connect skill; return to this gift card flow once the connector is deployed.

Recording the decision

In the requirements block, note: system · rung · connector name + version checked · why. Example:
Gift card: Voucherify · rung 1 (use) · checked marketplace 2026-07 — Voucherify public connector present and covers balance/redeem/refund for our single-currency (EUR) store · configuring it, paired with the existing Adyen PSP integration.
Gift card: in-house store-credit ledger · rung 4 (build) · checked marketplace 2026-07 — no public connector for our ledger · building both apps from the gift card template, paired with the existing Stripe integration.
integrations/giftcard/giftcard-contract.md

The two-app gift card contract

The rule that frames everything: never ship alone

A gift card Payment Integration must always be configured alongside at least one other Payment Integration (a PSP). A card frequently can't cover the whole cart; the fallback method covers the remainder. This is enforced in the Checkout Application configuration, not in the connector — but it shapes the connector's behavior: redeem must handle "balance < cart total" by redeeming what it can and leaving a remainder, never by rejecting the payment outright.

App 1 — the processor (service, endpoint /)

The backend middleware to the gift card system. It owns the commercetools Payment: it creates the Payment and records redeem/refund transactions on it. Routes are mounted at the root (endpoint: /).

Routes and their auth (the auth split is the thing to get right)

RouteAuthPurpose
GET /statusJWTHealth / liveness
POST /balanceSession (SessionHeaderAuthenticationHook)Body { code } → check the card's balance against the gift card system; report the amount and whether it covers the cart
POST /redeemSessionBody { code, redeemAmount } → redeem value against the system and record it on the Payment
POST /payment-intents/:idJWT / OAuth2 (manage_checkout_payment_intents)modifyPayment({ paymentId, data }) → post-order operations (refund, reverse/rollback) driven by the Payment Intents API
The split is deliberate and easy to get wrong: balance/redeem are shopper-driven and authenticated with the Checkout Session (the browser has a sessionId, not CT credentials); payment-intents is server/Checkout-driven and authenticated with a JWT/OAuth token carrying manage_checkout_payment_intents. Wiring session auth on the payment-intents route (or vice versa) breaks the corresponding flow. Use the SDK's session/JWT hooks as preHandler per route — don't hand-roll validation.

Balance

  • Take { code } (and PIN/security code if the system requires one), call the gift card system's balance API, and return the balance plus whether it's sufficient for the current cart. Checkout surfaces this to the shopper via the gift_card_balance_success Message (amount, isBalanceSufficient).
  • Balance is a read — it must not redeem or reserve value. A common bug is redeeming on the balance call.
  • Handle zero/invalid/expired codes cleanly → gift_card_balance_error, so the shopper can try another card or method.

Redeem

  • Take { code, redeemAmount }, redeem redeemAmount against the system, and record it on the commercetools Payment as a transaction (the processor owns the Payment). Checkout emits gift_card_redeem_success on success.
  • Partial redemption is the norm. If the balance is less than the cart total, redeem the available amount and leave a remainder — the fallback PSP integration covers it. Redeeming a card must not assume it settles the whole cart.
  • Multiple cards: a cart may redeem several cards in sequence, each reducing the outstanding amount. Each redeem is its own transaction on the Payment.
  • Be idempotent. A retried redeem (network hiccup, double-submit) must not double-charge the card. Key redemption on a stable identifier (the code + amount + payment/cart context, or the system's own idempotency key) so a replay is a no-op, and reconcile against the Payment's existing transactions before adding another.

Post-order operations (/payment-intents/:id)

  • Refund and reverse/rollback happen after the Order exists, through the Payment Intents APImodifyPayment. This returns redeemed value to the card (refund) or unwinds a redemption (reverse).
  • Automated reversals require the connector to declare support for the reversePayment action; implement it only if the requirements include automatic unwinding of authorized-but-not-completed payments.
  • These operations update the Payment's transactions to reflect the new state; keep them idempotent on the intent/operation id.

Keep the mapping pure and testable

The code→system-request and system-response→Payment-transaction mapping is deterministic — keep it a pure function with no network, so balance/redeem/refund logic is unit-testable without a deployment, a session, or a token. Assert: balance is read-only, redeem records the right transaction amount, partial redemption leaves the correct remainder, a duplicate redeem is a no-op, and refund/reverse produce the right transaction.

App 2 — the enabler (assets)

A browser JS library that renders the gift-card input (code, and PIN if needed) and calls the processor's /balance and /redeem with the session. Checkout loads it based on the Payment Integration configuration; it can also be embedded in a custom frontend. It is a thin slice — it holds no CT credentials and no gift-card-system secrets; it only carries the sessionId and talks to the processor. Sensitive operations stay server-side in the processor. Keep the enabler's job to: render, capture the code, call balance, call redeem, and surface the result.

Pitfall catalog

PitfallSymptomFix
Gift card integration shipped aloneShopper stuck when balance < total; "checkout is broken"Configure a fallback PSP Payment Integration alongside it (Checkout Application config)
Redeem rejects when balance < totalPartial payments impossible; valid cards refusedRedeem the available amount, leave a remainder for the fallback method
Session auth on /payment-intents (or JWT on /balance)The corresponding flow 401sSession hook on balance/redeem; JWT/OAuth (manage_checkout_payment_intents) on payment-intents
Balance call redeems/reserves valueBalance shrinks just from checkingBalance is a read; never mutate the card on /balance
Non-idempotent redeemDouble-submit or retry double-charges the cardIdempotency key on redeem; reconcile against existing Payment transactions
Wrong-region CT hosts / JWKS / issuerSession validation or JWKS lookup fails; every call 401sMatch CTP_AUTH/API/SESSION_URL, CTP_JWKS_URL, CTP_JWT_ISSUER to the project region
Currency mismatchRedeem fails or applies the wrong amountOne deployment per currency; validate the cart currency against the deployment's currency
Router not mounted at /Checkout's calls 404Processor endpoint: /; mount routes at the root
Using the sample connector in productionNo real redemption happens; Valid-… codes "work" but nothing settlesSample is PoC-only; build/use a real connector for production
Legacy SDK / no connect-payment-sdk hooksHand-rolled auth drifts from the platform contractUse @commercetools/connect-payment-sdk session/JWT hooks; pin current CT SDK versions (parent skill gate)

Test-first checklist (mirror in the suite)

Processor

  • /balance is read-only, session-authenticated; reports amount + sufficiency; handles zero/invalid/expired codes
  • /redeem session-authenticated; records the redeem transaction on the Payment
  • Partial redemption leaves the correct remainder; multiple cards accumulate transactions
  • Redeem is idempotent — a replayed request is a no-op (asserted)
  • /payment-intents/:id refund/reverse JWT/OAuth-authenticated; produces the right transaction (if in scope)
  • Boundary (gift card system, CT APIs) mocked; suite runs with no deployment/secrets

Enabler

  • Renders code (and PIN) input; carries only the session; holds no secrets
  • Calls /balance then /redeem; surfaces balance/redeem errors to the shopper
integrations/giftcard/overview.md

Gift card connector — integrate a gift card management system

This is the gift card integration sub-area of commercetools-connect: you want customers to pay with gift cards (or store credit / vouchers) at checkout, and you'll do it with a Connect connector that talks to a gift card management system. For the deep, type-agnostic build/publish/certify lifecycle and the production-readiness gate, that's the parent connect skill; this sub-area owns the gift-card-specific shape end to end — from "is there a connector already?" through configuring, forking, or building one.
A gift card Connector manages the communication between the merchant, Checkout, and the gift card management system, exposing gift cards as a payment method in the checkout flow (Gift card Connectors). It supports checking a card's balance in real time, applying its value toward the purchase, partial payments (the card covers part of the total and another payment method covers the rest), and multiple gift cards on one transaction.
  • processor (a service) — the backend middleware to the gift card system. It checks balances, redeems value, and owns the commercetools Payment object (creates it, adds/updates transactions). Its behavior is driven by its connect.yaml config; it authenticates callers with a Checkout Session (balance/redeem) or a JWT/OAuth token (post-order operations via the Payment Intents API).
  • enabler (an assets bundle) — a browser JS library that renders the gift-card input UI and calls the processor. Checkout loads it based on your Payment Integration configuration; it can also be embedded directly in a custom frontend.
The rule to internalize first: never ship a gift card integration alone. A gift card Payment Integration must always be configured alongside at least one other Payment Integration (docs). A gift card often can't cover the full cart total; without a fallback method the shopper is stuck when the balance falls short. This is a configuration requirement, not a nice-to-have.

Gift card connectors are consumed by Checkout

Unlike a raw payment connector (which you can wire into a custom storefront with no Checkout product), the gift card flow is designed around commercetools Checkout: Checkout renders the gift-card Payment Integration, drives balance/redeem through the enabler+processor, emits gift card Messages (gift_card_balance_*, gift_card_redeem_*) you subscribe to via the Browser SDK, and drives post-order operations (refund/reverse) through the Payment Intents API. If your team is wiring the storefront side of that (rendering the integration, reacting to gift card messages), that's the commercetools-checkout skill; this sub-area is the connector behind it.

Workflow

When integrating gift cards, follow these steps in order. The heart is Step 1 → Step 1.5 → Step 2 → Step 3 (requirements → use/customize/build? → config → the two apps).

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with gift-card-focused terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<gift card terms from the user's request, e.g. 'gift card connector checkout balance redeem payment method'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or https://docs.commercetools.com/checkout/connectors-and-applications for deeper follow-up.

Step 1 — Extract requirements (before any config or code)

Gift card behavior is downstream of business facts, and the wrong default silently produces a broken checkout. Extract these first; each maps to a config key in Step 2 or a rung in Step 1.5. Ask the user (don't assume):

  1. Which gift card system, and why? A dedicated gift-card/loyalty platform (e.g. Voucherify), an in-house ledger, or a store-credit service. Do they already have an account + API credentials?
  2. Is there a public connector for it? Voucherify has one; most systems don't. This decides configure-vs-build (Step 1.5) and changes the effort estimate — say it early.
  3. Region and project? e.g. europe-west1.gcp, project my-project — the CT API/Auth/Session hosts and JWKS/issuer config are region-specific.
  4. Which fallback payment method(s)? The gift card integration is configured alongside another Payment Integration (PSP). Which one, and is it already deployed? (Non-negotiable — see the rule above.)
  5. Currency handling? A single connector deployment is typically scoped to one currency; a multi-currency storefront may need multiple deployments or a system that handles conversion. Confirm the currencies in scope.
  6. Partial + multiple cards? Should one cart accept multiple gift cards, and combine a card with a PSP payment for the remainder? (Usually yes — confirm the system supports partial redemption.)
  7. Post-order operations? On cancellation/return, should redeemed value be refunded/reversed back to the card? → drives whether you implement the Payment Intents refundPayment/reversePayment operations, not just balance+redeem.
  8. Anything special or non-standard? (always ask — open-ended) Expiry rules, per-transaction caps, PIN/security-code entry, fraud checks, combining with discount codes, B2B store credit, or a specific gift-card account/program id. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before deriving config. Each special requirement feeds the Step 1.5 fit-check (it may push "configure" → "fork" or "build"). If the user surfaces nothing special, a sane default is: system chosen → one currency → partial + multiple cards on → paired with an existing PSP integration → balance + redeem + refund → and say so explicitly.

Step 1.5 — Use a public connector, customize one, or build a new one? (decide before wiring or building)

This is the core routing decision the user asked for. With the requirements in hand, answer: does a connector that already does this exist for this gift card system? Don't answer from memory — the marketplace changes. Check live data (the Connect marketplace + the gift-card docs, via the docs-search script / Knowledge MCP), and name the connector + version you checked.
Then walk the ladder — stop at the first rung that fits, because each later one is more to build and maintain:
  1. Use a public connector directly → if a Public Connector of type Gift Cards covers the system (e.g. Voucherify) or you just need a proof of concept (the sample gift card connector — see below), install + configure it (Step 2). Don't build.
  2. Public connector, gap looks like a capability → prove it isn't config first. Many "missing" behaviors (currency, which operations are enabled, fallback pairing) are connect.yaml values or Merchant Center Payment Integration settings → back to rung 1.
  3. Customize/fork a connector's code → genuine gap config can't close and an open-source connector exists for the system → fork it, add only the delta, deploy as an Organization connector. Don't rebuild a working one.
  4. Build a new one from the template → no connector for the system → build from the gift card integration template. The template ships both apps with the Connect + session/JWT plumbing done; you implement the calls to your gift card service and the mapping. This is the common case.
The sample gift card connector (installable from the marketplace) is for test/PoC only — it simulates payments with codes like Valid-10000-EUR and makes no real payment (docs). Use it to validate the checkout wiring before a real system exists; it is not a production integration.
Record the decision, the rung, and the version in the requirements block. Details and the landscape table: connector-selection.md. Rungs 3–4 switch to the parent commercetools-connect skill for the build/stage/publish lifecycle, then return here.

Step 2 — Derive the config from the requirements

Translate the Step 1 answers into concrete connect.yaml values for the chosen connector (or your own), with a one-line why for each. The mapping, the CT envelope keys, least-privilege scopes, and a worked example are in config-from-requirements.md. Key decisions that live here:
  • The commercetools connection block (CTP_PROJECT_KEY, CTP_AUTH_URL, CTP_API_URL, CTP_SESSION_URL, CTP_JWKS_URL, CTP_JWT_ISSUER) — region-specific; the session/JWKS/issuer values are what let the processor validate Checkout sessions and Merchant Center JWTs.
  • Currency config (one deployment ≈ one currency for the template/Voucherify) and the gift-card-system credentials.
  • Secured vs standard config — the gift card system API secret and the CT client secret are securedConfiguration; URLs, currency, and behavioral toggles are standardConfiguration.
  • The API-client scopes the connector needs (manage_payments, manage_orders, view_sessions, view_api_clients, manage_checkout_payment_intents, introspect_oauth_tokens).

Step 3 — Build/verify the two apps (the main body of work), test-first

Tests come before implementation. The rules that make a gift card integration correct — balance and redeem being session-authenticated, redeem creating/updating the commercetools Payment idempotently, partial redemption leaving a remainder for the fallback method, refund/reverse going through the Payment Intents route — are invisible at the call site and tedious to reproduce by hand. Each is one cheap assertion. Write the test first.
Read giftcard-contract.md and build, in order — test first for each:
  1. Processor — balance + redeem (session-authenticated): POST /balance ({ code }) checks the gift card system and reports the balance and whether it covers the cart; POST /redeem ({ code, redeemAmount }) redeems value against the system and records it on the commercetools Payment. Handle insufficient balance (partial redemption, remainder to the fallback method) and zero balance.
  2. Processor — post-order operations (POST /payment-intents/:id, JWT/OAuth, manage_checkout_payment_intents): implement modifyPayment for the operations in scope (refund, reverse/rollback). Driven by the Payment Intents API, not by the enabler.
  3. Enabler — the frontend touchpoint that renders the gift-card input and calls the processor with the session. Thin slice; contract is in giftcard-contract.md.
Mock the outbound boundary (the gift card system, the CT APIs) and assert on what your code decided — which endpoint, what body, what it did with the response. The suite must run with zero deployment and zero secrets. What to assert/mock per app is in giftcard-contract.md.

Step 4 — Verify the round trip

Don't declare done until a real gift card leaves a trace: a balance check returns the correct amount, a redeem creates a commercetools Payment with a transaction, the remainder (if any) is covered by the fallback method, and — if in scope — a refund/reverse through the Payment Intents API returns value to the card. See verification.md, which also covers the traps that look like bugs: the sample connector only simulates (nothing is really redeemed), and a gift card integration shown alone with no fallback looks broken when the balance is short.

References

NeedReference
Use / customize / build?: the ladder (public connector · fork · build-from-template), the sample connector, live-marketplace check, landscape tableconnector-selection.md
Requirements → config mapping: the CT connection block, currency, gift-card-system credentials, least-privilege scopes; the connect.yaml envelope; worked exampleconfig-from-requirements.md
The two-app contract: enabler (session-driven UI) + processor (balance/redeem session-auth, payment-intents modifyPayment for refund/reverse); partial/multiple cards; idempotency; full pitfall cataloggiftcard-contract.md
Verify the round trip: balance → redeem → Payment transaction → fallback remainder → refund/reverse; the sample-only-simulates and no-fallback trapsverification.md
Build/publish/certify lifecycle, deploy, scopes, production-readiness gate (type-agnostic)commercetools-connect
Storefront side: rendering the gift-card Payment Integration, reacting to gift card Messagescommercetools-checkout

Adding another gift card system later means adding a sibling provider note and extending the selection table — the two-app architecture, the contract, and the flow do not change.

Checklist

Requirements

  • Gift card system chosen + account/credentials; region + project
  • Fallback Payment Integration identified (gift card is never shipped alone); currency scope confirmed
  • Partial + multiple cards decided; post-order refund/reverse decided
  • Asked the open-ended "anything special?" question; each special requirement its own line
  • Requirements block written and confirmed; specials fed into the Step 1.5 fit-check

Use / customize / build (decide before wiring/building)

  • Checked live marketplace + gift-card docs (not memory); named the connector + version
  • Ladder rung chosen: use public (1) · config-closes-gap (2) · fork/customize (3) · build from template (4)
  • For a real gap on a system with a public connector, chose fork over rebuild
  • Used the sample connector only for PoC, not production

Config (the deliverable)

  • Only documented connect.yaml envelope fields; file at the repo root
  • CT connection block + JWKS/issuer set for the region; currency configured
  • Scopes = manage_payments, manage_orders, view_sessions, view_api_clients, manage_checkout_payment_intents, introspect_oauth_tokens
  • Gift-card-system secret + CT client secret in securedConfiguration; URLs/currency in standardConfiguration

The two apps (build test-first — do not write a function body before its red test)

  • /balance and /redeem session-authenticated; redeem creates/updates the Payment idempotently
  • Partial redemption leaves a remainder for the fallback method; zero balance handled
  • /payment-intents/:id refund/reverse implemented (if in scope), JWT/OAuth-authenticated
  • Boundary mocked; suite runs with no deployment/secrets

Verification

  • Balance check returns the correct amount; redeem creates a Payment transaction
  • Remainder covered by the fallback method; refund/reverse returns value (if in scope)
  • Understood: the sample connector only simulates; a gift card shown with no fallback is a config error, not a bug
integrations/giftcard/verification.md

Verify the gift card round trip

Don't declare done until a gift card has left a trace where it should: the balance reads correctly, a redeem records a transaction on a commercetools Payment, any remainder is covered by the fallback method, and — if in scope — a refund/reverse returns value. Two checks below regularly look broken when they're actually correct — read the traps.

Check 1 — balance reads correctly (and doesn't redeem)

Drive a balance check for a known card (in Checkout, via the gift-card Payment Integration; or POST { code } to the processor's /balance with a valid session):
  • The response reports the correct balance and whether it covers the cart. In Checkout this surfaces as the gift_card_balance_success Message with amount and isBalanceSufficient.
  • The balance did not change from checking it. A balance call that redeems or reserves value is a bug — check again and confirm the amount is unchanged.

Check 2 — redeem records a Payment transaction, remainder to fallback

Redeem the card (Checkout drives this, or POST { code, redeemAmount } to /redeem with a session), then inspect the cart's Payment:
  • A commercetools Payment exists with a transaction for the redeemed amount (gift_card_redeem_success in Checkout).
  • If the balance was less than the cart total, the outstanding amount is left for the fallback Payment Integration (the PSP), and completing the order requires paying that remainder. A short balance should never block checkout — it should route the rest to the fallback method.
  • Multiple cards, if used, each add their own transaction.

Check 3 — refund / reverse (if in scope)

For a post-order operation, drive it through the Payment Intents API (not the enabler):
  • A refund returns value to the card and records a refund transaction on the Payment.
  • A reverse/rollback unwinds a redemption; automated reversals require the connector to support reversePayment.

The two traps (correct behavior that looks like a bug)

Trap 1 — the sample connector only simulates

The sample gift card connector makes no real payment. Codes drive the outcome: Valid-10000-EUR simulates success, Valid-0010000-EUR forces a failure, Valid-0-EUR simulates a zero-balance card (docs). Amounts are in the currency's minor units (e.g. 500 = 5 CHF). So "it works with the sample but nothing settles in our gift card system" is expected — the sample never calls a real system. Use it to prove the checkout wiring, then verify real redemption against the actual connector.

Trap 2 — a gift card shown with no fallback looks broken

A gift card Payment Integration configured alone (no PSP alongside it) strands the shopper the moment the balance is short: there's no way to pay the remainder. This looks like a connector failure but is a configuration error — the gift card integration must be configured alongside another Payment Integration (docs). Before debugging the connector, confirm a fallback method is present in the Checkout Application.

Verification checklist

  • Balance check returns the correct amount and does not change the balance
  • Redeem records a transaction on a commercetools Payment
  • Short balance leaves a remainder covered by the fallback PSP integration (checkout not blocked)
  • Multiple cards each record a transaction (if in scope)
  • Refund/reverse via the Payment Intents API returns/unwinds value (if in scope)
  • Understood: the sample connector only simulates — verify real redemption against the real connector
  • Understood: a gift card with no fallback method is a config error, not a connector bug
integrations/marketplace/config-from-requirements.md

Requirements → seller model → marketplace connector config

Two deliverables, in this order: the seller/offer data model (where marketplace integrations actually succeed or rot), then the connect.yaml. For a public connector these are its documented keys; for a fork or a build these are the keys and apps you define.

Model the marketplace domain onto commercetools

commercetools has no "seller" resource. Sellers are modeled with Channels, Stores, and Custom Objects — pick per requirement, not all of them by default.

Marketplace conceptModel asWhy / the trap
Seller / vendora Channel keyed seller-<marketplaceSellerId>, with role InventorySupply (+ ProductDistribution if the seller prices independently)Channels are the scoping primitive for stock and price. The key is your idempotency key. A Channel can't be deleted while referenced by an InventoryEntry, Line Item, Store, or Price — so offboarding means removing it from Stores and stopping sync, not deleting it
Seller profile data (business name, rating, logo, opening hours, address)a CustomObject (container per entity type, key = marketplace seller id), and/or Custom Fields on the seller ChannelPOST /custom-objects is create-or-update on container+key, so it is idempotent for free — the right home for arbitrary seller payloads. Put anything the storefront filters or scopes on the Channel instead
Seller storefront / isolated assortment / seller-scoped MC accessa Store per seller (+ Product Selections)Only when isolation is a requirement — it also gives per-seller Merchant Center team permissions. Limits allow it at scale (300,000 Stores per Project), but a Store is capped at 100 Product Selections, so don't model one Product Selection per seller inside a shared Store
Offer / listing (a seller's sellable item)a Product/Variant keyed on the marketplace's stable listing id — or, when several sellers sell the same SKU, one Product with per-seller Prices and InventoryEntriesDuplicating the Product per seller is the classic marketplace modeling mistake: it splinters search, ratings, and reporting. One Product + N seller offers is the default
Offer stockan InventoryEntry per sku + supplyChannel (the seller's Channel)Stock is tracked per SKU and optionally per supply channel — that pair is the per-seller stock record. A Cart bound to a Store only sees stock from that Store's supply channels
Offer pricea Price / StandalonePrice with channel = the seller's distribution ChannelA price with no channel is visible in every Store — the leak that shows one seller's price on another seller's storefront. Always set the channel on seller prices
Marketplace order coming inOrder Import with orderNumber = the marketplace order id, store set, and per-line supplyChannel (+ line custom fields for the marketplace line id)orderNumber is your dedupe key — Order has no top-level externalId. Store-referenced import also filters languages, prices, and inventory to that Store's channels
Order handed off to a seller / marketplacethe Order's syncInfo via updateSyncInfochannel (a Channel with role OrderExport, or OrderImport for inbound), externalId = the marketplace id, syncedAtThis is the platform-native "already exported" marker. Use it instead of inventing a custom field, and read it back to skip re-exporting
Per-seller fulfilment progressLine Item state (ItemStates) per line, plus Deliveries/Parcels per shipmentOne multi-seller Order has many independent fulfilment tracks; a single order-level state can't express "seller A shipped, seller B cancelled"
Commission, payout, settlementnot in commercetools — the marketplace/PSP owns themcommercetools tracks Payment status only; it has no payout ledger. Sync commission values onto the Order/line as Custom Fields if reporting needs them, but don't build payouts here

Two limits that kill naive designs

  • 50 Subscriptions and 25 Extensions per Project. Never one per seller. Register one Subscription per message type and fan out to sellers inside your handler.
  • Store-scoped connectors don't scale per-seller either. The product-export template deploys one Deployment per Store; with many sellers, one Deployment per seller is an operational trap — build a single app that resolves the seller from the resource instead.

Role + direction → app composition

Build only what the role needs (overview.md). Keep each direction its own app; never one app with a mode switch.
Operator (marketplace → commercetools, plus order routing out):
  • service inbound webhook — the marketplace pushes seller/offer/inventory/price changes; you authenticate the caller and upsert. 5-min service timeout applies (not the extension limit).
  • job poll — when the marketplace can't push, or for large periodic feeds.
  • event app on OrderCreated — group the Order's lines by seller (their supply channel) and push each group to the marketplace; record syncInfo.
  • event app on order/state changes — fulfilment, cancellation, and return status both ways.
  • job reconciliation — full sweep for drift (missed offers, stock divergence, orders the event path dropped), checkpointed.
Seller role (commercetools → marketplace, orders in):
  • event app on Product/Product Selection/Store/price/inventory messages — export listing, price, and stock deltas (the product-export template is the closest starting shape).
  • job — full/batch feed export when the marketplace wants scheduled files instead of deltas.
  • service webhook or job — import marketplace orders (Order Import, keyed on orderNumber).
  • event app — push shipment/tracking/cancellation back to the marketplace.

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the docs. Use only documented envelope keys (deployAs / applicationType / endpoint / scripts / configuration; inheritAs), and keep the file at the repository root — a nested connect.yaml silently fails to deploy.

Native client provisioning (prefer this)

Declare scopes and let Connect mint a least-privilege API client instead of hand-supplying CTP_CLIENT_ID/CTP_CLIENT_SECRET as secured config (a pattern you will see in existing marketplace connectors and should not copy — check which form a fork candidate uses, per connector-selection.md):
inheritAs:
  apiClient:
    scopes:
      # operator, inbound seller + offer sync
      - manage_products               # Products/Variants — and Channels + Inventory Entries
      - manage_standalone_prices      # only if seller offers are Standalone Prices
      - manage_key_value_documents    # only if seller profiles are Custom Objects
      - manage_orders                 # Order Import (seller role) / updateSyncInfo (operator)
      - manage_types                  # only if postDeploy creates Custom Types
      # plus, per app, the narrowest of:
      # - view_products / view_orders  (read-only apps)
      # - manage_stores, manage_product_selections  (only with Store-per-seller)
      # - manage_subscriptions         (apps whose postDeploy registers Subscriptions)
  configuration:
    standardConfiguration:
      - key: MARKETPLACE_BASE_URL
        description: Marketplace API base URL (sandbox vs production)
      - key: SELLER_CHANNEL_KEY_PREFIX
        description: Prefix for seller Channel keys, e.g. "seller-"
    securedConfiguration:
      - key: MARKETPLACE_API_TOKEN
        description: Marketplace API token / OAuth client secret
      - key: MARKETPLACE_WEBHOOK_SECRET
        description: Shared secret or signing key used to authenticate inbound webhooks
Scope notes. manage_products covers Channels and Inventory Entries too — there is no separate channel or inventory scope, so seller Channels and per-seller stock need no extra grant. Standalone Prices, Stores, Product Selections, and Custom Objects each need their own scope (manage_standalone_prices, manage_stores, manage_product_selections, manage_key_value_documents) — a frequent cause of a working-locally-but-403-in-Connect connector. Check the current list in API scopes rather than guessing, and grant per app, not per connector. view_subscriptions is not a valid standalone scope; manage_subscriptions covers read + write. Give manage_orders only to the app that writes orders.

Per-app config

deployAs:
  - name: seller-offer-sync            # operator: marketplace pushes sellers + offers
    applicationType: service
    endpoint: /sellerOfferSync         # the Express router must mount at this same base path
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
  - name: order-router                 # operator: route each seller's lines out
    applicationType: event
    endpoint: /orderRouter
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the OrderCreated Subscription
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub or SNS"
  - name: marketplace-reconcile        # drift sweep
    applicationType: job
    endpoint: /marketplaceReconcile
    properties:
      schedule: "0 3 * * *"

Worked example (operator, Marketplacer-style service, build/fork)

Requirements: an operator marketplace; the service pushes seller and listing changes by webhook; ~200 sellers, no per-seller storefront isolation; multiple sellers may sell the same SKU; commercetools captures the order and each seller's lines are pushed back to the marketplace; commissions and payouts stay in the marketplace; near-real-time; europe-west1.gcp.
Model: one Channel per seller (seller-<id>, roles InventorySupply + ProductDistribution); seller profile payload in a CustomObject (container: seller, key: <marketplaceSellerId>); no Stores (no isolation requirement); one Product per listing id, with a StandalonePrice per seller (channel = seller channel) and an InventoryEntry per sku + seller supply channel; a Channel with role OrderExport per seller for syncInfo; per-line custom field holding the marketplace line id.
inheritAs:
  apiClient:
    scopes:
      [
        manage_products, # Products/Variants + seller Channels + Inventory Entries
        manage_standalone_prices, # per-seller offer prices
        manage_key_value_documents, # seller profile Custom Objects
        manage_orders, # updateSyncInfo on routed Orders
        manage_subscriptions, # postDeploy registers the OrderCreated Subscription
        manage_types, # postDeploy creates the line-item Custom Type
      ]
  configuration:
    standardConfiguration:
      - key: MARKETPLACE_BASE_URL
        description: "Marketplace API base (sandbox vs prod)"
    securedConfiguration:
      - key: MARKETPLACE_API_TOKEN
        description: Marketplace API token
      - key: MARKETPLACE_WEBHOOK_SECRET
        description: Signing secret for inbound webhooks
deployAs:
  - name: seller-offer-sync
    applicationType: service
    endpoint: /sellerOfferSync
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
  - name: order-router
    applicationType: event
    endpoint: /orderRouter
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
  - name: marketplace-reconcile
    applicationType: job
    endpoint: /marketplaceReconcile
Rationale to hand the user: one service webhook app upserting sellers (Channel + CustomObject) and offers (Product by listing id, price by seller channel, inventory by sku+channel), authenticating every call with MARKETPLACE_WEBHOOK_SECRET; one event app whose postDeploy registers a single OrderCreated Subscription and which groups lines by supply channel, pushes one payload per seller, and records updateSyncInfo per seller channel so a redelivery doesn't double-push; one job reconciling offers and stock nightly with a checkpoint. Scopes are exactly what those three do; the marketplace token and webhook secret are securedConfiguration. Correctness rules per app: marketplace-contract.md.
integrations/marketplace/connector-selection.md

Which path: use as-is, customise, or build?

This answers Step 1.5 of overview.md. It is a question you put to the user with live evidence attached — not a decision you make silently. Getting it wrong is expensive both ways: building from scratch when a connector already covers the service wastes weeks; assuming a listing is installable when it is a partner SaaS integration wastes the whole design.

Check live data first — don't answer from memory

The marketplace changes. Before recommending anything:

  1. Browse the live Marketplaces category and the connector list; run the parent skill's docs-search script / the Knowledge MCP for the service name.
  2. For each candidate, capture: name, vendor, is it a Connect connector, direction, and what it syncs (sellers / offers / inventory / prices / orders / shipments).
  3. Name the connector and version you checked — or record that none exists — in the requirements block. Don't quote a listing's badge wording as a capability; badges describe the listing relationship, not what the code does.

The marketplace landscape (verify live — this is only the shape)

Marketplace platforms that appear in the category include Marketplacer, Mirakl (including partner-built Mirakl connectors), Convictional, and generic integration middleware such as Patchworks. Treat that as a starting point to verify, not a current list, and not a claim that each is deployable through Connect.

Two structural facts shape almost every marketplace engagement:

  • Most marketplace listings are partner integrations, not Connect connectors. A vendor-operated integration, an iPaaS pipeline, or a cloud-function accelerator can be an excellent functional match and still have nothing Connect can deploy.
  • There is no marketplace Connect template. The templates are payment-integration, product-export, tax-integration, and transactional-emails. A build therefore starts from plain apps — though for the seller role (pushing your catalog out to a marketplace) the product-export template is a genuinely close starting shape: it already does Store-scoped full export plus an incremental updater driven by Product/Product Selection/Store messages.
So the realistic outcome for marketplace work is usually path 2 (customise/fork) or path 3 (build). Say so early — it changes the effort estimate.

Verify it's an actual Connect connector — then ask the user

This is the parent skill's general rule (SKILL.md → Marketplace listings are not all Connect connectors) — read it there and apply it here; it bites harder in this sub-area than anywhere else, because the Marketplaces category is mostly partner-operated platforms and iPaaS middleware.

Marketplace-specific checks before treating any listing as path 1 or 2:

  • Look for a connector repo with a root connect.yaml and deployAs apps. No connect.yaml, no Connect deployment.
  • A "connector" that deploys as the vendor's own cloud function, iPaaS flow, or hosted service is not a Connect application, whatever the listing or repo name says — a common shape for marketplace accelerators specifically.
  • The marketplace platform's own commercetools integration may be operated by the platform, with nothing for you to deploy at all; that's a vendor onboarding task, not a connector build.

Present the three paths and let the user choose

Show the live findings, then ask. Don't skip straight to building.

Path 1 — Use a public connector directly (configure, no code)

Valid when a Connect-deployable connector exists for the service and covers the requirements. Install and configure it: parent skill deployment-installation.md (a public connector is deployment create against the published connector — not the connectorstaged flow). Hand it the config you derive in config-from-requirements.md.
Before concluding a gap needs code, prove it isn't config: which entities sync, field/attribute mapping, which channel or store the offers land in, and feed cadence are configuration on most connectors.

Path 2 — Customise it (fork an open-source connector)

The common marketplace case: a connector exists for the service but is one-directional, reference-implementation grade, or maps a different data model than the user's. If it is open source, fork it, add only the delta, and deploy as an Organization connector — you keep its payload handling and mapping skeleton, which is the fork's real value.
A partner-private connector can't be forked: a genuine gap there means working with the vendor or going to path 3. The fork's build/stage/publish lifecycle is the parent commercetools-connect skill.

Assess the candidate before you fork — from the repo, not from memory

Read the actual repository at its current state. Marketplace connectors range from production-grade to demo scaffolding, and any specific finding ages out with the next upstream commit, so derive the gap list live rather than trusting a remembered one:
  1. connect.yaml at the repo root — the deployAs apps and their applicationType tell you which directions it covers (inbound service, outbound event, batch job) and therefore which of the user's requirements it can't meet at all. Also read whether it uses inheritAs.apiClient.scopes or hand-supplies CTP_CLIENT_ID/CTP_CLIENT_SECRET, and what its config keys are.
  2. The handler entry points — how it identifies resources (upsert by key vs blind create), whether it authenticates inbound callers, and whether it re-fetches by id.
  3. The mapping code — what it maps sellers and offers onto, which is what you'll be rewriting per config-from-requirements.md.
  4. Language and framework — Java/Spring and TS/Node connectors both exist. Don't port; the parent skill's contracts are language-agnostic, and a rewrite discards the mapping you forked for.
  5. README and repo framing — many marketplace connectors are published as accelerators or reference implementations, documented in the same spirit as the Connect templates: starting points that require customization before production use. Tell the user which grade they're forking.
Then score it against two lists you already have — this is the fork backlog, and it holds for any vendor:
  • the parent skill's production-readiness checklist — inbound authentication, native client provisioning, secrets in securedConfiguration, no stack traces in responses, idempotent lifecycle scripts, structured logs, health endpoint, tests that assert behavior, README;
  • this sub-area's contract and pitfall catalog — upsert by marketplace id, seller Channel actually created, offers linked to a seller, one Product for a shared SKU, channel on every seller price, supply channel on every InventoryEntry, integer minor-unit money conversion, no hardcoded currency/locale/region, delisting handled, syncInfo before order export, and the directions the original omits.
Report the findings to the user as a backlog with effort, and work it test-first, one item at a time.

Known landmarks (verify live — these change)

Pointers so you know a fork is even possible, not a substitute for reading the repo:

Path 3 — Build a new connector for the marketplace service they define

No listing fits, the service is bespoke, or the user explicitly wants their own. Scaffold with the Connect CLI (commercetools connect init, then connect application add --type service|event|job) — or start from product-export for the seller-role outbound direction and adapt. What you write is the marketplace API client, the mapping, and the keying; Connect scaffolds the plumbing.
The apps to build follow from role and direction (overview.md), and their contracts are in marketplace-contract.md:
  • Operator: inbound seller sync + inbound offer/inventory/price sync (service webhook and/or job poll), outbound order routing (event on OrderCreated), fulfilment status sync, reconciliation job.
  • Seller role: outbound catalog/price/stock export (event, or job for batch feeds), inbound marketplace order import (service webhook or job), outbound shipment/tracking.

The ladder (stop at the first rung that fits)

  1. Is the listing a deployable Connect connector at all? If not → outside this skill: surface it with the not-a-Connect-solution warning, then offer forking an open-source alternative or building.
  2. Connect-deployable connector covers the requirements → install + configure (path 1).
  3. Right service, gap looks like a capability → prove it isn't config/mapping first → back to rung 1.
  4. Right service, genuine gap config can't close, and it's open sourcefork (path 2). Don't rebuild a working sync engine.
  5. No usable connector for the servicebuild (path 3). No marketplace template; product-export is the closest shape for outbound.

Only rungs 3–4 leave this sub-area (hand off to the parent skill for build/publish); the flow resumes here once the connector is deployed.

Recording the decision

Note in the requirements block: service · role · path/rung · connector name + version checked (or "none exists") · Connect-deployable? · why. Example:
Marketplacer · operator role · path 2 (fork) · checked the Marketplaces category and the open-source Marketplacer connector repo — Connect-deployable (root connect.yaml, two service apps) but inbound catalog/seller only, accelerator-grade · forking to add order routing, native client provisioning, webhook auth, and idempotent seller upserts (backlog scored against the production gate + contract).

Checklist

  • Checked the live Marketplaces category + connector list (not memory); cited each candidate + version
  • Verified Connect-deployability per candidate (root connect.yaml / CLI registry — not the listing page)
  • A good-match listing that isn't a Connect connector was surfaced with the not-a-Connect-solution warning, not treated as path 1
  • Presented all three paths to the user (use as-is · customise/fork · build for their service) and let them choose
  • Apparent gaps re-checked as config/mapping before proposing code
  • Fork chosen over from-scratch whenever an open-source connector for the service exists
  • Told the user there is no marketplace template (and that product-export is the closest shape for outbound)
  • Decision + rung + version recorded in the requirements block
integrations/marketplace/marketplace-contract.md

The marketplace sync contract

Everything each app must do, and the pitfalls that silently break it. Which apps you build follows from role and direction (overview.md); how sellers and offers are modeled is config-from-requirements.md. These rules sit on top of the parent skill's contracts — service-applications.md, event-applications.md, job-applications.md, security.md — and add what is marketplace-specific.

The rule that spans every app: upsert by the marketplace's id, never blind-create

Every write, in either direction, is an upsert keyed on a stable marketplace identifier:
EntityKeyUpsert mechanics
SellerChannel key = seller-<marketplaceSellerId>get-by-key → create if 404, else update
Seller profile blobCustomObject container + keyPOST /custom-objects is create-or-update — idempotent for free
Offer / listingProduct key = marketplace listing id (Variant key/sku per variant)get-by-key → create or update actions
Offer priceStandalonePrice key = <sku>-<sellerId>-<currency>, or the embedded Price with the seller's channelupdate the seller's price only — never rewrite prices of other sellers
Offer stockInventoryEntry key = <sku>-<sellerId>, or query by sku + supplyChannelone entry per seller per SKU
Inbound marketplace orderOrder orderNumber = marketplace order idquery by orderNumber first; import only if absent
Outbound order hand-offthe Order's syncInfo entry for that seller's Channelread syncInfo before pushing; skip if already recorded
Webhooks and Subscription messages are at-least-once: every payload can arrive twice. A create-on-every-payload design produces duplicate Products, duplicate sellers, and duplicate orders — the most common marketplace-integration failure.

App 1 — seller sync (inbound, service webhook or job poll)

  • Authenticate the caller. The marketplace calls you, so validate its proof — signature, shared secret, or JWT — before any write (security.md). An unauthenticated seller endpoint lets anyone create Channels and Products in the Project. (AuthorizationHeaderAuthentication is the reverse mechanism, for commercetools calling your endpoint as an Extension destination; it does not authenticate inbound marketplace traffic.)
  • Upsert the seller Channel by key, with the roles the model requires (InventorySupply, and ProductDistribution when the seller prices independently). Store the profile payload in a CustomObject and/or Channel Custom Fields.
  • Only create a Store per seller if isolation was a requirement — and remember a Store is capped at 100 Product Selections.
  • Offboard by deactivating, not deleting. Remove the Channel from Stores, deactivate the seller's Product Selection / delist their offers, and stop syncing. A Channel referenced by an InventoryEntry, Line Item, Store, or Price cannot be deleted until those references are gone — the documented order is Carts → Orders → Stores → Channel. Deleting a departed seller's historical Orders to satisfy that is technically the documented path, but it destroys order history; deactivation is the right offboarding move.
  • Idempotent — the same seller webhook twice must be a no-op.

App 2 — offer / inventory / price sync

Inbound (operator): marketplace → commercetools

  • Upsert the Product by listing key; when several sellers sell the same SKU, resolve to the one shared Product and add the seller's price and stock, not a second Product.
  • Every price carries the seller's distribution channel. A channel-less price is visible in every Store — the cross-seller price leak.
  • Every InventoryEntry carries the seller's supply channel (sku + supplyChannel). Writing stock without a channel makes it global stock for all sellers.
  • Map deliberately. Localized names/descriptions, currency, and money precision are where feeds go wrong: build the LocalizedString from the marketplace's locale rather than hardcoding one, derive the currency per seller/country rather than hardcoding it, and convert to centAmount in integer minor units — multiply then round, never cast a float first (a (long) price * 100 style conversion silently drops the cents). High-precision cases: money types.
  • Keep the mapping a pure function — no network calls — so it is unit-testable without a deployment or token.
  • Don't publish blind. Decide whether an imported offer is published immediately or staged for review, and make it explicit in config.
  • Bulk belongs in the Import API. Initial and periodic full loads go through the Import API (asynchronous, dependency-resolving, keyed → idempotent); the webhook path handles deltas. Keep them separate apps.

Outbound (seller role): commercetools → marketplace

  • Driven by Subscription messages on products, prices, inventory, Product Selections, and Stores — the product-export template is the closest existing shape (full export endpoint + incremental updater).
  • Re-fetch the resource by id from resource.id; don't map from a possibly-stale or truncated payload. With no ordering guarantee, re-fetching makes the export converge on current state instead of replaying old deltas.
  • Scope what you export — which Store / Product Selection / channel defines "listed on this marketplace". Exporting the whole catalog to a marketplace that only sells a subset is a compliance and delisting problem.
  • Delist explicitly. Unpublish, removal from a Product Selection, and stock hitting zero each need a defined outbound action; otherwise you keep selling items you no longer carry.
  • Respect the marketplace's feed contract — batch sizes, schedules, and rate limits. Retry 429/5xx with exponential backoff.

App 3 — orders

Inbound (seller role): import a marketplace order

  • Dedupe on orderNumber = the marketplace order id: query first, import only if absent. Order has no top-level externalId, so orderNumber (or a Custom Field) is the link.
  • Use Order Import — it creates an Order without a Cart. Set store, per-line supplyChannel/distributionChannel, and per-line custom fields for the marketplace line id. Note totalPrice must be set explicitly (it is not derived from the line items) and negative prices/quantities are not rejected — validate the payload yourself.
  • Record the inbound sync with updateSyncInfo against a Channel with role OrderImport.
  • Decide the inventory mode deliberately: stock was already committed on the marketplace side, so double-decrementing local stock is a real risk.

Outbound (operator): route lines to sellers

  • Triggered by an OrderCreated MessageSubscription (registered idempotently in postDeploy — get-then-create, never delete-then-recreate).
  • Decode the envelope before use: the GCP transport wrapper is { "message": { "data": "<base64>" } }, and the message body is PlatformFormat or CloudEventsFormat depending on config. Validate the type, then ack-and-ignore anything you don't handle (including the platform's test messages).
  • Re-fetch the Order by id, then group Line Items by seller (their supplyChannel, or the seller reference you set at add-to-cart). Push one payload per seller — a multi-seller order is N seller orders downstream.
  • Record updateSyncInfo per seller Channel (role OrderExport) with the marketplace's id and syncedAt, and read syncInfo first so a redelivered message doesn't double-submit. That's your idempotency mechanism; combine it with the marketplace's own idempotency key if it has one.
  • Ack correctly2xx (the event contract treats 102/200/201/202/204 as "don't redeliver") for handled and deliberately-ignored messages; non-2xx only for transient failures you want redelivered.
  • Partial failure is normal. If seller A's push succeeds and seller B's fails, don't re-push A on retry — per-seller syncInfo makes the retry converge instead of duplicating.

App 4 — fulfilment, cancellation, and return status

  • One Order, many sellers → track per line, not per order. Use Line Item state (ItemStates) transitions for per-seller progress, and Deliveries/Parcels for split shipments; an order-level shipmentState alone can't express "seller A shipped, seller B cancelled".
  • Tracking numbers, carrier, and shipment events flow back per seller shipment; cancellations and returns must map to the marketplace's own state machine, not just a local status field.
  • Self-change filtering wherever a domain syncs both ways: a status you write inbound raises a message your outbound app would push straight back. Mark connector-originated writes (a syncSource Custom Field, or compare against syncInfo) and skip them. One-way per domain avoids this entirely.

App 5 — reconciliation job

Events drop, feeds throttle, and webhooks get lost — a marketplace integration without a sweep drifts silently. A scheduled job that pages the marketplace (and commercetools) and repairs differences: missing offers, stock divergence, orders never imported, orders never exported (empty syncInfo). Checkpoint progress (e.g. in a CustomObject) so a restart resumes mid-run rather than restarting, respect the 30-min job timeout, own your overlap locking, and keep every unit of work an upsert so a re-run can't double-write. Keep the initial bulk migration a separate job from ongoing reconciliation.

Pitfall catalog

PitfallSymptomFix
Create-on-every-payloadDuplicate sellers / Products / Orders after redeliveryUpsert by the marketplace id (table above)
One Product per seller for the same SKUSplintered catalog, duplicate PDPs, unusable search and reportingOne Product; per-seller Prices + InventoryEntries
Price without a distribution channelOne seller's price shows in every StoreAlways set channel on seller prices
InventoryEntry without a supply channelSeller stock becomes global stock; oversellingsku + supplyChannel per seller
Availability read as a single numberStorefront shows aggregated stock across sellersRead per-channel availability; a Store-bound Cart filters by its supply channels
Trusting the payloadStale offers overwrite newer ones; deltas replayed out of orderRe-fetch by resource.id
Envelope not decodedHandler sees base64 garbage / crashesDecode message.data (base64 → JSON), then validate the type
Wrong ackHandled message redelivered forever, or failures silently dropped2xx for handled/ignored; non-2xx only for retryable
No syncInfo check before exportMulti-seller order pushed twice on redeliveryRead syncInfo, write updateSyncInfo per seller channel
Order imported without orderNumber dedupeDuplicate Orders for one marketplace orderQuery by orderNumber first
totalPrice assumed to be calculated on importWrong order totalsSet totalPrice explicitly; validate the draft
One Subscription (or Extension) per sellerHits the 50-Subscription / 25-Extension Project limitOne Subscription per message type; fan out in the handler
Float → cents conversionCents dropped or inflated on every offerMultiply then round in integer minor units
Hardcoded currency/locale/regionWorks for one seller/market, breaks the restDerive from the payload/config; region from CTP_REGION
Seller offboarded by deleting the ChannelDelete fails; sync half-brokenDeactivate: unassign from Stores, delist offers, stop syncing
No self-change filter on a two-way domainStatus ping-pong, runaway API callsMark connector writes and skip; prefer one-way per domain
Unauthenticated inbound webhookAnyone can write Products/OrdersValidate signature/secret/JWT in-app
Secrets or PII in logs / stack traces in responsesCompliance incidentGeneric error responses; structured logs without payload dumps
Route ≠ connect.yaml endpointPlatform traffic 404sMount the router at the app's endpoint base path
Legacy SDKFails the parent skill's pinned-version gate@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4

Test-first checklist (mirror in the suite)

Seller sync

  • Rejects unauthenticated / bad-signature calls (parameterized auth matrix)
  • Upserts the Channel by key; second delivery is a no-op
  • Roles and profile storage asserted; offboarding deactivates rather than deletes

Offer / inventory / price sync

  • Same-SKU second seller adds a price + inventory entry, does not create a second Product
  • Every written price has a channel; every InventoryEntry has a supply channel
  • Money conversion asserted on a value with non-zero cents; locale/currency taken from input
  • Delist path asserted (unpublish / removed from selection / zero stock)
  • Outbound: re-fetches by id, exports only in-scope products

Orders

  • Inbound: duplicate marketplace order id imports once; totalPrice set; syncInfo recorded
  • Outbound: multi-seller order produces one payload per seller with only that seller's lines
  • Redelivered OrderCreated pushes nothing (syncInfo short-circuit)
  • Partial failure retries only the failed seller
  • Envelope/ack matrix covered

Fulfilment + reconciliation

  • Per-line state transitions asserted; split shipment maps per seller
  • Self-change filter asserted on any two-way domain
  • Reconciliation resumes from checkpoint after a simulated failure; repairs are upserts
  • Boundary mocked; suite runs with no deployment and no secrets
integrations/marketplace/overview.md

Marketplace connector — integrate a marketplace service

This is the marketplace integration sub-area of commercetools-connect: a multi-vendor marketplace platform (Marketplacer, Mirakl, Convictional, a channel manager, or a service the user defines) has to exchange sellers, offers, inventory, prices, and orders with commercetools, and you'll do it with a Connect connector. The type-agnostic build/publish/certify lifecycle and the production-readiness gate stay in the parent skill; this sub-area owns the marketplace-specific job end to end — from "is there a connector already?" through configuring one, customising (forking) one, or building one for a marketplace service the user defines.
First, disambiguate the word "marketplace" — ask if it isn't obvious. Two unrelated meanings collide here:
"Build a marketplace connector" almost always means the second. If the user actually meant "publish my connector on the Connect marketplace", that's the parent skill's deployment-installation.md, not this file.
Nothing here belongs on the cart hot path. Offer sync, order export, and inventory updates are asynchronous by nature — they must never be registered as an API Extension. The one arguable exception is a cross-seller cart validation extension (e.g. rejecting a cart that mixes sellers who can't ship together); if the user needs that, price it against the extension timeout budget in service-applications.md first, and keep it separate from the sync apps.

Step 1 — Fix the role, then the direction

Everything else follows from these two answers. Get them before proposing an architecture.

RoleThe user is…Direction(s)Connect app(s)
Operatorrunning the marketplace: third-party sellers' offers sell through their commercetools-powered storefrontsellers/offers/inventory/prices in; order lines and fulfilment status outservice inbound webhook and/or job poll for seller + offer sync; event app on OrderCreated to route each seller's lines to the marketplace; optional job for reconciliation/backfill
Seller (channel)selling their own catalog on an external marketplace (Amazon, eBay, a Mirakl operator) usually via a channel managercatalog/price/stock out; marketplace orders in; shipment/tracking outevent app exporting catalog/price/stock changes (or a job for batch feeds); service webhook or job to import marketplace orders; event app pushing shipment/tracking back
Bothhybrid (operates a marketplace and lists on others)bothboth sets — as separate apps, never one app with a mode flag
Then, per the parent skill's rule, name the source of truth per domain, not globally: catalog/offer content, inventory, price, order, and seller record can each be mastered on a different side. The platform guidance is explicit — pick one source of truth per data domain and avoid bi-directional syncs; a marketplace integration is where teams most often break that rule and get sync loops.

Workflow

The heart is Step 1 → Step 1.5 → Step 2 (seller modeling) → Step 4.

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent skill's docs-search script with marketplace-focused terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<marketplace terms from the request, e.g. 'marketplace seller supply channel distribution channel store product selection order import syncInfo'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root.) You may additionally use the commercetools Knowledge MCP for follow-up. There is no marketplace module in the public docs — the load-bearing references are Channels, Stores, Product Selections, Inventory, and Order Import. Read the ones your role needs.

Step 1 — Extract requirements (before any config or code)

Ask the user — don't assume:

  1. Which marketplace service, and what API access? Marketplacer, Mirakl, Convictional, a channel manager, an operator's own portal, or a service they define. Webhooks vs polling, credentials, sandbox, rate limits.
  2. Role and direction (the table above). Operator, seller, or both.
  3. Source of truth per domain — offer content, inventory, price, order, seller record.
  4. How many sellers, and do they need isolation? Isolated storefront/catalog/permissions per seller → Store-per-seller; a shared catalog with per-seller offers → Channel-per-seller only. Drives Step 2.
  5. Do multiple sellers sell the same SKU? If yes, one Product with per-seller prices and stock — not one Product per seller. This is the single most consequential modeling answer.
  6. Which entities sync? Sellers, offers/listings, inventory, prices, orders, shipments/tracking, returns/cancellations, invoices.
  7. Order flow. Does commercetools capture the order and route lines to sellers (operator), or does the marketplace capture it and you import it (seller)? Can one cart span sellers → split shipments and per-line fulfilment states?
  8. Commission, payout, and settlement. Confirm explicitly that these stay in the marketplace/PSP: commercetools only tracks Payment status and has no payout ledger. Don't model seller payouts as commercetools resources.
  9. Seller onboarding/offboarding. Approval flow, and what happens on offboarding — note up front that a Channel can't be deleted while it's referenced by inventory, a Line Item, a Store, or a Price, so offboarding is deactivation, not deletion.
  10. Anything special or non-standard? (always ask — open-ended) Seller-specific shipping rules or lead times, per-seller tax, drop-ship vs consignment, marketplace-imposed feed formats/schedules, returns arbitration, multi-currency or multi-country sellers, seller-facing UI in the Merchant Center. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before deriving config.

Step 1.5 — Ask the user which path: use as-is, customise, or build

  1. Use a public connector directly — install and configure it, no code. Only valid if the listing is an actually deployable Connect connector.
  2. Customise it — fork an open-source connector and add only the delta (the realistic path for marketplace work). Assess the candidate by reading its current repoconnect.yaml, handlers, mapping — and score it against the production gate and this sub-area's contract; don't work from a remembered gap list.
  3. Build a new one for the marketplace service they define — no listing fits, or the service is bespoke. There is no marketplace Connect template, so you scaffold plain service/event/job apps.
Apply the parent skill's listings-are-not-all-Connect-connectors rule — it bites hardest here. The Marketplaces category is mostly partner-operated platforms, accelerators that deploy as cloud functions, and iPaaS middleware, none of which Connect can deploy. Confirm a candidate has a root connect.yaml before calling it path 1; if a listing matches functionally but isn't a Connect connector, surface it and say plainly that this skill doesn't cover non-Connect integrations, then offer path 2 or 3. Marketplace-specific method, how to assess a fork candidate, and the ladder: connector-selection.md.

Record the chosen path, the connector name + version you checked (or "none exists"), and why, in the requirements block.

Step 2 — Model sellers and offers, then derive the config

Marketplace integrations fail at the data model long before they fail at the transport. Decide seller modeling (Channel per seller, Store per seller, CustomObject seller record, offer keying, price/stock scoping) and only then write connect.yaml. The mapping table, the limits that constrain it, scopes, and a worked example are in config-from-requirements.md.

Step 3 — Price the async contract (reference)

Restate in one sentence each before coding: idempotency (every write is an upsert by a stable marketplace id — seller id, offer id, marketplace order number — never a blind create), at-least-once with no ordering (re-fetch by id; a stale offer update must not overwrite a newer one), fan-out limits (50 Subscriptions and 25 Extensions per Project — never one per seller), and loop avoidance where a domain syncs both ways.

Step 4 — Build/verify the sync apps (the main body of work), test-first

Tests come before implementation. The rules that make a marketplace integration correct — upsert-by-marketplace-id, per-seller supply channel on every InventoryEntry, a channel on every seller price, dedupe on orderNumber, per-line fulfilment state — are invisible at the call site and expensive to reproduce by hand. Each is one cheap assertion.
Read marketplace-contract.md and build only the apps your role requires, test-first for each:
  1. Seller sync (inbound) — upsert a Channel (and Store/CustomObject) per seller, keyed on the marketplace seller id.
  2. Offer/listing sync — inbound (operator): upsert Products/prices/inventory per seller; outbound (seller role): export catalog/price/stock changes to the marketplace.
  3. Order app — inbound (seller role): import marketplace orders via Order Import keyed on orderNumber; outbound (operator): route each seller's lines on OrderCreated and record the hand-off in the Order's syncInfo.
  4. Fulfilment/status app — shipment, tracking, cancellation and return states back to the other side, per line/per seller.
  5. Reconciliation job — periodic full sweep that catches what events dropped (offers, stock drift, missed orders), checkpointed.
Mock the outbound boundary (the marketplace API and the commercetools APIs) and assert on what your code decided — which resource, what key, upsert-vs-create, which channel. The suite must run with zero deployment and zero secrets.

Step 5 — Verify the round trip

Don't declare done until a seller, an offer, and an order each flow end to end, and a multi-seller order splits correctly. See verification.md, including the traps that look like bugs but aren't (a channel-less price leaking into every Store, availability aggregated across sellers, throttled feeds).

References

NeedReference
Which path — use a public connector as-is, customise/fork one, or build for a defined service; live check, listing-is-not-a-connector verification, how to assess a fork candidate from its repo, the ladderconnector-selection.md
Seller + offer modeling and config — Channel/Store/CustomObject per seller, offer keying, price and stock scoping, the limits that constrain it, scopes, connect.yaml, worked exampleconfig-from-requirements.md
The sync contract — per-app rules for seller sync, offer sync, order import/export, fulfilment, reconciliation; idempotency keys, syncInfo, split shipments; full pitfall catalogmarketplace-contract.md
Verify the round trip — seller, offer, order, split order; the channel-less-price, aggregated-availability, and throttling trapsverification.md
Build/publish/certify lifecycle, deploy, scopes, production-readiness gate (type-agnostic)commercetools-connect

Adding another marketplace service later reuses this tree unchanged — the role/direction split, the seller model, and the keying rules don't change; only the service's API and payloads do.

Checklist

Requirements

  • "Marketplace" disambiguated (business model vs the Connect marketplace listing catalog)
  • Role fixed (operator / seller / both) and direction per domain decided
  • Source of truth named per domain (offer, inventory, price, order, seller)
  • Seller count and isolation needs known; same-SKU-multiple-sellers answered
  • Entities in scope listed; order flow (route vs import) decided
  • Commission/payout confirmed as out of scope for commercetools
  • Offboarding path decided (deactivate, not delete — Channel delete constraints)
  • Asked the open-ended "anything special?" question; each special requirement its own line
  • Requirements block written and confirmed

Path (asked, not assumed)

  • Checked live marketplace listings; named connector + version (or "none exists")
  • Verified any candidate is a deployable Connect connector, not a partner/SaaS listing
  • Presented all three paths — use as-is · customise/fork · build for their service — and let the user choose
  • Chosen path + rung recorded

Modeling and config

  • Seller modeling decided (Channel per seller; Store/Product Selection only if isolation is needed; CustomObject for profile data)
  • Offer keying decided; same-SKU sellers modeled as one Product with per-seller prices/stock
  • Every seller price carries a distribution channel; every InventoryEntry a supply channel
  • inheritAs.apiClient.scopes least-privilege; marketplace credentials in securedConfiguration
  • No per-seller Subscriptions or Extensions (Project limits)

The sync apps (build test-first)

  • Every write is an upsert keyed on a stable marketplace id
  • Inbound orders deduped on orderNumber; outbound hand-off recorded in syncInfo
  • Per-seller fulfilment tracked per line item, not per order
  • Reconciliation job checkpointed; rate limits respected (batch + backoff)
  • Boundary mocked; suite runs with no deployment/secrets

Verification

  • Seller, offer, and order round trips proven; multi-seller order splits correctly
  • Re-delivery of the same webhook/message creates no duplicate
integrations/marketplace/verification.md

Verify the marketplace round trip

Don't declare done until a seller, an offer, and an order each flow end to end, and a multi-seller order splits correctly. Run the checks for your role (overview.md); locally, without a real queue, POST the base64 message envelope straight to the event app's endpoint (test an event application locally).

Check 1 — the seller exists and is usable, not just present

Create or change a seller on the marketplace side, then confirm:

  • The seller Channel exists with the expected key (seller-<marketplaceSellerId>) and the roles the model needs (InventorySupply, ProductDistribution where the seller prices independently).
  • Profile data landed where the model says (CustomObject / Channel Custom Fields).
  • If Store-per-seller is in scope: the Store exists, references that Channel, and its Product Selection is assigned.
  • Re-send the same seller webhook: nothing duplicates and no version conflict is raised.

A seller that exists only as a CustomObject with no Channel is the tell that the model is incomplete — stock and price have nothing to scope to.

Check 2 — the offer is sellable, per seller

Create or update a listing, then confirm end to end (not just "the Product appeared"):

  • The Product/Variant exists, keyed on the marketplace listing id, published according to the configured publish/staging decision.
  • A Price carrying the seller's distribution channel exists — and the amount is exact, cents included (this is where float→cent bugs surface).
  • An InventoryEntry for sku + the seller's supply channel exists with the expected quantity.
  • Add it to a Cart in the seller's context (Store-bound if Store-per-seller, otherwise with the seller's channels on the Line Item) and confirm the correct price and availability are selected. A Product that exists but can't be added at the seller's price is not a working offer.
  • Second seller, same SKU: sync a second seller's offer for the same SKU and confirm no second Product is created — only an additional price and inventory entry.
  • Delist: unpublish / remove from the selection / drop stock to zero on the marketplace side and confirm it stops being sellable.

Check 3 — the order flows once

Seller role (inbound import): place a test order on the marketplace, then confirm one Order exists with orderNumber = the marketplace order id, the right store, per-line supplyChannel, correct totalPrice, and a syncInfo entry against the OrderImport channel. Re-deliver the same payload and confirm no second Order.
Operator role (outbound routing): place an order in commercetools with lines from two different sellers, then confirm:
  • Each seller received one payload containing only their own lines — with correct quantities and prices.
  • The Order carries a syncInfo entry per seller Channel with the marketplace's externalId.
  • Redeliver the OrderCreated message: nothing is pushed again (the syncInfo short-circuit works). This is the single most valuable assertion in the suite.
  • Force one seller's push to fail: on retry, only the failed seller is re-pushed.

Check 4 — fulfilment splits per seller

Ship one seller's lines and cancel another's, then confirm the per-line states (and Deliveries/Parcels) reflect both independently, and that each status reached the marketplace. If the whole Order flips to one state, per-line tracking is missing (marketplace-contract.md).

The traps (behavior that looks like a bug — or hides one)

Trap 1 — the channel-less price leak

A price written without a distribution channel is visible in every Store, so a seller's price appears on other sellers' storefronts, and Store-based price filtering looks broken. It isn't: Stores only filter prices that have a channel — a channel-less price is inherited everywhere. Assert the channel on every seller price.

Trap 2 — availability looks wrong because it's aggregated

ProductVariant.availability summarizes stock and lags real-time by seconds; with several sellers per SKU it reads as one blended number, and Order-driven stock changes are eventually consistent (up to ~10 s). Verify per-seller stock by querying the InventoryEntry for sku + supplyChannel, and verify storefront behavior through a Store-bound Cart — not by eyeballing the aggregate.

Trap 3 — "sync stopped" is usually throttling

Marketplaces rate-limit feeds hard. A sync that suddenly stops landing offers is typically 429 backpressure or a batch-size violation, not a logic bug. Confirm backoff/retry and that the reconciliation job resumes from its checkpoint rather than restarting the whole catalog.

Trap 4 — sandbox marketplaces don't behave like production

Sandbox accounts may cap sellers or listings, expire data, return canned payloads, or omit webhook signatures. Verify the contract (upsert, idempotency, mapping, ack, auth) against the sandbox; verify real persistence and volume behavior against a controlled production account, and clean up test sellers, listings, and orders afterwards.

Trap 5 — the seller you can't remove

Offboarding fails because the seller's Channel is referenced by inventory, Line Items, Stores, or Prices, and it cannot be deleted while any reference exists — including historical Orders. That's expected. Verify the deactivation path instead: unassigned from Stores, offers delisted, sync stopped, historical Orders intact.

Verification checklist

  • Seller Channel (and Store, if modeled) created with the right key and roles; re-delivery is a no-op
  • Offer sellable: price with channel and exact amount, InventoryEntry with supply channel, add-to-cart proven in the seller's context
  • Second seller of the same SKU adds price + stock, no duplicate Product
  • Delist path proven
  • Inbound order: one Order per marketplace order id, totalPrice correct, syncInfo recorded, redelivery creates nothing
  • Outbound order: one payload per seller with only their lines, syncInfo per seller channel, redelivery pushes nothing, partial failure retries only the failed seller
  • Fulfilment states tracked per line; split shipment and mixed ship/cancel proven
  • Reconciliation job resumes from checkpoint; backoff verified under throttling
  • No secrets or payload dumps in logs; error responses carry no stack traces
  • Test sellers/listings/orders cleaned up on both sides
integrations/order-management/build-oms-connector.md

Build a new OMS connector

This is ladder rung 4: no existing connector fits, or the OMS is bespoke/home-grown, so you build one connecting to the OMS API the user defines. This reuses the parent commercetools-connect build-side workflow — the platform contracts, security, testing, and deploy are all type-agnostic. This page only covers what's specific to order management; do not duplicate the parent references, route to them.

Start from the fulfilment-integration template

There is a dedicated starting template for this: the Connect CLI ships a fulfilment-integration template (connect-cli.md template list; repo connect-fulfilment-integration-template). Note it is not on the public Application templates overview page (which documents only payment, product-export, tax, and email) — it's exposed through the CLI, so scaffold from it rather than composing from scratch:
commercetools connect init my-oms-connector --template fulfilment-integration
The template declares four applications that map almost 1:1 onto the sync-architecture.md flows — a strong signal your design is on the intended path:
Template appTypeTriggerSync flow it implements
order-exporteventSubscription on OrderCreated / ReturnInfoAddedExport placed Orders → OMS
order-updatesservice (REST)inbound endpoint: /order-updatesInbound status/shipping/packaging/parcel/tracking OMS → commercetools
inventory-importservice (REST)inbound endpoint: /inventoryInbound stock/status updates → InventoryEntry
product-exporteventSubscription on ProductPublished(product sync — keep only if you need it)
Keep the apps the requirements call for and delete the rest. If you also need a reconcile job (nightly full sync — the template doesn't ship one), add it with commercetools connect application add --type job. The tax-integration template order-syncer is a secondary reference for the OrderCreated subscriber shape.
Confirm that order export reacts to Order Messages, so it is an event app — the template's order-export is deployAs: event, and Subscription Messages are delivered to event applications through the Connect message broker; service apps are for API Extensions or inbound webhooks (here, order-updates / inventory-import). See the parent decision framework in commercetools-connect and event-applications.md.
Use only documented connect.yaml envelope keys (deployAs/applicationType/configuration/inheritAs) and keep the file at the repository root — a nested file silently fails to deploy (project-structure.md).

Connecting to the user-defined OMS

The OMS is an arbitrary external system — treat its API as the untrusted outbound/inbound boundary:

  • Config, not code. OMS base URL, tenant/account id, and non-secret toggles → standardConfiguration. OMS API key/client secret, webhook signing secret → securedConfiguration, never hardcoded (security.md).
  • Deploy-time validation. postDeploy should test-connect to the OMS and surface bad credentials immediately, and register the Subscription + any custom State machine / Custom Types idempotently (get-then-create, never blind delete-recreate) → lifecycle-scripts.md.
  • Map at the boundary. Convert between the OMS's order/status model and commercetools' at the edge; keep SDK types end to end internally, no any escapes (project-structure.md). The concrete field/action mapping is sync-architecture.md.
  • Least-privilege scopes. inheritAs.apiClient.scopes with only what the flows need — typically manage_orders, view_orders, manage_subscriptions, and manage_inventory if syncing stock — not manage_project.
  • Fail-open vs fail-closed. Since the export is async and the inbound is a webhook (neither on a synchronous checkout path), a transient OMS outage should fail closed with retry (return non-ack / non-2xx to trigger redelivery), not silently drop. Document the stance in the README.

Build test-first, then deploy

Build on the parent workflow's Quality gate — test before implementation for each behavior (failing test → confirm red for the right reason → least code to pass → refactor). Mock the OMS API and the commercetools API; assert on which endpoint your code called, with what body, and what it did with the response. The suite runs with zero deployment and zero secrets. → testing.md.
Pin the sync-architecture.md invariants as regression tests: export idempotent on orderNumber/OMS ref, inbound idempotent (redelivery no-op, no stale overwrite), self-change filtering prevents loops, inbound webhook rejects unauthenticated callers.
Deploy is type-agnostic: an Organization connector goes connectorstaged create → publish → deployment create (the publish-time production-readiness scan applies) → deployment-installation.md.

Checklist

  • Scaffolded from the fulfilment-integration template (connect init --template fulfilment-integration); kept only the needed apps (order-export/order-updates/inventory-import), added a reconcile job if required
  • Applications declared in a root connect.yaml using only documented envelope keys; router mounts match endpoint; order-export is deployAs: event
  • OMS URL/tenant in standardConfiguration; OMS + webhook secrets in securedConfiguration
  • postDeploy validates OMS connectivity and idempotently registers Subscription + custom States/Types; preUndeploy cleans up
  • Least-privilege scopes (manage_orders / view_orders / manage_subscriptions / manage_inventory as needed)
  • Fail-open/closed stance documented; inbound webhook authenticated
  • Built test-first; sync invariants pinned as tests; deployed via connectorstaged → publish → deployment create
integrations/order-management/connector-selection.md

Is a public OMS connector enough?

Before wiring or building anything, answer one question: does a connector that already does what the user needs exist? Getting this wrong is expensive both ways — building from scratch when a public connector covers you wastes weeks; assuming a listing is a one-click Connect connector when it's a vendor-hosted product surfaces only at deploy time.

Two things that are easy to get wrong

1. The marketplace listing type. The order-management marketplace lists many integrations (Fluent Commerce, kbrw, OneStock, NewStore, Pipe17, NEKOM, OC fulfillment, ConnectPOS, and more — verify the current set live). But a marketplace listing is not automatically an installable Connect connector. There are two shapes:
  • Installable Connect connector — published to Connect, deployed into your project via the Connect CLI / UI. This is the "install + configure" case.
  • Vendor-hosted / partner integration — the OMS vendor operates the integration on their side (their connector calls the commercetools API, or you configure it in the vendor's console). You don't deploy anything in Connect; setup follows the vendor's docs.
Confirm which shape a given OMS uses before promising a Connect deployment. When in doubt, the vendor's own docs/repo are the source of truth for how their integration installs and what it covers.
2. "Order management" is not one connector. OMS integrations differ widely in scope — some do full bidirectional order + inventory + fulfillment sync, some only export orders, some only push inventory. Match the specific flows the user needs (Step 1 requirements), not the vendor's headline.

Discover public connectors programmatically — don't hardcode a list

The set of connectors and versions changes over time, so don't rely on a memorized matrix. The authoritative, agent-friendly source is the Connect API Search Connectors endpoint, which filters published connectors by integration type:
GET {connect-host}/connectors/search?integrationTypes=oms
# add &integrationTypes=shipping for fulfillment/shipping connectors; &text=<keyword> to narrow
  • Filter by the right type(s). IntegrationType values (verified against the Connect API): tax, marketplace, oms, psp, pim, promotion, search, erp, crm, email, analytics, shipping, giftcard. There is no separate fulfillment value — fulfillment/OMS connectors are tagged oms and/or shipping, so query both for an order-management use case. → schema: openApi-schemata.mjs --resource-name connect-Connector; host/auth: Connect hosts & authorization.
  • Read the result. Each returned Connector carries name, key, integrationTypes, creator, repository, configurations, supportedRegions, certified, private, and documentationUrl. Use certified: true / private: false to identify public certified connectors; repository tells you whether the source is available to fork; configurations is the config surface you'd fill at install. Name the version you found.
  • Equivalent surfaces: the same search is available in the Merchant Center (Connect) and the Connect CLI; the marketplace is the human-browse view (note connectors span both the order-management and fulfillment marketplace categories — the API oms+shipping query covers both).
  • For a specific candidate, its repository/documentationUrl is authoritative for capabilities, install shape, and config keys.
A concrete public, certified installable Connect connector example for this space is fulfillmenttools (fulfillmenttools/commercetools-connector) — an event order-export app (on Order Confirmed) + a service app for fulfillment status back to CT + Channel↔Facility inventory sync; deployable via the Merchant Center or Connect API. Others surface under the oms/shipping search (e.g. Fluent Commerce, kbrw, OneStock, NewStore, Pipe17) — verify each live rather than trusting this list.

Turn the search result into the decision

Run the search first, then branch on what it returns — this is how the ladder below is driven:

  • A published connector matches the OMS and covers the flowsinstall it (rung 1) — deployment create with the connector's configurations.
  • A published connector matches but a behavior is missing → try config first (rung 2); if genuinely missing and its repository is available → modify/fork it (rung 3).
  • No published connector matches the OMSbuild one (rung 4): from scratch or, preferably, the fulfilment-integration template → build-oms-connector.md.

State explicitly that you're checking current data, and cite the connector key + version you found.

The fit check

Compare the Step 1 requirements against what a candidate connector actually supports. Check each dimension:

DimensionQuestionIf not covered → which rung
OMS coverageIs the user's OMS available as a connector at all?No connector → rung 4 (build new).
Install shapeInstallable Connect connector or vendor-hosted integration?Vendor-hosted → follow vendor docs (still rung 1, but not a Connect deploy).
FlowsDoes it cover the flows needed — order export, status/shipment inbound, fulfillment, inventory, returns?Missing flow → config first (rung 2), else fork/customize (rung 3).
Direction / source of truthDoes its direction model match yours (who masters status, inventory)?Mismatch → fork (rung 3) or build (rung 4).
Data mappingCan statuses, SKUs, locations/channels, and OMS ids be mapped as needed?Fixed/unsuitable mapping → config (rung 2) then fork (rung 3).
Split/partial fulfillment, BOPIS, returnsDoes it handle split shipments, partial fulfillment, store pickup, RMA?Missing → fork (rung 3) or build (rung 4).
Region/complianceAvailable + supported for the region, volume, and data-residency needs?Not available → different connector or build.
Special requirementsEach open-ended requirement from Step 1 (B2B/approvals, marketplace split, subscriptions, custom workflow states, existing OMS account/tenant)Config (rung 2); bespoke logic → fork (rung 3); no connector at all → rung 4.
Most gaps on an existing connector are config, not missing features (which Messages, mappings, and which flows are enabled are often configurable). So before concluding anything needs building, confirm the gap can't be closed by configuration.

The decision ladder

Walk these in order and stop at the first that fits — each later rung is more work and more to maintain.
  1. A connector covers everything → install + configure (Connect connector) or follow the vendor's setup (vendor-hosted). Don't build. The common, recommended case.
  2. A connector exists, gap looks like a capability → prove it isn't config first (mappings, message selection, enabled flows). If config closes the gap, back to rung 1.
  3. A connector exists, genuine gap config can't closefork/customize it if its source is available: add only the delta and deploy as an Organization connector. You keep the working sync scaffolding and only change what's different. A commercetools-connect build-side task; the sync design is sync-architecture.md.
  4. No connector fits, or the OMS is bespoke/home-grownbuild a new connector connecting to the OMS the user defines. → build-oms-connector.md, then the parent commercetools-connect build-side workflow.
Only rungs 3–4 leave this sub-area for the parent build-side; the sync design (sync-architecture.md) applies to all four rungs. Record the decision, the rung, and the connector version checked in the requirements block.

Checklist

  • Ran GET /connectors/search?integrationTypes=oms (and shipping) — not memory; cited the connector key + version, and its certified/private flags
  • Confirmed install shape: installable Connect connector vs vendor-hosted integration
  • Flows, direction, mapping, split/partial fulfillment, region, and each special requirement compared to the requirements
  • Apparent gaps re-checked as config (rung 2) before considering any build
  • When a connector exists but has a real gap, chose fork/customize (rung 3) over building from scratch
  • Decision + rung + version recorded: use (1), config (2), fork (3 → commercetools-connect), or build (4 → build-oms-connector)
integrations/order-management/overview.md

Order-management connector — build & integration

This is the order-management sub-area of commercetools-connect: you need to connect commercetools to an order-management system (OMS) — export placed Orders downstream and keep status, shipment, fulfillment, and inventory in sync. The build-side platform contracts (event/service/job, idempotency, lifecycle, security) are the parent skill's; this sub-area owns the OMS-specific decision (use a public connector, customize one, or build a new one) and the sync design that sits on top of those contracts.
Unlike payment, order management has no fixed connector contract (no processor/enabler, no session BFF). It is fundamentally a directional data-sync problem between two systems that each hold part of the order lifecycle. So the deliverable is: the right connector choice, then a sync architecture built on the parent skill's event / service / job applications. For building from scratch, the Connect CLI ships a fulfilment-integration template whose apps (order-export, order-updates, inventory-import) map onto these flows — see build-oms-connector.md.

Direction & source of truth (settle this first — it decides everything)

Per commercetools' integration guidance, the Order master record usually lives downstream in the OMS/ERP; commercetools captures Orders and hands them off (Plan integrations → Order, Integration patterns). That yields two one-way flows, not one bidirectional one:
  • Export (commercetools → OMS): a placed Order is pushed to the OMS for routing/fulfillment. Triggered by the OrderCreated Message.
  • Inbound (OMS → commercetools): the OMS pushes status, shipment/tracking, fulfillment, and inventory back so the storefront and Merchant Center stay current.
Avoid a bidirectional sync of the same field — the docs call this out explicitly as a source of conflicts and loops (Integration patterns → Key takeaways). Assign each data domain (order status, shipment, inventory, customer) a single source of truth and make the other side read-only for that domain. Mark externally-mastered fields read-only in commercetools and store the reference to the external record on the right field per resource: Customer has an externalId; the Order does not — use its SyncInfo (the updateSyncInfo action) or a Custom Field (see sync-architecture.md). For architecture patterns on order replication, see the ERP integration tutorial.

Workflow

Follow these steps in order. The heart is Step 1 → Step 1.5 → Step 2 → Step 3 (requirements → is a connector enough? → sync design → build).

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context. Use the parent connect skill's docs-search script with OMS-focused query terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<OMS terms from the user's request, e.g. 'order export subscription OrderCreated shipment fulfillment inventory sync external system'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root, where scripts/docs-search.mjs lives.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or https://docs.commercetools.com for deeper follow-up.

Step 1 — Extract requirements (do this before any config or code)

The connector choice and the sync design are both downstream of these. Ask the user (don't assume):

  1. Which OMS, and is there an existing connector? Name the OMS (Fluent Commerce, kbrw, OneStock, NewStore, Pipe17, a home-grown service, …). Is a public/partner connector already deployed, or is this greenfield?
  2. Region and project? e.g. europe-west1.gcp, project my-project — drives the CTP_*_URL config and the deploy region.
  3. Source of truth per domain? Which system masters order status, shipment/tracking, inventory, and customer data? (Usually the OMS masters status/shipment/inventory once the Order is placed.)
  4. What must be exported, and when? All Orders on OrderCreated, or only after payment/approval? Do split shipments / partial fulfillment / store pickup (BOPIS) apply?
  5. What comes back inbound? Order/line-item status transitions, shipment + tracking, delivery/parcel data, cancellations, returns, inventory levels — and how does the OMS deliver them (webhook, polling, batch file)?
  6. Latency & volume? Real-time (event + webhook) vs near-real-time vs nightly batch (job). Order and inventory volume shape the design.
  7. Data mapping? How OMS statuses map to commercetools Order/line-item/shipment states; how SKUs/locations/channels map; where the OMS id is stored on the Order (SyncInfo via updateSyncInfo, or a Custom Field — Order has no externalId).
  8. Anything special or non-standard? (always ask — open-ended) The list above covers the common shape but not everything. Prompt to jog memory: B2B (PO numbers, approvals, business units), marketplaces/split fulfillment across many vendors, returns/RMA flows, multi-currency or per-market, subscriptions/recurring orders, existing OMS contract or a specific account/tenant, custom order workflows/states, compliance or data-residency constraints. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before choosing a connector or designing the sync. Flag every special requirement — each feeds the Step 1.5 fit-check and may push the decision from "use public" toward "customize" or "build".

Step 1.5 — Is a public connector enough? (decide before wiring or building)

With the requirements in hand, answer the prior question: does a connector that already does this exist? Don't answer from memory — discover published connectors programmatically via the Connect API: GET /connectors/search?integrationTypes=oms (add &integrationTypes=shipping — there's no separate fulfillment type). Filter results to public/certified (certified: true, private: false), compare requirement-by-requirement, and name the connector key + version. Full method (query params, IntegrationType values, reading the result, install-vs-vendor-hosted): connector-selection.md.
Then walk the decision ladder — stop at the first rung that fits, because each later one is more to build and maintain:
  1. A connector covers everything → install + configure. Don't build. Note the important distinction (covered in connector-selection.md): some OMS marketplace listings are installable Connect connectors (deploy via the Connect CLI / UI), others are vendor-hosted integrations the OMS provider operates. Confirm which before promising a Connect deploy.
  2. A connector exists but a gap looks like a capability → prove it isn't config first (mappings, which messages, which flows are often configurable). If config closes the gap, back to rung 1.
  3. A connector exists but has a genuine gap config can't closefork/customize it (if its source is available) — add only the delta and deploy as an Organization connector. Don't rebuild a working, maintained connector. → connector-selection.md, then the parent commercetools-connect build-side.
  4. No connector fits, or the OMS is bespoke/home-grownbuild a new connector, scaffolding from the fulfilment-integration CLI template (order-export event + order-updates/inventory-import service), adding a reconcile job if needed. → build-oms-connector.md.
Rungs 3–4 use the build-side workflow in the parent skill; the sync design (Step 2) applies to all four rungs. Record the decision, the rung, and the version in the requirements block. Full procedure and dimension table: connector-selection.md.

Step 2 — Design the sync architecture (the core deliverable)

Whether you configure, fork, or build, you must pin the sync design: which flows exist, which commercetools Messages the export subscribes to, how inbound updates authenticate and apply, and how OMS statuses map to commercetools Order/line-item/shipment/delivery state. This is where the OMS-specific value lives and where the expensive mistakes hide (loops, lost updates, non-idempotent replays). Read sync-architecture.md and produce, for the user: the flow diagram (export / inbound / reconcile), the message-subscription list, the state-mapping table, and the idempotency strategy per flow.

Step 3 — Build (rungs 3–4), test-first

Order management maps directly onto the parent skill's application types — there is no OMS-specific runtime contract to learn, so build on those references and their checklists:
  • Export = an event application subscribing to OrderCreated (and status Messages) → event-applications.md. At-least-once, no ordering: idempotent on orderNumber/OMS id, re-fetch the Order by id, filter self-changes.
  • Inbound = a service application as an inbound webhook the OMS calls → service-applications.md. Authenticate the caller, validate the payload, apply updates idempotently (upsert by key / re-check state), never blind-create.
  • Reconcile = a job for nightly full sync / drift repair → job-applications.md. Owns its own locking and checkpointing.
  • Registration of the Subscription and any custom types/statuses = idempotent postDeploy / preUndeploylifecycle-scripts.md.
Build test-first (parent skill's Quality gate): write the failing test that names the behavior, confirm it's red for the right reason, write the least code to pass, refactor. Mock the outbound boundary (the OMS API, the commercetools API) and assert on what your code decided to do. → testing.md.

Step 4 — Deploy

Deploy is type-agnostic — use the parent skill's deployment-installation.md. A public connector installs directly (Connect CLI deployment create --connector-key, or the Connect UI); a forked/built Organization connector goes through connectorstaged create → publish → deployment create. Pass the config you derived; secrets go in securedConfiguration, never in code.

Step 5 — Verify the round trip

Don't declare done until a real Order has traced end to end: place an Order → confirm it appears in the OMS (export) → drive an OMS status/shipment change → confirm it reflected on the commercetools Order (inbound). Then lock it in with an integration test that drives the deployed connector and asserts the commercetools trace at each commit point, so a failure localizes the broken seam. Observability, poison-message/replay runbook, and deployment logs are in observability-operations.md.

References

NeedReference
Is a connector enough? live fit-check against marketplace OMS connectors; installable-vs-vendor-hosted distinction; the use/configure/fork/build ladderconnector-selection.md
Sync design: direction & source of truth, export/inbound/reconcile flows, which Messages to subscribe to, OMS-status → CT-state mapping, idempotency per flowsync-architecture.md
Build a new connector for a user-defined OMS (rung 4): scaffold from the fulfilment-integration template, which applications to declare, connecting to the OMS APIbuild-oms-connector.md
Event app (export): envelope, ack, idempotency, re-fetch, Pub/Sub destinationevent-applications.md
Service app (inbound webhook): authenticated inbound, idempotent upsert, timeoutservice-applications.md
Job app (reconcile): schedule, timeout, concurrency, checkpointingjob-applications.md
Idempotent Subscription/custom-type registration in postDeploy/preUndeploylifecycle-scripts.md
Deploy/install (public vs forked/built), regions, redeploydeployment-installation.md
Testing (auth matrix, idempotency, ack edge cases), test-first looptesting.md
Logs + correlation IDs, health, poison-message/replay runbookobservability-operations.md

Checklist

Requirements

  • OMS named; existing connector checked (and its type: installable Connect connector vs vendor-hosted integration)
  • Region + project; source of truth fixed per domain (status, shipment, inventory, customer)
  • Export scope + trigger; inbound scope + delivery mechanism; latency/volume; data mapping; OMS-id storage
  • Open-ended "anything special?" asked; each special requirement captured as its own line
  • Requirements block written and confirmed; special requirements flagged into Step 1.5

Connector fit (decide before wiring/building)

  • Checked live marketplace + docs (not memory); named connector + version
  • Confirmed installable-vs-vendor-hosted before promising a Connect deploy
  • Apparent gaps re-checked as config before considering fork/build
  • Ladder rung chosen: use (1) · configure (2) · fork/customize (3) · build new (4); decision recorded

Sync design (the deliverable)

  • Direction fixed; no bidirectional sync of the same field
  • Flows enumerated: export (event), inbound (service webhook), reconcile (job) as needed
  • Export subscribes to the right Messages (OrderCreated, status transitions); inbound applies idempotently
  • OMS-status → CT Order/line-item/shipment/delivery state mapping table produced
  • Idempotency strategy stated per flow (orderNumber/OMS id; upsert by key; re-fetch by id)

Build & ship (rungs 3–4)

  • Built test-first on the parent event/service/job references and their checklists
  • Subscription + custom types registered idempotently in postDeploy; cleaned up in preUndeploy
  • Deployed via the type-agnostic deploy flow; secrets in securedConfiguration
  • Round trip verified (Order → OMS → status back to CT); integration test asserts the CT trace
integrations/order-management/sync-architecture.md

OMS sync architecture

The deliverable regardless of ladder rung (use/configure/fork/build): the flows, the Messages the export subscribes to, the state mapping, and the idempotency strategy per flow. This applies whether you configure a public connector, fork one, or build a new one — the design is the same; only who implements it differs.
Fetch exact fields/actions with the parent skill's schema scripts before writing code — do not hardcode field lists from memory: node scripts/openApi-schemata.mjs --resource-name api-Order-write (update actions), --resource-name api-Order-read, --resource-name api-InventoryEntry-write; and node scripts/graphql-schemata.mjs --resource-name Order. Message shapes: Cart and Order Messages.

The three flows

Design as one-way flows per data domain (see overview.md → Direction & source of truth). A typical OMS connector needs two, sometimes three:
flowchart LR
  subgraph CT[commercetools]
    Order[Order]
    Sub[Subscription]
    Inv[Inventory]
    Order -- OrderCreated / status Messages --> Sub
  end
  subgraph Conn[Connect connector]
    Export[event: export]
    Inbound[service: inbound webhook]
    Reconcile[job: reconcile]
  end
  OMS[Order-management system]
  Sub -- receives Messages --> Export
  Export -- create/update order --> OMS
  OMS -- status / shipment / fulfillment / inventory --> Inbound
  Inbound -- update actions --> Order
  Inbound -- adjust quantity --> Inv
  Reconcile -- poll / full sync --> OMS
  Reconcile -- repair drift --> Order

1. Export — event application (commercetools → OMS)

React to a placed Order and push it downstream. Build on event-applications.md — it owns the platform contract; below is only what's OMS-specific.
  • Subscribe to the right Messages. OrderCreated for the initial export; add order-lifecycle Messages only if the OMS must also learn about commercetools-side changes (OrderStateChanged, OrderCustomerSet, edits). Subscribe to the minimum set and ack-and-ignore the rest. Message catalog: Cart and Order Messages. The fulfilment-integration template's order-export app is the canonical working example — it subscribes to OrderCreated / ReturnInfoAdded (connect-fulfilment-integration-template); the tax template's order-syncer is a secondary OrderCreated-subscriber reference.
  • Re-fetch the Order by resource.id — don't trust the Message payload (it may be omitted when payloadNotIncluded). Fetch the full Order, map it, then push.
  • Idempotent export. At-least-once delivery means the same OrderCreated can arrive twice. Make the OMS create idempotent: prefer the OMS's own idempotency key (send the commercetools orderNumber or Order id as the OMS external reference and upsert), or check the OMS for an existing record before creating. Never keep a local dedup store.
  • Record the OMS id back on the Order so inbound updates and reconciliation can correlate — and so the export can detect "already exported". Order has no top-level externalId; use the purpose-built SyncInfo via the updateSyncInfo action (it carries externalId + channel and is exactly "synchronization activity information of the Order like export or import"), or a Custom Field. Query it back with the syncInfo(externalId="…") predicate (or the custom-field predicate).
  • Timing. If Orders should export only after payment/approval, either subscribe to the state-change Message instead of OrderCreated, or gate inside the handler on the Order's payment/approval state.

2. Inbound — service application as an inbound webhook (OMS → commercetools)

The OMS calls your endpoint when status, shipment, fulfillment, or inventory changes. Build on service-applications.md as the inbound-webhook mode (5-min service timeout, not the 2-s Extension limit; you authenticate the caller and call the commercetools API yourself — no Extension is registered).
  • Authenticate the caller and validate the payload before touching commercetools (security.md).
  • Correlate the inbound event to the commercetools Order by the stored OMS id — query by the syncInfo(externalId="…") predicate or a Custom Field predicate — not by position.
  • Apply as Order update actions, then persist. Common mappings (fetch exact action names via openApi-schemata.mjs --resource-name api-Order-write):
    • order-level status → changeOrderState (Open/Confirmed/Complete/Cancelled) and/or a custom State machine via transitionState
    • line-item fulfillment status → transitionLineItemState (custom line-item State)
    • shipment status → changeShipmentState (Shipped, Delayed, Ready, …)
    • shipment/tracking → addDelivery, addParcelToDelivery, setParcelTrackingData (and Delivery/Parcel custom fields for extra data)
    • returns/RMA → addReturnInfo, setReturnShipmentState
    • inventory → adjust the relevant InventoryEntry quantityOnStock for the SKU + supply channel (api-InventoryEntry-write)
  • Idempotent apply. Re-check current state before transitioning — a redelivered "Shipped" must be a no-op, and an out-of-order older event must not overwrite a newer state. Use the version/sequence the OMS provides (or the Order version for optimistic concurrency) and guard the transition. Decide what a failed write returns so the OMS can retry safely.
A scheduled full/delta sync that repairs drift the event/webhook path missed (dropped webhook, poison message, backfill). Build on job-applications.md: owns its own overlap locking and restart-safe checkpointing; each unit idempotent so a re-run can't double-write. Use it for nightly inventory snapshots and to re-push Orders the OMS never acknowledged.

State mapping (produce this table for the user)

The single most error-prone part is mapping OMS statuses onto commercetools' several state fields. commercetools separates concerns across orderState, shipmentState, paymentState, a custom order State machine, and per-line-item State — the OMS usually has its own status vocabulary. Produce an explicit mapping table, e.g.:
OMS statuscommercetools targetAction
RECEIVEDorder custom State = "Received"transitionState
ALLOCATED / PICKINGline-item StatetransitionLineItemState
SHIPPED (+ tracking)shipmentState = Shipped; add delivery/parcelchangeShipmentState, addDelivery, addParcelToDelivery, setParcelTrackingData
DELIVEREDorderState = CompletechangeOrderState
CANCELLEDorderState = CancelledchangeOrderState
RETURN_INITIATEDreturn infoaddReturnInfo, setReturnShipmentState
If the required order statuses don't exist as built-in orderState values, model them with a custom State machine and register the States + transitions idempotently in postDeploy (lifecycle-scripts.md). Decide up front which side wins on conflict for each field (source of truth), and make the other side read-only for that field.

Idempotency & anti-loop (the invariants to pin as tests)

  • Export idempotent on orderNumber/OMS external ref (upsert or check-first) — redelivered OrderCreated doesn't create a duplicate OMS order.
  • Inbound idempotent — a redelivered status webhook is a no-op; an older event never overwrites a newer state (guard on version/sequence).
  • No loops. If both an export subscription and an inbound webhook can touch order status, they must not master the same field. When the connector writes to the Order, that write emits its own Message — filter self-changes so the export doesn't re-push what the inbound flow just applied (event-applications.md self-change filtering).
  • Correlation stored, not inferred — OMS id on the Order via syncInfo (updateSyncInfo) or a Custom Field, never a bare externalId (Order has none).

Checklist

  • Flows chosen: export (event), inbound (service webhook), reconcile (job) as needed
  • Export subscribes to the minimum Messages (OrderCreated + only the lifecycle Messages actually needed); re-fetches Order by id
  • Export idempotent on orderNumber/OMS ref; OMS id recorded back on the Order
  • Inbound authenticates the caller, correlates by stored OMS id, applies via Order update actions, and is idempotent (redelivery no-op, no stale overwrite)
  • State-mapping table produced; custom State machine + transitions registered idempotently in postDeploy if needed
  • Single source of truth per field; no bidirectional sync of the same field; self-change filtering prevents loops
  • Inventory sync direction fixed; InventoryEntry updated per SKU + supply channel
  • Reconcile job (if used) locks against overlap and checkpoints
integrations/payment/backend-integration.md

Backend integration

The frontend flow (session → enabler → submit) and connector-contract.md get you a paid cart. The backend owns everything around it: minting the session securely, converting the cart to an Order, and the post-purchase money movements (capture, refund, cancel). The connector's processor deliberately does not create Orders — the payment integration template states cart-to-order conversion is out of its scope, "ensuring the payment connector is not directly responsible for cart-to-order conversion." That responsibility is yours.

Table of contents

Server-side session creation (BFF)

In production, the token, cart, and session (steps 1–3 of the flow) run on your backend-for-frontend, never the browser. The browser receives only the sessionId, the processor URL, and the enabler URL — never CT_CLIENT_SECRET or a manage_sessions token. The test harness (test-harness.md) cuts this corner for speed; the real integration must not.

A single BFF endpoint does the three server steps and returns the session:

// POST /api/checkout/session  — returns { sessionId, processorUrl, enablerUrl }
export async function createCheckoutSession(req, res) {
  // 1. Verify the cart belongs to this user (IDOR guard) — fetch it and compare
  //    customerId / anonymousId to the authenticated caller before trusting cartId.
  const cartId = req.session.cartId;

  const token = await getManageSessionsToken();   // client_credentials, manage_sessions:{projectKey}

  // 2. Ensure the cart is payable: recalculate and confirm a non-zero total
  //    (the processor rejects a €0 cart — see contract pitfall 3).

  // 3. Create the Checkout Session (cartRef + processor-matching metadata)
  const r = await fetch(`https://session.${region}.commercetools.com/${projectKey}/sessions`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      cart: { cartRef: { id: cartId } },
      metadata: { applicationKey: APP_KEY },   // or { processorUrl } — what the connector expects
    }),
  });
  const session = await r.json();
  res.json({ sessionId: session.id, processorUrl: PROCESSOR_URL, enablerUrl: ENABLER_URL });
}

Notes that matter:

  • Create the session as late as possible — when the user reaches the payment step — because sessions expire. Don't mint it at cart creation.
  • Verify cart ownership before creating a session (IDOR): fetch the cart and compare its customerId/anonymousId to the authenticated user. See the BFF responsibilities.
  • The browser never needs projectKey/region as public env vars — return them from this endpoint alongside sessionId.

Creating the Order after payment

This is the commit step, and it's yours. Create the Order from the Cart, server-side, only once preconditions hold (order creation):
Preconditions before POST /orders:
  1. The cart has shipping address, shipping method, and billing address (if required) — and the Payment is linked to the cart (the processor does the link via addPayment; confirm cart.paymentInfo.payments is populated).
  2. Payment authorization is complete for synchronous flows. For async PSPs, wait for the webhook to move the transaction to Success before committing (see reconciliation).
  3. You're using the latest cart version.
  4. Business validations (stock, min order value) pass.
// POST /api/checkout/place-order
const order = await apiRoot.orders().post({
  body: {
    cart: { typeId: 'cart', id: cartId },
    version: cartVersion,          // must be current
    orderNumber,                   // unique, pre-generated → idempotency
  },
}).execute();
Make it idempotent so a retry can't double-create: pre-generate a unique orderNumber (a duplicate is rejected) and reuse the same value on retry; cart versioning gives you a second guard (a stale version fails). Creating the Order snapshots prices/payments and flips cartState to Ordered. An OrderCreated Message lets you trigger confirmation email / ERP sync via a Subscription — keep that work out of the request path.
Never trust a client-supplied cartVersion for order creation. The processor bumps the cart version when it links the Payment to the cart via addPayment — this happens inside submit(), after the browser captured the version. Any version stored on the client (sessionStorage, URL param, hidden field) will be stale by the time the return URL fires. Always refetch the current cart version server-side immediately before calling POST /orders:
// server-side, inside the order-creation route
const { body: cart } = await apiRoot.carts().withId({ ID: cartId }).get().execute()
// cart.version is now current — use it, not the client-supplied version
const order = await apiRoot.orders().post({ body: {
  cart: { typeId: 'cart', id: cartId },
  version: cart.version,   // ← always freshly fetched
  orderNumber,
}}).execute()
The extra cart fetch is cheap and eliminates ConcurrentModification errors entirely on this path.

Order-creation timing — pick one and be consistent:

  • Authorize → create Order → capture on fulfillment (common for physical goods): the connector authorizes during submit(); you create the Order on a successful authorization, then capture later.
  • Immediate capture → create Order (digital goods): the connector captures during submit() (STRIPE_CAPTURE_METHOD=automatic); you create the Order once the Charge is Success.

If the cart total changed between authorization and order creation (discount expired, tax shift), don't silently proceed — cancel the authorization and re-authorize for the new amount, or surface the new total for confirmation. The processor/PSP handles the money; you orchestrate the decision.

Post-purchase: capture, refund, cancel

These happen after the Order exists and are a merchant responsibility, not the storefront's. How you trigger them depends on whether the Payment was created by Checkout or by a direct-connector flow — and this skill's path is the latter:
  • Direct-connector path (this skill): the processor exposes its own operation routes for capture/refund/cancel. The template states "The processor application exposes additional API endpoints for initiating the capture, refund, and cancellation transactions." Call those processor routes (session- or service-authenticated per the connector) from your back-office/fulfillment backend. The processor talks to the PSP and writes the resulting Charge / Refund / CancelAuthorization transaction onto the Payment.
  • Checkout-product path (not this skill): if the Payment was created by the hosted Checkout product, use the Checkout Payment Intents API (manage_checkout_payment_intents scope) to capture/refund/reverse/cancel. The Payment Intents API works only for payments created by Checkout — do not reach for it on the direct-connector path.
Either way, the resulting transaction types are the same and land on the Payment inside the Order:
OperationTransaction addedWhen
CaptureChargefunds taken (auto, or manual at fulfillment)
Cancel authorizationCancelAuthorizationvoid an auth before capture (order canceled/unfulfillable)
RefundRefundreturn captured funds; partial refunds allowed up to the captured amount, repeatable
Reconcile against the Payment's transactions, and keep these idempotent (one Charge per PSP interactionId) so a retried capture can't double-charge.

Webhook reconciliation

For PSPs that finalize asynchronously (Stripe partly; Adyen heavily), the authoritative payment state is the commercetools Payment, driven by the PSP webhook the processor receives — not the browser's onComplete. The processor verifies the webhook (e.g. signing secret / HMAC) and updates the transaction state. Your backend should:
  • Treat a UI "success" as provisional; gate Order creation (or order confirmation) on the transaction reaching Success when the PSP is async.
  • If a transaction is stuck Pending, suspect the webhook (endpoint registered, points at the processor, secret matches — see the provider reference).
  • Make handling idempotent: the same webhook may arrive twice.

The return URL race condition

This is the most common runtime failure on this path, and it's invisible in development until the webhook is wired: the browser reaches the return URL (payment-complete page) before the webhook has arrived at the processor and updated the CT Payment transaction to Success. The browser redirect is nearly instant; the webhook delivery takes 1–5 seconds even in a healthy setup.
If your return URL handler fires Order creation immediately on page load, it hits the gate while the transaction is still Pending and fails with "no successful payment found."
The fix: poll with a timeout, not a single fetch. Retry the Order creation call on a 422 response (gate not open yet) with a short gap, up to a generous timeout:
// return URL page — order creation with webhook-wait polling
const MAX_ATTEMPTS = 10
const GAP_MS = 1500   // 10 × 1.5s = 15s total — enough for any healthy webhook delivery

for (let i = 0; i < MAX_ATTEMPTS; i++) {
  const res = await fetch('/api/orders/create', { method: 'POST', body: ... })
  if (res.ok) return await res.json()            // gate opened, order created
  if (res.status !== 422) throw new Error(...)   // hard error — don't retry
  if (i < MAX_ATTEMPTS - 1) await sleep(GAP_MS) // 422 = still Pending, wait for webhook
}
throw new Error('Payment not confirmed after webhook timeout — check Stripe webhook delivery')
The orderNumber must be pre-generated and stable across retries (idempotency — see above), so a retry that races a concurrent success doesn't double-create. A DuplicateField error on orderNumber means the first attempt already succeeded — fetch and return the existing Order.
Do not use a fixed sleep instead of polling — a fixed sleep is either too short (still flaky on a slow webhook) or too long (bad UX on a fast one). Poll until the gate opens or the timeout expires.

Who creates the Payment, revisited

To keep the boundary crisp across this skill: on the direct-connector path the processor creates and owns the Payment (it adds the Authorization/Charge and links it to the cart during submit()). Your backend does not create Payment objects — doing so produces duplicates. Creating Payments yourself is the raw BFF / custom-checkout model (no connector), documented separately at custom checkout → payment. Your backend's job is sessions, the Order, and post-purchase operations — not the Payment itself.

Checklist

  • Session/cart/token creation is server-side; browser gets only sessionId + processor/enabler URLs
  • Cart ownership verified before session creation (IDOR guard)
  • Order created from cart with server-refetched version (never the client-supplied version — the processor bumps the cart via addPayment and makes any client-held version stale) + unique pre-generated orderNumber (idempotent)
  • Order creation gated on authorization complete (and on webhook Success for async PSPs)
  • Return URL handler polls for Order creation (retries on 422 with a gap) rather than firing once — avoids the return-URL/webhook race condition
  • orderNumber is pre-generated and reused across retries so polling can't double-create
  • Capture/refund/cancel go through the processor's operation routes (not the Payment Intents API, which is Checkout-only)
  • Post-order side effects (email, ERP) driven off the OrderCreated Subscription, not the request path
  • Backend does not create Payment objects (the processor owns them)
integrations/payment/backend-tdd.md

Test-driving the backend

Hard rule: no implementation code before its test. Install Vitest and write the first failing test before writing any function body. If you find yourself with working code and no test, you have skipped this step — stop, write the test (it may already pass, which means it's now a regression guard rather than a design tool, but it still must exist), and confirm it would fail if the behavior were removed before continuing.
The backend pieces in backend-integration.md — BFF session, Order creation, post-purchase capture/refund/cancel, webhook reconciliation — are an unusually good fit for TDD. Not because tests are virtuous, but because the rules that make this integration correct are invisible at the call site and only show up under conditions that are annoying to reproduce by hand: a retried webhook, a stale cart version, an async PSP that hasn't settled yet, a developer reaching for the Payment Intents API out of habit. Each of those is one cheap assertion. Writing the test first is the fastest way to pin the behavior down and leave a tripwire so the next change can't quietly undo it.
This is the discipline for Step 4. Write the test, watch it fail for the right reason, make it pass, then move on. The payoff is concentrated in the invariants the rest of this skill keeps repeating — they stop being prose you hope the reader internalizes and become checks that break the build.
Setup first. Before writing any backend code, install Vitest and verify npm test runs (even with zero test files). This takes two minutes and means every subsequent test-first cycle has a working harness to run against. Do not defer this to "after the backend is done."
npm install --save-dev vitest @vitest/coverage-v8
# add to package.json scripts: "test": "vitest run"
npm test   # should exit 0 with "no test files found" — harness is live
This "test": "vitest run" is right for the BFF/storefront (which you run yourself). But for a custom connector, prefer Jest — the Connect platform validates npm test at publish and its examples (and the connector templates) use Jest. If you do use Vitest, run each app's test through a wrapper that calls Vitest with a fixed arg list (Vitest aborts on unknown CLI options), and give every app — including the assets enabler — a test script. See stripe.md → "Prefer Jest for connector apps".

The loop

For each behavior, smallest first:

  1. Red — write one test that names the behavior and asserts the outcome. Run it. It must fail because the behavior is missing, not because the import is wrong or the mock isn't wired — a test that passes before you've written anything, or errors for a boring reason, is testing nothing. Read the failure and confirm it's the failure you expected.
  2. Green — write the least code that makes it pass. Resist generalizing; the next test will tell you what to generalize.
  3. Refactor — clean up with the test as a safety net.
Keep tests at the behavior level, not the line level. "Creating an Order twice with the same orderNumber doesn't double-create" is a behavior worth a test; "the function calls fetch with these exact headers" is usually too brittle to be worth pinning unless the header is the behavior (the X-Session-Id auth header is — see below).

Where to draw the test boundary

The backend's job is orchestration — it decides when to mint a session, when the Order may be created, which route a refund goes through, whether a webhook has already been handled. The PSP, the connector's processor, and the Sessions/Orders APIs are someone else's code across a network. So:
  • Mock the outbound boundary (the processor's operation routes, the Sessions API, the PSP, the CT Orders/Payments API client) and assert on what your code decided to do: which endpoint it called, with what body, in what order, and what it did with the response. These tests are fast, deterministic, and run with no deployment and no secrets — so they run on every commit.
  • Don't mock your own orchestration logic — that's the thing under test.
  • Don't try to assert the PSP actually charged a card here. That's the job of the full-flow integration test (integration-test.md), which runs against a real deployed connector with test cards. Unit tests prove your decisions; the integration test proves the wiring.
A thin port in front of each outbound dependency makes this painless: a processorClient with capture()/refund()/cancel(), a sessionsApi.create(), a ctOrders.create(). Tests inject a fake; production injects the real one. If you find a behavior hard to test, it's usually because the decision and the I/O are tangled — separating them is the refactor the test is asking for.
The examples below use Vitest + TypeScript to match the storefront stack, but nothing depends on Vitest specifics — vi.fn()jest.fn() and they read identically under Jest or node:test.

What to test, per backend piece

For each piece: the behaviors worth pinning, and — just as important — what the test is guarding against, since that's the bug the prose warning is trying to prevent.
Start each piece with the happy path, then the deviations. The happy-path test is the one every other test is a deviation from — "owned cart + a Success transaction → exactly one Order, Payment linked, cartState: Ordered." Write it first: it's the cheapest to get green, it forces the function's shape into existence, and without it a suite can drift into asserting every way the flow breaks while never asserting it actually works (a green build where the success path silently regressed). Then add the error and edge deviations below, which is where the real defects hide.

BFF session creation

The security-critical decisions happen here, and they're exactly the ones a happy-path manual test never exercises — so test the happy path and the guards.
  • Happy path: an owned, non-zero cart yields a sessionId and the processor/enabler URLs. This is the baseline the guards below deviate from.
  • IDOR guard: a session is created only when the cart belongs to the caller. Test the rejection path — a cart whose customerId differs from the authenticated user must not produce a session. This is the test that matters most and the one most likely to be missing.
  • Secrets stay server-side: the object returned to the browser contains sessionId, processorUrl, enablerUrl and nothing else — assert the response has no access_token, no client secret. A snapshot or explicit key-set assertion catches a careless res.json(session) that leaks the whole token response.
  • Non-zero cart: a €0 cart is refused before a session is minted (the processor would reject it anyway — contract pitfall 3).
import { describe, it, expect, vi } from 'vitest';
import { createCheckoutSession } from '../bff/session';

describe('BFF session creation', () => {
  it('refuses to create a session for a cart the caller does not own (IDOR)', async () => {
    const ctCarts = { get: vi.fn().mockResolvedValue({ id: 'cart-1', customerId: 'someone-else' }) };
    const sessionsApi = { create: vi.fn() };

    await expect(
      createCheckoutSession({ cartId: 'cart-1', user: { customerId: 'me' }, ctCarts, sessionsApi }),
    ).rejects.toThrow(/forbidden|ownership/i);

    expect(sessionsApi.create).not.toHaveBeenCalled();   // the real assertion: no session was minted
  });

  it('returns only sessionId + processor/enabler URLs to the browser', async () => {
    const ctCarts = { get: vi.fn().mockResolvedValue({ id: 'cart-1', customerId: 'me', totalPrice: { centAmount: 1999 } }) };
    const sessionsApi = { create: vi.fn().mockResolvedValue({ id: 'sess-1', accessToken: 'SECRET' }) };

    const out = await createCheckoutSession({ cartId: 'cart-1', user: { customerId: 'me' }, ctCarts, sessionsApi });

    expect(out).toEqual({ sessionId: 'sess-1', processorUrl: expect.any(String), enablerUrl: expect.any(String) });
    expect(JSON.stringify(out)).not.toContain('SECRET');   // no token leaks to the client
  });
});

Order creation

The whole point is the preconditions and idempotency — the Order is the commit, and committing twice or committing too early is the failure mode. Pin the success case first, then the two ways it must refuse.
  • Happy path: an owned cart whose linked Payment has a Success transaction creates exactly one Order at the current cart version and flips cartState to Ordered. This is the contract; the gates below are when it must not fire.
  • Gated on authorization: with no Success transaction on the linked Payment, placeOrder must not call ctOrders.create. For an async PSP, "authorization complete" means the webhook moved it to Success — so the gate is the same test with the transaction still Pending.
  • Declined payment never commits: a Failure transaction (card declined, insufficient funds — the most common real-world error path) must block Order creation just like Pending does, and the caller should get a clear decline back, not a generic 500. This is distinct from Pending: Pending is "not yet," Failure is "no" — and an Order built on a declined Payment is the worst outcome, an unpaid fulfilled order.
  • Idempotent on orderNumber: two calls with the same pre-generated orderNumber create at most one Order. Simulate the CT "duplicate orderNumber" rejection on the second call and assert your code treats it as success (returns the existing Order), not as an error to retry into a third attempt.
  • Uses the current cart version: a stale version is rejected; assert you refetch/propagate the version rather than reusing a cached one.
it('creates exactly one Order from an authorized cart and marks it Ordered (happy path)', async () => {
  const created = { id: 'order-1', orderNumber: 'ord-1', cartState: 'Ordered' };
  const ctOrders = { create: vi.fn().mockResolvedValue(created) };
  const payment = { transactions: [{ type: 'Authorization', state: 'Success' }] };  // authorized

  const order = await placeOrder({ cartId: 'c1', cartVersion: 3, orderNumber: 'ord-1', payment, ctOrders });

  expect(ctOrders.create).toHaveBeenCalledOnce();
  expect(ctOrders.create).toHaveBeenCalledWith(expect.objectContaining({ orderNumber: 'ord-1', version: 3 }));
  expect(order.cartState).toBe('Ordered');
});

it('does not create an Order until a Success transaction exists', async () => {
  const ctOrders = { create: vi.fn() };
  const payment = { transactions: [{ type: 'Authorization', state: 'Pending' }] };  // async PSP, not settled

  await expect(placeOrder({ cartId: 'c1', cartVersion: 3, orderNumber: 'ord-1', payment, ctOrders }))
    .rejects.toThrow(/not authorized|pending/i);
  expect(ctOrders.create).not.toHaveBeenCalled();
});

it('refuses to create an Order on a declined payment, surfacing the decline (error path)', async () => {
  const ctOrders = { create: vi.fn() };
  const payment = { transactions: [{ type: 'Authorization', state: 'Failure' }] };  // card declined

  await expect(placeOrder({ cartId: 'c1', cartVersion: 3, orderNumber: 'ord-1', payment, ctOrders }))
    .rejects.toMatchObject({ code: 'PaymentDeclined' });   // a clear decline, not a generic 500
  expect(ctOrders.create).not.toHaveBeenCalled();          // never an unpaid Order
});

it('is idempotent: a duplicate orderNumber returns the existing Order, not an error', async () => {
  const existing = { id: 'order-1', orderNumber: 'ord-1' };
  const ctOrders = {
    create: vi.fn().mockRejectedValueOnce({ statusCode: 400, code: 'DuplicateField', field: 'orderNumber' }),
    getByOrderNumber: vi.fn().mockResolvedValue(existing),
  };
  const payment = { transactions: [{ type: 'Authorization', state: 'Success' }] };

  const order = await placeOrder({ cartId: 'c1', cartVersion: 3, orderNumber: 'ord-1', payment, ctOrders });
  expect(order).toEqual(existing);     // a retry converges on the one Order, never a second
});

it('is idempotent: "cart not in active state" (cartState=Ordered) also returns the existing Order', async () => {
  // After the first order creation succeeds, CT flips cartState → Ordered.
  // A second POST /orders then fails with InvalidOperation "not in active state"
  // *before* CT checks the orderNumber, so DuplicateField is never raised.
  // The handler must also catch this case and return the existing Order.
  const existing = { id: 'order-1', orderNumber: 'ord-1' };
  const ctOrders = {
    create: vi.fn().mockRejectedValueOnce({
      statusCode: 400,
      body: { errors: [{ code: 'InvalidOperation', message: 'The cart is not in active state.' }] },
    }),
    getByOrderNumber: vi.fn().mockResolvedValue(existing),
  };
  const payment = { transactions: [{ type: 'Authorization', state: 'Success' }] };

  const order = await placeOrder({ cartId: 'c1', cartVersion: 4, orderNumber: 'ord-1', payment, ctOrders });
  expect(order).toEqual(existing);
});

Post-purchase capture / refund / cancel

The decision this code must get right is which API it calls — and the single most valuable test in the whole suite is the one that fails if someone routes a direct-connector refund through the Checkout Payment Intents API.
  • Routes through the processor, never the Payment Intents API: assert the processor's operation route was called and that no Payment Intents endpoint (/checkout/payment-intents, the manage_checkout_payment_intents path) was touched. This is a guardrail test — its job is to fail loudly the day someone "simplifies" it to the wrong API.
  • Capture idempotency: one Charge per PSP interactionId; a retried capture with the same interaction id doesn't double-charge.
  • Partial refund only when configured/allowed: a partial refund above the captured amount is rejected; multiple partial refunds sum correctly up to the captured total.
it('routes capture through the processor, not the Payment Intents API', async () => {
  const processor = { capture: vi.fn().mockResolvedValue({ ok: true }) };
  const paymentIntents = { capture: vi.fn() };   // the wrong API — must stay untouched

  await capturePayment({ paymentId: 'pay-1', amount: { centAmount: 1999 }, processor, paymentIntents });

  expect(processor.capture).toHaveBeenCalledOnce();
  expect(paymentIntents.capture).not.toHaveBeenCalled();   // guardrail against the Checkout-only API
});

it('does not double-charge on a retried capture (idempotent by interactionId)', async () => {
  const processor = { capture: vi.fn().mockResolvedValue({ interactionId: 'pi_123' }) };
  const seen = new Set<string>();

  await capturePayment({ paymentId: 'pay-1', interactionId: 'pi_123', processor, seen });
  await capturePayment({ paymentId: 'pay-1', interactionId: 'pi_123', processor, seen });   // retry

  expect(processor.capture).toHaveBeenCalledOnce();
});

Webhook reconciliation

This is where async PSPs live, and it's the piece most painful to exercise by hand because it depends on a signed event arriving — possibly twice. Tests pay off the most here.

  • Idempotent on redelivery: the same webhook event id applied twice leaves the Payment in the same state and creates at most one transaction. PSPs will redeliver; assert it.
  • Signature/verification is enforced: a tampered or unsigned payload is rejected before any state change. (For a custom processor you own this; for the public connector, test your own handler's gate if you have one in front.)
  • Drives the gate the Order waits on: after the webhook moves the transaction to Success, the Order-creation gate that was closed in the Order test now opens. A test that asserts "stuck Pending → no Order; webhook arrives → Order proceeds."
it('is idempotent when the PSP redelivers the same event', async () => {
  const ctPayments = { addTransaction: vi.fn().mockResolvedValue({}) };
  const processed = new Set<string>();
  const event = { id: 'evt_1', type: 'payment_intent.succeeded', paymentId: 'pay-1' };

  await handleWebhook({ event, ctPayments, processed });
  await handleWebhook({ event, ctPayments, processed });   // redelivery

  expect(ctPayments.addTransaction).toHaveBeenCalledOnce();
});

A note on not over-testing

The goal is a small suite of behaviors that would each represent a real production incident if broken: the happy path per piece (session minted, Order created once and marked Ordered, capture/refund recorded) plus the deviations that bite — IDOR, premature/declined Order, double-create, double-charge, wrong-API, webhook redelivery. That's roughly a dozen tests, and they're worth keeping forever. The happy path earns its place precisely because it's load-bearing: it's the one a broad refactor is most likely to break without any error test noticing. Resist mirroring every line of orchestration into an assertion; tests that pin implementation details (exact header order, internal call counts that aren't about idempotency) make refactoring miserable and tend to get deleted in frustration, taking the valuable tests with them. When in doubt, ask: "what production bug does this test catch?" If you can't name one, don't write it.
Once these pass, prove the wiring end to end with the full-flow integration test.

Checklist

Gate: do not proceed to Step 5 (integration test / verification) until every box below is checked and npm test exits 0 with no secrets in the environment.
  • Vitest installed and npm test runs before the first line of implementation — not after
  • Each backend behavior was written test-first: a failing test, confirmed to fail for the right reason, then the code; no function body existed before its test
  • Outbound boundary (processor, Sessions/Orders API, PSP) is mocked behind a port; orchestration logic is not mocked
  • Happy path pinned per piece: owned cart → session; Success → exactly one Order marked Ordered; capture/refund recorded
  • BFF: IDOR rejection tested; response asserted to carry no secrets; €0 cart refused
  • Order: gated on a Success transaction (async = webhook); declined (Failure) payment refused with a clear decline (not a generic 500); idempotent on orderNumber; current cart version used
  • Capture/refund/cancel: routed through the processor with the Payment Intents API asserted untouched; capture idempotent by interactionId
  • Webhook: idempotent on redelivery; signature verification enforced before any state change; opens the Order gate
  • npm test exits 0 with no deployment/secrets in the environment (those belong to the integration test)
integrations/payment/config-from-requirements.md

From requirements to config

The connector's behavior is set by connect.yaml configuration, and most of it has a default that quietly bakes in a decision. So the job is: take the requirements gathered in Step 1, decide each value deliberately, and hand the user a filled config block with a one-line why per non-obvious key. This reference gives the provider-agnostic mapping; exact key names, defaults, and the secured-vs-standard split are in the provider reference (e.g. stripe.md).

Table of contents

The connect.yaml envelope

Before deciding values, get the shape right. The values below (capture method, saved cards, origins) all live inside a fixed envelope that the connector author defines and Connect validates at publish/deploy time. There is no published JSON Schema or OpenAPI file for connect.yaml — the authoritative spec is the documentation, not a linter — so the envelope is easy to get subtly wrong. Two rules close the common gaps.
1. Don't invent fields. The envelope has a closed set of keys. Use only these; if a key you "remember" isn't on this list, it doesn't exist. The canonical reference is the docs page Configure connect.yaml (fetch https://docs.commercetools.com/connect/development.md to confirm against the current spec) — read it rather than reconstructing the structure from memory.
deployAs:                              # required — array of the connector's applications
  - name: processor                    # required — must match the application's folder name in the repo
    applicationType: service           # service | event | job | merchant-center-custom-application | merchant-center-custom-view | assets
    endpoint: /processor               # required for service/event/job; omit for assets and the MC types
    properties:
      schedule: '*/5 * * * *'          # job type only — cron expression
    scripts:                           # optional — only if the app installs Extensions/Subscriptions
      postDeploy: npm run connector:post-deploy
      preUndeploy: npm run connector:pre-undeploy
    configuration:                     # optional (omit for assets)
      standardConfiguration:           # non-secret env vars; each: key, description, required, default?
        - key: CTP_PROJECT_KEY
          description: ...
          required: true
          default: 'default-key'       # default? is allowed here only
      securedConfiguration:            # secrets; each: key, description, required — NO default
        - key: CTP_CLIENT_SECRET
          description: ...
          required: true
inheritAs:                             # optional — config/scopes shared across all applications
  configuration:
    standardConfiguration: [...]
    securedConfiguration: [...]
  apiClient:
    scopes:                            # for auto-generated API Client credentials
      - manage_payments
inheritAs.apiClient and self-supplied CTP_CLIENT_ID/CTP_CLIENT_SECRET are mutually exclusive — declaring both is a deploy/install-time conflict. Pick one credential model, not both:
  • Auto-generated (recommended): declare inheritAs.apiClient.scopes and let Connect mint the credentials and inject them. Then remove the CT-client keys from your config — CTP_CLIENT_ID, CTP_CLIENT_SECRET, and CTP_SCOPE from securedConfiguration, and CTP_PROJECT_KEY from standardConfiguration — Connect injects all of these at runtime, and leaving them declared causes a deploy conflict.
  • Self-supplied: declare CTP_CLIENT_ID/CTP_CLIENT_SECRET in securedConfiguration and drop the inheritAs.apiClient block; the deployer provides the values.
The securedConfiguration example below shows the self-supplied half; if you keep inheritAs.apiClient, remove those CT-client keys.
The only per-entry fields are: name, applicationType, endpoint, properties (with schedule), scripts (postDeploy/preUndeploy), and configuration. Each config item is exactly { key, description, required } plus default for standardConfiguration only. Anything else — a type, value, env, secret, validation field on a config item, or a top-level key other than deployAs/inheritAs — is hallucinated. (Note: the connector author writes connect.yaml with these key declarations; the deployer supplies the actual value for each at deployment create time. The YAML itself carries no value field — don't add one.)
2. connect.yaml lives at the repository root. It is the entry point Connect looks for, and it must sit at the top level of the connector repo — not inside processor/, enabler/, src/, or any nested folder. A nested connect.yaml is not discovered and the connector fails to stage/deploy with no obvious cause. The application folders (processor/, enabler/) are siblings below the root, and each app's name in deployAs points at its folder; the single connect.yaml at the root describes them all.
my-stripe-connector/
├── connect.yaml          ← here, and only here
├── processor/            ← name: processor, applicationType: service
└── enabler/              ← name: enabler, applicationType: assets

The mapping

Each requirement drives one or more config keys. The middle column is the concept (provider-agnostic); the provider reference gives the actual key name for the chosen PSP.
Requirement (Step 1)Config concept it drivesDecision guidance
Region + projectthe CTP_*_URL hosts (CTP_API_URL, CTP_AUTH_URL, CTP_SESSION_URL, CTP_CHECKOUT_URL), CTP_JWKS_URL, CTP_JWT_ISSUER, CTP_PROJECT_KEYAll must point at the user's region; defaults usually point at one region (often europe-west1.gcp) — change them or auth/session calls fail.
Capture mode (charge now vs. authorize→capture later)capture-method key (e.g. automatic vs manual)manual = authorize at submit(), you capture later via the processor on fulfillment → also delays when you create the Order (see backend). automatic = charged at submit().
Saved payment methods / returning customerssaved-cards config + "setup future usage"Enabling it requires the cart to carry a customerId (stored methods bind to a Customer). Off by default — only enable if the business wants reuse.
Partial refunds / split capturesmulti-operations toggleOff by default; enabling partial/multiple captures or refunds often also requires the capability enabled in the PSP account. Don't enable speculatively — it changes transaction handling.
Payment methods + drop-in vs componentslayout / appearance / express-element config; the integration type chosen in the Merchant CenterDrop-in (one element) is simplest; web components give per-method control. Layout/appearance keys are cosmetic and safe to leave default.
Storefront origin(s)allowed-origins (CORS)Must list every exact origin the browser calls the processor from (scheme + host + port). Missing origin → processor CORS-rejects the browser.
Post-payment return URLmerchant-return-URLMust be an absolute URL with a scheme — the enabler calls new URL() on it; a bare host throws and silently breaks the flow.
Payment interface namingpayment-interface valueThe paymentMethodInfo.paymentInterface written on the Payment; pick a stable identifier so you can query payments by interface later.
Sync vs. async settlementwebhook id + signing secret (secured)Required whenever final state arrives via webhook. Without it the transaction never finalizes. Drives whether Order creation waits on the webhook.
(always) PSP credentials, CT clientsecured: PSP secret key, webhook signing secret, CTP_CLIENT_ID, CTP_CLIENT_SECRETAlways securedConfiguration, never standard, never hardcoded, never invented — the user supplies the real values.

How to present the result

Give the user four things, not a vague pointer:

  1. A filled standardConfiguration block with the chosen values inline.
  2. The securedConfiguration keys they must set themselves (names only — never fabricate secret values).
  3. The API-client scopes the connector needs (at minimum: manage_payments, view_sessions; add manage_orders if the connector creates/links Carts or Orders). Two traps here:
    • Don't request manage_project as a shortcut. It's a broad superset that masks which scopes you actually need and over-privileges the connector; it also won't survive a least-privilege review or certification. List the specific scopes.
    • The runtime token scopes must match the declared scopes. Whatever the SDK client requests at token time (e.g. withClientCredentialsFlow({ scopes: [...] })) must be covered by the API client's granted scopes. With auto-generated credentials (inheritAs.apiClient.scopes), requesting a scope you didn't declare — e.g. manage_project — fails token acquisition with invalid_scope (400). Either omit the explicit scopes array (inherit the client's scopes) or request exactly the declared set.
  4. A short rationale list: for each non-default or non-obvious key, one line tying it to the requirement it came from. This is what lets the user catch a wrong assumption.

Worked example (Stripe)

Requirements gathered: Stripe connector, region europe-west1.gcp, project acme, authorize now and capture on shipment, save cards for logged-in customers, partial refunds expected, drop-in, storefront at https://shop.acme.com (+ http://localhost:5173 for dev), return to https://shop.acme.com/order-complete.
Derived config (key names/defaults from stripe.md). The value: lines below are the deployment-time inputs the deployer supplies (e.g. via --configuration) — they are not part of connect.yaml itself, which only declares the keys (see the per-entry-fields note above):
# processor — standardConfiguration — values shown are deployment inputs, NOT connect.yaml fields
- key: CTP_PROJECT_KEY
  value: acme
- key: CTP_API_URL
  value: https://api.europe-west1.gcp.commercetools.com      # region
- key: CTP_AUTH_URL
  value: https://auth.europe-west1.gcp.commercetools.com     # region
- key: CTP_SESSION_URL
  value: https://session.europe-west1.gcp.commercetools.com  # region
- key: STRIPE_CAPTURE_METHOD
  value: manual                  # authorize now, capture on shipment → also: create Order on auth, capture later
- key: STRIPE_SAVED_PAYMENT_METHODS_CONFIG
  value: '{"payment_method_save":"enabled"}'   # save cards → requires customerId on the cart
- key: STRIPE_ENABLE_MULTI_OPERATIONS
  value: 'true'                  # partial refunds expected (also enable multicapture in the Stripe account)
- key: STRIPE_COLLECT_BILLING_ADDRESS
  value: auto
- key: MERCHANT_RETURN_URL
  value: https://shop.acme.com/order-complete   # absolute URL w/ scheme
- key: ALLOWED_ORIGINS
  value: https://shop.acme.com,http://localhost:5173   # every browser origin that calls the processor
- key: PAYMENT_INTERFACE
  value: checkout-stripe         # written on the Payment; query payments by this later

# processor — securedConfiguration (user supplies the values)
- key: STRIPE_SECRET_KEY                  # Stripe test/live secret key
- key: STRIPE_WEBHOOK_SIGNING_SECRET      # verifies inbound Stripe webhooks
- key: CTP_CLIENT_ID
- key: CTP_CLIENT_SECRET
- key: CTP_SCOPE                           # required alongside ID/SECRET in the self-supplied model
# plus STRIPE_WEBHOOK_ID (standard) once the webhook endpoint exists

Rationale to hand back:

  • STRIPE_CAPTURE_METHOD: manual — they capture on shipment, so authorize at pay time and capture later via the processor; this is also why the Order is created on a successful authorization, not on charge.
  • STRIPE_SAVED_PAYMENT_METHODS_CONFIG: enabled — saving cards binds methods to a Customer, so the cart must carry a customerId; anonymous carts won't save.
  • STRIPE_ENABLE_MULTI_OPERATIONS: true — partial refunds were required; this also needs multicapture enabled in the Stripe account.
  • ALLOWED_ORIGINS / MERCHANT_RETURN_URL — the two values that silently break the browser flow if wrong; both pinned to the real storefront.

Pitfalls in the config itself

  • Leaving region URLs at their defaults when the project is in another region → auth/session failures.
  • Enabling saved cards without ensuring a customerId on the cart → no methods saved, confusing "why didn't it save" reports.
  • Enabling multi-operations in the connector but not in the PSP account → partial capture/refund calls fail at the PSP.
  • A bare-host MERCHANT_RETURN_URL or a missing origin in ALLOWED_ORIGINS → the frontend breaks at runtime, not at deploy.

Checklist

  • every requirement from Step 1 mapped to a concrete value (no silent defaults left on behavior-changing keys)
  • standardConfiguration filled; securedConfiguration listed by name only
  • scopes stated; region URLs match the project's region
  • rationale line per non-obvious key, tied to its requirement
  • capture-mode decision reflected in the Order-creation timing (→ backend-integration.md)
integrations/payment/connector-contract.md

Payment connector contract (provider-agnostic)

This is the shared contract every PSP connector built from the payment integration template follows. Only a few provider-specific values differ (enabler bundle filename + UMD global, a handful of config keys, test cards) — those live in the per-provider reference. The flow, the auth model, and the pitfalls below are the same for Stripe, Adyen, Mollie, and PayPal.

Table of contents

Two URLs you need

A deployed connector exposes two public URLs (visible in the Merchant Center deployment view, or via the Connect deployments API):

  • processor URL — the service app, e.g. https://service-….{region}.commercetools.app. Your frontend points the enabler at it; the enabler calls it; you can call GET /operations/status directly.
  • enabler URL — the assets app, e.g. https://assets-….{region}.commercetools.app. You load the enabler JS bundle from here.
Don't hardcode these — read them from config/env. A URL is assigned per deployment and stays stable across redeploys of that same deployment; a brand-new deployment create gets a fresh URL. Reading them from config keeps you correct either way.

The 8-step flow

1. OAuth token            POST {auth}/oauth/token (client_credentials, manage_sessions[+])
2. Cart (non-zero total)  POST {api}/{projectKey}/carts
3. Checkout Session       POST https://session.{region}.commercetools.com/{projectKey}/sessions
4. Warm processor         GET  {processorUrl}/operations/status        (cold-start guard)
5. Load enabler           <script src="{enablerUrl}/connector-enabler.umd.js">  → window.<Global>.Enabler
6. Construct + build       new Enabler({processorUrl, sessionId, onComplete, onError}) → createDropinBuilder('embedded') → build()
7. Mount + wait ready     dropin.mount('#container'); wait for `ready` before enabling Pay
8. Submit                 dropin.submit()  → processor authorizes/charges via PSP, writes the CT Payment
Steps 1–4 are server-side (or in a harness, done before mounting). Steps 5–8 are browser-side. The enabler hides the processor's HTTP calls — your code never calls GET /payments itself (see pitfall 8).

Sessions API: the request body

A Checkout Session is what authenticates the browser to the processor. It is created server-side with an access token carrying at least manage_sessions:{projectKey} (docs).
POST https://session.{region}.commercetools.com/{projectKey}/sessions
Authorization: Bearer <token with manage_sessions>

{
  "cart": { "cartRef": { "id": "<cartId>" } },
  "metadata": { "applicationKey": "<checkout-application-key>" }
}

Two things the docs make non-obvious for the direct-connector path:

  • cart.cartRef.id — a reference to an existing cart, not an inline cart (see pitfall 1).
  • metadata must identify the processor the session is for. With a Checkout Application configured in the Merchant Center, that is metadata.applicationKey. Some connector deployments instead validate metadata.processorUrl (the processor checks the session's metadata matches its own deployed URL and otherwise returns 401 "Session is not active"). Use whichever the connector expects — if you get a 401 from the processor with a freshly created session, this metadata mismatch is the first thing to check. The provider reference notes which one a given connector wants.
The response id is the sessionId you hand to the enabler.
Session response shape. The Sessions API returns the cart reference under activeCart.cartRef.id, not cart.cartRef.id. When the processor validates the session and reads the cart ID, use:
const cartId = session.activeCart?.cartRef?.id;
A processor that reads session.cart?.cartRef?.id will always get undefined and return "Session has no cart reference".

Loading the enabler

The enabler is published as two bundles: an ES module (…enabler.es.js) and a UMD build (…enabler.umd.js) that attaches a global (e.g. window.Connector). The exact filename and global name are per-provider — see the provider reference.
Use the UMD build via a <script> tag. See pitfall 5 for why dynamic import() of the ES bundle fails in browsers.
<script src="https://assets-….commercetools.app/connector-enabler.umd.js"></script>
<script>
  const Enabler = window.Connector.Enabler;   // global name is provider-specific
</script>

Then:

const enabler = new Enabler({
  processorUrl,
  sessionId,
  locale: 'en-US',                 // pass the real locale; don't hardcode in prod
  onComplete: (result) => { /* success → redirect to return URL */ },
  onError: (err) => { /* surface err.message / err.code */ },
});

const builder = await enabler.createDropinBuilder('embedded');
const dropin  = await builder.build({ showPayButton: false }); // own your Pay button
dropin.mount('#dropin-container');

Processor routes and auth

The processor exposes a small, stable surface (names from the template's /operations + payment routes):
  • GET /operations/config — public-ish config the enabler reads (publishable key, capture method, billing address setting, merchant return URL, etc.). On a custom connector you own this endpoint; ensure it returns at least the public key and merchantReturnUrl so the enabler can configure the PSP's JS SDK and the redirect. Some PSPs need additional session-authenticated config routes beyond /operations/config (e.g. one that returns the real cart amount/currency to initialize the payment element) — the provider reference documents any extra routes a given connector requires.
  • GET /operations/status — health/readiness. Ping it right after session creation to warm a cold container (pitfall 10).
  • the payment route (the enabler calls this for you) — see pitfall 8.
  • additional operation routes for capture/refund/cancel — not part of the storefront pay flow, but you call them from your backend for post-purchase money movements (→ backend-integration.md).
Auth to the processor is the session header, not Bearer. The enabler sends X-Session-Id: <sessionId> (the processor's session-authentication hook from @commercetools/connect-payments-sdk validates it). If you ever call a processor route directly, use X-Session-Id, not Authorization: Bearer (see pitfall 9).

Who owns the Payment object

On this path, the processor creates and maintains the commercetools Payment — it creates the Payment, adds the Authorization/Charge transaction, and records PSP interactions. Your frontend does not create Payment objects (that is the raw BFF model from custom checkout, which applies only when you integrate a PSP without a connector). Confusing the two leads to duplicate Payments. Verifying the round trip therefore means finding the Payment the processor wrote — see verification.md.

Pitfall catalog

Each pitfall below cost real debugging time. Treat them as pre-flight checks.

1. Session body requires cartRef, not an inline cart

The Sessions API rejects an inline cart. Always { "cart": { "cartRef": { "id": "<cartId>" } } }.

2. Session metadata must match what the processor expects

Missing/wrong metadata (e.g. applicationKey or processorUrl) → processor returns 401 "Session is not active". First thing to check on a processor 401 with a fresh session.

3. Cart must have a non-zero total

The processor checks paidAmount >= cartAmount; a €0 cart is rejected ("already paid in full"). Easiest non-zero cart without needing a tax category: taxMode: ExternalAmount with a customLineItem carrying an externalTotalPrice. Example:
{
  "currency": "EUR",
  "taxMode": "ExternalAmount",
  "customLineItems": [{
    "name": { "en": "Test item" },
    "quantity": 1,
    "money": { "currencyCode": "EUR", "centAmount": 1999 },
    "slug": "test-item",
    "externalTaxRate": { "name": "test", "amount": 0.0, "country": "DE" }
  }]
}

4. Stale CT API Extension returns 502 on cart updates

A leftover API Extension from a previous deployment (destination pointing at a dead URL) fires synchronously on every cart update — including the connector's addPayment — surfacing as a generic processor failure (500→502). Diagnose with GET {api}/{projectKey}/extensions and inspect each destination. API Extensions are project-global and may belong to tax, pricing, fraud, or another integration — deleting a live one silently breaks the project with no error. So do not delete one automatically: identify the suspect (destination URL matching the dead/old deployment), report it to the user with its key and destination, and remove it only on explicit confirmation — DELETE {api}/{projectKey}/extensions/key={key}?version=N. Modern templates do not register such an extension for the basic pay flow, but "likely legacy" is not proof — confirm the destination is actually dead before removing.

5. Load the enabler via UMD script tag, not dynamic ES import()

Dynamic import('…enabler.es.js') can fail with ERR_CONNECTION_CLOSED because the enabler internally imports the PSP's JS (e.g. @stripe/stripe-js), which injects its own script tag and trips up the ES-module loader in some browsers. Load the UMD bundle with a <script> tag and read the global (window.<Global>.Enabler).

6. MERCHANT_RETURN_URL must be an absolute URL with a scheme

The enabler calls new URL(merchantReturnUrl), which throws on a bare host (e.g. the default 127.0.0.1/processor/callback/...). Set it to a real absolute URL like http://localhost:5173/payment-complete in the connector config.

7. Wait for the enabler ready event before submit()

dropin.mount() returns before the PSP's payment iframe is actually ready. Calling submit() too early throws "could not retrieve data from the specified Element". Enable your Pay button only after the enabler signals ready (listen on the container, with a fallback timeout).

8. The payment-creation route is GET, not POST

Counter-intuitively the processor's payment-intent creation can be a GET /payments. The enabler calls it for you — don't call it directly. If you're tempted to, you're probably reimplementing the enabler; don't.

9. Processor auth header is X-Session-Id, not Bearer

The browser↔processor auth is X-Session-Id: <sessionId>. A GET /operations/status warm-up needs no auth; data routes need the session header. Authorization: Bearer will not authenticate you to the processor.

10. Processor cold-start 504

A sandbox processor container sleeps and takes some time to wake (see Connect overview: Environments), so the enabler's first call can time out (504). Fire GET {processorUrl}/operations/status right after creating the session to warm it before the enabler runs.

11. Raw-body webhook parsing can reject empty POST bodies on all routes

A custom processor that registers a raw-body plugin for webhook signature verification can have that plugin replace the JSON body parser globally — so any POST with Content-Type: application/json and an empty/missing body fails, including the POST /payments call from the enabler. Defensive fix that works regardless of the plugin's quirks: always send body: "{}" (a valid empty JSON object) from the enabler, never an empty string or no body. The exact plugin behavior is provider-specific — see the provider reference (e.g. stripe.md for the fastify-raw-body v5 case).

12. Deferred-intent: create the PSP intent inside submit(), not at mount time

For PSPs that use a deferred-intent pattern (the payment element mounts before the underlying payment intent exists), the order of operations matters: validate the form, then create the intent server-side inside submit() (your POST /payments call), then confirm with whatever token the create returns. Creating the intent at mount time instead — before the user has confirmed — leaves abandoned intents accumulating at the PSP, and confirming without the token the create returns fails. The provider-specific API names and the exact confirm sequence live in the provider reference — see stripe.md for the Stripe (stripe.elements() / clientSecret / confirmPayment) version.

13. ConcurrentModification on Order creation — cart version is always stale from the client

The processor calls addPayment on the cart inside submit() to link the newly created CT Payment. This bumps the cart version. Any cartVersion the browser captured before submit() (from the checkout page, sessionStorage, a URL param) is therefore stale by the time the return URL fires and Order creation runs. Passing it to POST /orders produces:
"Object <cartId> has a different version than expected. Expected: 1 - Actual: 3."
Fix: always refetch the cart version server-side inside the Order creation route, immediately before calling POST /orders. The extra GET is cheap and eliminates this error entirely:
const { body: cart } = await apiRoot.carts().withId({ ID: cartId }).get().execute()
// use cart.version — never the client-supplied value

Do not try to work around this by passing the version from the return URL query string or sessionStorage — those are just as stale. The only reliable source is a fresh GET.

14. Return URL fires before the webhook — Order creation gets a 422

The browser reaches MERCHANT_RETURN_URL (your payment-complete page) in under a second. The Stripe webhook that moves the CT Payment transaction from Pending to Success arrives 1–5 seconds later, even in a healthy setup. If your return URL handler calls Order creation immediately on page load, it hits the payment gate while the transaction is still Pending and gets back "no successful payment found" (422).
Fix: poll with a timeout on 422, never fire once. Pre-generate the orderNumber before the first attempt so retries reuse the same value and can't double-create:
const MAX_ATTEMPTS = 10
const GAP_MS = 1500

for (let i = 0; i < MAX_ATTEMPTS; i++) {
  const res = await fetch('/api/orders/create', { method: 'POST', body: JSON.stringify({ cartId, cartVersion, orderNumber }) })
  if (res.ok) return await res.json()
  if (res.status !== 422) throw new Error(await res.text())   // hard error — stop
  if (i < MAX_ATTEMPTS - 1) await new Promise(r => setTimeout(r, GAP_MS))
}
throw new Error('Webhook timeout — check processor webhook secret and Stripe dashboard delivery log')
A DuplicateField 400 on orderNumber means a concurrent retry already succeeded — fetch and return the existing Order. Do not use a fixed sleep: too short = still flaky; too long = bad UX. See backend-integration.md → Return URL race condition.

15. Order creation returns 400 "cart is not in active state" on idempotent retry

When POST /orders succeeds, CT flips cartState to Ordered. A second call with the same cartId then fails with InvalidOperation: The cart is not in active state before CT can check whether orderNumber is a duplicate. If your retry logic only catches DuplicateField 400, the second attempt throws instead of returning the existing Order.
Fix: also catch InvalidOperation with "not in active state" and fetch by orderNumber in that branch:
const isDuplicate = err?.statusCode === 400 &&
  err?.body?.errors?.some((e: any) => e.code === 'DuplicateField' && e.field === 'orderNumber');
const isCartOrdered = err?.statusCode === 400 &&
  err?.body?.errors?.some((e: any) => e.code === 'InvalidOperation' && e.message?.includes('not in active state'));

if (isDuplicate || isCartOrdered) {
  const { body: existing } = await apiRoot.orders().withOrderNumber({ orderNumber }).get().execute();
  return existing;
}

16. The id the webhook records may not be the id the refund route needs

A common refund failure: the interactionId your webhook writes on the Success transaction is the PSP's authorization/intent id, but the PSP's refund API operates on a different object (the charge/capture), so passing the recorded id to refund returns a "not found" error. Two fixes: resolve the correct id from the PSP before refunding, or have the webhook handler record the refundable id on the Charge transaction in the first place. The provider-specific id types and lookup are in the provider reference — see stripe.md for the Stripe pi_xxxch_xxx case.

17. /operations/status returns 401 during redeployment

While a deployment is mid-restart (status Deploying), the old container is torn down before the new one is ready. During this window GET /operations/status — normally public/unauthenticated — returns 401. This is transient: wait for the deployment to reach Deployed, then the endpoint returns 200 as normal. Don't confuse this with an auth misconfiguration; if the 401 appears immediately after triggering a redeploy, it's the restart window.

Configuration that breaks the frontend

These connector config values are set at install/deploy time but only fail at frontend runtime, so check them here:
ConfigWhy it breaks the frontendFix
MERCHANT_RETURN_URLenabler new URL() throws on a bare hostabsolute URL with scheme
ALLOWED_ORIGINSprocessor CORS-rejects the browserinclude the frontend's exact origin
connector API-client scopessession/payment calls 403manage payments + read sessions (provider reference lists exact set)
webhook id/secret (async PSPs)transaction state never finalizesregister the PSP webhook, store its id/secret in secured config
For the exact config key names and defaults of a specific connector, read the provider reference (e.g. stripe.md).

Webhook events — look up, then select for the use case

For async PSPs, the connector's processor reconciles payment state from webhook events. Which events to subscribe to is provider-specific and use-case-specific — do not hardcode a list. Instead:
  1. Look it up. Consult the chosen PSP's official webhook-events documentation for the catalog of event types it emits (each PSP names them differently).
  2. Map to the lifecycle this skill cares about. The reconciliation only needs the events that move a commercetools transaction or open the Order gate: authorization succeeded, amount became capturable (authorize-now/capture-later), payment failed/declined, refund settled, and — for production — dispute/chargeback opened. Ignore events that don't change payment state.
  3. Select the minimal set for this user's flow. The capture mode, refund policy, and whether disputes must be handled (all gathered in Step 1 / config-from-requirements.md) decide which of the above apply. Example: a charge-now flow with no partial refunds needs the "succeeded" and "refunded" events but not "amount capturable"; a manual-capture flow does need the capturable event. Subscribe to what the use case requires, nothing more.

Register exactly that set when setting up the PSP webhook endpoint (the mechanics of registering are in the provider reference and the deploy guide). If a needed event isn't subscribed, the corresponding transaction silently never finalizes.

Checklist

  • processor URL and enabler URL read from config (not hardcoded)
  • session created with cartRef + processor-matching metadata; got a sessionId
  • cart total is non-zero
  • processor warmed via GET /operations/status
  • enabler loaded from the UMD bundle; global resolved
  • Pay button gated on the ready event; submit() only after
  • no stale API Extension pointing at a dead URL
  • Payment object found after submit (→ verification.md)
  • webhook events selected by looking up the PSP's docs and matching the user's use case (not a hardcoded list) — see Webhook events
  • (Custom processor) POST /payments sends body: "{}"fastify-raw-body v5 rejects empty bodies on all routes
integrations/payment/connector-selection.md

Is a certified connector enough?

Before wiring or building anything, answer one question: does a connector that already does what the user needs exist? Getting this wrong is expensive in both directions — building a custom connector when a public one covers you wastes weeks; assuming a public connector supports a method it doesn't surfaces only at integration time.
There are two kinds of connector (docs):
  • Public connectors — listed in the Connect marketplace, ready to install. Some are built by commercetools (e.g. Adyen, PayPal), some by third parties (e.g. Stripe). If one covers the use case, this is almost always the right choice: install + configure, don't build.
  • Organization (custom/private) connectors — deployed for your organization only. These come in two flavors that matter a lot here: a fork of an existing public connector's open-source repo (you extend it), or a connector built from scratch off the payment integration template. Both are commercetools-connect tasks.
The common-but-tricky case: a certified connector exists for the PSP, but the user's specific requirement isn't covered by the public version. Don't jump straight to "build custom" — that throws away a working, maintained connector. Walk the ladder below.

Don't hardcode "what's supported" — check it live

The set of supported PSPs, payment methods, integration types, and capabilities changes over time (new methods via Adyen, new public connectors, new connector versions). So do not rely on a memorized matrix. Determine fit from current sources, in order:
  1. Run the skill's docs-search step and/or query the commercetools Knowledge MCP for "supported PSPs payment methods payment connectors".
  2. Read the live Supported PSPs, Payment Integration Types, and payment methods table: connectors-and-applications.md.
  3. Browse the live Connect marketplace for installable connectors and their versions: merchant-center/connect.md. For a third-party connector (e.g. Stripe), its own repo/README is the source of truth for capabilities and config keys.
State explicitly to the user that you're checking current data, and cite what you found — capabilities differ by connector version, so name the version.
Verify it's an actual Connect connector — and ask the user. Apply the parent skill's general rule (SKILL.md → Marketplace listings are not all Connect connectors): the marketplace lists integrations that are not necessarily commercetools Connect connectors, and it can be out of sync with what's actually deployable, so confirm a candidate is a real Connect connector (Connect affordance / repo / connect.yaml; the Connect CLI registry is authoritative over the listing) before treating it as rung 1/2. If a good-match option turns out to be a non–Connect (partner/SaaS) integration, surface it but warn that this skill does not cover using non–Connect connectors and offer the build/fork path instead.

The fit check

Compare the requirements gathered in Step 1 against what a candidate public connector actually supports. Check each dimension:

DimensionQuestionIf not covered → which rung
PSPIs the user's PSP available as a public connector?No public connector → rung 4 (build from template), or pick a different PSP.
Payment methodsDoes it support the methods they need (cards, wallets, BNPL, local methods)?Method missing → fork to add it (rung 3), or another connector/PSP.
Integration typeDrop-in vs. web components — does the connector offer what the storefront needs?Type missing → may force the other type, else fork (rung 3).
CapabilitiesCapture mode (manual/auto), saved payment methods, partial/multi capture & refund, regions/currenciesRe-check as config (rung 2) first; if genuinely missing → fork (rung 3).
Compliance/regionIs it available + certified for the user's region and currencies?Not available in region → fork/build, or different PSP.
Special requirementsEach open-ended requirement from Step 1 (B2B PO numbers, subscriptions, split payments, custom fraud/risk hooks, PSP metadata/descriptors, surcharging, stored-credential mandates…) — does the public connector do it?Re-check as config (rung 2); if it's bespoke processor logic → fork (rung 3); if it implies a PSP with no connector → rung 4.
Most capability gaps for a supported PSP are actually config, not missing features (e.g. partial refunds = a connector flag + a PSP-account setting). So before concluding anything needs building, confirm the gap can't be closed by configuration — that's the job of config-from-requirements.md. The special requirements are where this matters most: some are config, some are a small fork, some are neither — judge each on its own.

The decision ladder

Walk these in order and stop at the first that fits — each later rung is more work and more to maintain, so don't skip ahead.
  1. Public connector covers everything → install + configure (Step 2). Don't build anything. The common, recommended case.
  2. Supported PSP, gap looks like a capability → first prove it isn't config. Most "missing" behaviors on a supported PSP (partial refunds, manual capture, saved cards, layout) are connect.yaml toggles, sometimes paired with a PSP-account setting. If a config closes the gap, you're back at rung 1. → config-from-requirements.md.
  3. Supported PSP, genuine gap that config can't closefork/extend the public connector. Its repo is open source (e.g. stripe/stripe-commercetools-checkout-app); add the missing behavior to your fork and deploy it as an Organization connector. You keep the working processor/enabler contract, the session auth, the Payment-ownership model — and only change the delta. This is far cheaper and safer than rebuilding, and it's a commercetools-connect task (extending an existing connector).
  4. No public connector for the PSP at all → build from the payment integration templatecommercetools-connect. The from-scratch path, justified only when there's nothing to fork.

Only rungs 3–4 leave this skill (hand off to build/extend); the skill resumes once the resulting connector is deployed. Record the decision, the rung, and the connector version checked in the requirements block — so the rest of the work is grounded in a real, confirmed connector, not an assumed one.

Checklist

  • Checked live marketplace + supported-PSPs docs (not memory); cited the connector + version
  • Verified the candidate is a deployable Connect connector (not a partner/SaaS listing); asked the user, and warned if they chose a non–Connect integration this skill doesn't cover
  • PSP, methods, integration type, capabilities, region each compared to the requirements
  • Apparent capability gaps re-checked as config (rung 2) before considering any build
  • When a public connector exists but has a real gap, chose fork/extend (rung 3) over build-from-scratch
  • Decision + rung + connector version recorded: configure (1), config (2), fork (3 → commercetools-connect), or build (4 → commercetools-connect)
integrations/payment/deploy-custom-connector.md

Deploy a custom (Organization) connector

This is the flow for a connector you built or forked — ladder rung 3 (fork/extend) or rung 4 (build from template). You stage it, publish it as an Organization connector (no public certification required), then deploy it. This is different from installing a public connector → see deploy-public-connector.md.

The flow

0. connect validate         → run the platform's checks LOCALLY, before staging (fast feedback)
1. connectorstaged create   → registers your repo + tag, returns a connector id
2. connectorstaged publish  → server-side validation (SAST/SCA + connect.yaml), makes it deployable (async)
3. deployment create        → actually runs the connector in your project
Steps 1–2 use the same CLI client as public connector deployment (same auth, same scopes — manage_connectors + manage_connectors_deployments). No separate auth step.
Validate locally first. commercetools connect validate runs the same class of checks the platform runs at publish/preview (connect.yaml validation, image security analysis, SAST, SCA) — so running it before you stage turns a slow, async, server-side publish rejection into a local failure you fix in seconds. Do this before connectorstaged create/publish, not at deployment create: by the time you deploy, the code has already cleared validation at the publish gate, and deployment create fails on different things (missing config values, wrong scopes). For installing a public connector there's nothing for you to validate — it's already certified — so connect validate only applies to a connector you built or forked.

Step 0 — Authenticate

Same as the public connector path:

commercetools auth login \
  --client-credentials \
  --client-id <CLIENT_ID> \
  --client-secret <CLIENT_SECRET> \
  --region <region e.g. europe-west1.gcp> \
  --project-key <projectKey>
The client needs manage_connectors + manage_connectors_deployments (or manage_project).

Step 1 — Stage the connector

commercetools connect connectorstaged create \
  --name "my-connector" \
  --description "Custom Stripe payment connector" \
  --repository-url https://github.com/<org>/<repo>.git \
  --repository-tag <git-tag> \
  --creator-email <your-email> \
  --supported-regions europe-west1.gcp \
  --integration-types psp
CLI pitfalls (verified by live testing):
PitfallDetail
Wrong command pathThe command is commercetools connect connectorstaged createnot bare connectorstaged create. The CLI binary is commercetools, not ct.
No --region flagconnectorstaged create does not accept --region. Omit it — region is set via auth login.
URL must end in .githttps://github.com/org/repo → error "not a valid Git repository URL". Use https://github.com/org/repo.git.
--creator-email is requiredOmitting it causes a flag validation error. Pass your email.
Private repo → "not reachable"Connect clones the repo server-side. A private GitHub repo returns GitRepositoryNotReachable. Either make the repo public, or use the repo's SSH URL (git@github.com:org/repo.git) and grant read access to the connect-mu machine user — the documented way to give Connect access to a private repo.
Note the id in the response — you need it for step 2.

Step 2 — Publish

commercetools connect connectorstaged publish --id <id-from-step-1>
  • Only --id or --key — there is no --force flag.
  • Runs async — Connect clones your repo, validates connect.yaml, and registers the connector. It can take a minute or two. You can check status with connectorstaged describe --id <id>.
  • Once status shows published, proceed to step 3.

Publish runs a production-readiness scan — for private connectors too

Publish (and preview builds) don't just check connect.yaml. The validation process also runs image security analysis, SAST, and software composition analysis (SCA) over the code whenever you request a preview build or publish — for any connector, including private/Organization ones, not only for public marketplace certification. A connector that fails them won't publish, so clean the repo to the production bar before you publish, not after a rejected report. Catch most of it locally first:
commercetools connect validate    # connect.yaml + the same class of checks, locally
The bar these checks enforce is the same security bar described in the Connect certification requirements — a useful reference for what "clean" means, even though that page formally describes the certification process:
  • No logs or any code/configuration that isn't meant for production. Strip leftover console.log/debug logging, dev-only mocks or fixtures, test scaffolding, commented-out blocks, and local-only config (.env samples, NODE_ENV=development defaults baked into the build). If you forked a public connector (rung 3), this is where forks most often fail — leftover demo/sample code from the template.
  • No hardcoded URLs, tokens, credentials, or passwords in code or config — everything sensitive belongs in securedConfiguration and is supplied at deploy time (see config-from-requirements.md).
  • No outdated/insecure dependencies, and stateless apps (no in-memory session state — the runtime scales and restarts).
For the deeper code-quality and security baseline (error hygiene that hides stack traces in production, structured logging that doesn't leak PII, the no-dead-code rule), the connector-build skill owns it: commercetools-connect → security.md and observability-operations.md.

The three scans fail for different reasons — read which one failed

The publishing report lists the checks separately (Image security analysis, SAST and SCA analysis, Connector specification file validation, Application Build). Which one fails tells you where to look — they are not interchangeable:
  • Image security analysis failed (but SAST/SCA passed) → the finding is in the container base image's OS packages, not your code or your declared dependencies. The base image is chosen by the buildpack from your Node version, so the lever you control is pinning it. Add engines.node to every app's package.json (e.g. "engines": { "node": "20.x" }) so the buildpack selects a maintained, scanned-clean base image instead of a default. A stdlib-style CVE (e.g. a Go stdlib advisory) in this scan is the classic base-image symptom — it is never something in your package.json.
  • Runtime-version vs. framework-version trap. Pinning the runtime can collide with a dependency's own engine requirement, and the two fixes can be mutually exclusive. Real example hit in the field: Fastify v5 requires Node 20+, but Fastify v4's transitive deps (fast-uri, fast-json-stringify) carry HIGH-severity CVEs whose only fix is Fastify v5. So "pin Node 18" (image scan) and "downgrade Fastify to v4" (avoid a different finding) cancel out — the working combination was Fastify v5 + Node 20. When the image scan and the SCA scan seem to pull in opposite directions, check the framework's supported-Node matrix before downgrading anything; the fix for a dependency CVE is almost always to upgrade, not downgrade (downgrading lands you on the vulnerable version).
  • SCA failed → a declared dependency (in some package-lock.json in the repo) has a known CVE. Note the File field in each finding — it tells you which lockfile. If it points at a folder that isn't a connector app (see "Keep the repo to connector apps only" below), the fix is removing that folder, not upgrading.
commercetools connect validate reproduces all of this locally (its buildpack is version-synced to the platform) — but the image scan step needs Docker running, and the buildpack pulls several GB of images, so ensure Docker has disk headroom or the build fails with opaque input/output errors that look like connector problems but are local-environment problems.

Keep the repo to connector apps only — sibling folders poison SCA

The SCA scan walks the whole repository for lockfiles, not just the folders named in connect.yaml. If you keep a storefront, BFF, or test harness in the same repo (e.g. a backend/ Next.js app alongside processor/ and enabler/), its dependencies get scanned too — and a stale storefront dep (old next, vite, vitest) will fail the connector's publish even though it ships none of that code. Keep the connector repo to the connector applications only; move any storefront/harness to its own repo (or, as a stopgap, .gitignore + git rm --cached it so it leaves the published git tag — the platform builds from the tag, though connect validate still scans the on-disk working tree).
commercetools connect connectorstaged describe --id <id>

Step 3 — Deploy

Once published, deploy it into your project with your config:

commercetools connect deployment create \
  --region <region> \
  --connector-id <id-from-step-1> \
  --key <your-deployment-key> \
  --type sandbox \
  --configuration 'processor.CTP_PROJECT_KEY=<value>' \
  --configuration 'processor.CTP_CLIENT_ID=<value>' \
  --configuration 'processor.CTP_AUTH_URL=<value>' \
  --configuration 'processor.CTP_API_URL=<value>' \
  --configuration 'processor.CTP_SESSION_URL=<value>' \
  --configuration 'processor.CTP_CHECKOUT_URL=<value>' \
  --configuration 'processor.CTP_JWKS_URL=<value>' \
  --configuration 'processor.CTP_JWT_ISSUER=<value>' \
  --configuration 'processor.STRIPE_PUBLISHABLE_KEY=<value>' \
  --configuration 'processor.MERCHANT_RETURN_URL=<value>' \
  --configuration 'processor.ALLOWED_ORIGINS=<value>'
Secured config (secrets) goes via separate --configuration flags too — the platform stores them encrypted:
  --configuration 'processor.CTP_CLIENT_SECRET=<value>' \
  --configuration 'processor.STRIPE_SECRET_KEY=<value>' \
  --configuration 'processor.STRIPE_WEBHOOK_SIGNING_SECRET=<value>'
App-specific config is namespaced with the application name from connect.yaml (processor.KEY or enabler.KEY). Global (shared) config uses bare KEY=value.
The deployment must include every application declared in connect.yaml — including the assets enabler, even though it takes no config. If you build the deployment draft by hand (e.g. via the REST API) and list only processor, the deploy may appear to succeed but is malformed: the enabler never deploys (no enabler URL is produced), and a later redeploy fails with the confusing DeploymentApplicationDoNotBelong"deployment does not include application: 'enabler'". Include the enabler with empty config arrays: { "applicationName": "enabler", "standardConfiguration": [], "securedConfiguration": [] }. The CLI's deployment create handles this for you; raw API/scripted drafts are where this bites.

Step 4 — Get the URLs

After deployment, read the processor URL and enabler URL:
commercetools connect deployment describe --key <your-deployment-key>
These are what the BFF and storefront point at. They are stable across a redeploy — the URLs do not change when you redeploy the same deployment. But a delete + recreate gives new URLs (the host id is per-deployment). If you ever recreate a deployment — e.g. to fix a malformed one that omitted an app — you must update everything that hardcoded the old URL: the BFF/storefront env (PROCESSOR_URL/ENABLER_URL) and the Stripe webhook endpoint (the old URL is now dead, so events silently stop arriving and transactions hang in Pending). Prefer redeploy over recreate whenever possible precisely to keep the URLs stable.

Step 5 — Register the Stripe webhook

After you have the processor URL, go to the Stripe dashboard and register the webhook:

  1. Stripe Dashboard → Developers → Webhooks → Add endpoint

  2. Endpoint URL: {processorUrl}/stripe/webhooks
  3. Subscribe to the events this user's flow needs — don't copy a fixed list. Look up Stripe's webhook-event catalog and select per the use case, as described in connector-contract.md → Webhook events. (For a typical Stripe flow that often means events like payment_intent.succeeded, payment_intent.amount_capturable_updated for manual capture, payment_intent.payment_failed, and charge.refunded — but confirm against Stripe's current docs and the user's capture/refund/dispute requirements.)
  4. Copy the signing secret (whsec_…)
  5. Update the deployment's secured config via redeploy — there is no deployment update CLI command, and the Connect REST API does not accept a setApplicationConfiguration action (only redeploy is a valid discriminator):
    commercetools connect deployment redeploy \
      --key <your-deployment-key> \
      --configuration 'processor.STRIPE_WEBHOOK_SIGNING_SECRET=<whsec_…>' \
      --configuration 'processor.CTP_CLIENT_SECRET=<value>' \
      --configuration 'processor.STRIPE_SECRET_KEY=<value>'
    
    After a redeploy the deployment goes back through Deploying — same wait as the initial deploy. URLs remain stable.
    To pick up a newly published connector version, add --updateConnector:
    commercetools connect deployment redeploy \
      --key <your-deployment-key> \
      --updateConnector \
      --configuration 'processor.KEY=value'
    
    Without --updateConnector, redeploy keeps the current connector version and silently does not update the deployed code — it only refreshes config and restarts.
  6. Optionally set processor.STRIPE_WEBHOOK_ID=<we_…> for the post-undeploy cleanup script

Checklist

  • CLI authenticated with manage_connectors + manage_connectors_deployments
  • Repo is public (or private via SSH URL with connect-mu granted read access)
  • connectorstaged create used --repository-url ending in .git, included --creator-email
  • Production-ready before publish (applies to private too): commercetools connect validate passes before staging; no debug/console.log logging, dev mocks, test scaffolding, commented-out code, or local-only config left in the repo; no hardcoded secrets/URLs; deps current; apps stateless
  • engines.node pinned (e.g. 20.x) in every app's package.json (image-scan base image); dependency CVEs resolved by upgrading, not downgrading
  • Every app has a passing test script; Vitest apps route through a wrapper that ignores the buildpack's injected Jest flags
  • Repo contains only connector apps — no storefront/BFF/harness folder whose lockfile would be SCA-scanned
  • Deployment draft lists all apps from connect.yaml, including the assets enabler (empty config) — else redeploy fails and no enabler URL is produced
  • connectorstaged publish completed (status = published)
  • deployment create passed all required config, secrets in secured config
  • processor URL + enabler URL captured from deployment describe
  • Stripe webhook registered at {processorUrl}/stripe/webhooks; signing secret stored in secured config
integrations/payment/deploy-public-connector.md

Deploy a public payment connector

This is the install path for a public/certified connector (Stripe, Adyen, PayPal, …) — the common case (ladder rung 1). You do not build or stage it; you deploy the existing public connector into your project. Building/staging your own connector (rung 3/4) uses a different flow → deploy-custom-connector.md.
Most Merchant Center users install a public connector through the Connect UI (Organization → Connect → install + fill config). The CLI path below is the scriptable equivalent and the one to reach for in an agentic/automated context. Verify command shapes against the live Connect CLI docs — flags evolve.

Two clients — don't conflate them

This trips people up, and conflating them is the usual cause of auth/scope failures:

ClientUsed forScopes
CLI / deploy clientauthenticating the CLI to create the deploymentmanage_connectors:{projectKey} + manage_connectors_deployments:{projectKey} (plus manage_api_clients:{projectKey} if the connector auto-generates its runtime API client credentials — without it the deploy fails with 403 access denied), or manage_project:{projectKey} which covers all of these — see Connect authorization
Connector runtime clientthe credentials the deployed connector uses to call commercetools at runtime (create Payments, read sessions)auto-generated at deploy time — the Connect platform shows the scopes it needs (e.g. manage_payments, view_sessions) during the deploy step and provisions them; you usually don't hand-create this client
Common mistake: inventing a scope like manage_deployments (not a real scope — it's manage_connectors_deployments:{projectKey}), or pre-creating a runtime client with payment scopes and trying to authenticate the CLI with it. The CLI client needs the connector/deployment scopes above; the payment/session scopes belong to the auto-provisioned runtime client.

Step 1 — Authenticate the CLI

The command is client-credentials based; --region must match the project's region:
commercetools auth login --client-credentials \
  --client-id <CLI_CLIENT_ID> \
  --client-secret <CLI_CLIENT_SECRET> \
  --region <region e.g. europe-west1.gcp> \
  --project-key <projectKey>
The client behind these credentials needs manage_connectors:{projectKey} + manage_connectors_deployments:{projectKey} (and manage_api_clients:{projectKey} if the connector auto-generates its runtime credentials), or manage_project:{projectKey} which covers all of these.

Step 2 — Deploy the public connector directly

A public connector is referenced by its connector key or id — there is no connectorstaged step (that command stages your own connector for certification, which is a different, build-side flow). Deploy it:
commercetools connect deployment create \
  --region <region> \
  --connector-key <public-connector-key>   # or --connector-id <id> \
  --type sandbox                            # preview | sandbox | production \
  --key <your-deployment-key> \
  --configuration '<applicationName>.<KEY>=<value>' \
  --configuration '<KEY>=<value>'
  • Pass the config you derived in Step 2 of the skill via repeated --configuration flags ({applicationName}.{key}=value for app-specific, {key}=value for global). Secrets go here too — they land in the connector's secured config, not in the browser.
  • During this step the platform surfaces the runtime scopes the connector will be granted (the auto-generated client) — review them; that's expected, not an error.
  • Find the connector's key/id in the Connect marketplace (Merchant Center → Connect) or via the Connect API.

Step 3 — Get the URLs back

Once deployed, read the processor URL and enabler URL from the deployment (Merchant Center deployment view, or commercetools connect deployment describe --key <your-deployment-key>). Bring those back to the skill's Step 2 (config) / Step 4 (backend) — they're what the BFF and enabler point at. A URL is stable across redeploys of the same deployment but a fresh deployment create gets a new one, so read them from config rather than hardcoding.

If something fails

  • Auth/scope error on deploy → the CLI client is missing manage_connectors:{projectKey} / manage_connectors_deployments:{projectKey} (or you used a non-existent scope like manage_deployments). Fix the client's scopes and re-login.
  • "connector not found" → wrong --connector-key/--connector-id, or the connector isn't available to your organization yet (install it from the marketplace first).
  • Region mismatch--region on both auth login and deployment create must equal the project's region.
  • Anything about building, bundling, staging, or certifying a connector → that's commercetools-connect, not this path.

Checklist

  • CLI authenticated with a client that has manage_connectors:{projectKey} + manage_connectors_deployments:{projectKey} (plus manage_api_clients:{projectKey} if credentials are auto-generated), or manage_project:{projectKey}
  • Deployed via deployment create --connector-key … (no connectorstaged for a public connector)
  • Config passed via --configuration; secrets in secured config, never the browser
  • Runtime scopes reviewed at deploy time (auto-generated client — expected)
  • processor URL + enabler URL captured for the integration steps
integrations/payment/integration-test.md

The full-flow integration test

The unit tests in backend-tdd.md prove each backend decision in isolation against mocks. They run on every commit and never touch a network. But they cannot prove the wiring — that your session metadata actually matches what the deployed processor expects, that a real test card produces a real Charge, that the PSP webhook actually reaches the processor and finalizes the transaction. That's the gap this test closes: one automated test that drives the real, deployed pieces end to end and asserts the trace each step leaves in commercetools.
This is the capstone of Step 5. Where verification.md is a manual checklist you walk once, this turns that same round trip into a test you can re-run after every deploy — the difference between "I clicked through it and it worked" and "it provably still works."

Prerequisites

Do not write or run this test until the unit suite from backend-tdd.md is fully green. The integration test proves the wiring; the unit tests prove the decisions. Running the integration test first skips the decisions layer and makes failures much harder to localize. The correct order is always: unit tests green → integration test written → integration test run against a real deployment.

What it is (and isn't)

  • It runs against a real deployed connector (processor + enabler URLs from config) and a real commercetools project, using the PSP's test cards — never live cards, never production keys.
  • It reuses the test-harness.md flow as its driver for the browser half (session → enabler → submit), and verification.md as its oracle for the backend half (find the Payment, read its transactions).
  • It is not a unit test and should not run on every commit. It needs secrets and a live deployment, it's slower, and a PSP sandbox hiccup can make it flake. Run it in a dedicated job (nightly, pre-release, or post-deploy smoke), gated on the connector config being present — skip with a loud, explicit message when config is absent so a missing secret reads as "not configured here," never as a silent pass. A silent pass on a missing secret is the same as having no test.
  • Keep it to one or a few scenarios. Its value is breadth (it touches everything), not depth (the unit tests own the edge cases).

The flow it asserts

The test walks the same path a customer does, asserting the commercetools trace at each commit point — so a failure tells you which seam broke, not just "it didn't work":
1. Mint session server-side (BFF)      -> assert: sessionId returned; response carries no secrets
2. Drive enabler + submit a test card  -> assert: onComplete fired / no enabler error
3. Find the Payment (verification.md)   -> assert: cart.paymentInfo has a Payment; transaction is Success;
                                                   paymentInterface matches the connector; exactly one Payment
4. Place the Order                      -> assert: Order created; cartState -> Ordered; idempotent on orderNumber
5. Capture (if manual) via processor    -> assert: a Charge/Success transaction appears on the Payment
6. Refund via the processor route       -> assert: a Refund transaction appears; Payment Intents API never called
7. Webhook reconciliation (async PSP)   -> assert: transaction reaches Success after the webhook (poll, don't sleep)

Steps 5–7 are conditional on the requirements from Step 1: skip capture if the flow is immediate-charge, skip the webhook wait for a fully-synchronous method. Assert only what the configured flow actually does — a test that asserts a manual capture against an automatic-capture deployment is testing the wrong contract.

Shape

Async settlement is the part that bites: the webhook arrives after submit() returns, so the Payment isn't Success the instant the browser says "done." Poll with a timeout; never a fixed sleep. A fixed sleep is either too short (flaky) or too long (slow) — polling is both faster and more reliable.
import { describe, it, expect, beforeAll } from 'vitest';
import { loadConnectorConfig } from './support/config';
import { runHarnessFlow } from './support/harness';        // the test-harness.md flow, scripted
import { findPaymentForCart, findPaymentsForCart, getPayment, getCart } from './support/ct';
import { placeOrder, capture, refund } from '../src/backend';

const cfg = loadConnectorConfig();           // PROCESSOR_URL, ENABLER_URL, CT creds, project, region
const itLive = cfg ? it : it.skip;           // skip (loudly) when no deployment is configured

// poll until a predicate holds, so async webhook settlement doesn't force a brittle sleep
async function until<T>(fn: () => Promise<T>, ok: (v: T) => boolean, { tries = 20, gapMs = 1500 } = {}) {
  for (let i = 0; i < tries; i++) {
    const v = await fn();
    if (ok(v)) return v;
    await new Promise(r => setTimeout(r, gapMs));
  }
  throw new Error('condition not met within timeout — suspect the webhook (see backend-integration.md)');
}

describe('connector full flow (live deployment, test card)', () => {
  itLive('session -> pay -> Order -> capture -> refund leaves the right CT trace', async () => {
    // 1-2. server-side session + drive the enabler to submit a test card
    const { sessionId, cartId, result } = await runHarnessFlow(cfg, { testCard: '4242424242424242' });
    expect(result.error).toBeUndefined();

    // 3. the processor wrote the Payment — find it via the cart (verification.md)
    const payment = await until(
      () => findPaymentForCart(cfg, cartId),
      p => !!p && p.transactions.some(t => t.state === 'Success'),
    );
    expect(payment.paymentMethodInfo.paymentInterface).toBe(cfg.paymentInterface);  // e.g. 'checkout-stripe'
    const payments = await findPaymentsForCart(cfg, cartId);
    expect(payments).toHaveLength(1);                          // no duplicate Payment (frontend didn't create one)

    // 4. place the Order — and prove idempotency by doing it twice with the same orderNumber
    const orderNumber = `it-${sessionId}`;                     // deterministic per run; reused on retry
    const order  = await placeOrder({ cartId, orderNumber });
    const again  = await placeOrder({ cartId, orderNumber });
    expect(again.id).toBe(order.id);                           // converges on one Order
    expect(order.orderState).toBeDefined();
    const cart = await getCart(cfg, cartId);
    expect(cart.cartState).toBe('Ordered');

    // 5-6. capture then refund via the processor's operation routes
    if (cfg.captureMode === 'manual') {
      await capture({ paymentId: payment.id });
      const captured = await until(() => getPayment(cfg, payment.id),
        p => p.transactions.some(t => t.type === 'Charge' && t.state === 'Success'));
      expect(captured).toBeTruthy();
    }
    await refund({ paymentId: payment.id, amount: { centAmount: 100 } });
    const refunded = await until(() => getPayment(cfg, payment.id),
      p => p.transactions.some(t => t.type === 'Refund'));
    expect(refunded).toBeTruthy();
  }, 90_000);   // generous timeout: cold starts + webhook settlement
});
runHarnessFlow is the test-harness.md 8-step flow scripted instead of clicked — driven headlessly (e.g. Playwright loading the enabler UMD, or, if the connector supports it, replaying the processor calls the enabler would make). Stay close to the harness you already proved by hand; the integration test is that harness with assertions and an Order/capture/refund tail bolted on.

Reading a failure

The assertions are positioned so the first one to fail localizes the break — this is the whole reason to assert at each commit point rather than only at the end:
First failing stepMost likely causeWhere
1 — no sessionId / secret leakedBFF wiring, session metadata mismatchconnector-contract.md pitfalls 1–2
2 — enabler error / no onCompleteenabler load, cold start, ready timingconnector-contract.md pitfalls 5, 7, 10
3 — no Payment, or stuck Pendingsubmit never reached processor, or async webhookverification.md, backend-integration.md
3 — duplicate Paymentfrontend wrongly created a Paymentconnector-contract.md
4 — Order not created / not idempotentgate or orderNumber reuse wrongbackend-integration.md
6 — refund 404/wrong callreached for the Payment Intents APIbackend-integration.md
7 — never reaches Successwebhook not delivered/verifiedprovider reference → webhook setup

Checklist

Gate: only write this test after the unit suite from backend-tdd.md exits 0.
  • Unit suite green before this test was written — not after
  • One end-to-end test drives a real deployed connector with a PSP test card (never live keys)
  • It asserts the CT trace at each commit point (session, Payment+Success, Order, capture, refund), so a failure localizes the broken seam
  • Reuses the test-harness.md flow as the driver and verification.md as the oracle
  • Async settlement handled by polling with a timeout (until() helper), not a fixed sleep
  • Asserts no duplicate Payment and that capture/refund went through the processor routes (not the Payment Intents API)
  • Skips loudly (explicit it.skip or console.warn with a clear message) when deployment/secrets are absent — a silent pass on a missing secret is a broken test
  • Runs in a dedicated job (nightly/pre-release/post-deploy), not on every commit
integrations/payment/overview.md

Payment connector — direct integration (backend-focused)

This is the payment integration sub-area of commercetools-connect: you have (or will deploy) a payment connector and need to wire it into your own storefront and own the backend around it. For building a connector from the template, or the deploy/certify lifecycle, that's the parent connect skill; this sub-area is about integrating a deployed one.
Build the server side of a direct payment-connector integration: gather the user's payment requirements, turn them into the right provider config, then implement the backend around the payment.
A payment Connector is a Connect application built from the payment integration template, shipping two applications:
  • processor (a service) — talks to the PSP, orchestrates payment operations, and owns the commercetools Payment object (creates it, adds transactions). Its behavior is driven by its connect.yaml config. You authenticate to it with a Checkout Session.
  • enabler (an assets bundle) — a browser JS library on top of the PSP's UI components. It renders the payment UI and calls the processor. This is the frontend touchpoint — necessary, but a thin slice of the work.
This is the direct-connector path: you wire the connector into your own storefront and own the backend (sessions, Orders, refunds). You do not use @commercetools/checkout-browser-sdk (that's the hosted Checkout product → commercetools-checkout); you do not create Payment objects yourself (the processor does); and capture/refund go through the processor, not the Checkout Payment Intents API. If you're building the connector itself, that's commercetools-connect.
"Checkout" is overloaded. Lowercase = the buying journey (always present). Uppercase Checkout = the commercetools product that runs that journey for you. On this path there is a checkout, but no Checkout product — which is exactly why the Payment is owned by the processor and refunds use the processor's routes, not the Payment Intents API. See backend-integration.md.

Workflow

When integrating a deployed payment connector, always follow these steps in order. The heart of the workflow is Step 1 → Step 1.5 → Step 2 → Step 4 (requirements → is a certified connector enough? → config → backend); the frontend (Step 3) is a reference.

Step 0 — Gather context (required, run first)

The mandatory grounding step: it pulls the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with payment-focused query terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<payment terms from the user's request, e.g. 'payment connector processor session capture refund webhook'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root, where scripts/docs-search.mjs lives.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or https://docs.commercetools.com for deeper follow-up.

Step 1 — Extract requirements (do this before any config or code)

Config is downstream of requirements. The connector's behavior — when money is taken, whether cards are saved, whether you can partially refund — is set by connect.yaml values, and the wrong default silently bakes in the wrong behavior. So extract the requirements first; each answer maps to a concrete config key in Step 2. Ask the user (don't assume):
  1. Which PSP / connector, and is it deployed? Get the connector and version and, if deployed, its processor URL and enabler URL (Merchant Center deployment view, or the Connect deployments API).
  2. Region and project? e.g. europe-west1.gcp, project my-project — the Sessions API host and the CTP_*_URL config are region-specific.
  3. Capture mode? Charge immediately, or authorize now and capture later (on fulfillment)? → drives the capture-method config and when you create the Order.
  4. Saved payment methods / returning customers? Should cards be saved for reuse? → drives the save-cards config and requires a customerId on the cart.
  5. Refunds / partial captures? Will the business do partial refunds or split captures? → drives the multi-operations config.
  6. Which payment methods, and drop-in vs. web components? Drop-in (one element) is the default; web components give per-method layout control.
  7. Storefront origin(s) and post-payment return URL? → drives CORS and the return-URL config (a frequent silent breaker).
  8. Sync or async settlement? Some methods/PSPs finalize via webhook → drives whether Order creation waits on the webhook.
  9. Anything special or non-standard? (always ask — open-ended) The eight questions above cover the common shape, but they don't cover everything, and the requirements that decide config-vs-fork-vs-custom are often the ones a fixed list never asks. So explicitly ask the user: "Beyond the above, are there any specific constraints or behaviors you need?" Prompt with examples to jog memory — compliance/regulatory (PCI scope, SCA/3DS exemptions, local mandates), B2B (purchase-order numbers, invoices, multi-buyer approval), subscriptions/recurring or installments, multi-currency or per-market pricing, marketplace split payments/payouts, existing PSP contract terms or a specific PSP account/merchant id, custom fraud or risk-scoring, surcharging, stored-credential mandates, or anything that must appear on the PSP side (metadata, descriptors). Capture each as its own requirement line; don't force it into one of the eight slots.
Write these as a short requirements block and confirm with the user before deriving config. Flag every special requirement explicitly — each is a candidate that may not be a config toggle, so it directly feeds the Step 1.5 fit-check (could push the decision from "configure" to "fork" or "custom"). If the user just says "make Stripe work" and surfaces nothing special, default to: deployed Stripe connector → immediate capture → no saved cards → single capture/refund → drop-in → and say so explicitly.

Step 1.5 — Is a certified connector enough? (decide before wiring or building)

With the requirements in hand, answer the prior question the rest of the skill assumes: does a connector that already does this exist? Don't answer from memory — supported PSPs, methods, and capabilities change. Check live data (the Connect marketplace + the "Supported PSPs" docs, via the docs-search script/the Knowledge MCP), compare the requirements PSP-by-method-by-capability, and name the connector version you checked.
Then walk the decision ladder — stop at the first rung that fits, because each later one is more to build and maintain:
  1. Public connector covers everything → install + configure (Step 2). Don't build. Installing it (CLI auth, scopes, deployment create) is covered in deploy-public-connector.md — note it is not the connectorstaged flow.
  2. Supported PSP, gap looks like a capability → prove it isn't config first. Most "missing" behaviors (partial refunds, manual capture, saved cards) are connect.yaml toggles → back to rung 1. See config-from-requirements.md.
  3. Supported PSP, genuine gap config can't closefork/extend the public connector (its repo is open source); add only the delta and deploy as an Organization connector. Don't rebuild — you'd throw away a working, maintained connector. Hand off to commercetools-connect. For monitoring the connector you build: deployment logs, structured logging, and the poison-message runbook are in observability-operations.md.
  4. No public connector for the PSP at all → build from the payment integration template. This can be done inline (within this skill session) when the user explicitly asks to build custom — see the stripe.md "Building a custom Stripe connector" section for the key gotchas (raw body, API version, POST vs GET route). For staging, publishing, and deploying the built connector see deploy-custom-connector.md. Hand off to commercetools-connect when the full Connect publish/certification lifecycle is the goal. For monitoring: observability-operations.md.
Rungs 3–4 switch to the build-side workflow in the parent commercetools-connect skill, then resume this integration flow once the connector is deployed — but rung 4 can be executed inline when the user wants to build in the current session. Full procedure and dimension-by-dimension table: connector-selection.md. Record the decision, the rung, and the version in the requirements block.

Step 2 — Derive the provider config from the requirements

This is the core deliverable. Translate the Step 1 answers into the concrete connect.yaml values for the chosen connector, and give a one-line why for each so the user can sanity-check it. The mapping (which requirement → which key) and the provider-specific key names/defaults live in the provider reference — read config-from-requirements.md for the provider-agnostic mapping table and the worked example, plus the matching provider reference (stripe.md) for exact key names, defaults, and secured-vs-standard split.
connect.yaml has no published JSON Schema — its structure is defined only by the docs (Configure connect.yaml), so use only the documented envelope keys (deployAs/applicationType/configuration/inheritAs, each config item being {key, description, required, default?}) and don't invent fields. And it must live at the repository root, never in a nested folder (processor/, src/) — a misplaced file silently fails to deploy. Both are covered in config-from-requirements.md → The connect.yaml envelope.

Produce, for the user:

  • a filled standardConfiguration block (region URLs, capture method, saved-cards, multi-ops, billing collection, return URL, allowed origins, payment-interface name, …),
  • the securedConfiguration keys they must supply (PSP secret key, webhook signing secret, CT client id/secret) — names only, never invent values,
  • the API-client scopes the connector needs,
  • a short rationale per non-obvious key tied back to their requirement.
Then flag the config that silently breaks the integration if wrong — MERCHANT_RETURN_URL must be an absolute URL with a scheme; ALLOWED_ORIGINS must include the storefront origin; scopes must cover managing payments + reading sessions. These appear again as runtime pitfalls in connector-contract.md.
If the connector is not yet deployed: a public connector you install directly (CLI auth + deployment create --connector-key, passing this config) — see deploy-public-connector.md, which also lists the correct Connect scopes and warns against the wrong-scope / connectorstaged pitfalls. Building or staging your own connector is the broader Connect flow → commercetools-connect. Either way, hand over the config block you derived here.

Step 3 — Frontend touchpoint (reference)

The browser still has to create a session, load the enabler, and drive the drop-in to submit(). This contract is the same across PSPs and is fully covered — including the load/timing pitfalls (UMD vs ES, the ready event) — in connector-contract.md. For a quick proof-of-life before wiring the real storefront, scaffold the throwaway harness in test-harness.md. Treat this as a supporting step: the substance of this skill is the config (Step 2) and the backend (Step 4).

Step 4 — Build the backend (the main body of work), test-first

This step is non-negotiable: tests come before implementation. Do not write any backend function body before the test for it exists and is confirmed red. Skipping this order is a process violation — not a shortcut.
The processor takes the payment; everything around it is your backend, and on this path the connector deliberately won't do it for you. Build it test-first — the red-green-refactor loop is the only permitted order:
  1. Write a failing test that names the behavior and asserts the outcome.
  2. Run it. Confirm it fails for the right reason — not a missing import, not a wrong mock, but because the behavior is absent. A test that passes before you've written the code is testing nothing and must be fixed before proceeding.
  3. Write the least code that makes it pass. No extra logic, no generalizing ahead of the next test.
  4. Refactor with the test as a safety net. Then repeat for the next behavior.
The rules that make this integration correct (idempotency, gate-on-Success, processor-owns-the-Payment, the IDOR guard) are invisible at the call site and only surface under conditions that are tedious to reproduce by hand — a retried webhook, a stale cart version, an unsettled async PSP. Each is one cheap assertion. Writing the test first pins the behavior and leaves a tripwire so the next change can't quietly undo it.
Mock the outbound boundary (the PSP, the connector's processor, the Sessions/Orders APIs) and assert on what your code decided to do — which endpoint it called, with what body, and what it did with the response. Never mock your own orchestration logic. The suite must run with zero deployment and zero secrets. What to assert and what to mock per piece is in backend-tdd.md — read it before writing any code.
Do not proceed to Step 5 until:
  • Every behavior listed in the backend-tdd.md checklist has a passing test.
  • The test suite runs clean with npm test and no secrets in the environment.
Read backend-integration.md and build, in order — test first for each item:
  1. Server-side session creation (BFF) — mint token/cart/session on the server so secrets and manage_sessions never reach the browser; verify cart ownership (IDOR) and create the session as late as possible. The browser gets only sessionId + processor/enabler URLs.
  2. Order creation — convert the cart to an Order after authorization completes (and, for async settlement, after the webhook confirms Success), with a unique pre-generated orderNumber for idempotency. Timing follows the capture mode chosen in Step 1.
  3. Post-purchase operations — capture / refund / cancel on the authorized Payment via the processor's own operation routes, not the Checkout Payment Intents API (which only works for payments the Checkout product created). Whether partial captures/refunds are even available depends on the multi-ops config from Step 2.
  4. Webhook reconciliation — treat the commercetools Payment (driven by the PSP webhook the processor verifies) as the authoritative state, not the browser's onComplete. A transaction stuck Pending almost always means the webhook.

Step 5 — Verify the round trip, then lock it in with a full-flow integration test

Don't declare done until a real test-card payment has left a trace in commercetools: onComplete/return URL fired, and a commercetools Payment exists for the cart with a transaction (Authorization or Charge) in state Success, and — for production — the Order was created and a refund path works. See verification.md for the manual round-trip check.
Then turn that one-time check into a repeatable test: a single full-flow integration test that drives the real deployed connector with a PSP test card from session → pay → Order → capture/refund → webhook reconciliation, asserting the commercetools trace at each commit point so a failure localizes the broken seam. This is the capstone the unit tests can't provide (they mock the boundary; this proves the wiring), and it's what lets you re-verify after every deploy instead of re-clicking. See integration-test.md.

References

NeedReference
Is a certified connector enough?: fit-check a use case against public connectors vs. building custom, using live marketplace/docs dataconnector-selection.md
Deploy a public connector: CLI auth, the correct Connect scopes, and deployment create --connector-key (not connectorstaged)deploy-public-connector.md
Deploy a custom connector: connectorstaged create → publish → deployment create for Organization connectors (rung 3/4), with CLI pitfalls (URL format, private repo, required flags) and the production-readiness scan that runs at publish (SAST/SCA, no dev logs/code) — for private connectors toodeploy-custom-connector.md
Requirements → config mapping: which requirement drives which connect.yaml key, with a worked example producing a filled config + rationaleconfig-from-requirements.md
The backend: server-side session/BFF, Order creation after payment, capture/refund/cancel via the processor, webhook reconciliation, who owns the Paymentbackend-integration.md
Test-drive the backend: the red-green loop, what to assert vs. mock per piece (BFF/Order/capture-refund/webhook), turning the skill's invariants into Vitest regression testsbackend-tdd.md
Full-flow integration test: one end-to-end test against a real deployed connector + test card, asserting the CT trace at each commit point (the capstone of Step 5)integration-test.md
Stripe specifics: connector repo/version, exact connect.yaml keys (standard/secured) + defaults, enabler bundle name/global, test cards, webhook setupstripe.md
The provider-agnostic frontend contract: 8-step flow, Sessions API body, enabler load (UMD vs ES), processor routes + X-Session-Id auth, full pitfall catalogconnector-contract.md
Verifying the round trip: querying the Payment, reading transactions, confirming stateverification.md
A standalone throwaway harness to prove a deployed connector before building the real storefronttest-harness.md
Monitoring a forked/custom connector: deployment logs (CLI + Merchant Center), structured logging, poison-message / dead-letter runbookcommercetools-connect → observability-operations.md
Adding another provider later (Adyen, Mollie, PayPal) means adding a sibling reference like stripe.md and extending the mapping table — the requirements, the backend, and the flow do not change.

Checklist

Requirements

  • PSP/connector + version; processor URL and enabler URL (or routed to deploy)
  • Region + project; capture mode; saved-cards? partial refunds/captures? methods; origins + return URL; sync/async settlement
  • Asked the open-ended "anything special/non-standard?" question; captured each special requirement as its own line
  • Requirements block written and confirmed with the user; special requirements flagged into the Step 1.5 fit-check

Connector fit (decide before wiring/building)

  • Checked live marketplace + supported-PSPs docs (not memory); named the connector + version
  • PSP, methods, integration type, capabilities, region compared to the requirements; apparent gaps re-checked as config
  • Ladder rung chosen: configure (1) · config-closes-gap (2) · fork/extend public connector (3) · build from template (4)
  • For a real gap on a PSP that has a public connector, chose fork/extend over rebuild

Config (the deliverable)

  • Only documented connect.yaml envelope fields used (no invented keys); file placed at the repository root, not a nested folder
  • standardConfiguration filled from the requirements, with a rationale per non-obvious key
  • securedConfiguration keys listed (values supplied by the user, never invented)
  • API-client scopes cover managing payments + reading sessions
  • MERCHANT_RETURN_URL absolute w/ scheme; ALLOWED_ORIGINS includes the storefront origin
  • Capture-method / saved-cards / multi-ops config match the chosen flow

Backend

  • Token/cart/session creation server-side; browser gets only sessionId + processor/enabler URLs
  • Order created from cart after authorization (and webhook Success for async), idempotent via orderNumber
  • Capture/refund/cancel routed through the processor's operation routes (not the Payment Intents API)
  • Webhook reconciliation in place; Pending transactions traced to webhook delivery
Testing (build the backend test-first — gate: do not proceed to Step 5 until all boxes are checked)
  • Vitest (or equivalent) installed and npm test runs before any implementation code is written
  • Each backend behavior written test-first: failing test confirmed red for the right reason → least code to pass → refactor
  • No implementation function was written before its test — if you find yourself writing code without a red test, stop and write the test first
  • Boundary mocked (PSP/processor/Sessions API behind a port); orchestration logic not mocked; unit suite runs with no deployment/secrets
  • Happy path pinned per piece (session minted, Order created once marked Ordered, capture/refund recorded) — the one a broad refactor silently breaks
  • Invariants pinned as tests: IDOR rejection, no-secret-leak, Order idempotent on orderNumber, gate-on-Success (both Pending and Failure blocked), capture/refund via processor (Payment Intents API untouched), webhook idempotent on redelivery
  • npm test runs clean with zero secrets in the environment

Verification

  • Test-card payment completed; commercetools Payment found with a Success transaction
  • (Production) Order created; a refund through the processor succeeds
  • Full-flow integration test drives the real deployed connector with a test card, asserts the CT trace at each commit point, polls (not sleeps) for async settlement, and skips loudly when unconfigured
integrations/payment/stripe.md

Stripe payment connector

Provider specifics for Stripe. Read connector-contract.md first for the flow and pitfalls — this only fills in the Stripe-specific blanks.

The connector

Enabler bundle (browser)

  • File: connector-enabler.umd.js (and connector-enabler.es.js). Load the UMD one via <script> — see contract pitfall 5.
  • UMD global: window.Connectorwindow.Connector.Enabler.
  • Internally imports @stripe/stripe-js, which is exactly why dynamic ES import() is fragile here.
<script src="https://assets-….{region}.commercetools.app/connector-enabler.umd.js"></script>
<script>
  const { Enabler } = window.Connector;
  const enabler = new Enabler({ processorUrl, sessionId, locale: 'en-US', onComplete, onError });
  const dropin = await (await enabler.createDropinBuilder('embedded')).build({ showPayButton: false });
  dropin.mount('#dropin-container');   // then wait for `ready` before enabling Pay (pitfall 7)
</script>

Configuration keys (connect.yaml)

The processor application takes these. Secured values go in securedConfiguration and are never logged or returned. Don't hardcode any of them in the frontend — the publishable key and appearance reach the browser via the processor's GET /operations/config.

Secured (secrets):

KeyPurpose
CTP_CLIENT_IDcommercetools API client id
CTP_CLIENT_SECRETcommercetools API client secret
STRIPE_SECRET_KEYStripe secret API key
STRIPE_WEBHOOK_SIGNING_SECRETverifies inbound Stripe webhooks
Standard (notable ones — see the deployment's connect.yaml for the complete list and current defaults):
KeyNotes
CTP_PROJECT_KEYproject key
CTP_AUTH_URL / CTP_API_URL / CTP_SESSION_URLregion hosts; defaults point at europe-west1.gcp — set to your region
CTP_CHECKOUT_URLrequired
CTP_JWKS_URL / CTP_JWT_ISSUERMerchant Center JWKS + issuer for session JWT validation
STRIPE_PUBLISHABLE_KEYStripe publishable key (reaches the browser via the processor)
STRIPE_WEBHOOK_IDthe Stripe webhook endpoint id the connector manages
STRIPE_CAPTURE_METHODautomatic (immediate capture) or manual (authorize, capture later). Default automatic. Drives the capture-mode requirement and when you create the Order.
STRIPE_SAVED_PAYMENT_METHODS_CONFIGJSON, e.g. {"payment_method_save":"enabled"}. Default {"payment_method_save":"disabled"}. Enable for saved-cards requirement — needs a customerId on the cart.
STRIPE_PAYMENT_INTENT_SETUP_FUTURE_USAGE"Setup future usage" for the PaymentIntent — pairs with saved payment methods.
STRIPE_ENABLE_MULTI_OPERATIONStrue/false (default false). Enables multicapture + multirefund; also requires multicapture enabled in the Stripe account. Set for partial-refund/split-capture requirement. Don't enable speculatively — it changes transaction handling.
STRIPE_COLLECT_BILLING_ADDRESSauto | never | if_required (required; default auto). Whether the Payment Element collects billing address.
STRIPE_API_VERSIONpinned Stripe API version. Do not hardcode this in documentation or generated code. Derive it from the installed stripe npm package rather than pinning a literal — the value changes with each major SDK release and a stale value causes a TypeScript type error. Note that stripe/esm/apiVersion.js is not in the package's exports map, so it can't be imported directly; see the Stripe API version section below for the supported ways to read it.
STRIPE_LAYOUT / STRIPE_APPEARANCE_PAYMENT_ELEMENT / STRIPE_EXPRESS_ELEMENT_OPTIONSPayment Element layout/appearance + express button options (JSON; cosmetic, safe to leave default)
MERCHANT_RETURN_URLrequired; must be an absolute URL with a scheme (contract pitfall 6)
ALLOWED_ORIGINSrequired; comma-separated list; must include every frontend origin that calls the processor (CORS)
PAYMENT_INTERFACEthe paymentMethodInfo.paymentInterface written on the Payment; default checkout-stripe
For turning requirements into these values with a worked example, see config-from-requirements.md.

Session metadata for Stripe

The Stripe connector validates the session against its own deployed processor. If you hit 401 "Session is not active" from the processor with a fresh session, confirm the session metadata carries what this deployment expects — either the Checkout Application applicationKey, or processorUrl set to the connector's processor URL — and that the cart total is non-zero. See contract pitfalls 2 and 3.
Custom connectors (built from the payment-integration template) use metadata.processorUrl, not applicationKey. The template's session-auth hook validates that the session's metadata.processorUrl matches the processor's own deployed URL. There is no Checkout Application involved. Use:
{ "metadata": { "processorUrl": "https://service-….europe-west1.gcp.3.sandbox.commercetools.app" } }
applicationKey only applies if you have configured a Checkout Application in the Merchant Center (the hosted Checkout product path). Trying applicationKey on a custom connector will get you a 401 that looks like a session issue but is actually a metadata mismatch.

API client scopes

Two separate API clients are involved. Requesting a scope the client doesn't have returns a 400 invalid_scope (not a 403), which surfaces as a generic "Permissions exceeded" error at runtime.
ActorMinimum required scopes
Storefront BFF (session creation, order creation)manage_sessions:{projectKey}, manage_orders:{projectKey}
Processor (CT API client used for payments, cart reads, session validation)manage_payments:{projectKey}, view_sessions:{projectKey}, manage_orders:{projectKey}

Notes:

  • Checkout splits session scopes: manage_sessions:{projectKey} grants creating a session (the Storefront BFF needs this), while view_sessions:{projectKey} grants reading one — the latter is the scope required for connectors to interact with Checkout and validate sessions, so the Processor needs view_sessions. See Checkout Scopes.
  • manage_orders covers reading carts (needed for cart version lookups and addPayment) — do not request view_orders or manage_my_orders unless the client was explicitly granted them.
  • The processor and storefront BFF can share a single API client in development, but should use separate clients in production to enforce least-privilege.

Webhook setup

Stripe is partly asynchronous: the final transaction state can arrive via webhook. The connector manages a Stripe webhook endpoint (STRIPE_WEBHOOK_ID) and verifies it with STRIPE_WEBHOOK_SIGNING_SECRET. If a payment authorizes in the UI but the commercetools Payment transaction never moves to Success, suspect the webhook: confirm the endpoint exists in the Stripe dashboard, points at the processor, and the signing secret matches.

Test cards

Use Stripe test mode keys and test cards (see Stripe's testing documentation):
CardOutcome
4242 4242 4242 4242succeeds, no authentication
4000 0025 0000 3155requires 3D Secure authentication
4000 0000 0000 9995declined (insufficient funds)

Any future expiry, any CVC, any postal code.

Building a custom Stripe connector (from the payment-integration template)

When building your own connector (ladder rung 4 — no public connector, or forking), two things differ from the public connector experience:

Payment route method. The public Stripe connector's payment-creation route happens to be a GET /payments (the enabler calls it for you — see contract pitfall 8). When you build from the template you own that route and should implement it as POST /payments. Don't let the "GET" note in connector-contract.md confuse you — it applies to the public connector; in your own processor you write the HTTP method.
Raw body for webhook signature verification. Stripe's stripe.webhooks.constructEvent() requires the raw unparsed request body (a Buffer), not the JSON-parsed body. Fastify parses bodies by default. Use the fastify-raw-body npm package (note: the scoped @fastify/raw-body does not exist — it will 404 on install):
npm install fastify-raw-body
import rawBody from 'fastify-raw-body';

await server.register(rawBody, {
  field: 'rawBody',
  global: false,      // opt-in per route, not global
  encoding: false,    // keep as Buffer, not string
  runFirst: true,
  routes: ['/stripe/webhooks'],
});
Then in the webhook route, read (request as any).rawBody as the Buffer to pass to constructEvent.
fastify-raw-body v5 replaces the JSON content-type parser globally. Despite global: false, v5 replaces Fastify's default JSON content-type parser for ALL routes (not just webhook routes). The global flag only controls the preParsing hook, not the parser replacement. This means any POST route that receives Content-Type: application/json with an empty body ("") will be rejected by the patched almostDefaultJsonParser — even POST /payments. The fix: always send body: "{}" (a valid empty JSON object) from the enabler's fetch call to POST /payments, never an empty string or no body at all.
stripe.elements() requires mode + real cart amount (deferred-intent pattern). Without mode, amount, and currency, the Stripe Payment Element mounts as a blank box with no error — a silent failure. The certified connector solves this with a GET /config-element/:paymentComponent endpoint (session-authenticated) that returns the real cart amount, currency, capture method, and layout. This endpoint is not in the payment-integration template by default — you must add it. The enabler fetches /operations/config and /config-element/payment in parallel, then calls:
stripe.elements({
  mode: 'payment',
  amount: cartElement.cartInfo.amount,          // centAmount from the CT cart
  currency: cartElement.cartInfo.currency.toLowerCase(),
  capture_method: cartElement.captureMethod,    // 'automatic' | 'manual'
});
The processor endpoint should read the cart from session context (getCartIdFromContext()) and call ctCartService.getPaymentAmount({ cart }):
// GET /config-element/:paymentComponent — session-authenticated
async initializeCartPayment() {
  const ctCart = await this.ctCartService.getCart({ id: getCartIdFromContext() });
  const amount = await this.ctCartService.getPaymentAmount({ cart: ctCart });
  return {
    cartInfo: { amount: amount.centAmount, currency: amount.currencyCode },
    captureMethod: getConfig().stripeCaptureMethod,
    collectBillingAddress: getConfig().stripeCollectBillingAddress,
    layout: JSON.stringify({ type: 'tabs', defaultCollapsed: false }),
  };
}
PaymentIntent must use automatic_payment_methods, not payment_method_types. When Elements is initialized in deferred-intent / automatic mode (no explicit payment_method_types list — which is the correct pattern when using GET /config-element/payment), the PaymentIntent created in POST /payments must also use automatic_payment_methods: { enabled: true }. Using payment_method_types: ['card'] causes a Stripe 400: "Payment details were collected through Stripe Elements using automatic payment methods and cannot be confirmed through the API configured with payment_method_types." The payment-integration template scaffolds payment_method_types: ['card'] by default — remove it and replace:
await stripe.paymentIntents.create({
  amount: amountPlanned.centAmount,
  currency: amountPlanned.currencyCode.toLowerCase(),
  capture_method: cfg.stripeCaptureMethod,
  automatic_payment_methods: { enabled: true }, // not payment_method_types: ['card']
  metadata: { ... },
});
Deferred-intent: fetch clientSecret inside submit(), not at mount time. With stripe.elements({ mode: 'payment', ... }), stripe.confirmPayment() needs a clientSecret — but the PaymentIntent doesn't exist yet when the element mounts. Create it server-side inside submit(), then confirm. (This is the Stripe instance of connector-contract.md pitfall 12.)
// 1. Validate the form
const { error: submitError } = await elements.submit();
if (submitError) { /* handle */ return; }

// 2. Create the PaymentIntent server-side NOW (not at mount time)
const res = await fetch(`${processorUrl}/payments`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Session-Id': sessionId },
  body: '{}',
});
const { clientSecret } = await res.json();

// 3. Confirm with the clientSecret
const { error, paymentIntent } = await stripe.confirmPayment({
  clientSecret,   // ← required for deferred-intent
  elements,
  confirmParams: { return_url: merchantReturnUrl },
  redirect: 'if_required',
});
Calling stripe.confirmPayment({ elements, confirmParams }) without clientSecret throws IntegrationError: You must pass in a clientSecret. Calling POST /payments at mount time instead of submit time creates a PaymentIntent before the user has confirmed — abandoned intents accumulate in Stripe.
Refund needs a charge id (ch_xxx), not a PaymentIntent id. For automatic capture flows the webhook writes interactionId: paymentIntent.id (pi_xxx) on the Success transaction — that's what POST /payments/:id/refund receives as stripeChargeId. But stripe.refunds.create operates on charges, not PaymentIntents. Passing a pi_xxx id returns 404 No such charge. Fix: retrieve the charge id from Stripe before refunding — the PaymentIntent's latest_charge field carries it:
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
const stripeChargeId = pi.latest_charge as string; // ch_xxx
Alternatively, update the webhook handler to write the charge id (from paymentIntent.latest_charge) as the interactionId for Charge-type transactions, so the CT Payment itself carries the refundable id. (This is the Stripe instance of connector-contract.md pitfall 16.)
Classify Stripe errors in the enabler, not the storefront. card_error and validation_error (from both elements.submit() and stripe.confirmPayment()) are user-recoverable — show them inline near the payment form and clear on the next change event so the user can correct and retry without the storefront intervening. Non-recoverable errors (invalid_request_error, api_error) bubble to onError. Pattern:
// In mount():
this.errorEl = document.createElement('div');
this.errorEl.setAttribute('role', 'alert');
container.appendChild(this.errorEl);
paymentElement.on('change', () => { this.errorEl.textContent = ''; });

// In submit(), after elements.submit():
if (submitError?.type === 'validation_error') {
  this.errorEl.textContent = submitError.message ?? 'Please complete your payment details.';
  return;
}

// After stripe.confirmPayment():
if (confirmError?.type === 'card_error' || confirmError?.type === 'validation_error') {
  this.errorEl.textContent = confirmError.message ?? 'Payment failed. Please check your card details.';
  return;
}
// non-recoverable → onError(confirmError, { paymentReference })
Stripe API version. The goal is to stay in sync with the installed SDK without hardcoding a literal string that silently drifts when the package is upgraded. stripe/esm/apiVersion is not exposed via the package's exports map — importing it directly fails at runtime (ERR_PACKAGE_PATH_NOT_EXPORTED) and TypeScript can't find it (no .d.ts in esm/). Use whichever approach fits your module system and build setup — any of these are fine:
  • Read from disk at startup (CJS processors): fs.readFileSync('node_modules/stripe/esm/apiVersion.js') and regex-extract the value. Works without any build step.
  • Build-time codegen: a prebuild script that runs node -e "..." and writes the version to a generated src/generated/stripeApiVersion.ts file that TypeScript can import normally.
  • Pin it explicitly and own the update: hardcode the string (e.g. '2024-06-20'), add a comment like // update when upgrading stripe SDK, and enforce it in CI with a check that compares against the installed version. Honest and often the most pragmatic choice.
Do not leave it as the TypeScript default ('' or omitted) — Stripe will use its own latest version server-side, which may differ from what the SDK expects and cause subtle type mismatches.
Prefer Jest for connector apps; if you use Vitest, run it through a wrapper. Connect validates npm test at publish and its examples use Jest, which is what the connector templates assume. Vitest can work, but its CLI aborts on any unknown option it's passed — so make each app's test script call Vitest with a fixed, explicit arg list rather than letting extra arguments reach it. The simplest way is a small wrapper script:
// scripts/run-tests.mjs  →  "test": "node scripts/run-tests.mjs"
import { spawnSync } from 'node:child_process';
const r = spawnSync(process.execPath, ['node_modules/vitest/vitest.mjs', 'run', '--coverage'], { stdio: 'inherit' });
process.exit(r.status ?? 1); // forward exit code so real failures still fail the build
Two related points: every app needs a test script — since tests are mandatory and reviewed at publish, an assets/enabler app with no test script won't pass validation. Give it the same wrapper plus at least one real test. And put coverage config in vitest.config.ts, not CLI flags, so the wrapper stays the single source of truth.

Quick reference

  • Bundle: connector-enabler.umd.js, global window.Connector.
  • Auth to processor: X-Session-Id (contract pitfall 9).
  • Capture mode: STRIPE_CAPTURE_METHOD (automatic | manual).
  • Secrets: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SIGNING_SECRET, CTP_CLIENT_SECRET, CTP_CLIENT_ID.
  • 401 from processor → session metadata / cart-total check.
  • Raw body for webhooks: fastify-raw-body (not @fastify/raw-body — that package doesn't exist).
  • API version: do not hardcode and do not import stripe/esm/apiVersion directly (not in exports map). Options: fs.readFileSync at startup, build-time codegen, or an explicit pinned string with a CI check. See the "Stripe API version" section above.
  • Payment Element blank box (no error) → stripe.elements() missing mode/amount/currency — add GET /config-element/payment to the processor; fetch it in parallel with /operations/config before initializing Elements.
  • POST /payments 500 with empty body → fastify-raw-body v5 global JSON parser replacement. Send body: "{}" (not "" or no body) from the enabler's fetch.
  • PaymentIntent 400 "cannot be confirmed … configured with payment_method_types" → template default payment_method_types: ['card'] conflicts with automatic Elements mode. Replace with automatic_payment_methods: { enabled: true }.
  • Enabler error handling: card_error/validation_error → inline message + clear on change; invalid_request_error/api_erroronError.
  • Vitest test script failing at publish though it passes locally → Vitest aborts on unknown CLI options; route test through a wrapper that calls Vitest with a fixed arg list. Prefer Jest (templates assume it). Every app (incl. enabler) needs a test script. See the "Prefer Jest for connector apps" note above.
  • Image security analysis fails but SAST/SCA pass → base-image OS CVE, pin engines.node (e.g. 20.x) in every app's package.json. Dependency-CVE fixes are upgrades, never downgrades. See deploy-custom-connector.md.
integrations/payment/test-harness.md

Test harness

A small standalone app is the fastest way to prove a deployed connector works end to end. Build the harness, take one test payment, confirm the Payment object (→ verification.md), then port the proven flow into the real storefront. Keep it disposable — it holds secrets and uses shortcuts (client-side token, throwaway cart) that must never ship.

Shape

Any minimal stack works (Vite + React, or a single HTML file). It needs to do the 8 steps from connector-contract.md: get a token, make a non-zero cart, create a session, warm the processor, load the enabler, mount the drop-in, gate Pay on ready, submit.
Security note: a real app does steps 1–3 (token, cart, session) server-side so client credentials and manage_sessions never reach the browser. A local harness may do them client-side for speed, but say so and never deploy it.

Config the harness needs

CT_AUTH_URL, CT_API_URL            # region hosts
CT_SESSION_HOST                    # https://session.{region}.commercetools.com
CT_PROJECT_KEY
CT_CLIENT_ID, CT_CLIENT_SECRET     # client with manage_sessions (+ cart/payment read for verify)
PROCESSOR_URL                      # deployed connector processor URL
ENABLER_URL                        # deployed connector enabler URL (serves connector-enabler.umd.js)
CHECKOUT_APPLICATION_KEY           # or PROCESSOR_URL again, per what the connector's session metadata expects
Reading config in a Vite harness: if you store config in a plain file (e.g. connector-env) without a .env extension, Vite's loadEnv() won't pick it up — it only reads files whose names start with .env. Use fs.readFileSync + a custom parser in vite.config.js instead:
import fs from 'fs';
function parseEnvFile(filePath) {
  return Object.fromEntries(
    fs.readFileSync(filePath, 'utf8')
      .split('\n')
      .filter(l => l && !l.startsWith('#') && l.includes('='))
      .map(l => { const i = l.indexOf('='); return [l.slice(0,i).trim(), l.slice(i+1).trim()]; })
  );
}
const env = parseEnvFile('../connector-env');
export default { define: { __PROCESSOR_URL__: JSON.stringify(env.PROCESSOR_URL), /* … */ } };

The flow (pseudocode)

// 1) token (client_credentials)
const token = await oauth(CT_AUTH_URL, CT_CLIENT_ID, CT_CLIENT_SECRET, 'manage_sessions:'+CT_PROJECT_KEY);

// 2) non-zero cart (ExternalAmount avoids needing a tax category — see contract pitfall 3)
const cart = await post(`${CT_API_URL}/${CT_PROJECT_KEY}/carts`, token, {
  currency: 'EUR', taxMode: 'ExternalAmount',
  customLineItems: [{ name:{en:'Test item'}, slug:'test-item', quantity:1,
    money:{currencyCode:'EUR',centAmount:1999},
    externalTaxRate:{name:'test',amount:0,country:'DE'} }],
});

// 3) session — cartRef + processor-matching metadata (contract pitfalls 1, 2)
const session = await post(`${CT_SESSION_HOST}/${CT_PROJECT_KEY}/sessions`, token, {
  cart: { cartRef: { id: cart.id } },
  metadata: { applicationKey: CHECKOUT_APPLICATION_KEY }, // or { processorUrl: PROCESSOR_URL }
});

// 4) warm the processor (contract pitfall 10)
await fetch(`${PROCESSOR_URL}/operations/status`).catch(()=>{});

// 5) load enabler UMD (contract pitfall 5) — inject a <script> and await its load
await loadScript(`${ENABLER_URL}/connector-enabler.umd.js`);
const { Enabler } = window.Connector;            // global is provider-specific

// 6) construct + build
const enabler = new Enabler({
  processorUrl: PROCESSOR_URL, sessionId: session.id, locale: 'en-US',
  onComplete: (r) => setStatus('paid: ' + JSON.stringify(r)),
  onError:   (e) => setStatus('error: ' + (e?.message ?? e?.code)),
});
const dropin = await (await enabler.createDropinBuilder('embedded')).build({ showPayButton: false });

// 7) mount + wait for ready (contract pitfall 7)
dropin.mount('#dropin-container');
container.addEventListener('ready', () => enablePayButton(), { once: true });
setTimeout(enablePayButton, 5000); // fallback if no ready event

// 8) on Pay click
payButton.onclick = () => dropin.submit();

After it works

  • Verify the Payment (→ verification.md).
  • Move steps 1–3 server-side for the real integration, and add Order creation + post-purchase operations (→ backend-integration.md); the browser only ever gets the sessionId, processor URL, and enabler URL.
  • Delete the harness or scrub its secrets.

Checklist

  • harness reads processor/enabler URLs and CT creds from config, not hardcoded
  • non-zero cart; session with cartRef + correct metadata
  • enabler loaded from UMD bundle; ready-gated Pay button
  • one test-card payment completed and verified as a CT Payment
  • harness not deployed; secrets removed afterward
integrations/payment/verification.md

Verifying the round trip

A connector payment is only "done" when it has left a trace in commercetools. The processor (not your frontend) creates the Payment and adds its transactions — so verification means finding the Payment the processor wrote and confirming its transaction reached a terminal success state. The Payment's paymentMethodInfo.paymentInterface is whatever the connector's PAYMENT_INTERFACE is set to (Stripe default checkout-stripe).

What success looks like

After a successful dropin.submit():
  1. The enabler's onComplete fires (or the browser is sent to MERCHANT_RETURN_URL).
  2. The processor has created a Payment whose paymentMethodInfo.paymentInterface matches the connector (e.g. stripe) and added a transaction:
    • Charge / state Success for immediate capture (STRIPE_CAPTURE_METHOD=automatic), or
    • Authorization / state Success for authorize-now/capture-later (manual).
    The interface value comes from PAYMENT_INTERFACE (Stripe default checkout-stripe).
  3. The Payment is linked to the cart (cart.paymentInfo.payments).

Finding the Payment

The cart is the anchor — read it back and follow paymentInfo:
# Get the cart; paymentInfo.payments holds the Payment references the processor linked
curl -s "{api}/{projectKey}/carts/{cartId}" -H "Authorization: Bearer {token}"

Then fetch each referenced Payment and inspect its transactions:

curl -s "{api}/{projectKey}/payments/{paymentId}" -H "Authorization: Bearer {token}"

Look for, in the Payment:

  • paymentMethodInfo.paymentInterface = the connector's interface
  • transactions[] containing a Charge or Authorization with state: "Success"
  • interfaceId set to the PSP's payment/intent reference
  • optionally interfaceInteractions[] holding the raw PSP payload (audit trail)
If you prefer a query, filter payments by interface and recency, or by interfaceId if you captured the PSP reference. Reading scopes needed: view_payments (and view_orders to read the cart).

If the Payment is missing or stuck

SymptomLikely causeWhere
No Payment at allsubmit() never reached the processor; or processor 401/502contract pitfalls 2, 4, 7
Payment exists, transaction stuck Pendingasync PSP webhook not delivered/verifiedprovider reference → webhook setup; backend-integration.md → webhook reconciliation
Payment with Failure transactiondeclined card / PSP rejectioncheck PSP dashboard + the test card used
Duplicate Paymentsfrontend also creating Payments (wrong path)the processor owns the Payment — don't create it yourself

Checklist

  • onComplete fired or return URL was reached
  • Cart paymentInfo.payments references at least one Payment
  • That Payment has a Success Charge/Authorization transaction
  • paymentInterface matches the connector; interfaceId is set
  • No duplicate Payments (a sign the frontend wrongly created one)
integrations/pim/build-connector.md

Build or fork a PIM connector

Reached here from rung 3 (fork) or rung 4 (build) of the selection ladder, or because there's no public connector for the PIM. Your data mapping is the what; this is the how it moves. Two decisions define the connector; everything else is the parent commercetools-connect build contracts (service/event/job semantics, security, testing, lifecycle, deploy) — this reference only covers what's PIM-specific and routes the rest back.

If forking, change only the delta (a new resource type, a transform, a direction) and keep the working sync engine, keying, and dependency handling — don't rebuild.

Decision 1 — Which ingestion API?

commercetools offers two ways to write product data (docs: choose your approach, external product data patterns):
Import APIHTTP (Products) API
ShapeAsynchronous, bulk; submit then pollSynchronous, transactional; immediate result
Best forInitial catalog load, periodic full refresh, large scheduled batchesReal-time incremental updates, single-product fixes
SuperpowerAutomatic reference resolution — submit products/categories/types in any order within 48 h; up to 20 resources/requestInstant validation and errors; full update-action control
Watch outReference resolution ≠ data validity (SKU uniqueness etc. still checked by the commerce API); poll operations to a terminal stateYou resolve references and ordering yourself; rate limits under high volume
ReferenceImport API overview, best practicesProducts API, product drafts / import endpoints
Common answer: Import API for the bulk/initial/nightly path, HTTP API for the real-time event path — many connectors use both. Match the choice to volume and cadence, not habit.

Decision 2 — Which Connect application shape?

Derive it from the cadence (Step 1), using the parent skill's decision framework. For a PIM ingesting into commercetools:
  • Event-driven / near-real-time → a service as inbound webhook: the PIM (or its middleware) calls your endpoint when a product changes; you transform and upsert. This is the parent skill's inbound webhook mode — not an API Extension. Contract: 5-min service timeout (not the 2 s extension limit); you authenticate the caller and make the write idempotent. → service-applications.md, security.md.
  • Scheduled / bulk → a job: poll the PIM for deltas (or do a full pull), transform, and submit via the Import API. Contract: 30-min request timeout, no built-in concurrency guard (you own overlap locking), restart-safe checkpointing so a re-run resumes cleanly. → job-applications.md.
  • Both is common and recommended: a service webhook for live changes plus a job for nightly full reconciliation that heals anything the webhook missed. A single connector declares both applications in connect.yaml.
  • Bi-directional only: if some commercetools attributes must flow back to the PIM, add an event app subscribing to product Messages (at-least-once, no ordering, idempotent). Keep it scoped to CT-owned attributes only, and add self-change filtering so your own inbound writes don't loop back out. → event-applications.md.

Webhooks: which way they point

A common misread — the connector mostly receives a webhook, it doesn't call outbound ones:
  • Inbound (the one that matters): PIM → connector. The event-driven path is the PIM calling your service endpoint when a product changes. Two setup obligations come with it: (a) register your endpoint with the PIM's event system (e.g. Akeneo's event subscriptions) so it will call you — do this in an idempotent postDeploy lifecycle script or via the PIM's own config; (b) verify the PIM's signature on every inbound event. Note the scheme is often HMAC (a shared secret in securedConfiguration), not the JWT the parent security.md describes for commercetools-issued destinations — check the PIM's current docs for the exact header and algorithm, don't assume JWT.
  • Outbound is the commercetools API, not a webhook. The connector's outbound traffic is HTTP/Import API calls to commercetools — normal authenticated REST, not webhooks.
  • commercetools Subscriptions are queue deliveries, not webhooks you call — relevant only on the bi-directional event path above.

Full vs incremental

  • Full sends the whole catalog — right for the initial load and periodic complete refreshes; slower and leaves the catalog partially updated mid-run. Best on the Import API.
  • Incremental sends only what changed since the last run — faster and time-critical-friendly, but the PIM must support change tracking; if it doesn't, the connector needs its own change-detection (e.g. a stored hash/updatedAt per product) rather than reprocessing everything. (docs)
A robust connector does both: incremental for freshness, a scheduled full reconciliation to catch missed events and drift.

Idempotency (non-negotiable)

Every write is an upsert by the stable key from data mapping Principle 7 — create if absent, update if present. This is what makes the connector safe under the realities of both shapes: a webhook redelivered at-least-once, two job runs overlapping, a full re-import over existing data. Never blind-create (duplicates) and never full-overwrite another source's attributes (clobbering). On the HTTP API, upsert = get-by-key-then-update-actions or create; on the Import API, submitting the same key updates in place. State the idempotency strategy in one sentence before writing the handler — if you can't, you're not ready.

Delete / unpublish handling

Decide what a "removed in the PIM" signal means in commercetools — usually unpublish or deactivate, rarely hard-delete (orders and history reference products). If the PIM emits delete events, map them explicitly; if it only emits upserts, the scheduled full reconciliation is what detects disappearances (present in CT, absent in the PIM feed → unpublish). Don't leave delete semantics implicit.

Then follow the build-side contracts

The rest is type-agnostic and lives in the parent commercetools-connect skill — build to its production-readiness gate:
  • Inbound webhook authentication + least-privilege scopes (manage_products, manage_categories, manage_product_types, and Import API scopes as needed — not manage_project) → security.md
  • Idempotent postDeploy/preUndeploy lifecycle scripts (register the webhook/subscription, create Product Types & AttributeGroups as-code) → lifecycle-scripts.md
  • Test-first: pure mapping unit tests, then a bounded sandbox-only live sync run with a pre-flight item count and catalog-size gate → testing.md; router-level auth-rejection matrix, duplicate-delivery/idempotency, malformed-payload handling → parent testing.md
  • Structured logs with a correlation key (the PIM product id), health endpoint, poison-message/replay runbook → observability-operations.md
  • connect.yaml at the repo root, documented envelope keys only; scaffold/validate with the Connect CLI → connect-cli.md; deploy/stage/publish → deployment-installation.md

Checklist

  • Ingestion API chosen per volume/cadence (Import API bulk, HTTP API real-time; often both)
  • Application shape matches the cadence (service webhook, job, or both; event only for bi-directional write-back)
  • Full and incremental strategy decided; incremental has real change detection (or a documented fallback)
  • Every write is an upsert by stable key; idempotency strategy stated in one sentence
  • Delete/unpublish semantics defined (not implicit); reconciliation detects disappearances
  • Handed the type-agnostic contracts (auth, scopes, lifecycle, tests, observability, deploy) back to the parent connect skill and met its production-readiness gate
integrations/pim/connector-selection.md

Is a public PIM connector enough?

Before wiring or building anything, answer one question: does a connector that already syncs this PIM into commercetools exist? Getting it wrong is expensive both ways — building from scratch when a public connector covers you wastes weeks; assuming a public connector maps an attribute or supports a direction it doesn't surfaces only at integration time.

There are two kinds of connector:

First trap — a marketplace listing is not automatically a Connect connector. The marketplace PIM category also lists partner-operated / SaaS integrations that are not commercetools Connect applications (nothing to deploy via Connect; you engage the vendor instead). A listing being a great functional match does not make it installable through Connect. Verify Connect-deployability before treating any listing as rung 1 — see Not every marketplace listing is a Connect connector below.
The common-but-tricky case (once you've confirmed it is a Connect connector): a connector exists for the PIM, but the user's specific mapping or direction isn't covered by the public version. Don't jump to "build custom" — that throws away a working, maintained sync engine. Walk the ladder below.

Don't hardcode "what's supported" — check it live

The set of PIM connectors, their versions, and their capabilities changes over time. Do not rely on a memorized matrix. Determine fit from current sources, in order:
  1. Run the skill's docs-search step and/or query the commercetools Knowledge MCP for "PIM connector product data integration".
  2. Browse the live Connect marketplace — Product Information Management category for listings and versions: marketplace PIM integrations.
  3. For a partner listing, its own marketplace page / repo / docs is the source of truth for what it maps and how it's configured (e.g. the Akeneo listing).
  4. Confirm it's a Connect connector, not just an integration — the category mixes both. See the verification step below before counting a listing as a rung-1/2 option.
PIM-to-commercetools listings that have appeared in the marketplace category include Akeneo (a Vaimo partner integration), Bluestone PIM, Contentserv, inriver, Pimcore, Syndigo, ATAMYA (eggheads), Chioro (eCube), and Vaimo — so Akeneo is not the only option. Treat this as a starting point to verify live, not a definitive or current list, and not a claim that each is a deployable Connect connector. State explicitly to the user that you're checking current data, and cite each listing + version + whether it's Connect-deployable — capabilities differ by version.
Beware the direction trap. The same PIM category also lists adjacent entries that are not PIM ingestion — search/recommendations (e.g. GroupBy), 3D/AR (e.g. Threekit), generic iPaaS middleware — and some push commercetools → external (syndication, the product-export template). A PIM ingestion connector must sync PIM → commercetools. Confirm the direction before counting a listing as a fit.

Not every marketplace listing is a Connect connector — verify it

This is the parent skill's general rule (SKILL.md → Marketplace listings are not all Connect connectors), applied to PIM. A PIM listing can be an excellent functional match and still be a partner-operated / SaaS integration that is not deployable through Connect — and the marketplace can be out of sync with what's actually installable. So: verify a candidate is a real Connect connector (Connect affordance / repo / connect.yaml; the Connect CLI registry is authoritative over the listing — see deployment-installation.md), then ask the user what to do.

If it's a good match but is not a Connect connector

Surface it — a strong match is worth mentioning — but warn per the parent rule: it's a partner/SaaS integration, not a commercetools Connect solution, so this skill does not cover using it (its connect.yaml / Connect CLI / lifecycle / deploy patterns don't apply). Point to the vendor's own onboarding, and offer the in-skill alternative: build a Connect connector for this PIM, or fork an open-source one if it exists (rungs 3–4 → build-connector.md).

Only a listing that passes this check counts as a rung-1/2 (configure) option below.

Present the options first: install or modify (before considering build)

Lead with what already exists. Before any fit analysis, enumerate the live marketplace PIM connectors and show them to the user — don't silently pick one, and don't jump to building. For each candidate, give: name, vendor, sync direction, and a one-line "what it syncs."
Then, when the user's PIM matches a listed connector, offer the two low-effort paths explicitly and let the user choose — building is the last resort, not the opener:
  • Install it as-is → deploy the public connector and close any gaps with configuration / attribute mapping (ladder rungs 1–2). This is the default recommendation whenever a listed connector matches the PIM. → deployment-installation.md.
  • Modify it (fork) → the connector's a fit but has a genuine gap config can't close (e.g. bi-directional write-back, a missing resource type). Fork the open-source connector, add only the delta, deploy as an Organization connector (rung 3). → build-connector.md.
Only if no listed connector matches the PIM at all do you fall through to build from scratch (rung 4). Present install-or-modify as the primary choice; reach for build only when the list has nothing for this PIM. The fit check and ladder below are how you decide which of these two the matched connector needs.

The fit check

Compare the requirements gathered in Step 1 against what a candidate public connector actually does. Check each dimension:

DimensionQuestionIf not covered → which rung
PIM systemIs the user's PIM available as a public connector?No connector for this PIM → rung 4 (build).
DirectionOne-way (PIM → CT) vs bi-directional — does the connector match?Most public PIM connectors are one-way; bi-directional need → fork (rung 3) or build (rung 4).
Sync scopeDoes it sync what's needed — Product Types, products/variants, categories, attributes, media, prices?Missing resource type → re-check as config; if genuinely absent → fork (rung 3).
Attribute mappingCan it map the user's PIM attributes onto their Product Types, including localization and channels?Almost always config/attribute-mapping, not code → rung 2. See data-mapping.md.
CadenceEvent-driven, scheduled delta, full re-sync — does it offer what's needed?Missing cadence → fork (rung 3).
Special requirementsReference entities, measurement conversion, Product Selections/Tailoring per store, variant/family quirks, approval workflowJudge each: config (rung 2), small fork (rung 3), or build (rung 4).
Most gaps for a supported PIM are configuration / attribute mapping, not missing features — the field-to-attribute mapping, locale and channel selection, and category mapping are what public PIM connectors externalize as config. So before concluding anything needs building, confirm the gap can't be closed by configuration and mapping — that's data-mapping.md (the vendor-neutral method) plus the connector's own config docs, looked up live.

The decision ladder

Walk these in order and stop at the first that fits — each later rung is more work and more to maintain.
  1. Is the listing a deployable Connect connector at all? → if not (a partner/SaaS integration), it's outside this skill: surface it with the not-a-Connect-solution warning above and offer the build/fork rungs instead. Only Connect-deployable listings reach rung 1. → Not every marketplace listing is a Connect connector.
  2. Public connector covers everything → install + configure. Don't build. The common, recommended case. Deploying it: deployment-installation.md.
  3. Right PIM, gap looks like a capability → first prove it's not config / attribute mapping. Field mapping, locale/channel selection, category mapping, and which attributes sync are configuration on most PIM connectors → back to rung 1. See data-mapping.md and the connector's own config docs (looked up live).
  4. Right PIM, genuine gap config can't closefork/extend the public connector. Add only the delta (a new resource type, a transform, bi-directional write-back) and deploy as an Organization connector — you keep the working sync engine, keying, and dependency handling. → build-connector.md, a commercetools-connect task.
  5. No public connector for the PIM at allbuild using the connect skill's service (inbound webhook) and/or job patterns, ingesting via the Import API or HTTP API. The from-scratch path, justified only when there's nothing to fork. → build-connector.md.

Only rungs 3–4 leave this sub-area (hand off to build/fork); the flow resumes once the connector is deployed. Record the decision, the rung, and the connector version checked in the requirements block — so the rest of the work is grounded in a real, confirmed connector, not an assumed one.

Checklist

  • Checked the live marketplace PIM category (not memory); cited the connector + version
  • Verified each candidate is a deployable Connect connector, not a partner/SaaS integration listing (Connect affordance / repo / CLI registry — not the marketing page)
  • A good-match listing that is not a Connect connector was surfaced with the not-a-Connect-solution warning and the build/fork alternative, not treated as rung 1
  • Listed the available public PIM connectors to the user (name, vendor, direction, what it syncs) before any build discussion
  • For a matching connector, offered install-as-is vs modify/fork explicitly; build proposed only when no listed connector matches the PIM
  • Confirmed the candidate syncs PIM → commercetools (direction trap avoided)
  • PIM, direction, sync scope, cadence, and special requirements each compared to the requirements
  • Apparent capability gaps re-checked as config / attribute mapping (rung 2) before considering any build
  • When a public connector exists but has a real gap, chose fork/extend (rung 3) over build-from-scratch
  • Decision + rung + connector version recorded: configure (1), config/mapping (2), fork (3 → build-connector.md), or build (4 → build-connector.md)
integrations/pim/data-mapping.md

From PIM model to commercetools product model

This is where PIM integrations succeed or rot. The plumbing (webhook, job, Import API) is mechanical; the mapping decides whether the catalog stays correct and maintainable. It applies whether you configure a public connector (you set the mapping as config) or build one (you write it) — the decisions are identical. Ground every modeling choice in the Product catalog overview and the Integrate product data tutorial; this reference is the decision layer on top.
The core tension (docs): a PIM's model is optimized for enrichment (deep, exhaustive, editorial), commercetools' is optimized for commerce utility (search, display, pricing, fulfillment). They differ on purpose. Mapping is a transform and a filter, not a copy.

Principle 1 — Map only commerce-relevant data

Not every PIM attribute belongs in commercetools. Transfer only what search, display, pricing, or fulfillment needs; leave the rest in the PIM (it stays the source of truth and can be fetched on demand if ever needed). Every attribute you sync is one more thing to keep consistent — a smaller, sharper catalog is cheaper to run and faster to query.

Principle 2 — Do NOT map Product Types 1:1 with PIM families

The most common and most expensive mistake. Linking a commercetools Product Type directly to each PIM family/type/category means every structural change in the PIM forces a Product Type migration in commercetools — and Product Type changes are heavy (they constrain existing Products). Instead (docs):
  • Design a small set of flexible Product Types driven by how products are sold and searched, not by the PIM's taxonomy.
  • Give each Product Type a stable set of attributes; absorb PIM structural variety through attribute values, not new Product Types.
  • A PIM with hundreds of families usually maps to a handful of commercetools Product Types. If you find yourself minting a Product Type per family, stop — that coupling is the anti-pattern.

Principle 3 — Prioritize search-critical attributes; consolidate the rest

Split PIM attributes into two buckets (docs):
  • Search/filter/display-critical (brand, color, size, material, key specs) → map each to its own typed Product Type attribute. Type it precisely — enum/lenum for controlled vocabularies (so faceting works), number + a unit for measures, boolean for flags, ltext/text for copy. Precise types are what make query predicates and search facets work.
  • Supplementary (long-tail specs shown but never filtered) → consolidate into a single JSON/text attribute rather than exploding into dozens of rarely-used fields. This keeps the Product Type lean and the catalog queryable.
Match the attribute type to the PIM source: a PIM single/multi-select becomes enum/set of enum (lenum if the labels are localized); a PIM metric/measurement attribute becomes a number plus a unit (convert to one target unit at map time — don't ship mixed units). Key each enum option on the PIM's stable option code, not its localized label — labels change per translation and would silently break faceting.

Principle 4 — Localization

commercetools models translatable text as LocalizedString ({ "en-US": "...", "de-DE": "..." }). Map each PIM locale to a commercetools locale explicitly — PIM locale codes don't always match (en_US vs en-US), and a mismatch silently drops translations. Decide which locales are in scope (Step 1) and only sync those. For attributes that vary by locale in the PIM but shouldn't in commercetools (e.g. a unit system), resolve to one value at map time.

Principle 5 — Categories are a keyed tree, resolved by reference

Map the PIM category hierarchy to the commercetools Category tree. Each Category carries a stable key; a Product references its categories by key, and a child Category references its parent by key. When importing, you don't need categories to exist first — the Import API resolves references asynchronously (it holds an operation up to 48 h waiting for the referenced Category/Product Type to arrive, then retries). So you can submit products and categories in any order within that window — but the keys must match exactly. Derive category keys deterministically from a stable PIM identifier, never from a localized name (names change and aren't unique).

Principle 6 — Media, price, and inventory each have their own path

  • Media / assets. Map PIM image/asset URLs onto Product Variant images (or Assets for richer metadata). If the PIM only holds asset references into a DAM, sync the resolved public URLs. Large binary sets are better handled in the bulk/job path than on the hot webhook path.
  • Price and inventory — keep them SEPARATE from content (docs). They change far more often and are more time-critical than descriptions/images. Implement them as their own event-based integrations even when they originate in the same system, so a slow nightly catalog sync never blocks a price or stock update. Prices map to embedded Prices or Standalone Prices; inventory to InventoryEntry by SKU (variant availability updates asynchronously after the InventoryEntry lands).

Principle 7 — Every resource gets a stable key (idempotency backbone)

This is what makes the whole sync safe to re-run (docs). Give every resource — Product, Product Variant (plus sku), Price, Category, Product Type — a unique key derived from a stable PIM identifier (the PIM's product id / variant id, not a name or a position). Then every write is an upsert by key: create if absent, update if present. This makes re-delivery of a webhook, an overlapping job, and a full re-import all no-ops rather than duplicate-creators. A resource without a stable key cannot be safely re-synced — fix the key before writing any sync code.

Principle 8 — Source of truth and read-only enforcement

Decide, per attribute, which system owns it (Step 1). For attributes the PIM owns, prevent Merchant Center edits from silently diverging: group externally-owned attributes into a restricted AttributeGroup so they render read-only in the Merchant Center (docs). For multi-source setups (PIM for content, ERP for price/stock), one process creates the Product and each source updates only its own attributes — never a blind full overwrite that clobbers another system's fields.

Principle 9 — Scopes/channels and reference/related data (the concepts that catch people)

Two recurring PIM concepts don't have a 1:1 commercetools counterpart and need an explicit decision — whichever PIM you're on (the names differ; the shape doesn't):

  • Scope / channel / context. Many PIMs scope attribute values by a channel or context (e.g. Akeneo channels, inriver segments/channels), so one attribute holds different values per scope. Choose which scope's values feed commercetools — syncing the wrong one produces correct-looking but wrong storefront data, and ignoring scopes entirely mixes contexts. If different scopes must feed different storefronts, that's a Product Selection / Product Tailoring decision (which Products/values each Store sees), not just attribute mapping.
  • Reference / related entities. PIMs model related objects as first-class links (e.g. Akeneo reference entities, related products, cross-sells). Map these to commercetools attributes or product references — but this is often the gap a public connector doesn't cover, so confirm the chosen connector maps them; if not, it's a common fork trigger (→ build-connector.md).
The PIM's own vocabulary (Akeneo "families", other PIMs "product classes"/"templates"/"entity types") maps to the Product Type strategy in Principle 2 — the label varies, the anti-pattern (1:1 with Product Types) does not. For the specific connector's config keys and exact concept names, read its own current docs/repo (looked up live), not a hardcoded per-vendor table here.

Worked example (sketch)

A fashion PIM with families tshirt, hoodie, jeans, each with dozens of family-specific attributes, 3 locales (en-US, de-DE, fr-FR), category tree by department.
  • Product Types: one apparel Product Type (not three) with attributes brand (enum), color (lenum, localized labels), size (enum), material (set of enum), care-instructions (ltext), and spec-sheet (text holding consolidated JSON for the long-tail). Family differences live in attribute values, not new types.
  • Variants: one Product per style, one Variant per color/size combination; key = pim-<productId>, variant key/sku = pim-<variantId> / the real SKU.
  • Categories: key = dept-<pimCategoryId>, parent by key; products reference categories by key and let the Import API resolve.
  • Localization: PIM en_US/de_DE/fr_FR → LocalizedString en-US/de-DE/fr-FR; other locales dropped per scope.
  • Price/inventory: separate event integrations keyed by SKU; not part of the content sync.
Hand the user the Product Type definitions, the attribute→attribute table (with types and which are search-critical vs consolidated), the locale map, and the key derivation rules — that mapping is the deliverable, and it's identical whether a public connector consumes it as config or a custom connector implements it.

Checklist

  • Only commerce-relevant attributes mapped; the rest left in the PIM
  • A small set of flexible Product Types (not 1:1 with PIM families); structural variety absorbed as attribute values
  • Search-critical attributes typed precisely (enum/lenum/number+unit/boolean); supplementary consolidated into one JSON/text attribute
  • PIM locales explicitly mapped to commercetools locales; out-of-scope locales dropped
  • Categories keyed from stable PIM ids (not names); products & parents reference by key; Import API resolves references
  • Media mapped; price and inventory kept as separate integrations, keyed by SKU
  • Every resource has a stable key from a PIM identifier → every write is an upsert (safe to re-run)
  • Source of truth decided per attribute; externally-owned attributes made read-only via an AttributeGroup; multi-source writes scoped to owned attributes only
  • PIM scope/channel chosen (multi-scope → multi-Store routed through Product Selections/Tailoring); reference/related-entity mapping confirmed against the connector or flagged as a fork trigger
integrations/pim/overview.md

PIM connector — product data sync (build or integrate)

This is the PIM integration sub-area of commercetools-connect: getting product data out of a Product Information Management system (Akeneo, inriver, Bluestone, Pimcore, Contentserv, Syndigo, or a bespoke PIM) and into commercetools as Products, Product Types, Categories, Prices, and media. The build-side platform contracts (service/event/job semantics, connect.yaml, lifecycle scripts, testing, deploy) are the parent connect skill; this sub-area owns the PIM-specific job end to end — from "is there a connector already?" through configuring one, forking it, or building one, to the data model mapping and sync architecture that decide whether the catalog stays correct.
Direction. A PIM connector is almost always external system → commercetools (the PIM is the source of truth for product content; commercetools stores the sellable catalog). This is the inbound direction — the opposite of the product-export template, which pushes commercetools → external. A minority of setups are bi-directional (some attributes edited in the Merchant Center flow back); decide this in Step 1, because it changes ownership and conflict rules. Don't assume bi-directional — it is the expensive case.
Two things a PIM connector is not. It does not own the Cart/Order/Payment flow (that's the payment sub-area), and there is no browser/enabler touchpoint — a PIM connector is pure backend data movement (a service inbound webhook and/or a job), so this whole sub-area is server-side.

Workflow

Follow these steps in order. The heart is Step 1 → Step 1.5 → Step 3 (data mapping) — mapping is where PIM integrations succeed or rot, whether you configure a public connector or build your own.

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with PIM-focused query terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<PIM terms from the request, e.g. 'product data integration import API product type attribute mapping categories'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root, where scripts/docs-search.mjs lives.) The two most load-bearing docs for this sub-area are the Integrate product data tutorial and the Import API overview — read them. You may additionally use the commercetools Knowledge MCP for deeper follow-up.

Step 1 — Extract requirements (before any config or code)

The architecture is downstream of a handful of answers (docs: Plan your product data integration). Ask the user — don't assume:
  1. Which PIM system, and is a connector deployed? Name and version. If a public connector is in play, get its marketplace listing and version.
  2. Source of truth per attribute. Which system owns which field? A PIM typically owns enriched content (names, descriptions, images, specs); an ERP may own SKU/price/inventory. Multiple sources add sequencing and conflict rules.
  3. Direction — one-way or bi-directional? One-way (PIM → commercetools) is the simple, recommended default. Bi-directional means defining which attributes sync back and how conflicts resolve — flag it as expensive.
  4. Is product data editable in the Merchant Center? If product managers edit in commercetools, decide which attributes are read-only from the PIM (enforce with an AttributeGroup so externally-owned fields can't be hand-edited).
  5. Cadence — full vs incremental, event-driven vs bulk? Initial load and periodic refresh are full/bulk; time-critical fixes are incremental. Real-time correctness → event-driven; large nightly volumes → bulk/scheduled. → drives job vs service-webhook (Step 4).
  6. Volume and locales. Catalog size (drives Import API vs HTTP API) and which locales/currencies/channels are in scope (drives localization mapping).
  7. What to sync, and what NOT to. Product Types, products/variants, categories, media, prices, inventory — and explicitly what to leave behind. Not every PIM attribute belongs in commercetools; map only commerce-relevant data.
  8. Price and inventory ownership. Even if they come from the same system, treat them as separate integrations (they update far more often than content) — confirm where they originate.
  9. Anything special? (always ask — open-ended) Reference entities / related products, measurement-unit conversion, variant/family modeling quirks, publish/staging rules, channel- or store-specific catalogs (Product Selections / Product Tailoring), approval workflows, GDPR/PII in product data. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before choosing an approach.

Step 1.5 — List the available connectors, then offer install or modify (before building)

Lead with what already exists. Don't answer from memory — the Connect marketplace changes. Check live and show the user the public PIM connectors that fit their PIM first (name, vendor, direction, what it syncs); there are several (Akeneo, Bluestone, Contentserv, inriver, Pimcore, Syndigo, …), so don't assume Akeneo. Verify each candidate is an actually deployable Connect connector, not just a marketplace listing — the category also contains partner/SaaS integrations that are not commercetools Connect applications. A listing can be a great functional match yet be impossible to deploy through Connect; if so, surface it with a warning that it is not a Connect solution and this skill likely cannot implement/deploy it, and offer the build/fork path instead (connector-selection.md). When a listed connector is Connect-deployable and matches the PIM, present the two low-effort paths and let the user choose — install it as-is (configure) or modify it (fork) — and only fall to build when no listed connector matches. Name the connector + version you checked. Then walk the ladder — stop at the first rung that fits:
  1. Public connector covers it (and is Connect-deployable) → install + configure. Don't build. (Deploying a public connector: deployment-installation.md in the parent skill.)
  2. Right PIM, gap looks like a capability → prove it isn't config / attribute mapping first. Most "missing" behavior on a supported PIM (which attributes map where, locale/channel selection, category mapping) is configuration, not missing code → back to rung 1.
  3. Right PIM, genuine gap config can't closefork/extend the public connector and deploy it as an Organization connector — you keep the working sync engine and change only the delta. → build-connector.md, hand off to commercetools-connect.
  4. No public connector for the PIM at allbuild one using the connect skill's service (inbound webhook) and/or job patterns, ingesting via the Import API or HTTP API. → build-connector.md.
Full procedure and the dimension-by-dimension fit table: connector-selection.md. Record the decision, the rung, and the version in the requirements block.

Step 2 — If configuring a public connector: derive its config

Translate the Step 1 answers into the connector's connect.yaml configuration and its attribute-mapping setup (most PIM connectors externalize the field mapping as config, not code). The connect.yaml envelope rules — documented keys only (no invented fields), file at the repo root — are the same as any connector; see the parent skill's config-from-requirements pattern for the envelope and deployment-installation.md. For the provider-specific config keys and concept names, read the chosen connector's own current docs/repo (looked up live) — don't rely on a hardcoded per-vendor table, which goes stale; the vendor-neutral mapping method is data-mapping.md.

Step 3 — Data mapping (the heart — applies to configure and build)

Whether you configure a public connector or build one, the make-or-break work is mapping the PIM's model onto commercetools' product model: Product Type strategy (never 1:1 with PIM families), attribute mapping (search-critical vs consolidated JSON), localization, category tree, media, and keeping price/inventory separate — all keyed for idempotent upsert. This is data-mapping.md. Get it wrong and the catalog drifts no matter how good the plumbing is.

Step 4 — If building/forking: sync architecture

Pick the Connect application shape from the cadence in Step 1 (event-driven webhook service, scheduled job, or both) and the ingestion API from volume (Import API for bulk/async, HTTP API for real-time), then make every write idempotent. This is build-connector.md; it hands the type-agnostic build contracts (service/job semantics, security, testing, deploy) back to the parent commercetools-connect skill.

Step 5 — Verify the sync

Don't declare done until a real product change has flowed end to end: change it in the PIM (or trigger the job) → confirm the Product exists in commercetools with the mapped attributes, category assignment, and localized content, and that a re-run leaves it unchanged (idempotency). For bulk imports, poll the Import Container summary until operations reach a terminal state and inspect any rejected/validationFailed operations — reference resolution succeeding is not the same as the data being valid.
Do this safely: test the mapping with pure unit tests first, then run a bounded sync against a sandbox project only (never production), gated by a pre-flight item count that warns on large catalogs. The two-layer approach, the sandbox-credential and catalog-size guards, and the idempotency re-run are in testing.md.

References

NeedReference
Is a public connector enough?: live marketplace check, named PIM connectors, fit dimensions, the configure/fork/build ladderconnector-selection.md
Data mapping (the substance): Product Type strategy, attribute mapping, localization, categories, media, price/inventory separation, keys & idempotency, source-of-truthdata-mapping.md
Build or fork a connector: Import API vs HTTP API, service webhook vs job (vs both), full vs incremental, idempotent upsert, dependency resolution, delete handlingbuild-connector.md
Testing & safely running a sync: pure mapping unit tests, then a bounded sandbox-only live run with pre-flight item count, catalog-size gate, and idempotency re-runtesting.md
Deploy/install a public or custom connector; regions; certificationcommercetools-connect → deployment-installation.md
Inbound webhook auth, least-privilege scopes, secured configcommercetools-connect → security.md
Scheduled/on-demand job: schedule, 30-min timeout, overlap locking, checkpointingcommercetools-connect → job-applications.md
Structured logs, health, poison-message/replay runbookcommercetools-connect → observability-operations.md
This sub-area is vendor-neutral by design — the requirements, the data-mapping method, and the sync architecture are the same for any PIM (Akeneo, inriver, Bluestone, …). Don't add per-vendor reference files: they duplicate data-mapping.md and go stale on connector specifics. Instead, look up the specific connector and its config live (marketplace + the connector's own docs/repo, per connector-selection.md) and apply the vendor-neutral mapping method to whatever PIM vocabulary you find.

Checklist

Requirements

  • PIM system + version; whether a public connector is deployed (and its version)
  • Source of truth per attribute; direction (one-way default, bi-directional flagged as expensive)
  • Editable-in-MC decision; externally-owned attributes marked read-only (AttributeGroup)
  • Cadence (full/incremental, event/bulk); volume; locales/currencies/channels in scope
  • What to sync and what to leave behind; price & inventory treated as separate integrations
  • Asked the open-ended "anything special?" question; each special requirement captured as its own line
  • Requirements block written and confirmed with the user

Connector fit (decide before wiring/building)

  • Checked the live marketplace (not memory); named the connector + version
  • Verified the candidate is a deployable Connect connector, not a partner/SaaS integration listing; a good-match non-connector surfaced with the not-a-Connect-solution warning
  • PIM, direction, and sync scope compared to the requirements; apparent gaps re-checked as config/attribute-mapping
  • Ladder rung chosen: configure (1) · config/mapping-closes-gap (2) · fork/extend (3) · build (4)

Data mapping (the deliverable that decides correctness)

  • Product Type strategy chosen (flexible attributes, not 1:1 with PIM families); search-critical attributes mapped, supplementary consolidated
  • Localization, categories, and media mapped; price/inventory kept separate
  • Every resource keyed for idempotent upsert

Sync / verify

  • Application shape matches the cadence; ingestion API matches the volume; writes idempotent
  • Mapping unit-tested; live sync run against a sandbox only (never production), pre-flight item count run and large catalogs gated → testing.md
  • A real change flowed end to end; a re-run left the catalog unchanged; bulk imports polled to terminal state with rejects inspected
integrations/pim/testing.md

Testing a PIM sync

Two layers, and the order matters. Layer 1 — pure mapping unit tests — runs on every commit, needs no credentials, and owns the edge cases. Layer 2 — a guarded live sync run — proves the wiring against a sandbox project and is where the catalog-size and credential guards live. Never run Layer 2 before Layer 1 is green: a live run over a broken mapping just writes broken products into a real project.

Layer 1 — Mapping unit tests (no credentials, every commit)

The mapping from data-mapping.md is pure input→output: a PIM record in, a commercetools draft out. Test it directly with fixtures — no network, no secrets. Cover the cases that silently corrupt a catalog:
  • Locale mapping (en_USen-US); an out-of-scope locale is dropped, not passed through.
  • Enum keyed on the option code, not a localized label.
  • Measurement units normalized to one unit.
  • Keys derived from stable PIM ids (Principle 7) — the same input yields the same key every time (this is what makes the sync idempotent).
  • Category/product references emitted by key.
  • Attributes not in scope are omitted (Principle 1), not sent as null.
The connector's inbound webhook (the service app) is tested at the router level — auth-rejection matrix, malformed-payload handling, duplicate-delivery idempotency — using the parent skill's testing.md. This file adds only the PIM-specific live-run layer.

Testing the inbound webhook locally (no live PIM needed)

The PIM calls your service endpoint (see build-connector.md); you can exercise that whole path locally without the PIM reaching you:
  • Replay a captured event. Save a real PIM event payload as a fixture and POST it at the router with supertest (unit) or at a locally-running connector (commercetools connect application dev / the generated local server) with curl. This covers signature verification, mapping, and idempotency — mock the commercetools side with msw, or point at a sandbox for a real write.
  • Signature check with the sample secret. Compute the PIM's HMAC over the fixture body using a test secret and send it in the expected header — assert a valid signature is accepted and a tampered/missing one is rejected (401/403). No PIM involved.
  • Duplicate delivery. POST the same event twice and assert the second is a no-op (upsert by key).
  • Only for true end-to-end — having a hosted SaaS PIM actually deliver to your machine — expose the local endpoint through a public tunnel so the PIM can reach a public URL, and register that URL as the PIM's webhook target. For everyday testing, replay is faster and needs no tunnel or PIM account.

The outbound side is not a webhook — it's commercetools API calls, covered by the guarded sync run below (sandbox only).

Layer 2 — A guarded live sync run (SANDBOX ONLY)

A sync writes data — it creates and updates Products, Categories, and Product Types. So a live run is only ever pointed at a throwaway sandbox project you own, never production. Three guards make this safe; do not skip any.

Guard 1 — Sandbox credentials only, from .env, never production

  • Load credentials from a .env that is gitignored and holds sandbox values only. Never put production credentials in it, never commit it, never echo it to logs.
  • Require an explicit opt-in marker (e.g. CT_ENV=sandbox) and refuse to run without it — an accidental run should fail closed, not touch a project. Optionally pin an allowlist of permitted sandbox project keys and refuse any key not on it.
  • Use a least-privilege API client for the sandbox (manage_products, manage_categories, manage_product_types — not manage_project), so even a misfire is bounded → security.md.
// support/sandbox.ts — fail closed if this doesn't look like an explicit sandbox
import 'dotenv/config';

export function loadSandboxConfig() {
  const { CT_ENV, CTP_PROJECT_KEY, CTP_CLIENT_ID, CTP_CLIENT_SECRET, PIM_BASE_URL } = process.env;
  if (!CTP_PROJECT_KEY || !CTP_CLIENT_ID || !CTP_CLIENT_SECRET) return null;   // → skip loudly (unconfigured)
  if (CT_ENV !== 'sandbox') {
    throw new Error('Refusing to run a live sync: set CT_ENV=sandbox to confirm a throwaway project. Never use production credentials.');
  }
  const allow = (process.env.SANDBOX_PROJECT_ALLOWLIST ?? '').split(',').filter(Boolean);
  if (allow.length && !allow.includes(CTP_PROJECT_KEY)) {
    throw new Error(`Project '${CTP_PROJECT_KEY}' is not in SANDBOX_PROJECT_ALLOWLIST — aborting.`);
  }
  return { projectKey: CTP_PROJECT_KEY, clientId: CTP_CLIENT_ID, clientSecret: CTP_CLIENT_SECRET, pimBaseUrl: PIM_BASE_URL };
}

Guard 2 — Pre-flight count, and gate on large catalogs

Before syncing anything, find out how big the run is. A first sync that blindly pulls a full PIM export can be tens or hundreds of thousands of products — slow, expensive, and hard to undo in a shared sandbox. So count first, warn, and require an explicit decision above a threshold. Count from the source (the PIM's total, or the delta set for an incremental run) — that's what the sync will actually touch:
const SYNC_WARN_AT = 500;          // warn + require confirmation above this
const SAMPLE_SIZE  = 25;           // default bounded first run

export async function preflight(cfg, { confirmedLarge = false, limit = SAMPLE_SIZE } = {}) {
  const total = await countSourceItems(cfg);                 // PIM total, or the incremental delta count
  console.warn(`[pim-sync] pre-flight: ${total} source items would be in scope.`);
  if (total > SYNC_WARN_AT && !confirmedLarge) {
    throw new Error(
      `Large catalog: ${total} items exceeds the ${SYNC_WARN_AT} warn threshold. ` +
      `Re-run with an explicit limit (e.g. --limit ${SAMPLE_SIZE}) for a sample, ` +
      `or pass confirmedLarge to sync the full set deliberately.`);
  }
  return Math.min(total, limit ?? total);                    // the count this run will actually process
}

Guidance to give the user with the count:

  • Default to a bounded sample (a handful to a few dozen products) for the first run — enough to prove the mapping and wiring, cheap to inspect and clean up.
  • Only sync the full catalog deliberately, and prefer the Import API for it (async, bulk, up to 20 resources/request, reference resolution) over per-item HTTP calls — see build-connector.md. Mind Import API best practices for batching and rate limits.
  • If the source can't give an exact total cheaply, at least bound the run with a hard limit — never let a test run unbounded.

Guard 3 — Assert the trace, then re-run to prove idempotency

Run the bounded sync, then assert commercetools received the mapped data — and run it again to prove a re-run is a no-op (the idempotency backbone from data-mapping.md Principle 7). For a bulk (Import API) path, poll the Import Container to a terminal state and inspect rejects — reference resolution succeeding is not the same as the data being valid.
import { describe, it, expect } from 'vitest';
const cfg = loadSandboxConfig();
const itLive = cfg ? it : it.skip;          // skip LOUDLY when unconfigured — never a silent pass

async function until(fn, ok, { tries = 30, gapMs = 2000 } = {}) {
  for (let i = 0; i < tries; i++) { const v = await fn(); if (ok(v)) return v; await new Promise(r => setTimeout(r, gapMs)); }
  throw new Error('import did not reach a terminal state in time');
}

describe('PIM sync (sandbox, bounded)', () => {
  itLive('syncs a sample and is idempotent on re-run', async () => {
    const count = await preflight(cfg, { limit: 25 });        // Guard 2 gates large catalogs here
    const sample = await fetchSourceSample(cfg, count);

    const first = await runSync(cfg, sample);
    // bulk path: wait for a real terminal state — no operations still in flight.
    // include waitForMasterVariant, or a product awaiting its master variant lets the poll return early.
    const summary = await until(() => getImportSummary(cfg, first.containerKey),
      s => s.unresolved === 0 && s.processing === 0 && s.waitForMasterVariant === 0);
    expect(summary.rejected, JSON.stringify(summary.errors)).toBe(0);
    expect(summary.validationFailed).toBe(0);

    // assert the CT trace for a known sample product
    const p = await getProductByKey(cfg, sample[0].expectedKey);
    expect(p).toBeTruthy();
    expect(p.masterData.staged.name['en-US']).toBe(sample[0].expectedName);
    expect(p.masterData.staged.categories.length).toBeGreaterThan(0);

    // re-run the same sample → no new products, versions unchanged where content is unchanged
    const beforeCount = await countProducts(cfg);
    await runSync(cfg, sample);
    expect(await countProducts(cfg)).toBe(beforeCount);        // upsert, not duplicate-create
  }, 180_000);   // generous: bulk import + polling
});

Clean up

A sandbox accumulates test products. Either run against a disposable sandbox you can reset, or delete the products/categories the test created (by their deterministic keys) in an afterAll. Never leave a shared sandbox full of half-synced fixtures.

Checklist

Gate: Layer 1 (mapping unit tests) green before any live run.
  • Mapping unit tests cover locale mapping, enum-by-code, unit normalization, stable keys, by-key references, out-of-scope omission — no credentials needed
  • Webhook service tested at the router level (auth matrix, malformed payload, duplicate delivery) → testing.md
  • Sandbox only: credentials loaded from a gitignored .env; run fails closed without an explicit CT_ENV=sandbox marker; no production credentials ever used
  • Least-privilege sandbox API client (not manage_project)
  • Pre-flight count runs first; warns above a threshold and refuses a large full sync without explicit confirmation; every run is bounded by a limit
  • First run is a small bounded sample; full-catalog runs are deliberate and use the Import API
  • Live run asserts the CT trace (product by key, mapped localized name, category assignment); bulk imports polled to terminal state with rejected/validationFailed inspected
  • Idempotency re-run proves a repeat sync is a no-op (no duplicate products)
  • Skips loudly when unconfigured; runs in a dedicated job, not every commit
  • Test fixtures cleaned up (disposable sandbox or delete-by-key in teardown)
integrations/promotion/config-from-requirements.md

Requirements → promotion connector config

This turns the Step 1 requirements (overview.md) into concrete connect.yaml values. For a from-scratch build these are the keys you define. For an existing public connector, read its own connect.yaml for the authoritative key list — don't work from a copy, here or anywhere else (public-connectors.md); use the mapping below to decide what each of its keys should be set to.

The requirement → config map

Requirement (Step 1)Config / decisionWhy
Which engine + credentialssecuredConfiguration: engine API key / application keySecrets never in standardConfiguration, never hardcoded
Region + projectstandardConfiguration: CTP_REGION; scopes via inheritAsHost + client provisioning are region/project specific
Engine owns promotionsDiscount mechanism = setDirectDiscounts; native Discount Codes become inertDirect Discounts and Discount Codes are mutually exclusive (below)
Coupon/voucher codesCart custom type + field for the code, plus a field for the validation resultNative Discount Codes are unavailable once Direct Discounts are in play
Evaluation + redemptionDeploy both apps (evaluator + syncer); evaluation-only = just the evaluatorRedemption is a separate engine endpoint and a separate Connect app
Loyalty points / balancesMirror-target setting (Customer Custom Field) or "engine is sole source of record"Points must not silently diverge between systems
Rollback on cancel/returnSyncer subscribes to OrderStateChanged / return messages + order-state → action mappingA cancelled order must not consume a coupon or keep points
Fail-open vs fail-closedOutbound timeout + error behavior in the evaluator; documented in the READMEDecides whether a down engine breaks carts or just drops discounts
Cart/customer attributes the engine needsAttribute-mapping keys in standardConfigurationThe engine's rules can only match on what you forward
Discount line items need a tax categorystandardConfiguration: CTP_TAX_CATEGORY_ID (only if using custom line items)A custom line item requires a tax category; not needed for Direct Discounts

How discounts land on the cart

The single most consequential choice, and the one that leaks into the storefront. Three mechanisms:

The evaluator returns a setDirectDiscounts action carrying the engine's computed discounts. Each entry is a DirectDiscountDraft with a required value (relative, absolute, fixed, or giftLineItem) and an optional target (line items, custom line items, shipping cost, total price, multi-buy, or pattern) — the same value/target vocabulary as Cart Discounts, so an engine's percentage, fixed-amount, free-shipping, and free-gift effects all have a native landing spot. Fetch the current shape with the parent skill's openApi-schemata.mjs --resource-name api-Cart-write rather than trusting a copied field list.
Semantics that matter (docs):
  • Always active and valid — no validity window, no isActive to manage.
  • Default StackingMode Stacking, and no sortOrder — they apply in array order, so you control precedence by ordering the array. An engine that returns effects in priority order maps directly; one that doesn't means you sort before writing.
  • The action replaces the cart's directDiscounts array — the evaluator always writes the complete current set, never a delta.
  • They transfer to the Order automatically when the Order is created from the Cart. Changing them afterwards is not a plain cart update — it needs the Order Edits setDirectDiscounts action.
  • They also work on Quotes (valid for that quote only) — relevant for B2B negotiated pricing.
The exclusivity rule. Direct Discounts and Discount Codes are mutually exclusive: if a Direct Discount is applied to a Cart or Order, any matching Cart Discounts in the Project are ignored. Practical consequence to state to the user before writing a line of code: once the engine owns the cart, your native Discount Codes and Cart Discounts stop affecting it. "Engine promotions plus our existing native promo codes on the same cart" is not a supported design — pick one owner (see Step 1 question 3). The docs state the ignoring behavior; they do not define an API-level rejection for mixing, so do not rely on the platform to error out and warn you — enforce the ownership decision in your own code and configuration.

Negative custom line items — legacy/compat

Add a custom line item with a negative price per discount. This is what the public Voucherify integration does by default (it exposes Direct Discounts as an opt-in flag instead). Costs: it needs a tax category, it shows up as a cart line the storefront must render and filter, it distorts subtotals and reporting, and totals interact with tax differently. Choose it only when you need a per-code visible line item or you're matching an existing storefront that already handles it. New builds: prefer Direct Discounts.

Engine-managed native Discount Codes — narrow

The engine (or a job) creates/mirrors Cart Discounts and Discount Codes in commercetools, and the platform evaluates them natively. Keeps native semantics, no extension on the cart hot path, and no exclusivity problem — but adds sync lag, and Cart Discount and Discount Code limits apply, which is exactly what "unique codes at scale" requirements break against. Viable for a modest, slow-changing campaign set; not for per-customer unique codes.

Record the choice and why.

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the docs. Use only documented envelope keys (deployAs / applicationType / endpoint / scripts / configuration; inheritAs), and place the file at the repository root — a nested connect.yaml silently fails to deploy.

Native client provisioning (prefer this)

Declare the connector's scopes and let Connect mint a least-privilege API client, rather than hand-supplying CTP_CLIENT_ID/SECRET:
inheritAs:
  apiClient:
    scopes:
      - manage_extensions      # evaluator postDeploy registers the Cart API Extension
      - manage_subscriptions   # syncer postDeploy registers the OrderCreated Subscription
      - view_orders            # syncer re-fetches the Order to build the redemption
      - manage_types           # only if postDeploy creates the coupon-code custom type
  configuration:
    standardConfiguration:
      - key: PROMOTION_ENGINE_BASE_URL
        description: Engine API base URL (sandbox or live)
      - key: COUPON_CODE_FIELD
        description: Cart custom field holding the shopper-entered coupon code
    securedConfiguration:
      - key: PROMOTION_ENGINE_API_KEY
        description: Engine API key, used by both apps
Note: view_extensions / view_subscriptions are not valid standalone scopes — manage_extensions / manage_subscriptions cover read + write. Declaring the non-existent view scopes fails client creation.
Add view_customers only if the evaluator forwards customer attributes, and manage_customers only if you mirror points back onto the Customer. Both are easy to over-grant — justify each.
The public promotion connectors hand-declare CTP_CLIENT_ID/SECRET/SCOPE as secured config; migrating to inheritAs.apiClient.scopes is the more native, lower-maintenance form and is worth doing on a fork or a fresh build (public-connectors.md).

Per-app config

deployAs:
  - name: promotion-evaluator
    applicationType: service
    endpoint: /promotionEvaluator
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the API Extension + custom type
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: FAIL_MODE
          description: "'open' (drop discounts on engine error) or 'closed' (fail the cart update)"
        - key: ENGINE_TIMEOUT_MS
          description: Outbound engine timeout, must stay under the extension budget
  - name: redemption-syncer
    applicationType: event
    endpoint: /redemptionSyncer
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the Subscription
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub or SNS"
        - key: ROLLBACK_ORDER_STATES
          description: Order states that trigger a redemption rollback (e.g. Cancelled)
The extension destination URL must include the endpoint path (<url>/promotionEvaluator), and the Express router must be mounted at the same base path — a mismatch 404s all platform traffic (project-structure.md).

Worked example (in-house engine, from-scratch build)

Requirements: in-house "PromoSvc"; engine owns all promotions; shopper-entered coupon codes; loyalty points held solely in PromoSvc; rollback on cancellation; fail-open; europe-west1.gcp.

Derived config:

inheritAs:
  apiClient:
    scopes: [manage_extensions, manage_subscriptions, view_orders, manage_types]
  configuration:
    standardConfiguration:
      - key: PROMOTION_ENGINE_BASE_URL
        description: PromoSvc base URL
      - key: COUPON_CODE_FIELD
        description: "Cart custom field with the entered code (promoSvcCouponCode)"
      - key: CART_HASH_FIELD
        description: "Cart custom field holding the promo-relevant cart hash (loop guard + call reduction)"
    securedConfiguration:
      - key: PROMOTION_ENGINE_API_KEY
        description: PromoSvc API key (same key both apps)
deployAs:
  - name: promotion-evaluator
    applicationType: service
    endpoint: /promotionEvaluator
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
    configuration:
      standardConfiguration:
        - key: FAIL_MODE
          description: "'open' — a PromoSvc outage drops discounts, never blocks the cart"
        - key: ENGINE_TIMEOUT_MS
          description: "800 — well under the extension budget"
  - name: redemption-syncer
    applicationType: event
    endpoint: /redemptionSyncer
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub"
        - key: ROLLBACK_ORDER_STATES
          description: "Cancelled"
Rationale to hand the user: setDirectDiscounts so PromoSvc's amounts are authoritative and no custom line items pollute the cart; native Discount Codes will no longer affect these carts — coupon codes go through promoSvcCouponCode instead; both apps because redemption and point-awarding are real requirements, not just checkout display; manage_types only because postDeploy creates the two cart custom fields; fail-open with an 800 ms timeout so a PromoSvc incident degrades to "no promotions" rather than "no checkout".
The custom type creation belongs in postDeploy, get-then-update so a redeploy doesn't blow away existing fields (lifecycle-scripts.md).
For an existing connector, read its connect.yaml for the real key names and apply this mapping to their values; the fixes worth making while you're forking are in public-connectors.md.
integrations/promotion/connector-selection.md

Native, use, customise, or build?

This answers Step 1.5 of overview.md. Promotions differ from payment and tax in one decisive way: commercetools ships a capable promotion engine of its own, so the first question is not "which connector?" but "does this need a connector at all?"

Rung 0 first — is this native?

Before any marketplace lookup, test the requirement against the native surface:

Native primitiveCovers
Product DiscountsPercentage/absolute off a price before the cart, predicate-scoped
Cart DiscountsSpend thresholds, tiered discounts, item/shipping/total targets, buy-X-get-Y (multiBuy*), free gifts (giftLineItem), pattern targets, per-Store scoping
Discount CodesPromo/coupon codes with max-applications and per-customer limits
Discount Groups"Only the best of these N discounts applies", plus deactivating a whole campaign in one request
Project discountCombinationModeStacking vs BestDeal across Product and Cart Discounts
Direct DiscountsA discount computed elsewhere and applied to one cart/order/quote
The docs' own common discount use cases table maps most standard promotions onto these, and notes that further needs (geofencing, bulk discount codes, stacked discounts) are supported in combination with API Extensions — i.e. some requirements are a small extension over native, not a whole promotion platform.
Stop at rung 0 when the requirement is: percentage or fixed off, spend thresholds, buy-X-get-Y, free gift, free shipping, a promo code with usage limits, campaign windows, best-of-N. Building a connector for these adds a per-call cost, a latency budget on the cart, and an availability dependency — for behavior the platform already has. Say so plainly and stop.
Go past rung 0 when the requirement needs capabilities that are genuinely a promotion platform: unique-code generation at scale, referral programs, loyalty points/tiers/wallets, cross-channel (POS + web) shared budgets, CDP-driven per-customer targeting, geofencing, real-time campaign experimentation, or a marketing team that must author rules in their own tool of record.
Also check the converse: if the customer already owns an engine licence and their marketing team works in it daily, "use native instead" is usually not a real option even when the discount math is simple — the requirement is authoring in the engine, which is rung 1+.

Check live data — don't answer from memory

Listings and their capabilities change. Before deciding among rungs 1/3/4:

  1. Search the Connect marketplace (marketplace.commercetools.com/connectors) and the Promotions & Loyalty listings, plus the docs via the docs-search script or the Knowledge MCP.
  2. Distinguish an installable Connect connector from a partner integration you self-host — apply the parent skill's Marketplace listings are not all Connect connectors rule; don't re-derive it here. It bites especially hard in this category: promotions & loyalty is crowded with partner-operated SaaS, so a listing is weak evidence that anything is deployable via Connect. Treating one as installable is a planning error, not a detail.
  3. Compare the requirements engine-by-capability (evaluation, coupon codes, loyalty, rollback on cancel, POS, regions).
  4. Name the connector and version you checked, and record it in the requirements block.

The promotion landscape (verify, but this is the shape)

Promotions & loyalty is a crowded category compared with tax — several vendors have marketplace listings, and at least two have public source. As checked 2026-07:
EngineMarketplace presenceSource available?Default rung
Talon.One✅ Listed, with a Connect connectorMIT (composable-com/ct-connect-talonone) — maintained by Orium, not Talon.One1 (use) — or 3 (customise), since the source is public. Check you have the right repo: Talon.One's own commercetools repos are a separate, PoC-grade accelerator (public-connectors.md)
Voucherify✅ Listed (plus a separate Gift Card listing)MIT (voucherifyio/commerce-tools-integration) — but a standalone Node service, not a Connect app3 (customise/port) — see the caveat below
Dovetech Campaigns, Eagle Eye, NULogic, Annex Cloud, Currency Alliance, SheerID✅ ListedVendor-private — check the listing1 (use) if the listing covers it; otherwise partner conversation
In-house / unsupported engine❌ Nothing to install4 (build)

Two consequences worth stating to the user early, because they change the effort estimate:

  • "Just use the Talon.One connector" is a real answer — but name the repo, because there are three. The MIT-licensed Connect connector is Orium's; Talon.One's own commercetools repos are an accelerator their docs label proof-of-concept, not production. If the requirements fit the Connect connector, this is rung 1 and cheap.
  • Voucherify's public integration is not a Connect connector. It is an MIT-licensed standalone Node service that registers its own API Extensions and is documented for Heroku/self-hosted deployment. Using it as Connect means porting it into a Connect app (connect.yaml, lifecycle scripts, endpoint↔route wiring) — that is rung 3 work, not a marketplace install. Its logic (coupon validation, cart-custom-field code storage, redemption on payment) is excellent reference material either way.

The ladder (stop at the first rung that fits)

Rung 1 — Use a public connector as-is

A public connector exists and covers the requirements → install and configure it. Cheapest and most maintainable; the vendor/partner keeps it current. Installation (CLI auth, scopes, deployment create --connector-key, or Merchant Center install) is the parent skill's deployment-installation.md. Hand it the config you derive in config-from-requirements.md.
Before concluding a requirement forces a fork, check whether it is a setting (rung 2) — promotion connectors typically expose effect mapping, attribute/custom-field mapping, tax category for discount line items, and which order states trigger redemption as configuration.

Rung 2 — A gap that config can close

Usually a config value or Merchant Center setting: which engine effects map to which cart actions, where the coupon code is read from, which attributes are forwarded to the engine as cart/customer properties, which order states redeem vs roll back, sandbox vs live. Re-check the apparent gap against the connector's configuration surface before forking. Mapping in config-from-requirements.md.

Rung 3 — Customise/fork a public connector

A genuine gap config can't close and the connector is public (Talon.One's and Voucherify's both are, MIT) → fork it, add only the delta, deploy as an Organization connector. Don't rebuild: the effect-to-action mapping, session/identity handling, and lifecycle registration are the bulk of the work and already exist.
This is also the rung where you fix what you inherit. The public connectors predate parts of the current Connect guidance, and forking is the moment to correct it — hand-supplied CTP_CLIENT_ID/SECRET/SCOPE instead of inheritAs.apiClient.scopes, non-secrets sitting in securedConfiguration, missing loop guards. The concrete list is in public-connectors.md. Hand off to commercetools-connect for the fork's build/stage/publish lifecycle.

Rung 4 — Build for your own promotion service

No connector for the engine — an in-house promotion service, or a vendor with no listing → build it.

Note the difference from payment and tax: there is no promotion-integration template. Payment and tax each have one; promotions do not — check the current template list in connect-cli.md and the Connect docs rather than assuming one has appeared. So rung 4 here means scaffolding a plain connector with the Connect CLI declaring two applications, and implementing the contract yourself. Budget accordingly: this is more work than the equivalent tax rung 4, where a template hands you both app stubs.

What you actually write on rung 4:

  • The evaluator: cart → your service's evaluate request; response effects → setDirectDiscounts (+ custom fields for coupon validity/messaging); the loop guard; fail-open. → promotion-contract.md
  • The redemption-syncer: order → redeem/commit call, idempotent on order id; optional rollback on cancel/return.
  • Lifecycle scripts that idempotently register the Extension, the Subscription, and the custom type holding the coupon code. → lifecycle-scripts.md
  • Config + scopes (config-from-requirements.md).
Because you own the engine side too, rung 4 has one advantage worth using: you can design the service's API to be idempotent and cart-hash-friendly from the start (a stable session key per cart, an idempotent redeem keyed on order id), which removes most of the pitfalls in promotion-contract.md by construction.
The full build/stage/publish/certify lifecycle for rungs 3–4 is the parent commercetools-connect skill; return to this promotion flow once the connector is deployed.

Recording the decision

In the requirements block, note: engine · rung · connector name + version checked · why. Examples:
Promotions: none · rung 0 · checked requirements 2026-07 — "20% off orders over €100 plus a SUMMER promo code" is Cart Discount + Discount Code; no engine, no connector.
Promotions: Talon.One · rung 3 (customise) · checked marketplace 2026-07, public MIT connector composable-com/ct-connect-talonone · fits except loyalty-point rollback on returns, which config can't express → fork and add the return handler; also migrating its hand-supplied CTP credentials to inheritAs.apiClient.scopes.
Promotions: in-house "PromoSvc" · rung 4 (build) · checked marketplace 2026-07 — no listing, and no promotion template exists → scaffolding service + event with the Connect CLI.
integrations/promotion/overview.md

Promotion connector — integrate an external promotion engine

This is the promotion integration sub-area of commercetools-connect: promotions, coupons, vouchers, or loyalty are decided by an external engine, and you'll wire it up with a Connect connector. The parent skill owns the type-agnostic build/publish/certify lifecycle and the production-readiness gate; this sub-area owns the promotion-specific shape end to end — from "should this even be a connector?" through using, customising, or building one.
A promotion integration is two jobs, and the connector is two applications that mirror them:
  • promotion-evaluator (a service registered as a cart API Extension) — the evaluate half. On cart changes, commercetools calls it synchronously; it sends the cart to the engine, gets back the discount effects, and writes them onto the cart (normally via setDirectDiscounts). Nothing is consumed — this is a quote.
  • redemption-syncer (an event driven by an OrderCreated Subscription) — the redeem half. After the order is placed, it asynchronously tells the engine the promotion was actually used: redeem the coupon, close the session, award loyalty points — and, for a full integration, roll that back when the order is cancelled or returned.
Evaluate vs. redeem is the mistake to internalize first. "Why is the coupon still showing as unused / why are no loyalty points awarded / why is there nothing in the engine's dashboard?" is almost always because only the evaluator is wired. Evaluating a cart consumes nothing; only the redeem call does. They are different engine endpoints and different Connect apps here.
Two things make promotions harder than tax, and both are decided before you write code:
  1. commercetools already has a promotion engine. Product Discounts, Cart Discounts, Discount Codes, Discount Groups, multi-buy/pattern targets, and gift line items cover a large share of real requirements natively — with no connector to run, secure, and pay per call. A connector is the right answer when the requirement genuinely exceeds that surface; it is the wrong answer when it merely restates it. This is rung 0 of the ladder below and you must rule it out explicitly, not silently.
  2. An external engine and native Discount Codes cannot both own a cart. Direct Discounts and Discount Codes are mutually exclusive: once a Direct Discount is on a Cart or Order, matching project Cart Discounts are ignored. So "the engine does promotions and we keep our native discount codes" is not a coherent design on the same cart — see promotion-contract.md.

Workflow

Follow these steps in order. The heart is Step 1 → Step 1.5 → Step 2 → Step 4 (requirements → which path → config → the two apps).

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with promotion-focused terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<promotion terms from the user's request, e.g. 'cart discount direct discounts discount codes external promotion engine API extension'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or pricing-and-discounts-overview for deeper follow-up.

Step 1 — Extract requirements (before any config or code)

Promotion behavior is downstream of marketing intent, and the wrong default silently gives money away or blocks checkout. Extract these first; each maps to a config key in Step 2 or a rung in Step 1.5. Ask the user (don't assume):

  1. Which engine, and why? Talon.One, Voucherify, Dovetech, Eagle Eye, NULogic, an in-house service, or undecided. Do they already have an account + API credentials? If undecided, Step 1.5 may end at rung 0 (native).
  2. What can't commercetools do natively? Name the specific requirement — bulk/unique code generation at scale, referral or loyalty programs, geofencing, per-customer targeting from a CDP, cross-channel (POS + web) budgets, real-time campaign experimentation. If the answer is "percentage off, spend thresholds, buy-X-get-Y, a promo code" — that is native (Cart Discounts + Discount Codes) and you should say so.
  3. Who owns promotions after this — the engine or commercetools? Because Direct Discounts and Discount Codes are mutually exclusive, a split ownership model on the same cart doesn't work. Get an explicit answer: all engine, or native with the engine only for a carved-out case.
  4. Coupon/voucher codes? Does the shopper type a code? Then decide where the code lives (a cart custom field, since native Discount Codes are off the table) and how an invalid code is reported back to the storefront.
  5. Loyalty points, wallets, or gift cards? Points/balances are the engine's system of record — decide what (if anything) is mirrored into commercetools. Gift cards are a payment method, not a discount → that's the gift card sub-area, not this one. This split matters in practice because the same vendor often does both (Voucherify has a separate Gift Card listing): a voucher that reduces the cart total is a promotion, while stored value that pays for the order is a Payment.
  6. Order lifecycle beyond creation? Should a cancellation or return roll back the redemption and claw back points? → drives whether the syncer subscribes to OrderStateChanged / return messages, not just OrderCreated.
  7. Region and project? e.g. europe-west1.gcp, project my-project — host and config are region-specific.
  8. Fail-open or fail-closed? If the engine is slow or down, does the cart proceed without promotions (fail-open, the usual answer for promotions) or does the cart update fail (fail-closed)? See Step 3.
  9. Anything special or non-standard? (always ask — open-ended) B2B/quotes, multi-store or multi-currency budgets, marketplace/multi-seller, POS + web shared budgets, subscription/recurring orders, existing native discounts to migrate. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before deriving config. Each special requirement feeds the Step 1.5 fit-check.

Step 1.5 — Native, use, customise, or build? (decide before wiring or building)

This is the decision the rest of the flow assumes. Don't answer from memory — the marketplace changes. Check live data (the Connect marketplace and the promotions/loyalty listings, via the docs-search script / Knowledge MCP), and name the connector + version you checked. Details, the live-check procedure, and the per-engine landscape are in connector-selection.md.
Then walk the ladder — stop at the first rung that fits, because each later one is more to build and maintain:
  1. Native commercetools discounts are enough → build no connector. Model it with Cart Discounts, Discount Codes, and Discount Groups. The docs' own common discount use cases table maps most standard promotions to native primitives. Say this plainly and stop.
  2. A public connector for the engine covers everything → install + configure it (Step 2). Don't build. Installation (CLI auth, scopes, deployment create) is the parent skill's deployment-installation.md; it is not the connectorstaged flow.
  3. Public connector, gap looks like a capability → prove it isn't config first. Most "missing" behaviors (which effects map to which action, attribute/custom-field mapping, which order states redeem vs roll back) are connect.yaml values or Merchant Center settings → back to rung 1. See config-from-requirements.md.
  4. Public connector, genuine gap config can't closefork/customise it. The Talon.One Connect connector and the Voucherify integration are both MIT-licensed and public, so this is a real option — add only the delta and deploy as an Organization connector. Don't rebuild a working codebase. Provider specifics, and the known issues worth fixing while you're in there, are in public-connectors.md.
  5. No connector for the engine at all (an in-house or unsupported promotion service)build it. Note the difference from payment and tax: there is no promotion-integration template. You scaffold a plain service + event connector with the Connect CLI and implement the contract yourself — connect-cli.md for the scaffold, promotion-contract.md for what to build.
Ask the user to choose between rungs 1, 3, and 4 explicitly once you have the live landscape — "use the public connector as-is", "customise/fork it", or "build one for our own promotion service" are materially different amounts of work and the choice is theirs, not yours. Present rung 0 first if it applies at all. Record the decision, the rung, and the version in the requirements block.

Step 2 — Derive the config from the requirements

Translate the Step 1 answers into concrete connect.yaml values, with a one-line why for each. The mapping, the connect.yaml envelope, and a worked example are in config-from-requirements.md. The decisions that live here:
  • How discounts land on the cart: setDirectDiscounts (recommended) vs. negative custom line items vs. engine-managed native codes. This is the promotion equivalent of choosing a tax mode, and it is the one choice that leaks into the storefront. → config-from-requirements.md.
  • Where the coupon code lives — a cart custom field plus the custom type that postDeploy creates idempotently.
  • API-client scopes — declare them in inheritAs.apiClient.scopes so Connect provisions a least-privilege client (manage_extensions, manage_subscriptions, view_orders, plus manage_types if postDeploy creates the custom type), rather than hand-supplying CTP_CLIENT_ID/SECRET.
  • Secured vs standard config — the engine API key is securedConfiguration; region, behavioral toggles, and attribute mappings are standardConfiguration.

Step 3 — The extension trigger, call reduction, and the loop guard (reference)

The API Extension is what makes the evaluator fire, and promotions are the sub-area where the hot path bites hardest: engines bill and rate-limit per call, and your own setDirectDiscounts write is itself a cart update, so a naive evaluator re-triggers itself. Three things to get right — full detail in promotion-contract.md:
  • Condition the trigger so it only fires on carts worth evaluating (Active cart state, non-empty).
  • Short-circuit on an unchanged promo-relevant cart hash stored in a custom field — this is both the cost control and the loop guard.
  • Decide fail-open vs fail-closed and mean it. For promotions the usual answer is fail-open: a down promo engine should return no discounts, not break every cart update. That is the opposite of a compliance-driven tax integration — state the choice in the connector README.

Step 4 — Build/verify the two apps (the main body of work), test-first

Tests come before implementation. The rules that make a promotion integration correct — the extension returning 200/201 (never 202), not looping on its own writes, redeeming exactly once under redelivery, rolling back on cancel — are invisible at the call site and miserable to reproduce by hand. Each is one cheap assertion. Write the test first.
Read promotion-contract.md and build, in order — test first for each:
  1. Evaluator (API Extension) — map cart → engine session/evaluate request; call the engine; map effects → setDirectDiscounts (+ custom fields for coupon validity and campaign messaging); respond 200 fast; fail-open on engine error.
  2. Redemption-syncer (Subscription) — on OrderCreated, re-fetch the Order by id, redeem/close/award in the engine, idempotently on a stable key (the order id). For a full integration, also handle cancel/return → rollback.
Mock the outbound boundary (the engine, the commercetools APIs) and assert on what your code decided — which endpoint, what body, what it did with the response. The suite must run with zero deployment and zero secrets.

Step 5 — Verify the round trip

Don't declare done until a discount is visible on a real cart and a real order shows as redeemed in the engine. See verification.md, which also covers the traps that look like bugs but aren't — a coupon that "works twice", points awarded on an abandoned cart, and the cart-merge-on-login identity switch.

References

NeedReference
Native, use, customise, or build?: the rung-0 native check, the live-marketplace procedure, the per-engine landscape (Talon.One, Voucherify, Dovetech, Eagle Eye, NULogic, in-house)connector-selection.md
Requirements → config mapping: how discounts land on the cart, coupon-code custom field, scopes, the connect.yaml envelope; worked exampleconfig-from-requirements.md
The two-app contract: the evaluator (effect→action mapping, setDirectDiscounts, the self-trigger loop guard, 200-not-202, fail-open) and the redemption-syncer (redeem/rollback lifecycle, idempotency); full pitfall catalogpromotion-contract.md
Which public integration to use, and what to fix when forking: Talon.One's Connect connector is a third party's while the vendor's own repo is a PoC accelerator; Voucherify's is a port, not an install. Points at each repo and the vendor docs for config/API facts instead of copying thempublic-connectors.md
Verify the round trip: discount on the cart, redemption in the engine; the double-redemption, abandoned-cart, and cart-merge trapsverification.md
Build/publish/certify lifecycle, deploy, scopes, production-readiness gate (type-agnostic)commercetools-connect
Adding another engine later means a short section in public-connectors.md naming which artifact is the production one, plus a row in the selection table — not a copy of that engine's configuration reference. The two-app architecture, the contract, and the flow do not change.
Related: discount stacking order, sortOrder semantics, Discount Groups, and Direct-Discounts-block-Discount-Codes as domain concepts live in commercetools-commerce-patterns; this sub-area covers the connector that drives them. Gift cards and stored value as a payment method are the gift card sub-area.

Checklist

Requirements

  • Engine chosen (or deliberately deferred) + account/credentials; region + project
  • The specific requirement native discounts cannot meet is named — not just restated as "promotions"
  • Promotion ownership decided: all engine or native + carved-out case (never split on one cart)
  • Coupon-code entry path decided (custom field + invalid-code feedback), or explicitly out of scope
  • Loyalty/points mirroring decided; gift cards routed to the gift card sub-area if applicable
  • Rollback-on-cancel/return decided; fail-open vs fail-closed decided
  • Asked the open-ended "anything special?" question; each special requirement its own line
  • Requirements block written and confirmed; specials fed into the Step 1.5 fit-check

Path (decide before wiring/building)

  • Rung 0 ruled out explicitly — native Cart Discounts/Discount Codes/Discount Groups can't do it, and you said why
  • Checked live marketplace + docs (not memory); named the connector + version
  • User asked to choose between use-as-is (1), customise/fork (3), and build-new (4)
  • Rung recorded with rationale; for a real gap on a supported engine, chose fork over rebuild
  • If rung 4: understood there is no promotion template — plain service + event scaffold

Config (the deliverable)

  • Discount application mechanism chosen (setDirectDiscounts unless a reason not to) with rationale
  • Discount-Codes-are-now-inert consequence stated to the user
  • Only documented connect.yaml envelope fields; file at the repo root
  • inheritAs.apiClient.scopes least-privilege (+ manage_types only if postDeploy creates types)
  • Engine credentials in securedConfiguration; region/toggles/mappings in standardConfiguration

The two apps (build test-first — do not write a function body before its red test)

  • Evaluator returns 200/201 (never 202); fail-open on engine error/timeout
  • Loop guard: promo-relevant cart hash in a custom field; own writes don't re-trigger evaluation
  • Extension trigger conditioned to reduce engine calls (cart Active, non-empty)
  • Syncer re-fetches the Order by id; redeems idempotently on a stable key; rolls back on cancel/return (if in scope)
  • Boundary mocked; suite runs with no deployment/secrets

Verification

  • Discount visible on the cart (directDiscounts + discountOnTotalPrice/discountedPricePerQuantity) after a cart update
  • Order placed → syncer acks → redemption/points confirmed via the engine API
  • Redelivering the same message does not redeem twice
  • Cart-merge-on-login and anonymous→known session identity verified
integrations/promotion/promotion-contract.md

The two-app promotion contract

Everything the evaluator and the redemption-syncer must do, and the pitfalls that silently break each. Grounded in the public Talon.One and Voucherify integrations; which of those to actually use, and what to fix when forking one, is public-connectors.md. Engine-side payloads are the vendor's to document — read their API docs.
The type-agnostic mechanics — extension registration, envelope decoding, ack semantics — are the parent skill's service-applications.md and event-applications.md. This file covers only what is promotion-specific.

App 1 — the evaluator (cart API Extension)

What triggers it

An API Extension on the cart resource, actions: [Create, Update], registered by the app's postDeploy. Promotion engines bill and rate-limit per call and sit on the cart hot path, so condition the trigger to fire only on carts worth evaluating:
{
  "resourceTypeId": "cart",
  "actions": ["Create", "Update"],
  "condition": "cartState = \"Active\" and lineItems is not empty"
}
Validate any predicate you write against the conditional triggers docs — a predicate that fails to evaluate returns 400 ExtensionPredicateEvaluationFailed and breaks the cart operation, so a wrong condition is worse than none.

What it must return

An API Extension response is update actions applied before the cart persists. For a promotion connector that is normally:
  • setDirectDiscounts — the engine's discounts. The action replaces the whole array, so always emit the complete current set, never a delta. Order the array deliberately: Direct Discounts have no sortOrder and apply in array order.
  • setCustomField (coupon result) — whether the entered code was accepted, and why not if rejected. This is how the storefront shows "code invalid" (see below).
  • setCustomField (cart hash) — the promo-relevant cart fingerprint, for call reduction.
  • setCustomField (campaign messaging) — optional: "spend €10 more for free shipping" style engine copy the storefront renders.
Mapping engine effects to Direct Discount drafts — the shape is { value, target }, the same vocabulary as Cart Discounts:
Engine effectvaluetarget
% off eligible itemsrelative (permyriad)lineItems (+ predicate)
Fixed amount off itemsabsolutelineItems
Fixed price for items ("3 for €5")fixedlineItems / pattern
% or amount off the cart totalrelative / absolutetotalPrice
Free / discounted shippingrelative (10000 permyriad) / absoluteshipping
Free gift itemgiftLineItem(none — the draft carries the product/variant)
Buy X get Y at a discountrelative onlymultiBuyLineItems / multiBuyCustomLineItems
Fetch the authoritative field shapes with the parent skill's openApi-schemata.mjs --resource-name api-Cart-write (CartSetDirectDiscountsAction, DirectDiscountDraft, CartDiscountValueDraft, CartDiscountTarget) rather than trusting a copied list. Two mapping details that bite:
  • relative values are permyriad (1/10000), not percent — 10% is 1000. An engine returning 10 becomes a 0.1% discount if you forward it raw.
  • The target discriminator is shipping, not shippingCost — the type name (CartDiscountShippingCostTarget) and the discriminator value differ. Getting this wrong is a rejected action, not a silent miscalculation.
  • Multi-buy targets take a percentage only. multiBuyLineItems / multiBuyCustomLineItems accept a relative value; an engine effect expressing "buy 3, pay €5" as a fixed amount must map to pattern (which accepts an amount, a fixed price, or a percentage) or to lineItems, not to a multi-buy target.
  • A giftLineItem discount needs a product that exists in commercetools. An engine effect granting a free item the catalog doesn't have cannot be expressed; decide up front whether unmatched gift effects are dropped (with a log) or fail.

Rejecting a coupon code without breaking the cart

The shopper types an invalid code. It is tempting to return 400 with errorsdon't: that fails the entire cart update, so the shopper's real change (adding an item, setting an address) is lost too, and the storefront gets a generic platform error.
Instead: return 200, write the validation outcome to a custom field, and let the storefront read it. A valid code produces discounts and a success flag; an invalid one produces no discounts and a rejection reason. Reserve 400 { errors: [...] } for genuinely invalid requests, not for business-rule outcomes. The public Voucherify integration stores codes and their status in cart custom fields for exactly this reason.

The response-status trap

An HTTP API Extension must return 200 or 201. Any other status — including 202 — is treated as a failure to respond properly and fails the triggering cart operation (docs). A successful no-op is 200 with {} or { actions: [] }. Same trap as the tax sub-area; pin it with a test.

Latency and fail mode

The extension couples its latency and uptime to every cart operation: 1 s connection limit, 2 s response limit by default, configurable per extension up to 10 s (per-project increases available via support request, subject to performance review). The docs' own target is to respond fast rather than to use the whole budget. So:
  • Keep the outbound engine call on a tight timeout under the extension budget, aborting yourself rather than letting the platform time out.
  • Default to fail-open for promotions. Return 200 with no discount actions when the engine errors or times out: a promotion outage then degrades to "no promotions today" instead of "nobody can add to cart". This is the opposite default from a compliance-driven tax integration, where an untaxed cart may be unacceptable. If the business genuinely requires fail-closed (e.g. engine-managed contract pricing that must never be missing), say so explicitly in the README.
  • Fail-open has a consequence to state: a cart can persist without discounts the customer expected. Make the next successful evaluation self-healing — because the evaluator always writes the complete directDiscounts array, the following cart update repairs it automatically.

Call reduction (the biggest cost lever)

Skip the engine when nothing promotion-relevant changed: hash the promo-relevant cart fields (line items + quantities + prices, customer/group, shipping method, entered code, currency/country) into a cart custom field. On the next call, if the hash matches, return { actions: [] } immediately. The certified tax connectors use the same hashCart pattern (tax-contract.md).
Note what the hash is not for: it is not a substitute for engine-side idempotency, and it must include everything the engine's rules can match on — a hash that omits the customer group will serve one segment's discount to another.

Re-trigger and chaining

Two things people conflate:

  • Your response does not re-invoke you. The extension is called before the result is persisted and its returned actions are applied within that same operation — writing setDirectDiscounts in the response is not a fresh cart update and does not recurse.
  • Out-of-band cart writes by your own connector do. If another app (an event handler, a job) updates the cart via the API, that is a cart update and will trigger the evaluator. Filter your own changes (event-applications.md) or you get a call loop and duplicate engine charges.
Also plan for coexistence: a project may already have a tax extension on cart, and a project allows at most 25 extensions. When both promotions and tax extend the cart, discounts must be applied before tax is computed, since tax is calculated on discounted amounts. commercetools supports extension chaining with declared dependencies (bounded: max 5 direct dependencies, max 3 layers deep, no cycles — violations surface as ExtensionChainTooWide / ExtensionChainTooDeep / CircularDependency). If a promotion connector lands in a project that already has a tax connector, work the ordering out deliberately rather than hoping.
One more limit: an extension response carries at most 100 update actions. A large cart with per-line-item discounts plus custom fields can approach it — prefer fewer, broader-targeted Direct Discounts over one draft per line item.

Keep the mapping pure and testable

The cart→request and effects→actions mapping is deterministic — keep it a pure function with no network, so the whole evaluation is unit-testable without a deployment, a cart, or a token. Assert: each effect type maps to the right value/target, permyriad conversion, money minor-unit handling, the complete-array replacement, hash short-circuit returns [], invalid code returns 200 + rejection field (not 400).

App 2 — the redemption-syncer (OrderCreated Subscription)

What triggers it

A Connect event application: you register a Subscription on the order resource for OrderCreated (plus OrderStateChanged / return messages if rollback is in scope) in the app's postDeploy; Connect provisions the queue and delivers each message as an HTTP POST to the app's endpoint. Envelope decoding (base64 message.data on GCP), PlatformFormat vs CloudEventsFormat, message-type filtering, and ack semantics are all the parent skill's event-applications.md — don't re-derive them here.

What it must do

  • Re-fetch the Order by id from resource.id — don't trust the possibly stale or omitted payload.
  • Redeem in the engine: consume the coupon, close the customer session, award loyalty points. This is the call that makes the promotion real and the only one that shows up in the engine's reporting.
  • Be idempotent on a stable key — the order id. Redelivery is guaranteed, not hypothetical, and this is the one place where a bug costs money: a double redemption double-awards points and can consume a single-use coupon twice. Prefer the engine's own idempotency key / duplicate guard, and treat "already redeemed" as success, not an error to retry.
  • Ack correctly. Reply 200 for handled and irrelevant-but-acked messages; return non-2xx only for transient failures you want redelivered.

What it must not do

  • Don't redeem from the cart. Only an order is a purchase. Redeeming at cart-evaluation time consumes coupons and awards points for carts that are abandoned — the single most damaging design error in this sub-area.
  • Don't treat commercetools as the ledger for points/balances. The engine is the system of record. Mirroring a balance onto a Customer custom field is fine for display; reading it back as authoritative is not.

Full lifecycle (if in scope)

  • Cancel → roll back the redemption and claw back points, on the order states the merchant designates as cancellations.
  • Return → partial rollback, on return-shipment state changes.
  • Order edit → re-evaluate: note that changing discounts on an existing Order is not a plain cart update — it needs the Order Edits setDirectDiscounts action.
Drive these off merchant-configured order-state lists in config, not hardcoded state names — state keys differ per project (config-from-requirements.md).

Identity: the session key

An external engine tracks a session/profile; commercetools tracks a cart and a customer. The mapping must be stable across the whole journey:
  • Use the cart id as the engine session key, and the customer id (or a stable anonymous id) as the profile key.
  • Cart merge on login is the trap. When an anonymous cart merges into a customer's cart, the cart identity the engine has been evaluating can disappear, and per-customer usage limits may be attributed to the wrong profile. Decide explicitly what happens: re-evaluate under the surviving cart id, and re-key or close the abandoned session.
  • Carry the same key into the redemption call, so the engine can tie the redeem back to the session it evaluated.

Pitfall catalog

PitfallSymptomFix
Extension returns 202Every cart update failsReturn 200/201 only
Invalid coupon returned as 400Shopper's whole cart update fails; generic error in the UIReturn 200 + rejection reason in a custom field
Redeeming at cart timeCoupons consumed and points awarded for abandoned cartsRedeem only in the OrderCreated syncer
Non-idempotent redemptionRedelivery double-redeems / double-awards pointsStable key = order id; "already redeemed" = success
setDirectDiscounts emitted as a deltaOld discounts linger or vanish unpredictablyAlways write the complete array
relative value forwarded as percent10% becomes 0.1%Convert to permyriad (10% = 1000)
Native Discount Codes still expected to workCodes silently have no effect once Direct Discounts are setExclusivity is by design; pick one owner (config-from-requirements.md)
No hash / no trigger conditionEngine called on every cart keystroke; bill and rate limits blow upCondition the trigger; hash promo-relevant fields
Hash omits a field the engine matches onWrong segment's discount served from a stale evaluationHash everything the rules can read
Own connector writes the cart out-of-bandEvaluator re-triggers in a loop; duplicate engine chargesSelf-change filtering
Promotion + tax extension ordering unmanagedTax computed on undiscounted amountsOrder via extension chaining/dependencies; discounts before tax
>100 actions in one responseCart operation failsFewer, broader-targeted Direct Discounts
Extension destination = base URLPlatform's calls 404 the appRegister destination as <CONNECT_SERVICE_URL>/promotionEvaluator
postDeploy doesn't register the extension / custom typeEvaluator never fires, or setCustomField fails on a missing typeWire connector:post-deploy idempotently for both
Cart merge on login ignoredUsage limits attributed to the wrong profile; session orphanedRe-key/close the session on merge
Gift effect for a product not in the catalogMapping throws or silently drops the rewardDecide drop-with-log vs fail; assert it
Legacy SDKFails the parent skill's pinned-version gate@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4

Test-first checklist (mirror in the suite)

Evaluator

  • Each engine effect type maps to the right value/target; permyriad conversion asserted
  • Complete-array replacement asserted (previous discounts don't leak)
  • Returns 200 (asserted — the 202 regression is the one to pin)
  • Invalid coupon → 200 + rejection custom field, not 400
  • Hash short-circuit returns { actions: [] }; hash covers every engine-visible field
  • Fail-open asserted for engine error and engine timeout
  • Action count stays within the 100-action cap for a large cart

Syncer

  • Decodes the envelope; acks irrelevant/test messages
  • Re-fetches the Order by id
  • Redeems idempotently on the order id; "already redeemed" treated as success
  • Nothing is redeemed for a cart that never became an order
  • (If in scope) rollback on configurable cancel/return states
integrations/promotion/public-connectors.md

Public promotion integrations — which one, and what to fix

This file deliberately does not restate configuration keys, API payloads, or setup steps. Those live in each repo's connect.yaml and README and in the vendor's docs, they change, and whoever maintains them does it better than a copy here would. Read them at the source — links below.
What this file does cover is the two things no vendor page will tell you:
  1. Which artifact is the production one — for the engines here, that is not obvious, and the vendor's own documentation points elsewhere.
  2. What to change when you fork one — commercetools production-readiness judgment applied to someone else's connector.

Get the current facts from the source

For any public connector, in this order:

  1. The repo's connect.yaml — applications, types, endpoints, scripts, and the full standardConfiguration/securedConfiguration surface. This is the authoritative config contract; nothing else is.
  2. The repo's README — install, credentials, and setup.
  3. The repo sourcepostDeploy (which resources the extensions/subscriptions are registered on) and the effect-mapping module. connect.yaml tells you the deployment shape; only the source tells you the behavior.
  4. The vendor's API docs — the engine-side endpoints, session model, and effect vocabulary.
  5. The marketplace listing — for certified vs registered status, which changes. Record what you saw; don't assert it from memory.

Talon.One — mind which repository

Three different artifacts exist, and picking the wrong one is the most likely early mistake:

ArtifactMaintained byUse it?
composable-com/ct-connect-talonone — a Connect connector, MITOrium (a systems integrator), not Talon.OneYes — the production path, and the basis for a rung-1 install or a rung-3 fork
talon-one/commercetools-talonone-accelerator — AWS/GCP microserviceTalon.OneNo. Talon.One's own documentation describes it as an experimental method suited to proof-of-concept or simulation projects, not production
talon-one/commercetools-talonone-connector — AWS connectorTalon.OneSeparate, AWS-specific; not the Connect path
The consequence worth internalizing: the vendor's docs and the production connector point in different directions. Talon.One's commercetools integration docs describe their accelerator; the Connect connector is a third party's and is documented in its own repo. Use the vendor's docs for the engine (sessions, effects, the API), and Orium's repo for the integration. If someone cites the vendor's integration page as the implementation plan, redirect them and say why.

The model (concepts only — the API is the vendor's to document)

A Talon.One Customer Session is what a Cart is to commercetools, and a Customer Profile is what a Customer is. Talon.One is a rules-and-effects engine: it doesn't return "a discount", it returns a list of effects (set discount, add free item, award loyalty points, accept/reject coupon, show a message). The connector's real work is the effect → cart update action mapping — the table in promotion-contract.md — and keeping the session key stable across the cart's life. Everything else is engine-side and belongs in the vendor's docs.

Voucherify — a port, not an install

voucherifyio/commerce-tools-integration is MIT and instructive, but not a Connect application: it is a standalone Node service that registers its own API Extensions and is documented for self-hosted / Heroku deployment. Running it under Connect means porting it (a connect.yaml, lifecycle scripts, endpoint↔route wiring, env vars moved into standard/secured configuration). That is rung-3 work, not a marketplace install — plan it as such. Nothing in the vendor's docs frames it this way, because from their side it isn't a Connect product.

Three of its design decisions are worth copying — or consciously rejecting:

  • Discounts as negative custom line items by default, Direct Discounts behind a flag. Its docs are explicit that the custom-line-item path requires storefront changes and that the integration bypasses native Discount Codes, storing codes in cart custom fields instead — the exclusivity rule from config-from-requirements.md showing up in a shipped product. For a new build, invert the default: Direct Discounts first.
  • Codes and their validation status in cart custom fields — the same pattern promotion-contract.md prescribes for rejecting a coupon without failing the cart update.
  • Redemption on payment state → Paid, not on OrderCreated. A defensible variation: it avoids consuming a coupon for an order that is never paid. Put this to the user as a real decision — redeem at order creation (simpler, matches "the promotion was used", needs rollback on cancellation) or at payment confirmation (nothing consumed for unpaid orders, but the discount is shown before it is consumed, and the syncer subscribes to payment/order-state messages instead). Either is fine; drifting between them by accident is not.

What to fix when you fork (rung 3)

The public promotion connectors predate parts of the current Connect guidance. Forking is the moment to correct these — each maps to an item on the parent skill's production-readiness gate. Check each against the fork's actual connect.yaml and source rather than assuming it still applies:
  • Hand-supplied commercetools credentials → inheritAs.apiClient.scopes. CTP_CLIENT_ID / CTP_CLIENT_SECRET / CTP_SCOPE as secured config means a human provisions and rotates an API client with whatever scopes they happened to grant. Declaring scopes lets Connect mint a least-privilege client instead. Highest-value single change, and both public promotion integrations need it.
  • Non-secrets in securedConfiguration. A tax-category id, a locale, a region are configuration, not credentials. Their presence is also a signal: a tax-category id means the connector can represent discounts as custom line items (which require one) — check which mechanism you're inheriting and whether you want it (config-from-requirements.md).
  • A root endpoint: /. Works, but makes the route↔endpoint contract easy to break and gives you nothing to distinguish apps by. Prefer a named endpoint with the router mounted to match (project-structure.md).
  • npm install in lifecycle scripts → npm ci --omit=dev. Reproducible, and no dev dependencies in the deployed image.
  • One service doing both halves. If the connector performs redemption synchronously inside an order extension rather than an OrderCreated Subscription, understand the trade you are inheriting: it puts the engine on the critical path of order creation, so an engine outage can block orders — fail-closed on the money path. For new work, split evaluate (service) from redeem (event) as overview.md describes. If you keep synchronous redemption, document that stance in the README.
  • Pinned SDK versions — check against the parent skill's gate (@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4).

Other engines

Dovetech Campaigns, Eagle Eye, NULogic, Annex Cloud, Currency Alliance, and SheerID have marketplace listings without public source. For those, rung 1 (configure) or a partner conversation are the realistic options — a genuine gap you can neither configure around nor fork means either the vendor changes something or you build (rung 4). See connector-selection.md.
Adding an engine here means a short section naming which artifact is the production one and any fork fixes — not a copy of its configuration reference.
integrations/promotion/verification.md

Verify the promotion round trip

Don't declare done until the promotion has left a trace in both places it should: on the cart (evaluation) and in the engine (redemption). Several of the checks below regularly look broken when they're correct — read the traps.

Check 1 — the cart carries engine-computed discounts

Drive a cart update (add a line item, enter a coupon code) and inspect the cart:

  • directDiscounts is populated. This is the direct tell that the evaluator fired and mapped effects. Before the API Extension is registered and firing, it is simply empty — that means "not wired", not "no promotions apply".
  • The totals moved. totalPrice reflects the discount, and the per-item breakdown (discountedPricePerQuantity, and discountOnTotalPrice for a total-price target) shows where it landed. Confirm the exact reference/field shapes against the current schema with the parent skill's openApi-schemata.mjs --resource-name api-Cart-read rather than a remembered field list.
  • The version jumped more than your update alone would explain. The evaluator's setDirectDiscounts and setCustomField actions are extra writes folded into the same operation.
  • The coupon-result custom field is set — accepted, or rejected with a reason. An entered-but-invalid code should leave the cart update successful with a rejection reason, never a failed request.
A minimal driver: create a cart, add a priced line item that a currently active campaign matches, read back directDiscounts and the totals. (Same flow a storefront BFF would run.)

Check 2 — the order is redeemed in the engine

Place an order (convert the cart), let the OrderCreated subscription deliver (or, locally without Pub/Sub, POST the base64 envelope to the syncer directly), then:
  • The syncer returns a positive ack (200/204).
  • The engine's API confirms the redemption — coupon marked used, session closed, points awarded — keyed on the order id.
  • It appears in the engine's reporting/dashboard. Cart evaluation never does; only redemption surfaces there. If the dashboard is empty, suspect "only the evaluator is wired" before suspecting the engine.

Check 3 — redelivery does not double-redeem

This is the check people skip and the one that costs money. POST the same OrderCreated envelope twice and assert the engine shows one redemption and one point award. At-least-once delivery makes this a certainty in production, not an edge case.

The traps (correct behavior that looks like a bug)

Trap 1 — native Discount Codes stopped working

Expected. Direct Discounts and Discount Codes are mutually exclusive: once a Direct Discount is on the cart, matching project Cart Discounts are ignored. A pre-existing native promo code that "silently does nothing" after the connector goes live is the exclusivity rule working as designed — not a regression. If both are genuinely required, the ownership decision was wrong; revisit Step 1 of overview.md.

Trap 2 — zero discount is usually a correct answer

An engine returns nothing when no rule matches: the campaign isn't active, its schedule hasn't started, the budget is exhausted, the coupon is expired or already at its usage limit, the customer isn't in the targeted segment, or you're pointed at a sandbox/dev environment whose campaigns differ from production. Before concluding "discounts aren't calculating", verify in the engine's UI that an active campaign actually matches the test cart. Confirm the wiring separately with a rule you know matches — an unconditional "1% off everything" test campaign is the fastest way to separate "not wired" from "nothing matched".

Related: check the engine environment the connector points at. Evaluating against sandbox while inspecting the production dashboard produces exactly the "nothing is happening" symptom.

Trap 3 — discounts disappeared after an unrelated cart update

If the connector is fail-open (the recommended default), an engine error or timeout produces a cart update with no discounts — the customer's discount vanishes mid-session. That is the fail-open contract working, and because the evaluator always writes the complete directDiscounts array, the next successful evaluation self-heals it. Verify both halves deliberately: force an engine failure and confirm the cart update still succeeds, then let the next update repair the discounts. If you see a stuck empty state instead, the evaluator is emitting deltas rather than the full array.

Trap 4 — usage limits attributed to the wrong shopper

Log in with an anonymous cart that already carries an evaluated session. If the anonymous cart merges into the customer's cart, the cart identity the engine has been tracking can change, so per-customer usage limits and loyalty attribution can land on the wrong profile — or a single-use coupon can be spent twice across the two identities. Verify the anonymous → known transition explicitly; it is not covered by any happy-path test.

Trap 5 — points awarded for a cart that never became an order

Create a cart with a points-earning promotion, evaluate it, then abandon it. The engine must show no redemption and no points. If it doesn't, redemption is happening at evaluation time — see promotion-contract.md.

Verification checklist

  • directDiscounts populated and totals reduced after a cart update (extension registered + firing)
  • Coupon accepted → discount applied; coupon invalid → cart update succeeds with a rejection reason
  • Order placed → syncer acks → redemption/points confirmed via the engine API and visible in its reporting
  • Same envelope delivered twice → exactly one redemption and one point award
  • Abandoned cart → no redemption, no points
  • Forced engine failure → cart update still succeeds (fail-open), and the next update self-heals the discounts
  • Anonymous → logged-in cart merge verified; usage limits and attribution land on the right profile
  • Cancellation/return → rollback observed in the engine (if in scope)
  • Understood: inert native Discount Codes and zero discount from a non-matching campaign are correct, not bugs
integrations/search/config-from-requirements.md

Requirements → search document + connector config

Two deliverables, in this order: the search document shape (where a search integration succeeds or rots — the full method is data-mapping.md), then the connect.yaml. For a public connector these are its documented keys; for a fork or a build these are the keys and apps you define. The official scaffold is the Product export template.

The search document, in one line

Every record is a flat, denormalized projection of a published Product (or Variant), keyed on a stable objectID, carrying only what the storefront queries/filters/sorts/displays — resolved to one price context and one locale strategy, with categories denormalized and availability handled deliberately. Derive it from a Product Projection (staged=false), never the raw Product. The full decision method — granularity, price-context explosion, localization, category denormalization, Store assortment — is data-mapping.md; this file only maps the config those decisions imply.

Requirements → app composition

A search integration is two jobs, so two apps (search-contract.md). Pick the shape from cadence and trigger; keep each its own app, never one app with a mode switch.
JobDefault appAlternative
Full ingestion — (re)build the whole index from the catalogservice with an on-demand REST trigger (e.g. /fullSync), matching the Product export templatejob on a schedule (properties.schedule) when nightly rebuilds are enough and no on-demand trigger is needed
Incremental updater — keep the index fresh on catalog changesevent on product/store/selection Subscriptions (e.g. /deltaSync)job polling Product Projections on lastModifiedAt when the engine or ops model can't take a push
Whole-catalog vs Store-specific decides the read side, not the app shape: whole-catalog reads /product-projections?staged=false with cursor pagination; Store-specific reads /in-store/key={storeKey}/product-projections and is driven by Product Selection messages (the Product export template is Store-specific — one Deployment per Store; don't model one Deployment per Store at scale — see data-mapping.md).

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the Connect docs. Use only documented envelope keys (deployAs / applicationType / endpoint / scripts / properties / configuration; inheritAs), and keep the file at the repository root — a nested connect.yaml silently fails to deploy.

Native client provisioning (prefer this)

A search connector is read-only on commercetools (it exports; it never writes the catalog). Declare the narrow read scopes and let Connect mint a least-privilege client instead of hand-supplying CTP_CLIENT_ID/CTP_CLIENT_SECRET (a pattern you may see in existing search connectors and should not copy — check which form a fork candidate uses, per connector-selection.md):
inheritAs:
  apiClient:
    scopes:
      - view_products              # read Products / Product Projections (the catalog to index)
      # add only as the requirements need:
      # - view_product_selections  # Store-specific: read a Store's Product Selection assignments
      # - view_stores              # Store-specific: resolve the Store / in-store projections
      # - manage_subscriptions     # the incremental-updater app, whose postDeploy registers the Subscription
Scope notes. The engine side needs no commercetools scope — it is reached with the engine's own API key. Grant manage_subscriptions only to the event app (it registers the Subscription in postDeploy); the service/job full-export app needs only view_products (+ the Store read scopes for the Store-specific pattern). There is no write scope here — if you find manage_products on a search connector, it is over-privileged. Check the current list in API scopes rather than guessing, and grant per app, not per connector.

Per-app config

Engine credentials are securedConfiguration; index name, region, locale, price context, and behavioral toggles are standardConfiguration.
deployAs:
  - name: full-export                  # (re)build the whole index on demand
    applicationType: service
    endpoint: /fullSync                 # the Express router must mount at this same base path
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: SEARCH_INDEX_NAME
          description: Target index / collection name
        - key: PRICE_CONTEXT
          description: "currency[,country[,customerGroup,channel]] used to select the indexed price"
        - key: LOCALES
          description: "Comma-separated locales to index, e.g. en-US,de-DE"
        - key: STORE_KEY
          description: "Store key for the Store-specific pattern; omit for whole-catalog"
          required: false
      securedConfiguration:
        - key: SEARCH_ENGINE_API_KEY
          description: Engine admin/write API key (index management + record writes)
  - name: incremental-updater           # keep the index fresh on catalog changes
    applicationType: event
    endpoint: /deltaSync
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the product/store/selection Subscription
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub or SNS"

Worked example (whole-catalog, Algolia-style, build/fork)

Requirements: index the whole published catalog into one Algolia index; ~120k products, product-level records; three locales (en-US, de-DE, fr-FR); one price context (EUR/DE); availability as a coarse inStock boolean only; near-real-time on publish/unpublish plus a nightly safety rebuild; europe-west1.gcp.
Model: one index (SEARCH_INDEX_NAME=products), per-locale fields rather than an index per locale (three locales, one price context — a single index stays simple); objectID = the Product id; price selected with PRICE_CONTEXT=EUR,DE; inStock derived from variant availability but never treated as live stock; categories denormalized to name + breadcrumb path per locale (data-mapping.md).
inheritAs:
  apiClient:
    scopes:
      [
        view_products, # read the catalog to index
        manage_subscriptions, # incremental-updater postDeploy registers the Subscription
      ]
deployAs:
  - name: full-export
    applicationType: service
    endpoint: /fullSync
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
    configuration:
      standardConfiguration:
        - { key: SEARCH_INDEX_NAME, description: "Algolia index name" }
        - { key: PRICE_CONTEXT, description: "EUR,DE" }
        - { key: LOCALES, description: "en-US,de-DE,fr-FR" }
      securedConfiguration:
        - { key: SEARCH_ENGINE_API_KEY, description: "Algolia Admin API key" }
  - name: incremental-updater
    applicationType: event
    endpoint: /deltaSync
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
    configuration:
      standardConfiguration:
        - { key: CONNECT_SUBSCRIPTION_DESTINATION, description: "GoogleCloudPubSub" }
Rationale to hand the user: one service app that pages /product-projections?staged=false (cursor on id), builds records, and does an atomic replace-all into products — triggered on demand and by the nightly schedule (a job variant, or a scheduler hitting /fullSync); one event app whose postDeploy registers a single Subscription on ProductPublished/ProductUnpublished (+ the Store/Product-Selection messages if Store-specific) and upserts/removes one record per message, idempotently on objectID. Scopes are read-only + manage_subscriptions; the Algolia Admin key is securedConfiguration. Correctness rules per app: search-contract.md.
integrations/search/connector-selection.md

Native, use, fork, or build?

This answers Step 1.4 / 1.5 of overview.md. Search differs from PIM and marketplace in one decisive way: commercetools ships a capable search engine of its own, so the first question is not "which connector?" but "does this need an external engine at all?"

Rung 0 first — is this native?

Before any marketplace lookup, test the requirement against the native surface (storefront-search-overview):
Native capabilityCovers
Product SearchFull-text, fuzzy/prefix/wildcard matching, faceting (distinct/range/count/stats/filtered), sorting, price/Store/Product-Selection scoping. GA June 2024; facets GA October 2025 (Use Product Search)
Product Projection SearchThe older search endpoint — full-text, filters, facets, localeProjection/storeProjection; returns full projections rather than ids
ScopingPrice selection (currency/country/Customer-Group/Channel), storeProjection, and B2B assortment scope resolve a buyer's prices and catalog in the same query (Product Search for B2B)
Stop at rung 0 when the requirement is basic product discovery: full-text search, typo tolerance, prefix/type-ahead, facets, sort, and price/Store scoping for a PLP or search results page. An external engine here adds a standing indexing pipeline, an eventual-consistency lag, a per-record/query cost, and an availability dependency — for behavior the platform already has. Say so plainly and stop.
Go past rung 0 when the requirement needs capabilities that are genuinely a discovery platform: visual or AI-driven merchandising and manual curation, synonym / redirect / query-rule sets managed by a merchandiser, recommendations ("customers also bought"), search analytics dashboards, A/B testing of ranking, learned or personalized ranking, or a discovery engine the front end is already committed to. Note the platform docs' own framing: native search plus API Extensions covers a wider band than people assume — some needs are a small extension over native, not a whole engine.
Also check the converse: if the customer already owns an engine licence and their merchandising team works in it daily, "use native instead" is usually not a real option even when the query needs are simple — the requirement is merchandising in the engine, which is rung 1+.

Check live data — don't answer from memory

Listings and engine capabilities change. Before deciding among rungs 1/3/4:

  1. Search the Connect marketplace (marketplace.commercetools.com/connectors) and the search/discovery listings, plus the docs via the docs-search script or the Knowledge MCP.
  2. Distinguish an installable Connect connector from a vendor-hosted integration — apply the parent skill's Marketplace listings are not all Connect connectors rule; don't re-derive it here. It bites hard in search specifically (below).
  3. Compare the requirement engine-by-capability (indexing, merchandising, synonyms, recommendations, analytics, per-Store scope, locales).
  4. Name the connector/engine and version you checked, and record it in the requirements block.

The hosted-integration trap (search's sharpest case)

Several engines ship their own commercetools integration configured entirely in the engine's dashboard — Algolia's "Algolia for commercetools" is the textbook example. These are vendor-hosted integrations, not deployable Connect connectors: there is no connect.yaml, nothing for Connect to deploy, and this skill cannot build or operate them. If one is a good functional match, surface it with a warning that it is not a Connect solution — the customer configures it in the engine, and there is no connector to build — then, if they need a Connect-deployed pipeline (own the mapping, run it in Connect's infrastructure, no dashboard dependency), offer the fork/build path below.

The search landscape (verify live — this is only the shape)

As checked 2026-08:

ArtifactWhat it isDefault rung
Product export template (commercetools/connect-product-export-template)The official Connect scaffold for outbound catalog export, explicitly positioned "for external services such as search". Store-specific: full-export (service, /fullSync) + incremental-updater (event, /deltaSync)4 (build) — start here for any engine
commercetools/launchpad-algolia-syncAn open-source worked Algolia example built on that same two-app shape (full-ingestion + incremental-updater)3 (fork) for Algolia — read the repo live before forking
Engine dashboard integrations (e.g. "Algolia for commercetools")Vendor-hosted, dashboard-configured — not a Connect connectoroutside this skill — surface with the not-a-Connect-solution warning
Bespoke / unsupported engineNothing to install4 (build) — scaffold from the Product export template
Unlike promotion/marketplace/CRM, search is a templated sub-area: the Product export template hands you both app stubs (it is one of the four current Connect templatespayment-integration, product-export, tax-integration, transactional-emails). So even a from-scratch engine is rung 4 from a scaffold, not from nothing. Budget accordingly — the plumbing exists; what you write is the engine's SDK calls and the mapping.

The ladder (stop at the first rung that fits)

  1. Native Product Search is enough → build no connector (above). Say why and stop.
  2. A public connector for the engine covers everything, and it's a real Connect connector → install + configure it. Installation (CLI auth, scopes, deployment create) is the parent skill's deployment-installation.md. Hand it the config from config-from-requirements.md.
  3. Right engine, gap looks like a capability → prove it isn't config first (index name, which fields are indexed, locale/price context, which Store). Most "missing" behavior is a connect.yaml value or engine-side setting → back to rung 1.
  4. Right engine, genuine gap config can't close, and source existsfork it (for Algolia, launchpad-algolia-sync), add only the delta, deploy as an Organization connector. Assess the candidate from its current repo (root connect.yaml, the full/incremental handlers, the mapping, inheritAs.apiClient.scopes vs hand-supplied credentials) — not from memory.
  5. No usable connector for the engine (a bespoke or unsupported engine)build by scaffolding from the Product export template and implementing the engine's client + the mapping. What you build is search-contract.md; config is config-from-requirements.md.
Ask the user to choose between rungs 1, 3, and 4 explicitly once you have the live landscape — "use it as-is", "fork it", or "build for our engine" are materially different amounts of work and the choice is theirs. Present rung 0 first if it applies at all. Only rungs 3–4 leave this sub-area (hand off to the parent commercetools-connect skill for the build/stage/publish lifecycle); the flow resumes here once the connector is deployed.

Recording the decision

In the requirements block, note: engine · rung · connector/template + version checked · why. Examples:
Search: none · rung 0 · checked requirements 2026-08 — "typo-tolerant search with brand/size facets and price sort" is native Product Search; no engine, no connector.
Search: Algolia · rung 3 (fork) · checked marketplace 2026-08 — the dashboard "Algolia for commercetools" is vendor-hosted (not Connect); forking the open-source launchpad-algolia-sync to add per-locale indices and migrate its hand-supplied CTP credentials to inheritAs.apiClient.scopes.
Search: in-house "DiscoverSvc" · rung 4 (build) · checked marketplace 2026-08 — no listing → scaffolding from the Product export template (full-export service + incremental-updater event) and writing the engine client + mapping.
integrations/search/data-mapping.md

From commercetools projection to search document

This is where search integrations succeed or rot. The plumbing (full load, subscription, upsert) is mechanical; the mapping decides whether the index stays correct, queryable, and affordable. It applies whether you configure a public connector (you set the mapping as config) or build one (you write it) — the decisions are identical. Ground every choice in the Product catalog overview and the Integrate external search tutorial; this reference is the decision layer on top.
The core tension: commercetools' model is normalized and reference-based (Products reference Categories, Prices, Channels by id/key); a search engine wants a flat, denormalized, self-contained record optimized for one query. Mapping is a transform and a filter, not a copy — index only what the storefront searches, filters, sorts, or displays.

Principle 1 — Project the current, published data — never the raw Product

Read from a Product Projection with staged=false (the current projection), not the Product resource. Only published Products have a current projection; a storefront index must never contain staged edits or unpublished products (current/staged). This one choice prevents the most common data leak — draft content showing up in search. On the incremental path, ProductPublished carries the productProjection in its payload, so you can index it without a re-fetch; for other triggers, re-fetch the projection by id (Principle 9).

Principle 2 — Every record gets a stable objectID (the idempotency backbone)

Give each record a deterministic id derived from a stable commercetools identifier — the Product id for product-level records, or <productId>-<variantId> (or the SKU) for variant-level. This is what makes every write an upsert and every delete targetable: re-indexing the same product, redelivering a message, and re-running a full load must all converge, not duplicate. A record whose id you can't reconstruct from a later message is a record you can't update or delete — fix the key before writing any sync code. (Algolia calls this objectID; other engines call it the primary key — same role.)

Principle 3 — Record granularity: product-level vs variant-level (a UX decision)

Decide up front whether a search hit is a product or a variant — it shapes the whole document and the storefront result grid:
  • Product-level (one record per Product): variant-specific facets (size, color) become sets aggregated across variants; a hit links to the PDP. Fewer records, simpler; the default for most catalogs.
  • Variant-level (one record per Variant): each color/size is its own hit with its own image and price; needed when the grid shows "red shirt" and "blue shirt" separately. More records; watch the engine's record-count/price tiers.

Match it to how the storefront wants to display results, and keep it consistent — don't mix granularities in one index.

Principle 4 — The price-context explosion (the decision that bites hardest)

A commercetools price is contextual — it varies by currency, country, Customer Group, and Channel (embedded Prices or Standalone Prices, resolved by price selection). A flat record cannot hold every combination. Pick one strategy deliberately:
  • Index one context (e.g. EUR/DE) — simplest; correct only for a single-market storefront. Select it with the projection's price-selection parameters at map time.
  • Index facetable price fields per context (price_EUR_DE, price_USD_US) — one record, several price fields; the storefront picks the field for the shopper's context. Scales to a handful of contexts.
  • Emit one record per context (a context attribute + a filter) — when contexts are many or B2B Customer-Group pricing must be searchable; multiplies record count.
State the choice; a mismatch here is the classic "wrong price in search" bug. B2B/Customer-Group pricing that must be queryable is where native Product Search (which resolves the buyer's context in-query) often wins over an external index — re-check rung 0.

Principle 5 — Localization: index-per-locale vs per-locale fields

commercetools models translatable text as LocalizedString ({ "en-US": "…", "de-DE": "…" }). A search engine wants one language per searchable field (so stemming/synonyms are per-language). Two shapes:
  • One index per locale (products_en, products_de) — the cleanest for language-specific relevance config; the Store-specific and multi-market default.
  • Per-locale fields in one index (name_en, name_de) — fewer indices; the storefront queries the shopper's language fields. Fine for a few locales.
Reduce translations at the source with the projection's localeProjection so you only carry in-scope locales, and map locale codes explicitly (commercetools uses en-US, not en_US).

Principle 6 — Denormalize categories, and know the fan-out cost

A Product references Categories by id; search wants the category names and breadcrumb path embedded in the record (for facets and category listing pages). Denormalize them at map time — but understand the consequence: a category rename or move fans out to reindex every product in it. That's why category messages (Category Slug Changed, and your own reconciliation sweep) matter on the incremental path, and why the nightly full rebuild is the backstop. Key facets on the stable category id/key, and carry the localized name as a display field.

Principle 7 — Store assortment: whole-catalog vs Store-specific

If different Stores expose different assortments via Product Selections or Product Tailoring, decide how the index reflects it:
  • A stores / productSelections filter field on each record — one index, the storefront filters by the current Store. Simple; fine when tailored content per Store is minimal.
  • One index per Store — driven by the Store's Product Selection; use the Populate a Store-specific external search tutorial and read /in-store/key={storeKey}/product-projections (with storeProjection, which also resolves Store locales and prices). This is what the Product export template implements — one Deployment per Store.
The per-Store-Deployment model doesn't scale to many Stores; for a large fleet, build one app that resolves the Store from the message and writes the matching index instead of one Deployment each. Store/Selection changes (StoreProductSelectionsChanged, ProductSelectionProductAdded/Removed, ProductSelectionVariantSelectionChanged) drive add/remove on the incremental path.

Principle 8 — Availability is high-churn and eventually consistent — decide deliberately

Inventory changes constantly and lags real time; a search index is a poor stock ledger. ProductVariant.availability is eventually consistent and never authoritative. Decide explicitly:
  • Usual answer: index a coarse inStock boolean (or a bucketed level) for filtering "in stock only", refreshed on a cadence — and let the storefront read live quantity from the Inventory API / native search at render time.
  • Never make the search index the source of truth for live stock, and never wire per-unit inventory events into the index — the write volume will overwhelm it for no UX gain.

Principle 9 — Trust the id, not the payload; and keep the mapping pure

Subscription messages are at-least-once with no ordering guarantee, so a payload can be stale by the time you process it. Except for ProductPublished (whose productProjection payload is the just-published state), re-fetch the projection by resource.id so the index converges on current state instead of replaying old deltas. Keep the projection→document transform a pure function — no network calls — so it is unit-testable without a deployment or engine key. Everything the engine needs to rank and display should be in the record; the connector's only job is to keep that record equal to the current projection.

The relevance-config boundary (what does NOT live here)

Searchable-field weighting, ranking/tie-breaking, synonyms, redirects, query rules, merchandising, and A/B tests live in the search engine, configured by merchandisers — not in commercetools and not in the connector. The connector feeds correct, current data; the engine decides relevance. Don't try to encode ranking in the mapping.

Worked example (sketch)

An apparel catalog, product-level records, two locales (en-US, de-DE), one price context (EUR/DE), one global index.
  • objectID = Product id. Source = /product-projections?staged=false (full load) and ProductPublished.productProjection (delta).
  • Fields: name_en/name_de, description_en/description_de (per-locale, localeProjection limited to the two); brand, color (set across variants), sizes (set), categories (denormalized breadcrumb names per locale) + categoryIds (facet on stable id); price (selected EUR/DE) + price as a numeric sort/facet field; inStock boolean (coarse, refreshed nightly); imageUrl, slug_en/slug_de.
  • Left out: staged data, out-of-scope locales, per-unit inventory, internal-only attributes, every non-EUR price.
  • Delta triggers: ProductPublished → upsert; ProductUnpublished/ProductDeleted → remove objectID; category rename → reindex affected products (or wait for the nightly rebuild).
Hand the user the record schema (field → source, type, searchable/facetable/display), the price-context and locale decisions, and the objectID rule — that mapping is the deliverable, and it's identical whether a public connector consumes it as config or a custom connector implements it.

Checklist

  • Records built from the current projection (staged=false) — never staged or unpublished data
  • Every record keyed on a stable objectID (product id, or product-id+variant) → every write is an upsert, every delete targetable
  • Record granularity (product vs variant) chosen for the result UX and kept consistent
  • Price context resolved to one strategy (single context · per-context fields · per-context records); no "wrong price in search"
  • Locales handled (index-per-locale or per-locale fields); localeProjection limits to in-scope locales; codes mapped (en-US)
  • Categories denormalized (names/breadcrumb) with the rename→reindex fan-out understood; facets keyed on stable id
  • Store assortment reflected (filter field or index-per-store); Store-specific reads in-store projections; per-Store-Deployment scale limit noted
  • Availability handled deliberately (coarse flag at most); index is not the live-stock source of truth
  • Delta path re-fetches by id (except ProductPublished); the mapping is a pure, unit-testable function
  • Relevance config (ranking/synonyms/merchandising) left to the engine, not encoded in the mapping
integrations/search/overview.md

Search connector — outbound catalog indexing (build or integrate)

This is the search integration sub-area of commercetools-connect: getting the product catalog out of commercetools and into an external search / product-discovery engine (Algolia, Constructor, Bloomreach Discovery, Coveo, Elasticsearch, Typesense, Meilisearch, or a bespoke engine) so a storefront can query it. The build-side platform contracts (service/event/job semantics, connect.yaml, lifecycle scripts, testing, deploy) are the parent connect skill; this sub-area owns the search-specific job end to end — from "do you even need an external engine?" through configuring, forking, or building the connector, to the data mapping and sync architecture that keep the index correct.
Direction. A search connector is commercetools → external engine (commercetools is the source of truth for the catalog; the engine holds a denormalized copy optimized for query). This is the outbound direction — the same as the product-export template, and the opposite of the inbound PIM / CRM sub-areas. commercetools does not read back from the engine.
Two things a search connector is not. It does not touch the Cart/Order hot path — there is no API Extension and no synchronous call at checkout — and there is no browser/enabler contract; it is pure backend data movement (a service/job full load plus an event/job that keeps it fresh), so this whole sub-area is server-side. The engine's relevance configuration (searchable fields, ranking, synonyms, merchandising rules, A/B tests) is owned in the engine, not here — the connector only feeds it correct, current data.

Workflow

Follow these steps in order. The heart is Step 1.4 (native gate) → Step 2 (data mapping) → Step 3 (the two apps) — ruling out native search can end the project on day one, and the mapping is where an external index stays correct or silently rots.

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with search-focused query terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<search terms from the request, e.g. 'integrate external search product export product projections staged product search subscriptions'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root, where scripts/docs-search.mjs lives.) The load-bearing docs for this sub-area are the two tutorials — Integrate external search (whole-catalog) and Populate a Store-specific external search (Store/Product-Selection-scoped) — the Product export template (the official scaffold), and, for the native gate, the Storefront search overview. Read them. You may additionally use the commercetools Knowledge MCP for deeper follow-up.

Step 1 — Extract requirements (before any config or code)

The architecture is downstream of a handful of answers. Ask the user — don't assume:

  1. Which engine, and why this one over native? Algolia, Constructor, Bloomreach, Coveo, Elasticsearch/OpenSearch, Typesense, Meilisearch, or undecided. Do they already have an account + API keys? If undecided, Step 1.4 may end at rung 0 (native).
  2. What can native Product Search not do? Name the specific need — merchandising/curation, synonyms and query rules, recommendations, search analytics, A/B testing, learned ranking, or a headless engine the front end already talks to. "Typo-tolerant full-text with facets" is native (storefront-search-overview) — say so (Step 1.4).
  3. Whole-catalog or Store-specific? One global index, or a Store-scoped index driven by Product Selections / Product Tailoring? This picks the tutorial and the projection endpoint (Step 3).
  4. Which locales? Drives index-per-locale vs per-locale fields (localeProjection).
  5. Which price context(s)? Currency, country, Customer Group, Channel — a record can't hold every combination; you must pick (Step 2).
  6. Does availability/inventory belong in the index? High-churn and eventually consistent — usually a deliberate no or a coarse flag, never the live stock system of record.
  7. Record granularity — product-level or variant-level? A UX decision (one hit per product vs one per variant) that shapes the whole document.
  8. Catalog volume and cadence. Size drives batch/pagination; real-time correctness → event-driven, large periodic rebuilds → scheduled job.
  9. Anything special? (always ask — open-ended) B2B/scoped assortments, multi-currency budgets, category-tree depth, staged-vs-published rules, GDPR in product data. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before choosing an approach.

Step 1.4 — Rung 0: is native search enough? (STRONG — rule it out first)

commercetools ships its own search: Product Search and Product Projection Search cover full-text, fuzzy/prefix/wildcard matching, faceting, and price/Store/Product-Selection scoping (storefront-search-overview; Product Search reached general availability in June 2024, with facets GA in October 2025 — Use Product Search). An external engine is a standing indexing pipeline to own, secure, and pay for, plus an eventual-consistency lag and an operational dependency on the cart-adjacent PLP.
Stop at rung 0 when the requirement is basic PLP/search — full-text, typo tolerance, facets, sort, price/Store scoping. Say so plainly and stop; do not build a connector for what the platform already does. Go past rung 0 only when a stated need genuinely exceeds native: visual/AI merchandising and curation, synonym/redirect rule sets, recommendations, search analytics dashboards, A/B testing, learned/personalized ranking, or a discovery engine the front end is already committed to. The full gate and the native-capability table are in connector-selection.md.

Step 1.5 — Native, use, fork, or build?

Once native is ruled out, decide the path — use a public connector, fork one, or scaffold from the Product export template — from live marketplace data, and watch the hosted-integration trap (an engine's own dashboard-configured integration is not a deployable Connect connector). Full procedure and fit table: connector-selection.md.

Step 2 — Data mapping (the heart)

Whether you configure a connector or build one, the make-or-break work is mapping a commercetools Product Projection onto a flat, denormalized search document: id/objectID keying, record granularity, the price-context explosion, localization, category denormalization, Store assortment, and where (if anywhere) availability belongs. This is data-mapping.md. Get it wrong and the index drifts no matter how good the plumbing is.

Step 3 — Sync architecture (the two apps)

A search integration is two jobs, mirrored by two apps: a full ingestion (service on-demand trigger or scheduled job) that reindexes the whole catalog atomically, and an incremental updater (event on product/store/selection Subscriptions, or a polling job) that keeps it fresh. The contract for each, and the pitfall catalog, is search-contract.md; the connect.yaml config derived from Step 1 is config-from-requirements.md. The official scaffold is the Product export template.

Step 4 — Deploy

Deploying a public or custom connector (CLI auth, scopes, deployment create, regions, certification) is the parent skill's deployment-installation.md.

Step 5 — Verify the sync

Don't declare done until a real product change has flowed end to end: publish → the record appears in the index; unpublish/delete → it's gone; a full ingestion → counts match the published catalog; a re-run leaves the index unchanged. The checks and the traps that look like bugs but aren't (eventual-consistency lag, availability drift, a non-atomic rebuild) are in verification.md.

References

NeedReference
Native, use, fork, or build?: the rung-0 native-search gate, the live-marketplace check, the hosted-integration trap, scaffolding from the Product export templateconnector-selection.md
Requirements → config: the search document shape, index/engine keys, connect.yaml envelope, scopes, secured config; worked exampleconfig-from-requirements.md
Data mapping (the substance): projection → flat document, objectID keying, record granularity, price-context explosion, localization, category denormalization, Store assortment, availability boundarydata-mapping.md
The two-app contract: full ingestion (cursor pagination, atomic/blue-green reindex, count check) + incremental updater (idempotent upsert, deletion propagation, staleness guard); full pitfall catalogsearch-contract.md
Verify the sync: publish/unpublish/delete/full-load/idempotency/per-store checks; the eventual-consistency, availability-drift, and non-atomic-rebuild trapsverification.md
Deploy/install a public or custom connector; regions; certificationcommercetools-connect → deployment-installation.md
Least-privilege scopes, secured config, engine-key handlingcommercetools-connect → security.md
Scheduled/on-demand job: schedule, 30-min timeout, overlap locking, checkpointingcommercetools-connect → job-applications.md
This sub-area is vendor-neutral by design — the requirements, the native gate, the data-mapping method, and the two-app architecture are the same for any engine (Algolia, Constructor, Bloomreach, Elasticsearch, …). Don't add per-engine reference files: they duplicate data-mapping.md and go stale on engine specifics. Instead, look the specific engine and any connector up live (marketplace + the engine's own SDK/docs, per connector-selection.md) and apply the vendor-neutral mapping method to whatever index vocabulary you find.

Checklist

Requirements

  • Engine chosen (or deliberately deferred) + account/API keys; whole-catalog vs Store-specific decided
  • The specific need native Product Search cannot meet is named — not just restated as "search"
  • Locales, price context(s), and record granularity (product vs variant) decided
  • Availability-in-index decision made deliberately (usually no / coarse flag)
  • Volume + cadence captured; asked the open-ended "anything special?" question, each special its own line
  • Requirements block written and confirmed with the user

Path (decide before wiring/building)

  • Rung 0 ruled out explicitly — native Product Search / Product Projection Search can't do it, and you said why
  • Checked live marketplace + docs (not memory); a hosted engine integration surfaced with the not-a-Connect-connector warning
  • Path chosen: use (1) · config-closes-gap (2) · fork (3) · build-from-template (4)

Mapping + sync (the deliverables)

  • Projection → flat document mapped; objectID keyed for idempotent upsert; price context and localization resolved → data-mapping.md
  • Full ingestion reindexes atomically and verifies counts; incremental updater is idempotent and propagates deletionssearch-contract.md
  • A real change flowed end to end; a re-run left the index unchanged → verification.md
integrations/search/search-contract.md

The two-app search-sync contract

Everything each app must do, and the pitfalls that silently break the index. Which apps you build follows from cadence and trigger (config-from-requirements.md); what each record contains is data-mapping.md. These rules sit on top of the parent skill's contracts — service-applications.md, event-applications.md, job-applications.md, security.md — and add what is search-specific. The official scaffold is the Product export template (full-export + incremental-updater).

The rule that spans both apps: the index is a projection, keyed and idempotent

Every write is an upsert keyed on objectID (a stable commercetools id — data-mapping.md) and every removal targets that same id. The index is a derived copy of the published catalog: any record must be reproducible from the current Product Projection, and running either app twice must converge, not duplicate or double-delete. Both full loads and subscription messages are at-least-once, so idempotency is not optional.

App 1 — full ingestion (service on-demand trigger, or job on a schedule)

Rebuilds the entire index from commercetools. It is the initial load, the disaster-recovery path, and the nightly backstop that repairs whatever the incremental path missed.

  • Authenticate the trigger. A service with a public /fullSync endpoint must validate the caller (shared secret / signature) before kicking off a rebuild — an open reindex endpoint is a denial-of-wallet and data-exposure risk (security.md). (AuthorizationHeaderAuthentication is the reverse mechanism, for commercetools calling an Extension — irrelevant here; there is no Extension in this sub-area.)
  • Page with a cursor, not offset. Read /product-projections?staged=false&withTotal=false, sort=id asc, limit=100 (or up to 500), and page with where=id > "<lastId>" (Integrate external search). Offset pagination breaks past a few thousand products; the id-cursor is stable and resumable. For the Store-specific pattern, iterate the Store's Product Selection assignments and read /in-store/key={storeKey}/product-projections instead (Populate a Store-specific external search).
  • Map with the pure function (data-mapping.md) and bulk/batch writes to the engine — never one HTTP call per record.
  • Reindex atomically — build-and-swap, never wipe-then-fill a live index. Build the new index into a temporary/secondary index (or tag every record with a build/generation id), then atomically swap it in and drop the stale set (Algolia's "replace all objects" / a blue-green index alias). Clearing the live index and refilling it leaves a half-empty index serving zero results for the length of the rebuild — the most visible search outage there is.
  • Verify counts. After the swap, confirm the engine's record count matches the number of published products (± your granularity multiplier); a large mismatch means the map dropped or duplicated records — fail loudly, don't leave a bad index live.
  • Respect the runtime. A job has a 30-min timeout and needs overlap locking and checkpointing (job-applications.md); a very large catalog may need the full load chunked or moved to the job shape. Keep the initial migration and the ongoing rebuild the same code path.

App 2 — incremental updater (event on Subscriptions, or a polling job)

Keeps the index in step with catalog changes between full loads.

  • Subscribe once per message type and fan out in the handler — never one Subscription per index or per Store (the Project allows 50 Subscriptions). Register them idempotently in postDeploy (get-then-create, never delete-then-recreate). The relevant Product Catalog Messages:
    • ProductPublishedupsert the record. Its payload carries the productProjection (the just-published current data), so you can map it directly without a re-fetch.
    • ProductUnpublishedremove the record by objectID. An unpublished product must leave the index or it becomes a ghost result linking to a dead PDP.
    • ProductDeletedremove the record. (Design augmentation — not in the tutorial's set. Its payload field is currentProjection, not productProjection; in practice a delete is usually preceded by an unpublish that already removed the record, so treat this as a belt-and-braces removal.)
    • Store / Product Selection messages (Store-specific): StoreProductSelectionsChanged, ProductSelectionProductAdded → add to that Store's index; ProductSelectionProductRemoved → remove from it; ProductSelectionVariantSelectionChanged → re-index; StoreCreated/StoreDeleted → provision/tear down the Store's index.
  • Decode the envelope, then ack correctly. The GCP transport wrapper is { "message": { "data": "<base64>" } }; decode message.data (base64 → JSON), validate the message type, and ack-and-ignore anything you don't handle (including the platform's test message). Return 2xx for handled and deliberately-ignored messages; non-2xx only for transient failures you want redelivered.
  • Upsert is idempotent under redelivery — writing the same record twice is a no-op by construction (keyed on objectID).
  • Deletion propagation is a first-class path, not an afterthought. Every removal trigger (unpublish, delete, removed-from-selection, and — if inStock filtering matters — dropping to zero stock) needs a defined action; a missing one leaves ghost records.
  • Guard against stale writes. With no ordering guarantee, an older message can arrive after a newer one. Except for ProductPublished (whose payload is current), re-fetch the projection by resource.id so the index converges on current state; where the engine supports it, additionally guard on a version / lastModifiedAt so an out-of-order write can't overwrite newer data.
  • The polling job alternative: query /product-projections?where=lastModifiedAt > "<checkpoint>" on a schedule, upsert the page, advance the checkpoint. Simpler ops, but it cannot see deletions (a deleted product no longer appears in the query) — pair it with the nightly full rebuild to purge ghosts, or subscribe to ProductUnpublished/ProductDeleted for removals.

Pitfall catalog

PitfallSymptomFix
No deletion propagation on unpublish/deleteUnpublished products still appear in search; hits link to dead PDPs (ghost records)Handle ProductUnpublished/ProductDeleted → remove by objectID
Wipe-then-fill a live indexSearch returns zero/partial results for the whole rebuild windowBuild-and-swap / replace-all-objects (atomic); drop the old set after the swap
Indexing staged / unpublished dataDraft content and unpublished products surface in searchRead the current projection (staged=false) only
Trusting a stale message payloadOlder delta overwrites newer state; out-of-order writesRe-fetch by resource.id (except ProductPublished); guard on version/lastModifiedAt
Price context mismatchWrong price in search results / facetsSelect one context at map time; index per-context fields/records (data-mapping.md)
Category rename not fanned outStale category names/breadcrumbs on productsReindex affected products on category messages; nightly rebuild as backstop
Per-unit inventory wired into the indexWrite volume overwhelms the engine; cost spikesCoarse inStock flag refreshed on cadence; live stock from the Inventory API
Offset pagination on the full loadFull load misses/duplicates products past a few thousandCursor on sort=id asc + where=id > "<lastId>"
One call per recordFull load times out / hits engine rate limitsBatch/bulk writes
No count check after reindexA silently truncated index goes liveAssert engine count ≈ published-product count; fail loudly on mismatch
Envelope not decodedHandler sees base64 garbage / crashesDecode message.data (base64 → JSON), then validate type
Wrong ackHandled message redelivered forever, or failures silently dropped2xx for handled/ignored; non-2xx only for retryable
One Subscription per index/StoreHits the 50-Subscription Project limitOne Subscription per message type; fan out in the handler
Unauthenticated /fullSync triggerAnyone can trigger a full reindex (denial-of-wallet)Validate a shared secret/signature before starting
Engine key over-scoped or in logsAdmin key leaked; compliance incidentKey in securedConfiguration; generic error responses; no payload dumps
Route ≠ connect.yaml endpointPlatform traffic / trigger 404sMount the router at the app's endpoint base path
Legacy SDKFails the parent skill's pinned-version gate@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4

Test-first checklist (mirror in the suite)

Full ingestion

  • Rejects unauthenticated / bad-signature trigger calls
  • Pages with the id cursor (asserts where=id > "<lastId>"), not offset; maps via the pure function
  • Reindex is atomic (build-and-swap) — a rebuild never leaves the live index empty/partial
  • Count check asserts engine count ≈ published-product count; mismatch fails the run
  • Store-specific: iterates the Store's Product Selection and reads in-store projections

Incremental updater

  • ProductPublished upserts from the payload projection; second delivery is a no-op
  • ProductUnpublished / ProductDeleted remove the record by objectID
  • Store/Selection add/remove messages add/remove from the right Store index
  • Stale/out-of-order message doesn't overwrite newer state (re-fetch by id / version guard asserted)
  • Envelope decode + ack matrix covered (handled, ignored, retryable)
  • Polling-job variant (if used): advances checkpoint; deletions covered by rebuild/removal messages
  • Boundary mocked (engine + commercetools APIs); suite runs with no deployment and no secrets
integrations/search/verification.md

Verify the search sync

Don't declare done until a change appears, a removal disappears, and a full rebuild matches the catalog — in the engine's index, not just in your logs. Locally, without a real queue, POST the base64 message envelope straight to the incremental app's endpoint (test an event application locally) and hit the /fullSync trigger directly. Two of the checks below regularly look broken when they're actually correct — read the traps.

Check 1 — a publish appears in the index (delta path)

Publish a Product (or change and re-publish one), then confirm:

  • A record with the expected objectID exists in the engine, with the mapped fields — name/description in the in-scope locales, the selected-context price, denormalized categories, and image.
  • Searching for a term in the product's name returns it, and its facets (brand/color/size) are populated.
  • Only the current, published data is present — no staged edits, no unpublished siblings. If staged content shows up, the connector is reading the wrong projection (data-mapping.md).
  • Re-deliver the same ProductPublished message: nothing duplicates (idempotent upsert on objectID).
A record that appears but is missing the price or a locale is the tell that the price-context or localeProjection mapping is wrong — not that indexing failed.

Check 2 — an unpublish/delete disappears (deletion propagation)

Unpublish the Product (and separately, delete one), then confirm the record is gone from the index and no longer returned by search. This is the check people skip, and its failure is the ghost-record bug: a hit that still shows in results and links to a dead PDP. If the polling-job variant is in use, confirm removals are covered by the removal messages or the nightly rebuild — a lastModifiedAt poll alone can't see deletions.

Check 3 — a full ingestion matches the published catalog, atomically

Trigger /fullSync (or run the job) against a known catalog, then confirm:
  • The engine's record count equals the published-product count (× your granularity multiplier for variant-level). A mismatch means the map dropped or duplicated records.
  • The index never went empty or partial during the rebuild. Query it while a rebuild runs (or inspect that the connector built into a temporary index and swapped) — a live index that returns zero/partial results mid-rebuild is the non-atomic-reindex bug (Trap 3), not a timing quirk.
  • Re-run the full ingestion: the resulting index is identical (same count, same records) — the load is idempotent.

Check 4 — per-Store scope holds (Store-specific pattern)

If the index is Store-scoped: add a Product to a Store's Product Selection and confirm it appears in that Store's index only; remove it and confirm it disappears from that index while remaining in others that still list it. Confirm the in-store projection resolved the Store's locales/prices, not the Project defaults.

The traps (behavior that looks like a bug — or hides one)

Trap 1 — the lag is eventual consistency, not a dropped update

commercetools' own projections and native search are eventually consistent — an update takes time to be queryable — and the engine adds its own indexing delay on top. So "I published and it's not in search yet" is usually expected lag, not a lost message. Confirm by waiting and re-querying, or by checking the record landed via a direct get before concluding the pipeline dropped it. Only treat it as a bug if it never converges.

Trap 2 — availability in the index drifts, and that's by design

If you indexed an inStock flag, it is a cadence-refreshed snapshot, not live stock — ProductVariant.availability itself lags and is eventually consistent (up to ~10 s). A search result showing "in stock" for something that just sold out is expected; the storefront must confirm live quantity from the Inventory API / native search at render or add-to-cart. Verify the flag refreshes on its cadence — don't expect it to track real-time stock.

Trap 3 — a "flaky, half-empty" index during rebuilds is a non-atomic reindex

If search returns fewer results (or none) for a stretch and then recovers, the full ingestion is wiping the live index and refilling it instead of building-and-swapping. That's not load flakiness — it's the rebuild window exposed to shoppers. Fix it in the connector (build into a temporary index, then swap / replace-all-objects atomically — search-contract.md), then re-verify Check 3.

Trap 4 — sandbox catalog vs production

A sandbox project has a small, static catalog: counts, locales, and Store assortments won't match production, and volume/throttling behavior won't surface. Verify the contract (upsert, deletion propagation, atomic rebuild, count check, idempotency, trigger auth) against the sandbox; verify real volume, pagination depth, and engine rate-limit behavior against a production-sized catalog, and clean up test records afterward.

Verification checklist

  • Publish → record present with mapped fields (locales, selected-context price, categories, image); current data only; redelivery doesn't duplicate
  • Unpublish and delete → record gone from the index (no ghost results)
  • Full ingestion → engine count ≈ published-product count; index never empty/partial mid-rebuild; re-run identical (idempotent)
  • Store-specific: add/remove from a Product Selection scopes to the right Store index; in-store projection resolved Store locales/prices
  • Confirmed eventual-consistency lag converges (not a dropped update)
  • inStock/availability treated as a cadence snapshot, not live stock; live quantity read from Inventory/native search
  • Contract verified on sandbox; volume + rate-limit behavior verified on a production-sized catalog; test records cleaned up
  • No engine key or payload dumps in logs; the /fullSync trigger rejects unauthenticated calls
integrations/tax/avalara.md

Avalara (and TaxJar) specifics

Two grounded engines: Avalara — the certified, open-source connector (rung 1/3), the authoritative model — and TaxJar — a from-template build (rung 4), the contrast for when no connector exists. Read alongside tax-contract.md.

Avalara — the certified connector (ground truth)

Three applications

ApptypeendpointRole
serviceservice/serviceCalculator (cart API Extension)
eventevent/eventRecorder (order Subscription): commit / void / refund / recalculate
mc-appmerchant-center-custom-application(MC)Config/admin UI — credential test, address-origin validation, settings
TypeScript throughout; avatax npm SDK; Express; Jest. Both service+event run postDeploy: npm install && npm run build && npm run connector:post-deploy.

Calculation (the service app)

  • API Extension on cart, [Create, Update], condition shippingAddress is defined and shippingInfo is defined and lineItems is not empty — the strong call-reduction gate.
  • AvaTax call: AvaTaxClient.createTransaction() (Avalara /api/v2/transactions/create). For the quote phase it sets type = SalesOrder (0) and commit: false — a tax estimate that files nothing.
  • Tax mode ExternalAmount, returned via changeTaxMode plus the full set of tax actions: setLineItemTaxAmount, setCustomLineItemTaxAmount, setShippingMethodTaxAmount, setCartTotalTax. taxRate name is avaTaxRate, amount derived from the AvaTax response detail.
  • Idempotency / call reduction: hashCart(cart) compared to a stored avalaraHash custom field; recalculates only when the hash changed or taxedPrice is absent, then persists the new hash.
  • Fail-closed: returns 400 ("No Avalara merchant configuration found.") on error/misconfig — blocks the cart rather than persisting untaxed.
  • Ship-from / ship-to: shipFrom = configured origin address; shipTo = cart.shippingAddress.

Recording (the event app)

  • Subscription on order, destination GoogleCloudPubSub, message types OrderCreated, OrderStateChanged, OrderStateTransition, OrderReturnShipmentStateChanged.
  • A transaction manager drives the lifecycle, mostly keyed on merchant-configured order-state ID lists (settings in a custom object), not hardcoded names:
    • commitTransaction — files the sale (on OrderCreated if the boolean commitOnOrderCreation, or when state ∈ commitOrderStates).
    • voidOrRefundTransaction — on state ∈ cancelOrderStates (plus a residual hardcoded orderState === 'Cancelled' check in the OrderStateChanged path).
    • partiallyRefundTransaction — on return-shipment state change, gated by the boolean activateReturns (a flag, not a state-ID list).
    • recalculateTransaction — for order edits.
    The lesson to carry over on a fork/build: model the commit/cancel states as configurable lists (state keys differ per project); returns can be a simpler on/off flag.

Config keys (exact)

  • service standardConfiguration: CTP_REGION; custom-type keys/names for shipping, line item, category, shipping-method, customer, order (e.g. avalara-connector-custom-shipping, avalara-connector-order); AVATAX_PRODUCT_ATTRIBUTE_NAME (optional, default avatax-code).
  • service securedConfiguration: CTP_PROJECT_KEY / CTP_CLIENT_ID / CTP_CLIENT_SECRET / CTP_SCOPE, AVALARA_USERNAME, AVALARA_PASSWORD, AVALARA_COMPANY_CODE, AVALARA_ENV, FRONTEND_API_KEY (optional).
  • event: standard CTP_REGION + AVATAX_PRODUCT_ATTRIBUTE_NAME; secured = same CTP + Avalara keys.
  • mc-app: standard CUSTOM_APPLICATION_ID, CLOUD_IDENTIFIER (default gcp-eu), ENTRY_POINT_URI_PATH.
Note: it supplies CTP_CLIENT_ID/SECRET manually (secured config), not via inheritAs.apiClient.scopes. On a fork or new build, prefer native client provisioning (config-from-requirements.md) — it's the more modern, lower-maintenance form.

Enterprise features worth knowing (they're config, not forks)

  • Tax-code mapping, multi-level: product attribute (AVATAX_PRODUCT_ATTRIBUTE_NAME, default avatax-code) → category custom fields (getCategoryTaxCodes) → shipping/custom-line-item types.
  • Exemptions: getCustomerEntityUseCode(cart.customerId) reads the customer's Avalara entity-use code from a Customer custom field (avalaraEntityUseCode).
  • Address validation: /avalara/check-addressclient.resolveAddress() (/addresses/resolve), toggleable.
  • Settings in custom objects, managed by the MC app: address-validation toggle, commitOnOrderCreation, commitOrderStates/cancelOrderStates, activateReturns, logging, tax-code map, entity-use codes.
The takeaway for the decision ladder: most Avalara "customization" requests are settings, not forks — check the MC-app/custom-object surface before concluding rung 3.

TaxJar — the build-from-template contrast (rung 4)

TaxJar has no public connector (connector-selection.md), so it's the canonical from-template build. It's a good contrast to Avalara because the architecture is identical — only the engine calls and mapping differ, and the enterprise features (MC app, address validation, multi-level tax codes) are simply absent unless you add them.

The engine calls (the two halves)

  • Calculate: POST /v2/taxes (live api.taxjar.com, sandbox api.sandbox.taxjar.com). Send destination address + line items (major-unit unit_price) + shipping; get back tax.amount_to_collect, tax.rate, and tax.breakdown.line_items[] / tax.breakdown.shipping. Stateless — stores nothing.
  • Record: POST /v2/transactions/orders. Send transaction_id (= order id, for idempotency), transaction_date, destination, amount (net), shipping, sales_tax, line_items[]. This is what appears in the dashboard.

Mapping notes

  • Convert commercetools minor units (centAmount/fractionDigits) to TaxJar major-unit decimals once, in the mapper.
  • Emit all four ExternalAmount actions; take per-line tax from tax.breakdown.line_items[] keyed by the line id you sent, shipping tax from tax.breakdown.shipping, and fall back to the effective tax.rate when a breakdown entry is absent.
  • Product tax code: read a Custom Field (e.g. taxjar-tax-code) and pass as product_tax_code; omit when absent (TaxJar treats it as fully taxable).

TaxJar-specific gotchas (learned from a real build)

  • to_state is required on transactions — a destination without a state yields 406 to_state can't be blank. Ensure the address carries state, and omit blank optional fields rather than sending empty strings.
  • Sandbox does not persist transactions. POST /v2/transactions/orders returns 201, but GET returns canned demo data and nothing appears in the dashboard. Transactions only show up on a live account. Prove recording against live (with cleanup), not sandbox — verification.md.
  • Zero tax without nexus. TaxJar only collects where the account has nexus; a destination outside your nexus correctly returns amount_to_collect: 0. Test against a nexus region.
  • Duplicate = success. A redelivered order hits TaxJar's duplicate-transaction_id guard (422); treat it as already-recorded.

Cross-engine summary

DimensionAvalara (certified)TaxJar (from template)
Rung1 configure / 3 fork4 build
Calculate APIcreateTransaction (commit:false)POST /v2/taxes
Record APIcreateTransaction (commit:true)POST /v2/transactions/orders
Tax modeExternalAmountExternalAmount
Lifecyclecommit/void/refund/recalc on configured statesOrderCreated (add void/refund yourself)
Tax codesproduct attr → category → type (multi-level)single custom field passthrough
Exemptionsentity-use code from Customer fieldadd yourself
Address validationyes (resolveAddress)no
Config UIMC app + custom objectsenv/config only
Extra apps+ merchant-center-custom-applicationnone
Both are the same two-app spine; Avalara shows how far the pattern hardens for compliance, TaxJar shows the minimal correct core.
integrations/tax/config-from-requirements.md

Requirements → tax connector config

This turns the Step 1 requirements (overview.md) into concrete connect.yaml values. For a certified connector these are its documented keys; for a from-template build these are the keys you define. Provider-exact key names/defaults are in avalara.md.

The requirement → config map

Requirement (Step 1)Config / decisionWhy
Which engine + credentialssecuredConfiguration: engine API token or username/password/company-codeSecrets never in standardConfiguration, never hardcoded
Nexus regions(engine-side account setting, not connect.yaml)The engine only returns tax where you have nexus; a missing nexus is the usual "tax is zero" cause
Region + projectstandardConfiguration: CTP_REGION; scopes via inheritAsHost + client provisioning are region/project specific
Calculation + recordingDeploy both apps (calculator + syncer); calculation-only = just the calculatorRecording is a separate engine API and a separate Connect app
Void on cancel / refund on returnSyncer subscribes to OrderStateChanged / return messages + the order-state → action mappingFiling must follow the order's real lifecycle, not just creation
Product tax categories/codesTax-code source setting (Product attribute name / Tax Category / Custom Field)The calculator must know where to read each item's tax code
Tax-exempt buyersExemption/entity-use-code source (Customer Custom Field)Passed to the engine so exempt buyers are taxed correctly
VAT-inclusive / roundingCart taxMode, taxCalculationMode, taxRoundingMode (and includedInPrice on the external rate)Controls how the platform combines the external amounts

Tax mode

The single most consequential choice. Set on the cart (the connector usually sets it via changeTaxMode), it decides who owns the arithmetic:
  • ExternalAmount (recommended). You supply the exact tax amounts; commercetools stores them as-is. No re-derivation, so no rounding drift between what the engine files and what the cart shows. This is what the tax docs recommend and what the certified Avalara connector uses. Requires taxing every priced element — line items, custom line items, and shipping — plus a cart total (see tax-contract.md).
  • External. You supply tax rates; commercetools computes amounts. Simpler payloads, but the platform's rounding can differ from the engine's by cents — a reconciliation headache when the engine is the system of record for filing.
  • Platform / Disabled are not external-engine modes.
Default to ExternalAmount unless the user has a specific reason (e.g. they only have rates, not amounts). Record the choice and why.

The connect.yaml envelope

connect.yaml has no published JSON Schema — its shape is defined only by the docs. Use only documented envelope keys (deployAs / applicationType / endpoint / scripts / configuration; inheritAs), and place the file at the repository root — a nested connect.yaml silently fails to deploy.

Native client provisioning (prefer this)

Declare the connector's scopes and let Connect mint a least-privilege API client, rather than hand-supplying CTP_CLIENT_ID/SECRET:
inheritAs:
  apiClient:
    scopes:
      - manage_extensions      # calculator postDeploy registers the Cart API Extension
      - manage_subscriptions   # syncer postDeploy registers the OrderCreated Subscription
      - view_orders            # syncer re-fetches the Order to build the transaction
  configuration:
    standardConfiguration:
      - key: TAX_SANDBOX
        description: "'true' to call the engine sandbox instead of live"
    securedConfiguration:
      - key: TAX_PROVIDER_API_TOKEN
        description: Tax engine API token, used by both apps
Note: view_extensions / view_subscriptions are not valid standalone scopes — manage_extensions / manage_subscriptions cover read + write. Declaring the non-existent view scopes fails client creation.
The official template hand-declares CTP_CLIENT_ID/SECRET/SCOPE as secured config; migrating to inheritAs.apiClient.scopes is the more native, lower-maintenance form and is worth doing on a from-template build.

Per-app config

deployAs:
  - name: tax-calculator
    applicationType: service
    endpoint: /taxCalculator
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the API Extension
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
  - name: order-syncer
    applicationType: event
    endpoint: /orderSyncer
    scripts:
      postDeploy: npm ci --omit=dev && npm run connector:post-deploy   # registers the Subscription
      preUndeploy: npm ci --omit=dev && npm run connector:pre-undeploy
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub or SNS"
Template gotcha: the official template's tax-calculator postDeploy was just npm install — it never registered the extension, and its post-deploy pointed the destination at the app's base URL instead of <url>/taxCalculator. Wire connector:post-deploy for both apps, and make the extension destination include the endpoint path.

Worked example (TaxJar, from-template build)

Requirements: TaxJar; nexus in DE; calculation and recording; tax code from a Product attribute tax-code; no exemptions yet; ExternalAmount; europe-west1.gcp.

Derived config:

inheritAs:
  apiClient:
    scopes: [manage_extensions, manage_subscriptions, view_orders]
  configuration:
    standardConfiguration:
      - key: TAXJAR_SANDBOX
        description: "'true' for sandbox; note the sandbox does not persist transactions"
    securedConfiguration:
      - key: TAX_PROVIDER_API_TOKEN
        description: TaxJar API token (same token both apps)
deployAs:
  - name: tax-calculator
    applicationType: service
    endpoint: /taxCalculator
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
  - name: order-syncer
    applicationType: event
    endpoint: /orderSyncer
    scripts: { postDeploy: "npm ci --omit=dev && npm run connector:post-deploy", preUndeploy: "npm ci --omit=dev && npm run connector:pre-undeploy" }
    configuration:
      standardConfiguration:
        - key: CONNECT_SUBSCRIPTION_DESTINATION
          description: "GoogleCloudPubSub or SNS"
Rationale to hand the user: ExternalAmount so TaxJar's amounts are authoritative; both apps because they want filing, not just checkout tax; manage_extensions+manage_subscriptions+view_orders are exactly what the two postDeploy scripts and the syncer's order re-fetch need — nothing more. Nexus is DE-only on the account, so US destinations will (correctly) show zero tax — flag this so it isn't mistaken for a bug (verification.md).
For the certified Avalara connector's exact standard/secured keys (custom-type keys, AVATAX_PRODUCT_ATTRIBUTE_NAME, AVALARA_USERNAME/PASSWORD/COMPANY_CODE/ENV, commit/void order-state settings), see avalara.md.
integrations/tax/connector-selection.md

Is a certified tax connector enough?

This answers Step 1.5 of overview.md: given the requirements, do you configure an existing connector, fork one, or build from the template? The answer is engine-specific — unlike a generic build, the right rung depends entirely on whether that engine has a certified connector.

Check live data first — don't answer from memory

Supported engines and their capabilities change. Before deciding:

  1. Search the Connect marketplace (marketplace.commercetools.com/connectors) and the tax docs via the docs-search script or the Knowledge MCP.
  2. Compare the requirements engine-by-capability (calculation, recording/filing, void/refund, exemptions, address validation, regions).
  3. Name the connector and version you checked, and record it in the requirements block.

The tax landscape (verify, but this is the shape)

EnginePublic certified connector?Source available?Default rung
Avalara (AvaTax)✅ Yes (marketplace)Open source (mediaopt/avalara-commercetools-connector)1 (configure) — or 3 (fork) since the source is open
Vertex (O Series)✅ Yes (marketplace)❌ Partner-private1 (configure) — fork not possible without source
TaxJarNo public connector— (only the generic template)4 (build from template)
Other (Sovos, ONESOURCE, …)Check the marketplaceVariesLikely 4 unless a listing exists
The practical consequence: "just use the certified connector" is the right answer for Avalara and Vertex, and impossible for TaxJar. A request to "integrate TaxJar" is a build-from-template job, not a marketplace install — there is nothing to install. This is worth stating plainly to the user early, because it changes the effort estimate.

The ladder (stop at the first rung that fits)

Rung 1 — Configure a certified connector (Avalara, Vertex)

If a certified connector exists and covers the requirements, install and configure it. This is the cheapest, most maintainable path — the vendor/partner keeps it certified and updated. Installation (CLI auth, scopes, deployment create --connector-key, or Merchant Center install) is the parent skill's deployment-installation.md. Hand the connector the config you derive in config-from-requirements.md.
Most tax "customization" isn't code — it's configuration. The certified Avalara connector, for example, exposes commit/void order states, tax-code mapping, exemptions, and address validation as Merchant Center settings stored in custom objects, not as forks. So before concluding a requirement forces a fork, check whether it's a setting (rung 2).

Rung 2 — A gap that config can close

A "missing" behavior is usually a config value or MC setting: which order states trigger a commit vs a void, where the product tax code is read from, whether returns file refunds, whether addresses are validated. Re-check the apparent gap against the connector's configuration surface before forking. Details and the mapping are in config-from-requirements.md.

Rung 3 — Fork/extend the public connector (Avalara)

If there's a genuine gap config can't close and the connector is open source (Avalara's is), fork it, add only the delta, and deploy as an Organization connector. Don't rebuild — you'd throw away a working, certified-quality codebase (its tax-code mapping, exemption handling, commit/void lifecycle, and MC config app are substantial; see avalara.md). Hand off to commercetools-connect for the fork's build/stage/publish lifecycle. Vertex can't be forked (no public source) — a genuine Vertex gap means working with the partner or, as a last resort, building custom.

Rung 4 — Build from the tax template (TaxJar, or any engine with no connector)

No public connector for the engine → build from the tax integration template. The template ships the two apps (tax-calculator service + order-syncer event) with the Connect plumbing done — lifecycle scripts, extension/subscription registration, envelope handling — but the engine calls and mapping are stubs you implement. This is the TaxJar path.

What you actually write on rung 4:

  • The calculator: cart → engine calculate-request, response → the four ExternalAmount update actions (see tax-contract.md).
  • The syncer: order → engine record-transaction call, idempotent on order id; optionally void/refund on lifecycle.
  • Config + scopes (config-from-requirements.md).
Because rung 4 is the most work, it's also where the template's own gotchas bite (the extension must return 200 not 202; shipping and custom line items must be taxed or the Order won't create in ExternalAmount mode; legacy SDK versions). Those are catalogued in tax-contract.md and grounded, with a real engine, in avalara.md (which contrasts the certified Avalara approach with a from-template TaxJar build).
The full build/stage/publish/certify lifecycle for rungs 3–4 is the parent commercetools-connect skill; return to this tax flow once the connector is deployed.

Recording the decision

In the requirements block, note: engine · rung · connector name + version checked · why. Example:
Tax: TaxJar · rung 4 (build) · checked marketplace 2026-07 — no public TaxJar connector, Avalara/Vertex exist but engine is fixed to TaxJar by an existing account · building both apps from the tax template.
integrations/tax/overview.md

Tax connector — integrate an external tax service (backend-focused)

This is the tax integration sub-area of commercetools-connect: you need an external tax engine to compute (and file) tax on carts and orders, and you'll do it with a Connect connector. For the deep, type-agnostic build/publish/certify lifecycle and the production-readiness gate, that's the parent connect skill; this sub-area owns the tax-specific shape end to end — from "is there a connector already?" through configuring, forking, or building one.
A tax integration is two jobs, and the connector is two applications that mirror them:
  • tax-calculator (a service registered as a cart API Extension) — the calculate half. On cart changes, commercetools calls it synchronously; it asks the tax engine for the tax on the current cart and returns cart update actions that put the tax onto the cart. This is a quote — nothing is filed.
  • order-syncer (an event driven by an OrderCreated Subscription) — the record half. After an order is placed, it asynchronously records the finalized order as a transaction in the tax engine (for reporting/filing), and — for a full integration — commits/voids/refunds as the order's lifecycle changes.
This two-app split is not incidental: it's the architecture the official tax integration template ships, the one the certified Avalara connector implements, and the one the tax integration tutorial documents. Calculation must be synchronous (it blocks the cart so the shopper sees correct tax); recording must be asynchronous (filing must not block or fail checkout).
Calculation vs. recording is the mistake to internalize first. "Why don't my transactions show up in the tax provider's dashboard?" is almost always because only the calculator is wired: the calculate API stores nothing; only the record API (the order-syncer) persists a transaction. They are different endpoints on the provider and different Connect apps here.

Workflow

When integrating tax, follow these steps in order. The heart is Step 1 → Step 1.5 → Step 2 → Step 4 (requirements → is a certified connector enough? → config → the two apps).

Step 0 — Gather context (required, run first)

The mandatory grounding step: pull the latest verified documentation as context for you (the agent). Use the parent connect skill's docs-search script with tax-focused terms. Do not skip it, and do not replace it with another tool:
node scripts/docs-search.mjs \
  --query "<tax terms from the user's request, e.g. 'tax connector external tax API extension cart tax order sync'>" \
  --app-name "<current-app ex: claude, copilot, codex>" \
  --model "<current-model>" \
  --skill-name "commercetools-connect" \
  --limit 10
(Run it from the commercetools-connect skill root.) Use its output as primary grounding. You may additionally use the commercetools Knowledge MCP or https://docs.commercetools.com/tutorials/tax-integration for deeper follow-up.

Step 1 — Extract requirements (before any config or code)

Tax behavior is downstream of business facts, and the wrong default silently produces wrong or missing tax. Extract these first; each maps to a config key in Step 2 or a rung in Step 1.5. Ask the user (don't assume):

  1. Which tax engine, and why? Avalara/Vertex (enterprise US sales tax + global, compliance/filing), TaxJar (simpler US sales tax), or another. Do they already have an account + credentials?
  2. Where do they have nexus / an obligation to collect? Which countries/states. This decides which destinations produce non-zero tax and is a frequent "why is tax zero?" cause (see verification.md).
  3. Region and project? e.g. europe-west1.gcp, project my-project — the API host and config are region-specific.
  4. Do they need transaction recording / filing, or just calculation at checkout? Calculation-only (rare) is one app; recording (the norm for compliance) needs the order-syncer too. → decides whether you build one app or two.
  5. Order lifecycle beyond creation? Should cancellations void the filed transaction and returns refund it? → drives whether the syncer subscribes to OrderStateChanged/return messages, not just OrderCreated.
  6. Product tax categories / codes? Are products taxed differently (clothing, food, digital, luxury)? Where is the tax code stored — a Product attribute, a Tax Category, or a Custom Field? → drives the tax-code mapping in the calculator.
  7. Tax-exempt buyers? B2B/non-profit/government exemptions, exemption certificates or entity-use codes → stored on the Customer (Custom Field) and passed through.
  8. B2B / included-in-price / rounding needs? VAT-inclusive pricing (includedInPrice), taxCalculationMode (LineItem vs UnitPrice), taxRoundingMode.
  9. Anything special or non-standard? (always ask — open-ended) Marketplace/multi-seller, cross-border/customs, multi-currency, address validation, invoicing, or a specific engine account/company code. Capture each as its own requirement line; don't force it into a slot above.
Write these as a short requirements block and confirm with the user before deriving config. Each special requirement feeds the Step 1.5 fit-check (it may push "configure" → "fork" or "custom"). If the user surfaces nothing special, a sane default is: engine chosen → destination has nexus → calculation and recording → ExternalAmount tax mode → tax code from a Product attribute → and say so explicitly.

Step 1.5 — Is a certified connector enough? (decide before wiring or building)

With the requirements in hand, answer the question the rest of the flow assumes: does a connector that already does this exist for this engine? Don't answer from memory — the marketplace changes. Check live data (the Connect marketplace + the tax-integration docs, via the docs-search script / Knowledge MCP), and name the connector + version you checked. The tax landscape as of writing: Avalara and Vertex have certified public connectors; TaxJar does not (build from template). See connector-selection.md.
Then walk the ladder — stop at the first rung that fits, because each later one is more to build and maintain:
  1. Public connector covers everything → install + configure (Step 2). Don't build. Installing it (CLI auth, scopes, deployment create) is the parent skill's deployment-installation.md; it is not the connectorstaged flow.
  2. Supported engine, gap looks like a capability → prove it isn't config first. Most "missing" behaviors (which order states commit/void, tax-code source, exemptions) are connect.yaml values or Merchant Center settings → back to rung 1. See config-from-requirements.md.
  3. Supported engine, genuine gap config can't closefork/extend the public connector (Avalara's is open source; see avalara.md); add only the delta and deploy as an Organization connector. Don't rebuild a working, maintained connector. Hand off to commercetools-connect for the build/publish lifecycle.
  4. No public connector for the engine at all (e.g. TaxJar) → build from the tax integration template. The template ships both apps as stubs; you implement the engine calls and the mapping. The exact contract, gotchas, and a worked engine are in tax-contract.md and avalara.md (with TaxJar as the from-scratch example).

Record the decision, the rung, and the version in the requirements block.

Step 2 — Derive the config from the requirements

Translate the Step 1 answers into concrete connect.yaml values for the chosen connector (or your own), with a one-line why for each. The mapping and the provider-specific key names/defaults are in config-from-requirements.md and avalara.md. Key decisions that live here:
  • Tax mode: ExternalAmount (recommended) vs External. ExternalAmount means the engine's exact amounts are authoritative — no re-derivation, no rounding drift between what's filed and what's shown. External has commercetools compute from a rate you supply. The docs and the certified connector both prefer ExternalAmount. → config-from-requirements.md.
  • API-client scopes the connector needs — declare them in inheritAs.apiClient.scopes so Connect provisions a least-privilege client (manage_extensions, manage_subscriptions, view_orders), rather than hand-supplying CTP_CLIENT_ID/SECRET.
  • Secured vs standard config — the engine API token/credentials are securedConfiguration; region and behavioral toggles are standardConfiguration.

Step 3 — The extension trigger & call-reduction (reference)

The API Extension is what makes the calculator fire. External tax engines bill per call and rate-limit, so the trigger condition matters: fire only when the cart can actually be taxed and is worth taxing (taxMode="ExternalAmount", a shipping address is set, line items exist), and consider hashing the cart to skip redundant calls. Full contract and the call-reduction pattern: tax-contract.md.

Step 4 — Build/verify the two apps (the main body of work), test-first

Tests come before implementation. The rules that make a tax integration correct — the extension returning 200/201 (not 202), taxing shipping and custom line items too (or the Order can't be created in ExternalAmount mode), idempotent recording, committing only on the right order states — are invisible at the call site and tedious to reproduce by hand. Each is one cheap assertion. Write the test first.
Read tax-contract.md and build, in order — test first for each:
  1. Calculator (API Extension) — map cart → engine request; call the engine's calculate API; map the response to setLineItemTaxAmount + setCustomLineItemTaxAmount + setShippingMethodTaxAmount + setCartTotalTax (and changeTaxMode if you own that); respond 200 fast; decide fail-open vs fail-closed.
  2. Order-syncer (Subscription) — on OrderCreated, re-fetch the Order by id, map it to the engine's record/commit transaction API, POST idempotently (stable transaction_id = order id). For a full integration, also handle cancel→void and return→refund.
Mock the outbound boundary (the tax engine, the CT APIs) and assert on what your code decided — which endpoint, what body, what it did with the response. The suite must run with zero deployment and zero secrets. What to assert/mock per app is in tax-contract.md.

Step 5 — Verify the round trip

Don't declare done until a real cart carries engine-computed tax and a real order shows up as a transaction. The taxedPrice appears on the cart (it's absent until the extension is registered and firing), and — with a live engine account whose nexus covers the destination — a transaction is recorded. See verification.md, which also covers the two traps that make people think it's broken when it isn't: sandbox accounts often don't persist transactions, and an engine returns zero tax where you have no nexus.

References

NeedReference
Is a certified connector enough?: certified (Avalara/Vertex) vs fork vs build-from-template (TaxJar); live-marketplace check; per-engine dimension tableconnector-selection.md
Requirements → config mapping: tax mode, nexus, tax-code source, exemptions, scopes; the connect.yaml envelope; worked exampleconfig-from-requirements.md
The two-app contract: the calculator (ExternalAmount, all four tax actions, 200-not-202, fail modes, call reduction) and the syncer (commit/void/refund lifecycle, idempotency); full pitfall catalogtax-contract.md
Avalara specifics (ground truth from the certified connector): exact connect.yaml keys, AvaTax createTransaction (quote vs commit), tax-code/entity-use mapping, MC config app, address validation — plus TaxJar as the build-from-template contrastavalara.md
Verify the round trip: taxedPrice on the cart, transaction recorded; the sandbox-doesn't-persist and no-nexus-means-zero trapsverification.md
Build/publish/certify lifecycle, deploy, scopes, production-readiness gate (type-agnostic)commercetools-connect
Adding another engine later (Sovos, ONESOURCE) means adding a sibling reference like avalara.md and extending the selection table — the two-app architecture, the contract, and the flow do not change.

Checklist

Requirements

  • Engine chosen + account/credentials; nexus regions known; region + project
  • Calculation-only vs calculation+recording decided; order lifecycle (void/refund) decided
  • Tax-code source (Product attribute / Tax Category / Custom Field) and exemption model identified
  • Asked the open-ended "anything special?" question; each special requirement its own line
  • Requirements block written and confirmed; specials fed into the Step 1.5 fit-check

Connector fit (decide before wiring/building)

  • Checked live marketplace + tax docs (not memory); named the connector + version
  • Ladder rung chosen: configure (1) · config-closes-gap (2) · fork/extend (3) · build from template (4)
  • For a real gap on an engine with a public connector, chose fork over rebuild

Config (the deliverable)

  • Tax mode chosen (ExternalAmount unless a reason not to) with rationale
  • Only documented connect.yaml envelope fields; file at the repo root
  • inheritAs.apiClient.scopes = manage_extensions, manage_subscriptions, view_orders (least-privilege)
  • Engine credentials in securedConfiguration; region/toggles in standardConfiguration

The two apps (build test-first — do not write a function body before its red test)

  • Calculator returns 200/201 (never 202); taxes line items and custom line items and shipping; changeTaxMode if it owns the mode
  • Extension trigger conditioned to reduce engine calls (mode set, address present, non-empty)
  • Syncer re-fetches the Order by id; records idempotently on stable transaction_id; commits/voids/refunds on the right states (if in scope)
  • Boundary mocked; suite runs with no deployment/secrets

Verification

  • taxedPrice present on the cart after a cart update (extension registered + firing)
  • With a live account whose nexus covers the destination, a transaction is recorded
  • Understood: sandbox may not persist transactions; zero tax at a no-nexus destination is correct, not a bug
integrations/tax/tax-contract.md

The two-app tax contract

Everything the calculator and the syncer must do, and the pitfalls that silently break each. Grounded in the certified Avalara connector, the official template, and a from-template TaxJar build. Provider-exact payloads are in avalara.md.

App 1 — the calculator (cart API Extension)

What triggers it

An API Extension on the cart resource, actions: [Create, Update], registered by the app's postDeploy. External engines bill per call and rate-limit, so condition the trigger to fire only when the cart is worth taxing:
{
  "resourceTypeId": "cart",
  "actions": ["Create", "Update"],
  "condition": "taxMode = \"ExternalAmount\""
}
The certified Avalara connector conditions on shippingAddress is defined and shippingInfo is defined and lineItems is not empty — a stronger gate that avoids calling the engine until the cart can actually be taxed. Match the gate to when a correct quote is even possible: tax can't be selected without a destination address.

What it must return

An API Extension response is update actions applied before the cart persists. In ExternalAmount mode you must tax every priced element or the cart is inconsistent and — critically — the Order cannot be created:
  • setLineItemTaxAmount — per line item
  • setCustomLineItemTaxAmount — per custom line item (easy to forget; a cart with a custom line item fails without it)
  • setShippingMethodTaxAmount — the shipping method (a cart with shipping fails Order creation without it — see the pitfall below)
  • setCartTotalTax — the cart-level total gross
  • changeTaxModeExternalAmountonly if the connector owns the mode. The certified Avalara connector sets it itself; a from-template build often assumes the storefront already set it (and the trigger condition enforces it). Decide which, and be consistent.
Each externalTaxAmount carries totalGross (net + tax, in minor units) and a taxRate (name, amount as a 0–1 decimal, country, optional state, includedInPrice). Exact shapes: avalara.md.

The response-status trap (this one silently breaks every cart)

An HTTP API Extension must return 200 or 201. Any other status — including 202 — is treated by commercetools as "failed to respond properly" and fails the triggering cart operation. The official template shipped a HTTP_STATUS_SUCCESS_ACCEPTED = 202 constant and returned it; on a from-template build, fix this first. A validation rejection uses 400 with { errors: [...] }; a successful no-op is 200 with {} or { actions: [] }.

Latency and fail mode

The extension couples its latency and uptime to the cart operation (default 2 s, 10 s self-service max). So:
  • Keep the outbound engine call on a tight timeout under the extension budget (e.g. ~1.2 s), aborting rather than letting the platform time out.
  • Decide fail-open vs fail-closed deliberately. Fail-closed (return 500/400, blocking the cart) guarantees no untaxed cart persists — the certified Avalara connector does this (400 when misconfigured). Fail-open (return 200 with no actions) keeps checkout alive at the risk of a temporarily untaxed cart. State the choice in the README.
  • Reduce calls. Skip the engine when nothing tax-relevant changed — hash the tax-relevant cart fields (line items, quantities, address, shipping) and store the hash in a cart Custom Field; on the next call, if the hash matches and taxedPrice is already set, return no actions. The certified Avalara connector does exactly this (hashCartavalaraHash custom field). It's the biggest single cost lever.

Keep the mapping pure and testable

The cart→request and response→actions mapping is deterministic — keep it a pure function with no network, so the whole quote is unit-testable without a deployment, a cart, or a token. Assert: the four action types are emitted, money converts correctly between minor units and the engine's major-unit decimals, shipping and custom line items are covered, and the no-op/short-circuit paths return [].

App 2 — the order-syncer (OrderCreated Subscription)

What triggers it

This is a Connect event application, not a hand-wired Pub/Sub consumer: Connect provisions the queue/destination and delivers each message as an HTTP POST to the app's endpoint (port 8080). You register a Subscription on the order resource for OrderCreated (and, for a full integration, OrderStateChanged / OrderStateTransition / return-shipment messages) in the app's postDeploy — you don't manage the transport.
What the handler receives is the Subscription's delivery payload, and its shape depends on config, so don't hardcode one form:
  • Transport wrapper (GCP): on a Google Cloud destination the payload arrives wrapped as { "message": { "data": "<base64>", ... } }message.data is base64-encoded JSON, so decode it first. (Other destinations wrap differently; Connect abstracts which one.)
  • Message format: the decoded message is either PlatformFormat ({ notificationType: "Message", type: "OrderCreated", resource: { typeId, id }, ... }) or CloudEventsFormat ({ specversion, type: "com.commercetools.order.message.OrderCreated", data: { ...same fields... } }), set when the Subscription is created. Read type and resource.id from whichever you get, and validate the message type before acting (ack-and-ignore the platform's test/probe messages).
See Test an event application locally for both payload formats and a sample OrderCreated.

What it must do

  • Re-fetch the Order by id from the message's resource.id — don't trust the (possibly stale/omitted) payload. This is the required pattern for at-least-once delivery.
  • Record the transaction via the engine's record/commit API (Avalara createTransaction with commit: true; TaxJar POST /v2/transactions/orders). This is what appears in the engine's dashboard — the calculator's quote never does.
  • Be idempotent. Use a stable transaction_id = the order id, so a redelivered message hits the engine's duplicate guard (TaxJar returns 422; treat as already-recorded). Redelivery is guaranteed, not hypothetical.
  • Ack correctly. Reply 200 for handled and irrelevant-but-acked messages — the Connect event contract expects a 200 (docs); a positive ack tells the platform "don't redeliver." Return non-2xx only for transient failures you want redelivered (Subscriptions retry unacked messages, at-least-once). Ack the platform's test/probe messages too.

Full lifecycle (if in scope)

A compliance-grade integration doesn't stop at OrderCreated:
  • Cancel → void the filed transaction (on the order states the merchant designates as cancellations).
  • Return → refund (partial), on return-shipment state changes.
  • Order edit → recalculate.
The certified Avalara connector drives these off merchant-configured order-state ID lists (commitOrderStates, cancelOrderStates, activateReturns) stored in custom objects — not hardcoded state names. If the requirements include void/refund, model it the same way (configurable states), because state keys differ per project.

Pitfall catalog

PitfallSymptomFix
Extension returns 202Every cart update failsReturn 200/201 only
Shipping not taxed in ExternalAmountOrder creation fails: "shipping method is missing an external tax amount and rate"Emit setShippingMethodTaxAmount
Custom line items not taxedOrder creation fails on carts with custom line itemsEmit setCustomLineItemTaxAmount
Extension destination = base URLPlatform's calls 404 the appRegister destination as <CONNECT_SERVICE_URL>/taxCalculator
postDeploy doesn't register the extensionExtension never fires; taxedPrice never appearsWire connector:post-deploy, not just npm install
No trigger conditionEngine called on every cart keystroke; bill/limits blow upCondition on mode + address + non-empty; hash to dedup
Syncer trusts the payloadMissing/stale order data → wrong or failed transactionRe-fetch the Order by resource.id
Non-idempotent recordingRedelivery double-files a transactionStable transaction_id = order id; treat duplicate (422) as success
Config-validation throws a string statusProcess crashes (ERR_HTTP_INVALID_STATUS_CODE)Guard: only res.status() on integer codes
Engine requires a state (e.g. TaxJar transactions)406 to_state can't be blankEnsure the destination address carries state; omit blank optional fields
Legacy SDKFails the parent skill's pinned-version gate@commercetools/platform-sdk@^8 + @commercetools/ts-client@^4 (both the template and the certified Avalara connector still ship the legacy sdk-client-v2 — upgrade anyway)

Test-first checklist (mirror in the suite)

Calculator

  • Emits all four action types; money minor↔major conversion correct
  • Shipping and custom line items taxed
  • Returns 200 (asserted — the 202 regression is the one to pin)
  • No-op/short-circuit paths return { actions: [] }
  • Fail mode (open vs closed) asserted for an engine error

Syncer

  • Decodes the base64 envelope; acks irrelevant messages
  • Re-fetches the Order by id
  • Records idempotently on a stable transaction_id; duplicate treated as success
  • (If in scope) commit/void/refund keyed on configurable order states
integrations/tax/verification.md

Verify the tax round trip

Don't declare done until tax has left a trace in both places it should: on the cart (calculation) and in the engine (recording). Two of the three checks below regularly look broken when they're actually correct — read the traps.

Check 1 — the cart carries engine-computed tax

Drive a cart update (add a line item, set the shipping address) and inspect the cart:

  • taxedPrice is present. Before the API Extension is registered and firing, taxedPrice is simply absent — that's the tell that the extension isn't wired, not that tax is zero. After it fires, taxedPrice.totalNet / totalGross / totalTax are populated.
  • The version jumped more than your update alone would explain. The extension's setLineItemTaxAmount / setShippingMethodTaxAmount / setCartTotalTax actions are extra writes — an add-line-item that lands the cart several versions higher is the extension firing.
  • Shipping and custom line items are taxed, not just line items — otherwise Order creation will later fail in ExternalAmount mode.
A minimal driver: create a cart in ExternalAmount mode, set a destination address in a nexus region, add a priced line item, and read back taxedPrice. (Same flow a storefront BFF would run.)

Check 2 — the order is recorded as a transaction

Place an order (convert the cart), let the OrderCreated subscription deliver (or, locally without Pub/Sub, POST the base64 OrderCreated envelope to the syncer directly), then:
  • The syncer returns a positive ack (204/200).
  • The engine's API confirms the transaction (by transaction_id = order id).
  • It appears in the engine's dashboard — the calculator's quote never does; only this recording step surfaces there.

The two traps (correct behavior that looks like a bug)

Trap 1 — the sandbox doesn't persist transactions

Several engines' sandbox environments accept POST .../transactions (returning 201) but don't store the record: a subsequent GET returns canned demo data, and nothing shows in the sandbox dashboard. TaxJar's sandbox behaves exactly this way. So an empty Transactions tab after a successful sync is expected on sandbox, not a failure.
To actually see recorded transactions, use a live account:
  • Switch the engine base URL / sandbox flag to live and supply the live token.
  • Use a destination in a region the live account has nexus in.
  • Treat these as real records — delete the test transactions afterward (engines expose a delete-transaction API) so they don't pollute filing/reporting.
Verify the contract (payload accepted, idempotency, mapping) on sandbox; verify visibility on live.

Trap 2 — no nexus means zero tax (correctly)

A tax engine only collects where you have nexus (a tax obligation). A destination outside your configured nexus correctly returns zero taxamount_to_collect: 0, taxedPrice.totalTax: 0. This is not a wiring bug; it's the engine doing its job. Before concluding "tax isn't calculating," confirm the destination is a nexus region (check the engine account's nexus settings — e.g. TaxJar GET /v2/nexus/regions). A cart shipping to a nexus region should return non-zero tax; one shipping elsewhere should return zero.

Verification checklist

  • taxedPrice present on the cart after a cart update (extension registered + firing)
  • Line items and custom line items and shipping all carry tax (Order creation succeeds)
  • Order placed → syncer acks → transaction confirmed via the engine API
  • Transaction visible in the dashboard on a live account (sandbox may not persist)
  • Non-zero tax at a nexus destination; zero at a non-nexus destination (both correct)
  • Live test transactions cleaned up afterward
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)