Integrate external search

Ask about this Page
Copy for LLM
View as Markdown

Index your published catalog into an external search engine and keep that index correct as the catalog changes.

This guide covers the whole job. It starts with deciding whether you need an external search engine at all. It then moves through mapping Products into search documents, and ends with the two applications that keep the index in step with your catalog. If a PIM or ERP supplies the catalog, settle its source-of-truth and synchronization rules in Integrate product data first.

Data flows in one direction. commercetools is the source of truth for the catalog, and the search engine holds a denormalized copy optimized for querying. commercetools never reads back from the engine.

A search integration is backend data movement. It sits outside the Cart and Order path, it uses no API Extension, and it adds no synchronous call at checkout. If the pipeline stops, shoppers see a stale index rather than a broken checkout.

Before you start, you need the following:

  • a commercetools Project with published Products, and an API Client that can read them
  • an account and API credentials for the search engine you intend to use
  • somewhere to run the integration, such as Connect, a cloud function, or your own infrastructure

The following topics are out of scope: relevance tuning inside the engine, storefront rendering, and inventory as a live stock ledger. Each is covered as a boundary rather than as an implementation step.

For the wider context of building integrations on commercetools, see Integration essentials.

Decide what you need

When an external search engine is justified

commercetools ships its own search. Rule it out before you commit to an indexing pipeline, because an external engine is a standing system to own, secure, monitor, and pay for.

Built-in capabilityCovers
Product SearchFull-text, fuzzy, prefix, and wildcard matching, faceting, sorting, and scoping by price, Store, and Product Selection. The API reached general availability in June 2024, and Facets followed in October 2025.
ScopingPrice selection resolves currency, country, Customer Group, and Channel, so a buyer's prices and catalog resolve in the same query.
For the full capability list, see the Product Search reference.
Stop here when the requirement is product discovery. That means full-text search, typo tolerance, type-ahead, facets, sorting, and price or Store scoping for a product listing or search results page. The built-in search already does all of it. Adding an engine adds an indexing lag and an extra dependency in front of the catalog.
Continue when the requirement genuinely needs a discovery platform:
  • visual or AI-driven merchandising and manual curation
  • synonym, redirect, or query-rule sets that merchandisers maintain themselves
  • recommendations, such as "customers also bought"
  • search analytics dashboards
  • A/B testing of ranking
  • learned or personalized ranking
  • a discovery engine your storefront already uses

The reverse case matters too. Suppose your organization already licenses an engine and the merchandising team works in it daily. Staying with the built-in search is then rarely a real option, even when the query requirements look simple. The requirement is merchandising in the engine, which puts you past this gate.

Some requirements sit between the two. The built-in search combined with an API Extension or a thin storefront layer covers more ground than people expect. Test the specific gap before you conclude that you need an engine.

Requirements that shape the design

The architecture follows from a handful of answers. Settle them before you write code or configure a Connector, because each one changes the shape of the search document or the applications around it.

QuestionDesign impact
Which engine, and what can built-in search not do?Names the destination and justifies the pipeline. Record the specific capability gap rather than a general need for search.
Whole-catalog or Store-specific?Decides whether you read /product-projections or the in-store projections, and whether you run one index or one per Store.
Which locales?Decides index-per-locale against per-locale fields, and limits what localeProjection returns.
Which price contexts?A flat record cannot hold every currency, country, Customer Group, and Channel combination. You must choose.
Product-level or variant-level records?Decides whether a search hit is a Product or a single Variant, which shapes the whole document.
Does availability belong in the index?Usually a deliberate no, or a coarse flag. Never a live stock ledger.
What is the catalog volume and change cadence?Volume drives batching and pagination. Cadence decides event-driven against scheduled updates.
Do you use Product Tailoring?Store-specific names, descriptions, images, and assets must reach the index that serves that Store.
Anything else?B2B assortments, multi-currency budgets, deep category trees, prices that are tiered or discounted, aggregated reviews, and personal data in Product Attributes each become their own requirement.

Write the answers down and confirm them with whoever owns the storefront. Most later rework traces back to a price context or a record granularity that was assumed rather than decided.

Choose how to build it

Use, configure, fork, or build

Stop at the first of the following options that fits:

  1. The built-in search is enough. Build nothing. See When an external search engine is justified.
  2. A public Connector for your engine covers everything. Install and configure it. For installation, see the Connect getting started guide.
  3. The right engine, but a gap that looks like a missing capability. Prove it isn't configuration first. Index name, indexed fields, locale, price context, and Store selection are usually settings rather than code. If configuration closes the gap, you are back at the previous option.
  4. A genuine gap that configuration cannot close, and source is available. Fork the existing Connector, add only the difference, and deploy it as an Organization Connector.
  5. No usable Connector for your engine. Build one, scaffolding from the Product export template.

The last two options are materially more work than the others. Decide explicitly rather than defaulting to a build.

