commercetools Connect

Description

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

Installation

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

In any Claude Code session:

/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
Start at that overview.md for any payment-connector task — integrating a deployed one or building/forking one. Its decision ladder routes you: rung 1 configure, rung 2 config-closes-the-gap, rung 3 fork, rung 4 build-from-template (payment-specific gotchas live in integrations/payment/stripe.md). 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.
Each sub-area lives under references/integrations/<type>/ with its own overview.md. Adding another connector type later (e.g. shipping, tax) means adding a sibling references/integrations/<type>/ tree and one row here — the build-side guidance does not change.

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
Related skills: SDK client setup, scopes, query predicates, and core data model live in commercetools-platform — link to it rather than restating client/auth basics here.

References

architecture-decisions.md

Architecture Decisions

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

Table of Contents


Pattern 1: Pick the application type

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

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

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

Pattern 3: Price the asynchronous contract (event)

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

Pattern 4: Combine application types in one connector

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

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

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

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


Checklist

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

Connect CLI

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

Step 1. Install the CLI and authenticate

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

Step 2. Scaffold the connector

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

Add another application to an existing connector later:

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

Step 3. Pin dependency versions

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

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

Step 4. Develop and test locally

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

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

Step 5. Stage, preview, publish, and deploy

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

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

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

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

Deployment & Installation

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

Table of Contents


Pattern 1: The connect.yaml configuration contract

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

Pattern 2: Deployment types and lifecycle

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

Pattern 3: Regions

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

Pattern 4: Install and redeploy

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

Pattern 5: Certification for public connectors

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

Pattern 6: The required connector README

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

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

Pattern 7: Troubleshooting

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

Checklist

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

Event Applications (Subscription Handlers)

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

Table of Contents


Contract facts (verified)

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

Pattern 1: Validate the envelope before processing

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

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

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

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

Pattern 3: Filter message types and ignore the rest

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

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

Pattern 4: Idempotency under at-least-once delivery

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

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

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

Pattern 6: Self-change filtering

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

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

Pattern 7: Register the Pub/Sub subscription destination

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

Checklist

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

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
  • 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)
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)