Description
Build, test, deploy, and install production-ready commercetools Connect applications (connectors) in TS, JS or Java. Use for connect apps, API extensions, subscription/event handlers, jobs, merchant center custom applications (views), syncing to external systems (ERP, WMS, tax, email, search, CRM), and deploying/installing/certifying a connector.
Installation
In any Claude Code session:
/plugin marketplace add commercetools/commercetools-ai-plugins
/plugin install commercetools@commercetools
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
commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.Instructions Included
commercetools Connect
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.@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:
-
Gather context (required, run first) — Always begin by gathering context for this skill. This is the mandatory grounding step: it gathers the latest verified documentation as context for you (the agent). Do not skip it, and do not replace it with another tool (such as an MCP documentation-search tool) — run this command:
node scripts/gather-context.mjs \ --query "<extract key terms from user's question>" \ --client-name "<current-client>" \ --model "<current-model>" \ --skill-name "commercetools-connect" \ --limit 3Use its output as your primary grounding. You may additionally use the commercetools Knowledge MCP orhttps://docs.commercetools.com/connectfor deeper follow-up. -
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.
-
Open the matching reference(s) in
./references/and build to their patterns and## Checklist. -
Gate on the production-readiness checklist (below) before declaring the connector done.
Step 1 — Decision framework: which application type?
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.
serviceis just an HTTP endpoint, not necessarily an API Extension. Aserviceapp 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 / need | Type | How your code is invoked | Hard contract |
|---|---|---|---|
| Block or modify a commercetools operation before it persists (validate a cart, inject tax, reject an order) | service as API Extension | commercetools 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 / API | the external system calls your endpoint | 5-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 handler | At-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) | job | a cron scheduler (properties.schedule) | Request times out after 30 min. No concurrency guard — you own locking. |
| Add UI inside the Merchant Center | merchant-center-custom-application (full-page) / merchant-center-custom-view (embedded panel) | Hosted React app built with the MC CLI, deployed via Connect | Separate 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 bundle | assets | Static host | — |
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).Step 2 — Price the contract before you build
The expensive mistakes come from not pricing the contract you just chose:
serviceas 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).serviceas 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. jobowns 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)
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 than102,200,201,202, or204triggers a retry. → event-applications.md - Re-fetch by ID, don't trust the payload. Handlers fetch the current resource by
resource.id; required whenpayloadNotIncludedis 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(orAzureFunctions) 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.scopeswith only the scopes the apps need (e.g.manage_orders,manage_subscriptions,manage_extensions) — not an admin/manage_projectclient. → security.md - Secrets in
securedConfiguration. API keys, client secrets, JWT secrets are neverstandardConfigurationand 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.datais 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.yamlendpoint. The Express router is mounted at the same base path as the app'sendpoint(e.g.endpoint: /service↔app.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-parent3.5.15+ and commercetools Java SDK 19+. Typed end to end, noanyescapes, 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-IDfor extensions,resource.id+sequenceNumberfor 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.
postDeploycreates resources get-then-update (create only if absent), never blind delete-then-recreate.preUndeploycleans them up. → lifecycle-scripts.md - Deploy-time dependency validation.
postDeploytest-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 idempotentpostDeployregistration. A couple of happy-path tests is not enough. → testing.md - No dead code, no
anyescapes. 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 validatepasses. → 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.yamlkey), and the poison-message/replay runbook. → deployment-installation.md
Reference index
| Concern | Reference |
|---|---|
Connect CLI mechanics: install/auth, connect init templates, pinned versions, build/test/validate, stage/preview/publish/deploy commands | connect-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 lifecycles | monorepo-with-storefront.md |
| event vs service vs job; sync vs async contract cost | architecture-decisions.md |
| CLI scaffold + local dev, monorepo layout, client setup (ts-client), connect.yaml anatomy, route↔endpoint matching, fail-fast env validation | project-structure.md |
| subscriptions: envelope, ack semantics, idempotency, redelivery, re-fetch, Pub/Sub destination | event-applications.md |
| API extensions: authenticated registration, triggers, timeout budget, fail-open/closed, hot-path | service-applications.md |
| scheduled/on-demand jobs: schedule, timeout, concurrency, checkpointing | job-applications.md |
| post-deploy/pre-undeploy: idempotent registration, schema-as-code, deploy-time validation | lifecycle-scripts.md |
| endpoint auth, least-privilege scopes, securedConfiguration, error hygiene | security.md |
| structured logs + correlation IDs, health, feature flags, runbook, DLQ | observability-operations.md |
| auth/envelope test matrices, supertest + msw patterns, what to mock | testing.md |
| connect.yaml config, sandbox→preview→publish, install, redeploy, certification, regions, CLI | deployment-installation.md |
References
Architecture Decisions
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
- Pattern 2: Price the synchronous contract (service as API Extension)
- Pattern 3: Price the asynchronous contract (event)
- Pattern 4: Combine application types in one connector
- Pattern 5: Is Connect the right fit? (best practices)
- Checklist
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).| Question | Answer → 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 |
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.Aserviceapp is just an HTTP endpoint; API Extension is one mode, inbound webhook is another — see service-applications.md. Note thateventapps consume commercetools' own Subscription messages only; an external system's changes never arrive aseventmessages, so "external → commercetools" is alwaysservice(reactive) orjob(scheduled).
Pattern 2: Price the synchronous contract (service as API Extension)
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.- 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.
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)
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.
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/ 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)
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
serviceAPI Extension: latency budget and fail-open/closed stance written down → service-applications.md - For every
serviceinbound webhook: caller auth and idempotent-upsert strategy written down → service-applications.md - "External system → commercetools" routed to
service(reactive) orjob(scheduled), neverevent - For every
eventapp: 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 aservice - Shared code factored into a
shared/workspace, not duplicated per app
Connect CLI
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
service/event/job and adapt.commercetools connect init my-connector # add: --template <name> to start from a template
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
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 theendpointinconnect.yaml(e.g.endpoint: /service↔app.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.
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.
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
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>
postDeploy re-runs, so registration must be idempotent.Flag names and exact options can evolve — confirm withcommercetools connect <command> --helpand the Connect CLI docs. Source of truth for platform behavior: docs.commercetools.com/connect.
Deployment & Installation
Table of Contents
- Pattern 1: The connect.yaml configuration contract
- Pattern 2: Deployment types and lifecycle
- Pattern 3: Regions
- Pattern 4: Install and redeploy
- Pattern 5: Certification for public connectors
- Pattern 6: The required connector README
- Pattern 7: Troubleshooting
- Checklist
Pattern 1: The connect.yaml configuration contract
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 forstandardConfigurationwhere possible to reduce install friction. - Put secrets in
securedConfiguration(security.md). - Prefer
inheritAs.apiClient.scopesso the platform auto-generates the API client at install — the installer doesn't have to create one.
Pattern 2: Deployment types and lifecycle
| Deployment type | Purpose | Notes |
|---|---|---|
sandbox | Default; dev/QA | Scales to zero when idle → ~15 s cold-start after inactivity. Cannot deploy a ConnectorStaged here. |
preview | Test a ConnectorStaged during development | Requires isPreviewable: true. Delete when done; scales to zero. |
production | Live | Only published connectors; project must not be a trial; warmed instances. |
auth login → connect validate → connectorstaged 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.postDeploy (lifecycle-scripts.md); deployment can take up to ~15 minutes. For a public marketplace listing, add connectorstaged certify (Pattern 5).Pattern 3: Regions
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
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.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
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.yamlkey (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. Checkdeployment logs; common causes: missing required config, invalid external credentials (validate them inpostDeployso 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
productionfor 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 validatepasses before staging; staged/previewed/published/deployed via the CLI - Every
configurationkey has a cleardescription; sensible defaults onstandardConfiguration; secrets insecuredConfiguration - 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;
postDeployis 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)
Event Applications (Subscription Handlers)
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)
- Pattern 1: Validate the envelope before processing
- Pattern 2: Acknowledge correctly — redelivery is driven by your status code
- Pattern 3: Filter message types and ignore the rest
- Pattern 4: Idempotency under at-least-once delivery
- Pattern 5: Re-fetch by ID, never trust the payload
- Pattern 6: Self-change filtering
- Pattern 7: Register the Pub/Sub subscription destination
- Checklist
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.datais base64-encoded. All Google Cloud Platform event payloadmessage.datais 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, or204. 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"theresource.id+sequenceNumber; for Change payloads (ResourceCreated/Updated/Deleted) theresource.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
{ "message": { "data": "<base64>" } }. All GCP event payload message.data is base64-encoded — decode and structurally validate it before touching business logic.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
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.
| Situation | Return | Why |
|---|---|---|
| Processed successfully | 200/201/204 | Ack — don't redeliver |
| Irrelevant message (wrong type, feature off, not applicable) | 200 | Ack — there is nothing to retry |
| Platform test/subscription message | 200 | Ack |
| 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 |
if (!isSupported(message)) {
throw new CustomError(400, `Resource type ${message.resource.typeId} not supported`);
}
try { await handle(message); } catch (e) { logger.error(e); } // always falls through to 200
res.status(200).send();
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();
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
const seen = new Set<string>(); // lost on restart; not shared across instances
if (seen.has(message.id)) return;
seen.add(message.id);
// 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
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
payloadNotIncluded). Fetch current state.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.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.
Pattern 7: Register the Pub/Sub subscription destination
postDeploy. Build the destination from the injected vars (verified: automation scripts):| Injected vars | Destination 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,
};
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 injectedCONNECT_GCP_*vars.
Checklist
- Pub/Sub envelope decoded (base64
message.data) and structurally validated (→ JSON → resource ref → notificationType) before processing - Status codes follow the ack table: 2xx for handled/irrelevant, non-2xx only for retryable failures
- No 4xx/5xx on unsupported-but-subscribed types (no redelivery loop); no blanket error-swallowing (no silent loss)
- Reprocessing is a no-op via stateless means (target's own idempotency, re-fetch-and-re-check, or upsert by stable key) — no local dedup store
- Handler re-fetches the resource by ID; handles
payloadNotIncluded - Self-change filtering prevents write-back loops
- Subscription registers only the needed message types; destination built from the injected
CONNECT_GCP_*vars - Processing stays within the 10 s ack timeout (offload long work; ack fast)
Job Applications (Scheduled / On-Demand Batch)
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.Table of Contents
- Contract facts (verified)
- Pattern 1: Schedule
- Pattern 2: Self-managed concurrency
- Pattern 3: Restart-safe checkpointing within the timeout
- Pattern 4: Stateless idempotency per unit of work
- Checklist
Contract facts (verified)
- Cron-scheduled.
properties.scheduleinconnect.yamlsets the default cron expression; it can be overridden per deployment via theschedulefield 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
Pattern 2: Self-managed concurrency
Because Connect won't stop overlapping runs, a long run colliding with the next tick can double-process.
// 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
Checklist
-
properties.scheduleset 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 (postDeploy / preUndeploy)
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)
- Pattern 2: Schema-as-code for custom types
- Pattern 3: Deploy-time external dependency validation
- Pattern 4: Clean teardown in preUndeploy
- Pattern 5: Exit codes and platform-injected variables
- Checklist
Pattern 1: Idempotent registration (get-then-update, not delete-then-recreate)
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.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
Subscriptions are different — and the public docs example uses delete-then-recreate for them. The event-applicationpostDeployexample 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.
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
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/preUndeployrolls back the deployment. Wraprun()and setprocess.exitCode = 1on 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_NAMEandCONNECT_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 -
preUndeploydeletes every resourcepostDeploycreated (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
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
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).| Command | What it does |
|---|---|
mc-scripts start | Dev server with hot reload at http://localhost:3001 |
mc-scripts build | Production bundle into public/ (--build-only skips HTML compilation) |
mc-scripts compile-html | Compiles index.html.template → index.html per the config file (--transformer <path> to customize) |
mc-scripts serve | Serves the already-built public/ locally — production-mode smoke test |
mc-scripts login | Authenticates the CLI against your project (--headless for CI) |
mc-scripts config:sync | Creates/updates the customization's config in the Merchant Center |
mc-scripts config:sync:ci | Non-interactive config:sync for pipelines (--dry-run to preview) |
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
@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 withnpx @commercetools-frontend/mc-scripts --helpand the Merchant Center CLI docs. Source of truth for platform behavior: docs.commercetools.com/merchant-center-customizations.
Merchant Center Custom Applications & Views
oAuthScopes, or a botched register→deploy→URL handshake either blocks the UI from loading or exposes data the operator shouldn't see.Table of Contents
- Contract facts (verified)
- Pattern 1: Custom application vs custom view
- Pattern 2: The config-file contract
- Pattern 3: Permissions
- Pattern 4: Develop and test locally
- Pattern 5: Deploy via Connect (the vessel)
- Checklist
Contract facts
- 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
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
CustomPanelrendered 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 declareslocators(which MC locations it may render in) andtypeSettings.size(SMALL/LARGE) instead of menu links.
Pattern 2: The config-file contract
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 & routing —
entryPointUriPath(apps, unique per cloud Region environment) ortype: CustomPanel+locators(views). - Region —
cloudIdentifier(e.g.gcp-eu); must match the project's region. env.development—initialProjectKey,teamId: which project/team and permission set you run against locally.env.production—applicationId(apps) /customViewId(views) andurl: the registered ID and the hosting URL.- Permissions —
oAuthScopes(the defaultview/managepair) and optionaladditionalOAuthScopes(Pattern 3). - Navigation (apps) —
mainMenuLink/submenuLinks, each with their own requiredpermissions.
${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
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
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.- Jest with the
@commercetools-frontend/jest-preset-mc-apppreset. - The application-shell test-utils:
renderAppWithRedux(applications) andrenderCustomView(views), so components mount with a realistic shell. - Drive permission paths explicitly (a
view-only user must not seemanagecontrols). - Cypress for end-to-end flows.
Pattern 5: Deploy via Connect (the vessel)
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'
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.- Register the custom app/view in the Merchant Center with a placeholder URL → obtain its ID (
CUSTOM_APPLICATION_ID/CUSTOM_VIEW_ID). - Scaffold a Connect-shaped project containing the MC app/view (→ merchant-center-cli.md).
- Wire the config file's
${env:...}placeholders and add theconnect.yamlblock above. - Push to git and cut a release tag.
- Stage → publish → deploy with the Connect CLI:
connectorstaged create→publish→deployment create, supplying the ID, entry-point path, and region — exact commands and flags in connect-cli.md Step 5. - Retrieve the deployed URL from the deployment.
- Update the Merchant Center registration, replacing the placeholder URL with the deployed one.
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 forapplicationId/customViewId,url, andentryPointUriPath— no hardcoded per-project values -
oAuthScopesrequests only what the UI uses; UI gated withuseIsAuthorizedand menu-linkpermissions - Run and tested locally via the application-shell (
mc-scripts start, jest-preset +renderAppWithRedux/renderCustomView), including a permission-denied path -
connect.yamluses the correctmerchant-center-*applicationTypewith no strayendpoint,securedConfiguration, orAPPLICATION_URL - Register-first / update-URL-last sequence followed; deployed in the project's region with a matching
cloudIdentifier
Monorepo: Connector + Storefront
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.- Connector internals (scaffold,
shared/folder, route↔endpoint,connect.yamlfields) → project-structure.md. connect.yamlconfiguration contract and deploy lifecycle → deployment-installation.md, CLI commands → connect-cli.md.- Merchant Center custom app/view (scaffold, register, deploy via Connect) → merchant-center-cli.md, merchant-center-customizations.md.
- Storefront layout, framework wiring, and its deploy → the commercetools-storefront skill and its stack adapter.
Table of Contents
- Pattern 1: The layout
- Pattern 2: Why this shape (the constraints)
- Pattern 3: Two independent deploy lifecycles
- Pattern 4: One repo or two?
- Checklist
Pattern 1: The layout
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
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.yamllives at the repo root, anddeployAs[].namemaps to a sibling folder. Each app'snameallows only[A-Za-z0-9_-]— no slashes — so aconnectors/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 installand the build script from inside each app folder — never once from a workspace root. A rootpackage.jsonwith 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 owndependencies), keep the rootpackage.jsona tooling hub only (dev scripts), and share code through a plainshared/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.yamland deploys on its own (Pattern 3). Connect ignores it; it must ignore Connect.
Pattern 3: Two independent deploy lifecycles
| Half | Deploys via | Follow |
|---|---|---|
| Connector (service/event/job apps) | commercetools Connect | connect-cli.md Step 5, deployment-installation.md |
| Merchant Center custom app/view | commercetools Connect (a merchant-center-* app in the same connect.yaml) | merchant-center-customizations.md |
Storefront (<root-dir>/) | Vercel or Netlify | the commercetools-storefront skill's stack adapter + its /nextjs/nuxtjs-deploy-* commands |
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 VercelignoreCommand) — a storefront-deploy detail; configure it per the storefront skill, not here.
Pattern 4: One repo or two?
Checklist
-
connect.yamlat the repo root; every backend app is a root-sibling folder whose name matches itsdeployAs[].name - Root
package.jsonis 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
Observability & Operations
Table of Contents
- Pattern 1: Structured logs with correlation IDs
- Pattern 2: Health endpoint
- Pattern 3: Runtime feature flags
- Pattern 4: Accessing deployment logs
- Pattern 5: Poison-message / replay runbook
- Checklist
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-IDrequest 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) orresource.id+version(Change) — the same fields used for idempotency, so a duplicate is recognizable in logs.
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.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' }));
/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
}
Pattern 4: Accessing deployment logs
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
- 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
TemporaryErrorfor 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
jobor admin route that re-runs processing for a givenresource.idfrom 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-IDfor service;resource.id+sequenceNumber/versionfor event) - Request bodies/PII are not logged — identifiers only
- A fast, unauthenticated
/statusliveness 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
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
- Pattern 2: Match the route path to the connect.yaml endpoint
- Pattern 3: Multi-application layout and the shared workspace
- Pattern 4: commercetools client setup
- Pattern 5: connect.yaml anatomy
- Pattern 6: Fail-fast environment validation
- Pattern 7: Typed SDK usage at the boundary
- Local development with the CLI
- Checklist
Pattern 1: Scaffold with 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.auth login → connect init (template) → version-pin → local-dev → ship sequence.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
{connect-provided-url}/{endpoint} (verified: connect.yaml reference). Your Express app must serve that exact path, or every request 404s./ while connect.yaml says /service:// connect.yaml → endpoint: /service
app.use('/', serviceRouter); // app serves POST / , platform calls POST /service → 404
…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.)// 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);
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
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
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
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
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
const key = process.env.EXTERNAL_API_KEY!; deep in a handler — undefined → cryptic 500 in production, and ! hides it.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);
}
app.ts/index.ts before the server starts.Pattern 7: Typed SDK usage at the boundary
@commercetools/platform-sdk types and map to your own domain types at the edge; no any escapes, no dead code.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
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
deployAsentry; folder name matches applicationname - Express router mounted at the same base path as
connect.yamlendpoint;/statusreachable - Pinned versions:
@commercetools/ts-client@^4+@commercetools/platform-sdk@^8(notsdk-client-v2); Javaspring-boot-starter-parent3.x+ & commercetools Java SDK 19+;apiRootbuilt once and reused - Shared code in a single
shared/workspace (multi-app connectors); imported, not duplicated - Secrets only in
securedConfiguration; least-privilegeinheritAs.apiClient.scopes -
readConfiguration()validates all env vars once at startup and throws on invalid; app is stateless - SDK types end to end; no
anyescapes; no dead code -
commercetools connect validatepasses;commercetools connect application testruns the suite
Security
Table of Contents
- Pattern 1: Authenticate every inbound endpoint
- Pattern 2: Validate JWTs fully
- Pattern 3: Least-privilege commercetools scopes
- Pattern 4: Secrets in securedConfiguration
- Pattern 5: Error hygiene
- Checklist
Pattern 1: Authenticate every inbound endpoint
Two kinds of inbound endpoint, both must be authenticated:
- API extension endpoint — called by commercetools. Register destination auth and verify it in-app (see service-applications.md, Pattern 1). commercetools sends the
Authorizationheader (orx-functions-key) you configured. - 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.
serviceRouter.post('/', handleExtension); // no auth middleware
serviceRouter.use(['/admin'], verifyJWT); // auth only on a different route
/status open:router.get('/status', statusHandler); // liveness only, no secrets
router.post('/', verifyInbound, handler); // every processing route authenticated
Pattern 2: Validate JWTs fully
const { payload } = jwt.decode(token, { complete: true }); // decode ≠ verify; signature unchecked
if (payload.iss === expectedIssuer) next(); // trivially forged
decode does not check the signature; an attacker forges any payload. Accepting alg: none or an unverified signature is a full auth bypass.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
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
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.manage_project API client.
Why this fails: a leaked or misused connector credential then has full project access. Scope to the specific resources.manage_orders view_products), never "admin".Pattern 4: Secrets in securedConfiguration
securedConfiguration (write-only, not echoed back), never standardConfiguration, never hardcoded.| Value | Where |
|---|---|
| External API keys, passwords, connection strings | securedConfiguration |
| JWT shared secret | securedConfiguration |
Pre-created CTP_CLIENT_ID/CTP_CLIENT_SECRET/CTP_SCOPE (if not auto-generated) | securedConfiguration |
| Region, project key, feature flags, non-secret defaults | standardConfiguration |
Pattern 5: Error hygiene
Error responses and logs must not leak stack traces, secrets, or internals to callers.
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
/statusis 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 instandardConfiguration; secrets never logged - Error responses hide stack traces and internals in production
- Request bodies/PII not logged; only identifiers and correlation keys
Service Applications (HTTP Endpoints)
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.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)
- Pattern 1: Authenticate the extension destination
- Pattern 2: Trigger conditions — don't fire when you can't act
- Pattern 3: Timeout budget
- Pattern 4: Fail-open vs fail-closed
- Pattern 5: Minimize work on the hot path
- Pattern 6: Response format
- Pattern 7: Inbound webhook mode (external system → commercetools)
- Checklist
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
timeoutInMsup 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/201for success (empty body or update actions),400with anerrorsarray for validation failure. Any other status = failure to respond. - Headers in:
X-Correlation-IDis provided and echoed to the original API caller — log it.Authorization/x-functions-keyset if you configured destination auth. additionalContext.includeOldResource: trueaddsoldResourceto 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
await apiRoot.extensions().post({ body: {
key, destination: { type: 'HTTP', url: serviceUrl }, // no authentication block
triggers: [...],
}}).execute();
// 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();
}
}
{ 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
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.
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); }
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
- 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
400so the operation is rejected. Right only when proceeding would be incorrect or unsafe (e.g. compliance validation that must hold).
catch (error) { return { statusCode: 400, error: error.message }; } // any outage blocks ALL carts
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' }] });
}
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 emptyactions). - Updates:
200/201with{ "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:changeTaxMode→ExternalAmount, thensetLineItemTaxAmount/setCartTotalTax. - Validation failure:
400with{ "errors": [{ "code": "InvalidInput", "message": "..." }] }—codemust be a known error code; optionallocalizedMessage,extensionExtraInfo.
Pattern 7: Inbound webhook mode (external system → commercetools)
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:
- 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.
- Validate the payload before trusting it; reject malformed input with a 4xx.
- Write idempotently. The same update may be delivered twice (most senders retry). Upsert by a stable key, don't blind-create.
- Return a status the caller can act on — 2xx on success, 4xx on bad input, 5xx on a transient failure so the sender retries.
router.post('/products', async (req, res) => {
await apiRoot.products().post({ body: toProductDraft(req.body) }).execute(); // duplicates on retry
res.status(201).end();
});
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
}
}
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
- Destination registered with
AuthorizationHeader(orAzureFunctions) auth, and the secret validated in-app - Trigger
conditionset so the extension only fires when it can actually act - Outbound calls have an explicit timeout under the extension response limit;
timeoutInMsset 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)
- 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
-
X-Correlation-ID(or your own correlation key) logged on every line → observability-operations.md
Testing
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.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)