A marketplace listing is not always a deployable Connector. Several engines ship their own commercetools integration that is configured entirely in the engine's own dashboard. These are vendor-hosted integrations with no connect.yaml and nothing for Connect to deploy. They can be a good functional fit, but you cannot build, fork, or operate them through Connect, and you do not own the mapping. Confirm which kind a listing is before you plan around it.

Available Connectors and templates

ArtifactWhat it isWhere it fits
Product export templateThe official Connect scaffold for outbound catalog export, positioned for external services such as search. Provides a full-export service application and an incremental-updater event application.The starting point for a build
Launchpad Algolia syncAn open-source worked Algolia example built on the same two-application shape. Designed for use with the B2C sample data.A reference to read, or a fork candidate for Algolia
Engine dashboard integrationsVendor-hosted and configured in the engine.Outside Connect
Both repositories are starting points rather than finished Connectors. See What the templates leave you to build before you plan the work.

The method in this guide is the same for any engine. Algolia, Constructor, Bloomreach, Coveo, Elasticsearch, Typesense, Meilisearch, and an in-house engine all share the same requirements and the same gate. They also share the same mapping method and the same two-application architecture. Only the client library and the index vocabulary change.

What the engine owns

Searchable-field weighting, ranking, tie-breaking, synonyms, redirects, query rules, merchandising, and A/B tests live in the search engine and are configured by merchandisers. They belong neither in commercetools nor in the mapping.

The integration has one job: keep each record equal to the current published Product. The engine decides relevance.

Design the search document

This is where a search integration succeeds or degrades over time. Moving the data is mechanical. The mapping decides whether the index stays correct, queryable, and affordable.

The tension is structural. The commercetools model is normalized and reference-based, so Products reference Categories, Prices, and Channels by identifier. A search engine queries most efficiently against a flat, self-contained record built for one query. The mapping is a transform and a filter, not a copy. Index only what the storefront searches, filters, sorts, or displays.

Read the published projection

Read from a Product Projection with staged=false, which returns the current projection. Do not read the Product resource itself.
Only published Products have a current projection, and a storefront index must never contain staged edits or unpublished Products. For the difference between the two representations, see Current and staged.

This single choice prevents the most common leak in search integrations: unreleased names, prices, and draft Products appearing in customer-facing results.

Key every record for idempotent writes

Give each record a deterministic identifier derived from a stable commercetools identifier:

  • the Product id for product-level records
  • the Product id combined with the Variant id, or the SKU, for variant-level records
This identifier underpins the whole integration. It makes every write an upsert and every removal targetable. Re-indexing the same Product, redelivering a message, and re-running a full load then all converge instead of duplicating. Algolia calls this the objectID, and other engines call it the primary key or the document identifier.

If you cannot reconstruct a record's key from a later message, you cannot update or delete that record. Settle the key before you write any synchronization code.

Choose product-level or variant-level records

This is a storefront design decision, and it shapes the entire document. Choose one of two granularities:

  • Product-level, one record per Product: variant-specific values such as size and color become sets aggregated across Variants. A hit links to the product detail page. Fewer records, and the simpler default for most catalogs.
  • Variant-level, one record per Variant: each color or size is its own hit with its own image and price. Needed when the results grid shows "red shirt" and "blue shirt" as separate tiles. Many more records, so check the engine's record-count pricing.

Match the choice to how the storefront displays results, and keep it consistent. Do not mix granularities in one index.

Resolve the price context

A commercetools price is contextual. It varies by currency, country, Customer Group, and Channel, whether it comes from an Embedded Price or a Standalone Price. Each of those contexts is resolved through price selection. A flat record cannot hold every combination, so choose one strategy:
  • Index one context, for example euros in Germany. The simplest option, and correct only for a single-market storefront. Select the context with the price-selection query parameters at map time.
  • Index one field per context, for example price_EUR_DE and price_USD_US. One record with several price fields, and the storefront reads the field matching the shopper. This scales to a handful of contexts.
  • Emit one record per context, with a context attribute you filter on. Use this when contexts are numerous, or when B2B Customer Group pricing must be searchable. It multiplies the record count.

State the choice explicitly. A mismatch here produces the classic wrong-price-in-search defect. The record exists and looks healthy, but it shows a price no shopper is entitled to.

If Customer Group pricing must be queryable, re-check whether you need an external engine. Product Search resolves the buyer's price context inside the query, which an external index can only approximate.

Handle locales

commercetools models translatable text as a LocalizedString, such as a name carrying an en-US and a de-DE value. A search engine applies stemming and synonyms per language, so each searchable field must hold one language. Two shapes work:
  • One index per locale, for example products_en and products_de. The cleanest option for language-specific relevance configuration, and the usual choice for multi-market and Store-specific setups.
  • One index with per-locale fields, for example name_en and name_de. Fewer indices, and the storefront queries the fields for the shopper's language. Fine for a few locales.
Use the localeProjection query parameter to reduce translations at the source, so you carry only the locales in scope. Map locale codes explicitly, because commercetools uses en-US rather than en_US.

Denormalize categories

A Product references Categories by identifier, and search wants the category names and breadcrumb path inside the record for facets and category listing pages. Denormalize them at map time.
Understand the consequence: renaming or moving a Category fans out to a reindex of every Product in it. That is why Category Messages matter on the incremental path, and why a periodic full rebuild is the backstop.
Key facets on the stable Category id or key, and carry the localized name as a display field. A rename then changes what shoppers read without invalidating the facet values the storefront filters on.

Scope by Store, Product Selection, and Product Tailoring

If different Stores expose different assortments through Product Selections or Product Tailoring, decide how the index reflects it. Choose one of two shapes:
  • One index with a Store filter field. Each record carries the Stores or Product Selections it belongs to, and the storefront filters by the current Store. Simple, and a good fit when tailored content per Store is minimal.
  • One index per Store. Driven by each Store's Product Selection, reading /in-store/key={storeKey}/product-projections. The in-store projection also resolves the Store's languages and distribution channels, so tailored names, descriptions, images, and assets arrive already applied. This is the shape the Product export template implements, with one Deployment per Store.

One Deployment per Store does not scale to a large Store fleet. For many Stores, build one application that resolves the Store from the incoming message and writes to the matching index. Deploying a copy of the Connector for each Store does not hold up at that size.

Retrieve Product Tailoring efficiently

If you need tailored data separately from the in-store projection, query the tailorings rather than probing for them.

Querying /product-tailoring with a list of Product IDs looks natural, but it only performs well when most of the Products in that list are actually tailored. In a large catalog where a small share of Products carry tailoring, most of those lookups return nothing.

Instead, paginate through the published tailorings themselves. Every request then returns data, regardless of catalog size:

Paginate published Product Tailorings for a Storehttp
GET /{projectKey}/product-tailoring?where=store(key="your-store-key") and published = true&sort=id asc&limit=30

Use the identifier of the last item from the previous response to fetch the next page:

Fetch the next page of Product Tailoringshttp
GET /{projectKey}/product-tailoring?where=store(key="your-store-key") and published = true and id > "{lastId}"&sort=id asc&limit=30
Some processing logic benefits from receiving all tailorings for a single Product together. ProductTailoring references its Product through the product field rather than a flat identifier, so filter a single Product with product(id = "{productId}"). To process a Product's tailorings as a group, page the full set with the identifier cursor above and group them in your own code.
The store filter is optional. Omit it to page through every published tailoring in the Project.

Decide where availability belongs

Inventory changes constantly and lags real time, which makes a search index a poor stock ledger. The availability field on a ProductVariant is eventually consistent and can lag real stock levels by a few seconds. For the guarantees, see Inventory checks and consistency.
The usual answer is a coarse in-stock boolean, or a bucketed level, refreshed on a cadence. Use it only to filter results to available items. The storefront then reads live quantity from the Inventory API or Product Search at render or at add-to-cart.

Keep the search index out of the role of live stock ledger, and keep per-unit inventory events out of it. That write volume overwhelms the engine and costs money without improving the shopper's experience.

Keep the mapping a pure function

Write the projection-to-document transform as a pure function with no network calls. Everything the engine needs to rank and display should already be in the record. The applications around the mapping should do nothing but move data.

A pure mapping is unit-testable without a deployment, an engine key, or a Project. That is what makes the tests in Test the contracts you cannot see practical to write.

Worked example

An apparel catalog with product-level records, two locales, one price context, and one global index.

ElementDecision
Record keyThe Product id
Source/product-projections?staged=false for the full load, and the productProjection field of the ProductPublished Message for updates
Text fieldsname_en, name_de, description_en, and description_de, with localeProjection limited to en-US and de-DE
Facet fieldsbrand, color and sizes as sets across Variants, and categoryIds keyed on the stable identifier
Display fieldscategories as denormalized breadcrumb names per locale, imageUrl, slug_en, and slug_de
PriceOne selected context, euros in Germany, indexed as a numeric field for sorting and faceting
AvailabilityA coarse in-stock boolean refreshed nightly
Left outStaged data, out-of-scope locales, per-unit inventory, internal-only Attributes, and every non-euro price

That record schema is the deliverable. It is the same artifact whether a public Connector consumes it as configuration or a Connector you build implements it in code.

Build the two applications

A search integration is two jobs, so build two applications. Keep them separate rather than putting a mode switch in one application.

JobDefault shapeAlternative
Full ingestion, which rebuilds the whole indexA service application with an on-demand triggerA scheduled job, when periodic rebuilds are enough
Incremental updates, which keep the index freshAn event application driven by SubscriptionsA job that polls Product Projections on lastModifiedAt

One rule spans both. The index is a derived copy of the published catalog. Every record must be reproducible from the current Product Projection. Every write is an upsert on the record key, and every removal targets that same key. Both full loads and Subscription deliveries are at-least-once. Running either application twice must therefore converge rather than duplicate or double-delete.

Full ingestion

Full ingestion rebuilds the entire index from commercetools. It is your initial load, your disaster-recovery path, and the periodic backstop that repairs whatever the incremental path missed.

Page with a cursor, not an offset

Read /product-projections?staged=false&withTotal=false sorted by id, and page by filtering on the last identifier you saw:
Query the first page of Product Projectionshttp
GET /{projectKey}/product-projections?staged=false&withTotal=false&sort=id asc&limit=100
Query each following pagehttp
GET /{projectKey}/product-projections?staged=false&withTotal=false&sort=id asc&limit=100&where=id > "{lastId}"
Repeat while the number of returned results equals the requested limit. The maximum limit is 500.
Offset pagination breaks on exports. The maximum offset is 10 000, so a growing catalog silently outgrows it, and a Product inserted mid-run shifts every later page. The identifier cursor is stable and resumable. For the full pattern, see Iterate over all elements.

Read the Store assortment for a Store-specific index

For one index per Store, the assortment comes from the Store's Product Selections rather than from the whole catalog:

  1. Iterate over all Product Selection Assignments in the Store, sorting and filtering on product.id. When more than one active Product Selection includes the same Product, the response contains that Product more than once. Deduplicate the identifiers before you continue.
  2. Fetch the Product Projection in the Store for each Product. A ResourceNotFoundError means the Product is not available in that Store, so exclude it from the index.

Rebuild atomically

Build the new content into a temporary or secondary index, or tag every record with a build identifier. Then swap it in and drop the stale set. Many engines provide this directly, such as a replace-all-records operation or an index alias you repoint.

Clearing the live index and refilling it leaves a half-empty index serving few or no results for the entire length of the rebuild. That is the most visible failure this integration can produce, and it happens during a routine maintenance run rather than during an incident.

Write in batches and check the count

Send documents to the engine in batches. One HTTP call per record exhausts your runtime budget and hits the engine's rate limits on any real catalog.

After the swap, confirm that the engine's record count matches the number of published Products, multiplied by your granularity factor for variant-level records. A large mismatch means the mapping dropped or duplicated records. Fail the run and raise an alert rather than leaving a truncated index live.

Respect the runtime

If you run full ingestion on Connect, the runtime budget depends on the application type. A service application request times out after 5 minutes, and a job application request after 30 minutes. For a large catalog, chunk the load and checkpoint your progress. Run the rebuild as a job when a single pass cannot finish inside the service budget. Add overlap locking so a scheduled run cannot start on top of a run that is still going. Keep the initial migration and the recurring rebuild on the same code path. The path you depend on in an emergency is then the one you exercise routinely.

Incremental updates

Incremental updates keep the index in step with catalog changes between full loads. Choose the trigger first.

Choose Messages or resource changes

Two Subscription styles apply, and they suit different scopes.

ApproachWhat you receiveUse it when
A MessageSubscription on ProductPublished and ProductUnpublishedOnly published state. The ProductPublished payload carries the full productProjection, so you can map it without a re-fetch.You index the whole catalog and only customer-visible state matters.
A ChangeSubscription on product, combined with Message Subscriptions on store and product-selectionA notification for any change to the resource, carrying the resource reference.The index is Store-scoped, so you must react to changes that alter Store membership as well as Product content.
The Product export template takes the Message approach. Its incremental updater subscribes to Messages for Products being published and unpublished, and for Product Selection and Store changes.

A Change Subscription also fires for staged edits that no shopper can see. If you use one, filter those out or accept the extra reindex work.

The two styles also deliver different payloads. A Message Subscription delivers the Message types listed in the next section. A Change Subscription delivers a resource notification whose notificationType is ResourceCreated, ResourceUpdated, or ResourceDeleted, so your handler branches on that value and then reads the projection by resource.id.

Handle each trigger

Define an action for each trigger you subscribe to:

  • ProductPublished: upsert the record. The payload carries the productProjection as it was just published, so map it directly.
  • ProductUnpublished: remove the record by its key. This payload carries only a resource reference, with no projection.
  • ProductDeleted: remove the record. Its projection field is named currentProjection and is optional, so key the removal on resource.id rather than on the payload. A Product must be unpublished before it can be deleted, so in practice the unpublish already removed the record and this is a safety net.
  • Product Selection and Store Messages, for Store-scoped indices:

For a Store-scoped index, resolve which Stores an incoming change affects by querying Stores that hold the relevant active Product Selections:

Query predicate for affected Storestext
productSelections(active=true and productSelection(id in :productSelectionIds))
Pass the identifiers as input variables.
Store configuration changes, such as Product Selection assignments and Variant Selections, are cached for up to one minute. Allow for that delay before you fetch the Product Projection in the Store, or the projection you read still reflects the previous assortment.

Subscribe once per message type

Create one Subscription per message type and fan out inside your handler. Avoid one Subscription per index or per Store. A Project allows a maximum of 50 Subscriptions. This is a soft limit that can be raised after a performance impact review, but a Store fleet reaches it. Fanning out in the handler avoids the request entirely.

Register Subscriptions idempotently by reading first and creating only what is missing. A registration routine that deletes and recreates the Subscription drops every notification generated during the gap, and those changes never reach the index.

Creating, updating, or deleting a Subscription can take up to one minute to take effect. commercetools also sends a test notification when a Subscription is created. If that test notification cannot be delivered, the Subscription is not created at all.

Decode the envelope and acknowledge correctly

Messages arrive wrapped in the transport's envelope. For Google Cloud Pub/Sub, the payload is base64-encoded in message.data, so decode it before you inspect the message type.

Acknowledge positively for messages you handled and for messages you deliberately ignored, including the platform's test notification. How you signal that depends on where the application runs:

  • A Connect event application receives each message as an HTTP request. Connect treats 102, 200, 201, 202, and 204 as positive acknowledgments, and retries every other status.
  • Your own subscriber, such as the Pub/Sub pull consumer in the following example, acknowledges through the client library instead. In @google-cloud/pubsub that is message.ack() for success and message.nack() for redelivery.

Either way, a handler that signals failure for an unrecognized message type receives that message forever. Reserve failure signals for transient errors you actually want redelivered.

Keep the acknowledgment fast. A Connect event application has an acknowledgment timeout of 10 seconds, and its application request times out after 5 minutes. Re-fetching a projection and writing to the engine inside the handler can exceed the acknowledgment window. Keep the synchronous path short, and measure it against that budget.

Handle ProductPublished and ProductUnpublished notifications with a Pub/Sub pull consumerTypeScript
import { PubSub } from "@google-cloud/pubsub";
import type {
  ProductProjection,
  ProductPublishedMessage,
  ProductUnpublishedMessage,
} from "@commercetools/platform-sdk";

//...

async updateSearchIndex(product: ProductProjection) {
    console.log(`Updated/added product in search engine: ${product.id}`);
    // TODO: Format and send to search index
  }

async removeFromSearchIndex(productId: string) {
    console.log(`Removed product from search engine: ${productId}`);
    // TODO: Remove by objectID from search index
  }

async fetchMessages(): Promise<void> {
    const subscriptionName = "ct-product-consumer"; //the Subscription ID value provided in step 4

    // Initialize the Pub/Sub client with explicit credentials
    const pubSubClient = new PubSub({
      keyFilename: "./ctapisubscription.json", // Path to the location of the service account JSON key
      projectId: "ct-search-sync", // the GCP project ID set in step 1
    });

    const subscription = pubSubClient.subscription(subscriptionName);

    // Start listening to incoming messages from the subscription
    subscription.on("message", async (message) => {
      try {
        const ctMessage = JSON.parse(message.data.toString()) as
          | ProductPublishedMessage
          | ProductUnpublishedMessage;

        switch (ctMessage.type) {
          case "ProductPublished": {
            // The productProjection is embedded in the message — no extra API call needed
            await this.updateSearchIndex(ctMessage.productProjection);
            break;
          }

          case "ProductUnpublished": {
            // Remove the product from the search index
            await this.removeFromSearchIndex(ctMessage.resource.id);
            break;
          }

          default:
            console.log(` Unhandled message type: ${ctMessage.type as string}`);
        }

        // Acknowledge successful handling of the message
        message.ack();
      } catch (err) {
        console.error(" Error handling message:", err);

        // Optionally requeue the message for later processing
        message.nack();
      }
    });
    // Error handler
    subscription.on("error", (err) => {
      console.error("Subscription error:", err);
    });
  }

Guard against stale writes

Subscriptions are delivered at least once, and there is no guarantee on delivery order. An older notification can arrive after a newer one and overwrite current data with stale data.

The ProductPublished payload is the state at publication, so map it directly. For every other trigger, re-fetch the projection by resource.id so the index converges on current state instead of replaying an old delta. Where the engine supports it, also store an ordering value on the record and skip the write when the incoming data is older. For Message notifications, use resource.id with sequenceNumber, which increases in order per resource. For resource-change notifications, use resource.id with version, which is not sequential and can therefore only be compared, not gap-checked.

Poll instead, if you cannot receive a push

If your operating model cannot accept a push, poll Product Projections on lastModifiedAt and advance a stored checkpoint after each successful run:
Query Product Projections changed since the last checkpointhttp
GET /{projectKey}/product-projections?staged=false&where=lastModifiedAt > "2026-01-01T00:00:00.000Z"&sort=lastModifiedAt asc&sort=id asc&withTotal=false&limit=100
Sort ascending so you process changes in chronological order, and page on a compound cursor of lastModifiedAt and id so Products sharing a timestamp are not skipped. A Product modified during the run is processed again, so your handler must tolerate repeats.
Poll for changed Product Projections
import type { ProductProjectionPagedQueryResponse } from '@commercetools/platform-sdk';
import { apiRoot } from './ctpClient.js';

// The number of results to fetch per API call (page size)
const PAGE_SIZE = 20;

/**
 * Fetches a single page of product projection changes.
 * @param lastTimestamp - The lastModifiedAt from the previous page.
 * @param lastId - The ID from the last item of the previous page.
 */
const fetchNextPageOfChanges = async (
  lastTimestamp: string,
  lastId: string | null
): Promise<ProductProjectionPagedQueryResponse> => {
  let whereClause: string;

  // Use an if/else block to construct the WHERE clause.
  if (lastId) {
    // This is for page 2 and onwards. We have a previous product's ID.
    // We query for anything modified AFTER the last timestamp,
    // OR anything modified at the EXACT same timestamp but with a greater ID.
    whereClause = `(lastModifiedAt = "${lastTimestamp}" and id > "${lastId}") or (lastModifiedAt > "${lastTimestamp}")`;
  } else {
    // This is for the very first page of the sync, where lastId is null.
    // The query is simpler.
    whereClause = `lastModifiedAt > "${lastTimestamp}"`;
  }

  const response = await apiRoot
    .productProjections()
    .get({
      queryArgs: {
        where: whereClause,
        staged: false, // Only query for current (published) projections
        sort: ['lastModifiedAt asc', 'id asc'], // Sort by timestamp, then ID
        limit: PAGE_SIZE,
        withTotal: false,
      },
    })
    .execute();

  return response.body;
};

/**
 * Main function to run the delta sync process.
 */
const runDeltaSync = async (): Promise<void> => {
  try {
    // 1. Define the starting point for the sync
    const oneHourAgo = new Date(
      new Date().getTime() - 60 * 60 * 1000
    ).toISOString();
    console.log(`--- Starting sync for changes since ${oneHourAgo} ---`);

    // 2. Initialize state variables for pagination
    let lastSyncTimestamp = oneHourAgo;
    let lastId: string | null = null;
    let hasMoreResults = true;
    let totalProductsProcessed = 0;
    let page = 1;

    // 3. Loop to fetch all pages of results
    while (hasMoreResults) {
      console.log(`\nFetching page ${page}...`);

      const pageData = await fetchNextPageOfChanges(lastSyncTimestamp, lastId);
      const results = pageData.results;

      if (results.length > 0) {
        console.log(`Found ${results.length} products on this page.`);
        totalProductsProcessed += results.length;

        // Process each product
        results.forEach((product) => {
          console.log(
            `  -> Processing ID: ${product.id}, Modified: ${product.lastModifiedAt}`
          );
        });

        // 4. Update the "cursor" with the details of the LAST item in the page
        const lastProduct = results[results.length - 1];
        lastSyncTimestamp = lastProduct.lastModifiedAt;
        lastId = lastProduct.id;

        // If we received fewer results than we asked for, we're on the last page.
        if (results.length < PAGE_SIZE) {
          hasMoreResults = false;
        }
      } else {
        // No more results found, exit the loop.
        hasMoreResults = false;
      }
      page++;
    }

    console.log('\n--- Sync Complete ---');
    console.log(`Total products processed: ${totalProductsProcessed}`);
    console.log(
      `For the next run, start with timestamp: "${lastSyncTimestamp}" and ID: "${lastId}"`
    );
  } catch (error) {
    console.error('An error occurred during the sync process:', error);
  }
};

// Run the sync
runDeltaSync();
Polling cannot see deletions, because a deleted Product no longer appears in the query results. Pair it with the periodic full rebuild, or subscribe to ProductUnpublished and ProductDeleted for removals.

The two approaches trade off as follows.

ConsiderationScheduled pollingEvent-driven Subscriptions
LatencyThe polling interval, so minutes to hours.Seconds.
API usageEvery poll calls the API even when nothing changed.Notifications arrive only when something changes.
ComplexityLower to start, but you own the checkpoint state.Requires a messaging service and message handlers.
ReliabilityIf the checkpoint is lost, you need a full resynchronization.The queue provides retry and delivery guarantees.
DeletionsInvisible to the query.Delivered as Messages.

Propagate deletions

Deletion is a first-class path, not an afterthought. Every trigger that should remove a record needs a defined action:

  • a Product is unpublished or deleted
  • a Product is removed from a Product Selection that scopes a Store's index
  • a Product is no longer available in a Store, which the in-store projection reports as a ResourceNotFoundError
  • a Store is deleted, which retires that Store's index

Skipping any of these leaves ghost records: results that still appear in search and link to a product detail page that no longer exists.

On the Store-scoped path, a Not Found response has two possible causes. The Product is genuinely outside the Store's assortment, or the Store configuration cache has not caught up. Wait out the cache before you treat a Not Found as a removal. Otherwise a routine assortment change removes Products that should stay indexed.

Repair drift

Even a correct incremental path drifts. A Category rename fans out to Products that generated no Product Message. A notification is lost during a deployment gap. A Store configuration change reshapes an assortment. Run a periodic full rebuild as the backstop, and reconcile record counts between the engine and the published catalog.

Delivery failures are not always visible. commercetools retries an undelivered notification for up to 48 hours when the Subscription reports a TemporaryError, after which notifications may be dropped. A ConfigurationError stops delivery after 24 hours on production Projects, or 1 hour on development and staging Projects. Monitor the health status of each Subscription and alert on it. A misconfigured Destination otherwise leaves the index stale with no obvious signal.

To rebuild without disturbing live traffic, follow these steps:

  1. Create a second index for the same content.
  2. Apply incremental updates to both the live index and the new one.
  3. Run full ingestion into the new index.
  4. Move live traffic to the new index once it is complete and its count checks out.
  5. Delete the previous index.

To avoid unnecessary writes, store the Product version alongside each record. Skip the update when the version you already hold is not older than the incoming one.

Configure scopes, secrets, and the trigger

A search integration reads from commercetools and writes to the engine, so it needs no write scope on the catalog. Grant the following scopes:

  • Grant view_products to both applications, and add view_product_selections and view_stores for a Store-scoped index.
  • Grant manage_subscriptions only to the application whose deployment step registers the Subscription.
  • Grant scopes per application rather than per Connector, and check the current list in Scopes. A search integration that holds manage_products is over-privileged.
  • The engine needs no commercetools scope. It is reached with the engine's own API key.

Store the engine's API key as secured configuration rather than as plain configuration, and keep it out of logs and error responses. Index name, region, locale list, price context, and behavioral toggles are ordinary configuration.

Authenticate the full-ingestion trigger. A publicly reachable trigger endpoint lets anyone start a complete rebuild of your index. That is both a cost attack and a way to disturb live search. Validate a shared secret or a request signature before the rebuild starts.

For the Connect specifics of declaring applications, scopes, and configuration, see Connect development and the Connect getting started guide.

What the templates leave you to build

If you scaffold from the Product export template or fork Launchpad Algolia sync, you inherit the application structure and the commercetools read path. You do not inherit a production integration. At the time of writing, both repositories left the following work to you.
AreaWhat you must add or verify
Engine clientThe save and remove functions in the Product export template are deliberately empty. The engine client and the mapping are yours to write.
Atomic rebuildBoth repositories clear records before refilling, rather than building into a second index and swapping. Implement build-and-swap before production.
Trigger authenticationNeither validates the caller of the full-ingestion endpoint. Add a shared secret or a signature check.
Subscription registrationThe deployment step in the Product export template deletes the Subscription and recreates it, so notifications generated in that window are lost. Change it to read first and create only what is missing.
CredentialsBoth supply commercetools client credentials by hand through configuration. Prefer a Connect-provisioned client with least-privilege scopes.
Memory and volumeThe Product export template loads a Store's whole assortment into memory before writing. Stream or chunk it for a large assortment.
DependenciesBoth pin older commercetools SDK versions. Check and upgrade before you rely on either one.

Read the current source of any repository before you fork it, rather than relying on this table or on a previous reading. These are starting points that reduce the boilerplate, not Connectors you can deploy as they are.

Test the contracts you cannot see

Most of what breaks a search integration is invisible in a code review, so cover it with automated tests. Mock both boundaries, the engine and the commercetools APIs, so the suite runs with no deployment and no secrets.

For full ingestion, assert that:

  • a trigger without authentication, or with an invalid signature, is rejected
  • pagination uses the identifier cursor rather than an offset
  • the rebuild is atomic, so the live index is never empty or partial mid-run
  • the count check compares engine records against published Products and fails the run on a mismatch
  • a Store-scoped load reads in-store projections and deduplicates Products held in more than one Selection

For incremental updates, assert that:

  • ProductPublished upserts from the payload projection, and a second delivery of the same notification changes nothing
  • ProductUnpublished and ProductDeleted remove the record by its key
  • Store and Product Selection Messages add to and remove from the correct Store's index
  • an out-of-order notification does not overwrite newer data
  • the envelope is decoded, and handled, ignored, and retryable messages each return the intended status
  • for the polling variant, the checkpoint advances and removals are covered elsewhere

Test the mapping directly as a pure function. Given a Product Projection, it should produce the exact record you expect, including the selected price context, the locale fields, and the denormalized categories.

Verify and operate

Treat the integration as unfinished until a change appears, a removal disappears, and a rebuild matches the catalog. Observe each of those in the engine's index rather than in your own logs.

You can exercise the applications without a live queue. Post the encoded notification envelope directly to the incremental application's endpoint, and call the full-ingestion trigger yourself. See Test applications locally.

Verify that a publish reaches the index

Publish a Product, or change and republish one, then confirm that:

  • a record with the expected key exists. It carries the mapped fields: names and descriptions in each in-scope locale, the selected-context price, the denormalized categories, and the image
  • searching for a word from the Product name returns it, and its facets are populated
  • only published data is present, with no staged edits and no unpublished Products
  • redelivering the same notification changes nothing, because the write is an upsert on a stable key
A record that appears but is missing a price or a locale points at the price-context or localeProjection mapping rather than at the indexing pipeline.

Verify that a removal disappears

Unpublish a Product, and separately delete one, then confirm the record is gone from the index and no longer returned by search. This is the check people skip, and skipping it is how ghost records reach production.

If you poll rather than subscribe, confirm that removals are covered by removal Messages or by the periodic rebuild.

Verify that a rebuild matches the catalog

Trigger full ingestion against a known catalog, then confirm that:

  • the engine's record count equals the published Product count, multiplied by your granularity factor
  • the index never went empty or partial during the rebuild, checked by querying it while a rebuild is running
  • re-running full ingestion produces an identical index, with the same count and the same records

Verify that Store scoping holds

For a Store-scoped index, add a Product to one Store's Product Selection and confirm it appears in that Store's index only. Remove it and confirm it disappears from that index while remaining in the other Stores that still list it. Confirm that the in-store projection resolved the Store's languages and prices rather than the Project defaults. Where Product Tailoring applies, confirm that the tailored content arrived.

Signals that look like failures

Three correct behaviors read as defects, and one defect reads as flakiness. Recognize them before you escalate.

A change is not in search yet. commercetools projections and Product Search are eventually consistent, and the engine adds its own indexing delay. Subscriptions carry no delivery-time guarantee either, and although most notifications arrive within seconds, delays of several minutes can occur. Wait and query again, or fetch the record directly, before you conclude that the pipeline dropped it. Treat it as a defect only when it never converges.
Search says in stock for something that just sold out. An indexed availability flag is a snapshot refreshed on a cadence. The underlying availability field is itself eventually consistent and lags real stock levels by a few seconds. This is the design working as intended. Verify that the flag refreshes on schedule, and have the storefront confirm live quantity at render or at add-to-cart.
Sandbox results do not match production. A sandbox Project has a small, static catalog, so counts, locales, and Store assortments differ, and throttling behavior never appears. Verify the contract against a sandbox, then verify volume, pagination depth, and rate-limit behavior against a production-sized catalog. Remove test records afterwards so they do not reach reporting.
Search returns fewer results for a while and then recovers. This one is a defect. Full ingestion is clearing the live index and refilling it rather than building and swapping. What you are seeing is the rebuild window exposed to shoppers. Fix it in the application, then repeat the rebuild verification.

Symptom, cause, and resolution

SymptomLikely causeResolution
Unpublished Products still appear, and hits link to dead pagesDeletion is not propagatedHandle ProductUnpublished and ProductDeleted, and remove by record key
Search returns zero or partial results during a rebuildThe rebuild clears the live index before refillingBuild into a second index and swap atomically
Unreleased names or prices are visible to shoppersThe integration reads staged dataRead the current projection with staged=false
A Product reverts to older data after a later editAn out-of-order notification overwrote newer stateRe-fetch by resource.id, and guard on sequenceNumber or version
The record exists, but the price is missing or wrongThe price context does not match the storefront's contextSelect one context at map time, or index per-context fields or records
The record exists, but a language field is emptyThe locale is outside localeProjection, or the code is mapped wronglyInclude the locale, and map codes as en-US rather than en_US
Category names in facets and breadcrumbs are out of dateA Category rename was not fanned outReindex affected Products on Category Messages, with the rebuild as backstop
The full load misses or duplicates Products past a few thousandOffset paginationCursor on sort=id asc with where=id > "{lastId}"
A truncated index goes live unnoticedNo count check after the rebuildCompare the engine count against published Products, and fail on a mismatch
A message is redelivered forever, or a real failure vanishesThe acknowledgment status is wrongAcknowledge with 102, 200, 201, 202, or 204 when handled or deliberately ignored, and any other status only when retryable
The index silently stops updatingThe Subscription Destination is failing and delivery has stoppedCheck the Subscription health status, repair the Destination, then run a full rebuild
The handler receives unreadable dataThe transport envelope was not decodedDecode message.data from base64, then validate the message type
Creating a Subscription failsThe Project has reached the Subscription limitUse one Subscription per message type, and fan out in the handler
An in-store projection returns Not Found right after a Selection changeStore configuration is cached for up to one minute, or the Product genuinely left the StoreWait out the cache and retry before treating the response as a removal
The full load exhausts memory or times outThe whole assortment is held in memory, or the run exceeds the runtime limitStream or chunk the load, checkpoint, and add overlap locking
The engine bill spikes and writes queue upPer-unit inventory events are wired into the indexIndex a coarse availability flag on a cadence instead

Query the index from your storefront

Reading the index is your storefront's job, and it sits outside this integration. The engine's own client library handles querying, faceting, and pagination.

If you build on commercetools Frontend, two pieces connect a storefront to an external engine:

Some pricing rules cannot be expressed in the engine, such as price hints or Customer Group resolution. Keep those in your storefront or backend-for-frontend layer rather than encoding them in the index.