# Integrate an external promotion or loyalty engine Let an external promotion, coupon, or loyalty engine decide the discounts on a Cart, and record the redemption after the Order is placed. Learn more about integrations in the self-paced [Integration patterns](/learning-integrate-with-commercetools/integration-patterns/overview.md) module. A promotion integration hands the discount decision to an external engine. In the architecture used by this guide, the engine holds the campaign rules, coupon inventory, and loyalty balances. commercetools holds the Cart, the Order, and the resulting prices. The exact operations depend on the selected engine. This guide defines the following contract: - **Evaluation** runs synchronously while the shopper is shopping. It sends the Cart to the engine, receives the discount effects, and applies them to the Cart. The evaluation endpoint must not consume a coupon, close a session, or award points. - **Redemption** runs asynchronously after the Order exists. It performs the final operations supported by the engine, such as consuming a coupon, closing a session, or awarding loyalty points. Before you begin, obtain a commercetools Project, access to [Connect](/connect), and credentials for a promotion-engine test environment. Two adjacent problems are out of scope. Discounts that commercetools can model on its own belong to [Cart Discounts and Discount Codes](/api/pricing-and-discounts-overview.md#discounts), and the next section is the gate that decides this. Stored value that pays for an Order, such as a gift card balance, is a [Payment](/api/projects/payments.md) rather than a discount, even when the same vendor sells both, and it belongs in [Integrate a gift card system](/guides/gift-card-integration.md). A voucher that reduces the Cart total is a promotion and belongs here. A balance that settles part of the Order total does not. For the wider context of building integrations on commercetools, see [Integration essentials](/learning-integrate-with-commercetools/integration-patterns/overview.md). ## Define the promotion requirements Promotion behavior follows marketing intent, and a wrong default either gives money away or blocks checkout. Document these decisions before you select or configure a Connector: - **Engine**: identify the engine, the account environment, the credentials, and the evaluation and redemption endpoints it exposes. - **The capability gap**: name the specific requirement that the built-in discounts cannot meet. The next section tests this answer. - **Discount ownership**: decide whether the engine owns the Cart Discounts and Discount Codes on the Cart, or whether commercetools keeps that ownership and the engine covers only a carved-out case. A Cart cannot split it, for the reason given in [Decide who owns discounts on the Cart](/guides/promotion-loyalty-integration.md#decide-who-owns-discounts-on-the-cart). Product Discounts are decided separately, by the Project's [DiscountCombinationMode](/search.md?urn=ctp:api:type:DiscountCombinationMode). - **Coupon codes**: decide whether a shopper types a code, where that code is stored, and how a rejected code reaches the storefront. - **Loyalty points and balances**: decide what, if anything, is mirrored into commercetools for display, and confirm that the engine remains the system of record. - **Order lifecycle**: decide whether a cancellation or a return rolls the redemption back and reclaims points. This determines which Messages the redemption application subscribes to. - **Redemption point**: decide whether redemption happens when the Order is created or when payment is confirmed. Both are defensible and they carry different rollback duties. - **Failure policy**: decide whether an engine outage drops discounts and lets the Cart proceed, or fails the Cart operation. - **Region, Project, and Stores**: record these, along with any multi-currency, multi-Store, or business-to-business requirements such as Quotes. Record any requirement that does not fit one of these lines as its own item rather than forcing it into a category. Cross-channel budgets shared with a point-of-sale system, marketplace sellers, and recurring Orders each change the design. ## Rule out the built-in discounts first commercetools provides built-in promotion capabilities. Rule them out before you plan a Connector, because a Connector adds cost, latency, and an availability dependency to Cart operations. Start with the [common discount use cases](/api/pricing-and-discounts-overview.md#common-discount-use-cases). If the requirement maps to the built-in capabilities in that table, stop here. Some requirements need an API Extension over the built-in discounts rather than an external promotion platform. To model promotions in commercetools, work through the self-paced [Cart Discounts](/learning-price-and-discount-your-products/cart-discounts/overview.md) and [Discount Codes](/learning-price-and-discount-your-products/discount-codes/overview.md) modules, and the implementation guide's [Create promotions](/guides/implementation-guide/create-promotions.md) page. Continue past this gate when the requirement needs capabilities that belong to a promotion platform: unique code generation at scale, referral programs, loyalty points, tiers, or wallets, budgets shared across web and point-of-sale channels, per-customer targeting driven by a customer data platform, geofencing, or real-time campaign experimentation. One further case justifies an engine even when the discount arithmetic is simple. If a marketing team already works in an engine daily and must author rules there, the requirement is the authoring surface rather than the calculation, and the built-in discounts do not satisfy it. Record which case applies and why. An integration that restates behavior the platform already provides is the most expensive way to obtain it. ## Decide who owns discounts on the Cart An external engine and the built-in Discount Codes cannot both own the same Cart. [Direct Discounts and Discount Codes are mutually exclusive](/api/pricing-and-discounts-overview.md#direct-discounts), and the [Set DirectDiscounts](/search.md?urn=ctp:api:type:CartSetDirectDiscountsAction) and [Add DiscountCode](/search.md?urn=ctp:api:type:CartAddDiscountCodeAction) actions enforce that constraint. The consequence to settle before implementation is that a design where the engine calculates promotions and the Project keeps its existing platform promo codes on the same Cart is not supported. Choose one owner: - **The engine owns discounts.** Matching Cart Discounts are ignored, and Discount Codes cannot be added to Carts that carry engine discounts. This is the usual choice when a marketing team authors campaigns in the engine. - **commercetools owns discounts.** The engine covers only a carved-out case on Carts that the built-in discounts leave alone. Keep the boundary explicit, because a Cart that receives an engine discount no longer receives matching Cart Discounts or Discount Codes. The cited sources do not document a dedicated error for a mixed design. Enforce the ownership decision in your own configuration and code. Ownership settles Cart Discounts and Discount Codes, but not Product Discounts. Those are governed by the Project's [DiscountCombinationMode](/search.md?urn=ctp:api:type:DiscountCombinationMode). Under `Stacking`, a Product Discount and an engine Direct Discount both apply to a Line Item. Under `BestDeal`, only one discount type applies per Line Item, and the platform selects the type that produces the lower Cart total, so a Product Discount can replace an engine discount without returning an error. Record the Project's mode alongside the ownership decision. Ownership also relocates the coupon code. When the engine owns discounts, a shopper-entered code cannot live in a Discount Code, because adding one is what the exclusivity rule prevents. Store the code in a Cart [Custom Field](/api/projects/custom-fields.md) instead, and store the engine's validation result in a second Custom Field so that the storefront can display a rejection. [Store the coupon code and its result](/guides/promotion-loyalty-integration.md#store-the-coupon-code-and-its-result) covers the field design. ## Choose a Connector path Check the live Connector catalog when the implementation starts rather than working from a remembered landscape. Use an API Client with the `view_connectors` scope to call the [Search Connectors](/connect/connectors.md#search-connectors) endpoint, filtering on the `promotion` [IntegrationType](/search.md?urn=ctp:connect:type:IntegrationType), which covers promotions and loyalty: ```http title="Search for promotion and loyalty Connectors" GET https://connect.{region}.commercetools.com/connectors/search?integrationTypes=promotion&private=false ``` Pass `private=false` so that the results are not limited to Connectors already assigned to your Project. For the host to use in each Region, see [Hosts and authorization](/connect/hosts-and-authorization.md). Record the Connector key, the version, and the date you checked. The promotions and loyalty category of the marketplace is crowded, and it lists partner-operated software-as-a-service products alongside deployable Connectors. A marketplace listing alone does not prove that an integration deploys through Connect. Confirm that a candidate publishes a Connect application before you plan around it. Choose the first path that satisfies the requirements: 1. **Configure an existing deployable Connector** when its evaluation, coupon handling, loyalty, redemption, rollback, and regional capabilities fit. 2. **Prove whether configuration closes a gap** before forking. Promotion Connectors commonly expose the effect-to-action mapping, the attributes forwarded to the engine, the Custom Field that holds the coupon code, the Tax Category used for discount Line Items, and the Order States that trigger redemption as configuration. 3. **Fork an existing Connector** when the gap is genuine and the source is available. Add only the missing behavior and deploy the result as an Organization Connector. 4. **Build both applications** when no Connector exists for the engine, which is the usual case for a promotion service that you operate. Building both applications costs more here than in comparable domains. No promotion template is listed in the [application templates overview](/connect/templates/templates-overview.md). Scaffold a promotion Connector as plain `service` and `event` applications, and implement the contract yourself. ### Assess a public Connector before you adopt it Two promotion integrations publish their source under an MIT license. Both need the same checks as any inherited codebase. The following external observations were verified on 2026-09-10 at the linked commits. Re-check them at the current commit, because they change independently of this guide. | Repository | State when checked | What to verify before adopting | | --- | --- | --- | | [`composable-com/ct-connect-talonone`](https://github.com/composable-com/ct-connect-talonone/tree/6a01731), commit `6a01731` | A Connect Connector maintained by a systems integrator. Latest commit dated 2023-10-30. | It declares one `service` application. A single API Extension is registered on both `cart` and `order`, so redemption and loyalty run synchronously during Order creation. Discounts are applied as negative Custom Line Items rather than Direct Discounts. The Extension carries no trigger condition, so it runs on every Cart write. `postDeploy` deletes and recreates the Extension instead of updating it. commercetools credentials are supplied by hand, and non-secret values are held in secured configuration. | | [`voucherifyio/commerce-tools-integration`](https://github.com/voucherifyio/commerce-tools-integration/tree/ed0cb62), commit `ed0cb62` | A Connect application maintained by Voucherify. Latest commit dated 2025-08-21. | It declares one `service` application. A single API Extension is registered on `cart` and `order` with a 2-second timeout and no trigger condition. Discounts are applied as negative Custom Line Items unless the Direct Discount flag is enabled. Redemption happens when the Order is paid. The Extension endpoint is protected by basic authentication. commercetools credentials are supplied by hand. | Neither public Connector implements the two-application architecture that the rest of this guide describes. Both perform redemption inside a synchronous `order` Extension. That is a deliberate trade rather than a defect: it removes the asynchronous path, and it places the engine on the critical path of Order creation, so an engine outage can prevent Orders from being created. If you adopt or fork one of these Connectors, decide whether you accept that trade, and record the decision. ## Design the two applications ### Separate evaluation from redemption In the contract used by this guide, evaluating a Cart and redeeming a promotion are different engine operations with different consequences. Evaluation answers the question of what this Cart would cost. The evaluation operation must not consume a coupon, close a session, or award points. It runs many times during a shopping session, including for Carts that are never ordered. Redemption records that the promotion was used. Configure it to perform the final operations that the selected engine supports, such as consuming a coupon, closing a session, or awarding points. This separation prevents abandoned Carts from consuming coupons or awarding points. It also ensures that engine reporting can distinguish a price evaluation from a completed redemption when the engine provides that distinction. The two jobs therefore become two Connect applications: - A `service` application registered as a Cart [API Extension](/api/projects/api-extensions.md), which evaluates. - An `event` application driven by a [Subscription](/api/projects/subscriptions.md), which redeems. ```mermaid title="Synchronous evaluation on the Cart followed by asynchronous redemption after the Order" sequenceDiagram autonumber participant Client participant Platform as commercetools box commercetools Connect participant Evaluator as Promotion evaluator participant Syncer as Redemption syncer end participant Engine as Promotion engine Note over Client,Evaluator: Synchronous evaluation Client->>Platform: Create or update the Cart Platform->>Evaluator: Send the Extension request with the pending Cart Evaluator->>Evaluator: Compare the promotion hash written by the last evaluation Evaluator->>Engine: Evaluate the Cart Engine-->>Evaluator: Return discount effects Evaluator-->>Platform: Return Set DirectDiscounts and Set CustomField actions Platform-->>Client: Persist and return the discounted Cart Client->>Platform: Create the Order Note over Syncer,Engine: Asynchronous redemption Platform-->>Syncer: Deliver the OrderCreated Message Syncer->>Platform: Fetch the current Order Syncer->>Engine: Redeem, keyed on the Order ID Engine-->>Syncer: Confirm the redemption ``` The evaluator receives the Cart as it will be after the triggering change, and the actions it returns are applied within that same operation. The hash it compares in step 3 was written by the previous evaluation, and the discounts it returns in step 6 are not yet persisted when it builds the response. A backend-for-frontend can evaluate instead of an API Extension, by applying the Cart change, calling the engine with the returned Cart, and writing the discounts in a second update. That removes the engine from the platform's synchronous path, at the cost of guaranteeing that every client reaches the Cart through that backend. Any client that bypasses it produces an undiscounted Cart. Use an API Extension when discounts must apply regardless of which client changes the Cart. ### Apply engine discounts to the Cart Three mechanisms can put an engine's discount on a Cart. Choose one deliberately, because the choice reaches the storefront. **Direct Discounts are the recommended mechanism.** Apply them with [Set DirectDiscounts](/search.md?urn=ctp:api:type:CartSetDirectDiscountsAction). Each [DirectDiscountDraft](/search.md?urn=ctp:api:type:DirectDiscountDraft) maps an engine effect onto the Cart Discount value and target vocabulary. See [Direct Discounts](/api/pricing-and-discounts-overview.md#direct-discounts) for their ordering, stacking, and lifecycle behavior. To change them after Order creation, use the Order Edit [Set DirectDiscounts](/search.md?urn=ctp:api:type:StagedOrderSetDirectDiscountsAction) action. **Negative Custom Line Items** add a Cart line with a negative amount for each discount. Both public Connectors described above use this mechanism by default. The line appears as a row that the storefront must render or filter, it changes how subtotals and reporting read, and it needs tax data that depends on the Cart's [TaxMode](/search.md?urn=ctp:api:type:TaxMode): | TaxMode | Required on the discount line | | --- | --- | | `Platform` | A Tax Category | | `External` | An external tax rate | | `ExternalAmount` | An external tax amount and rate | | `Disabled` | Nothing | Choose this mechanism only when a visible per-code line is a requirement, or when an existing storefront already handles it. **Engine-managed Discount Codes**, where the engine mirrors campaigns into Cart Discounts and Discount Codes, keep the platform's own discount semantics and put nothing on the Cart hot path, at the cost of synchronization lag. Which [limit](/api/limits.md#cart-discounts) bounds the mirrored catalog depends on how each campaign is mirrored: | Mirrored as | Bound | | --- | --- | | An automatic Cart Discount | The Project-wide limit on active Cart Discounts without a Discount Code, plus a per-Store allowance | | A Cart Discount requiring a Discount Code | Not covered by that limit, and no Project-wide count is documented | | The [Discount Codes](/api/limits.md#discount-codes) themselves | Per Cart and per code-to-Cart-Discount association only | Size the catalog against those distinctions rather than a single ceiling, and confirm high code volumes with commercetools support. This mechanism suits a small, slow-changing campaign set. When you use Direct Discounts, map the engine's effects onto the value and target vocabulary: | Engine effect | `value` | `target` | | --- | --- | --- | | Percentage off matching items | `relative` | [CartDiscountLineItemsTarget](/search.md?urn=ctp:api:type:CartDiscountLineItemsTarget) or [CartDiscountCustomLineItemsTarget](/search.md?urn=ctp:api:type:CartDiscountCustomLineItemsTarget) | | Fixed amount off matching items | `absolute` | `lineItems` or `customLineItems` | | A set price for matching items | `fixed` | `lineItems`, `customLineItems`, or `pattern` | | Percentage or amount off the Cart total | `relative` or `absolute` | [CartDiscountTotalPriceTarget](/search.md?urn=ctp:api:type:CartDiscountTotalPriceTarget) | | Free or reduced shipping | `relative` or `absolute` | [CartDiscountShippingCostTarget](/search.md?urn=ctp:api:type:CartDiscountShippingCostTarget) | | A free gift item | `giftLineItem` | None. The value carries the Product and Variant. | | Buy X, get Y at a reduced price | `relative` only | [MultiBuyLineItemsTarget](/search.md?urn=ctp:api:type:MultiBuyLineItemsTarget) or [MultiBuyCustomLineItemsTarget](/search.md?urn=ctp:api:type:MultiBuyCustomLineItemsTarget) | Five details in that mapping cause defects that are hard to attribute: - A `relative` value is a **permyriad**, one ten-thousandth. A 10% discount is `1000`. An engine value of `10` forwarded without conversion produces a 0.1% discount, which looks like a rounding fault rather than a mapping fault. - The shipping target's discriminator is `shipping`, although its type is named `CartDiscountShippingCostTarget`. A draft that sends `shippingCost` is rejected. - Multi-buy targets accept a percentage only. An engine effect expressed as a fixed amount for a quantity, such as three items for a set price, maps to `pattern`, which accepts an amount, a fixed price, or a percentage. A `lineItems` target is not a substitute, because it discounts the matching Line Items as a whole and cannot express the trigger and discounted quantities. - A `giftLineItem` value must not carry a `target`, and it references a Product and Variant in the Project. An unpublished Product still produces a Gift Line Item, so that case needs no guard. Guard instead against a Product that does not exist, and against a `supplyChannel` without the `InventorySupply` role or a `distributionChannel` without the `ProductDistribution` role. Decide in advance whether an unmatched gift effect is dropped with a log entry or treated as a failure. - Set DirectDiscounts **replaces** the whole array. Always send the complete current set. A response that sends only the changed entry removes the others. Sending an empty array removes all Direct Discounts and recalculates the affected prices. A successful evaluation returns those drafts as the update actions of an [Extension response](/api/projects/api-extensions.md#response). The following response applies a 10% engine effect and free shipping, and records the engine's verdict for the storefront: ```json title="Extension response for an accepted 10% and free shipping campaign" { "actions": [ { "action": "setDirectDiscounts", "discounts": [ { "value": { "type": "relative", "permyriad": 1000 }, "target": { "type": "lineItems", "predicate": "1=1" } }, { "value": { "type": "relative", "permyriad": 10000 }, "target": { "type": "shipping" } } ] }, { "action": "setCustomField", "name": "promotionResult", "value": "{\"status\":\"accepted\",\"campaign\":\"SPRING10\"}" }, { "action": "setCustomField", "name": "promotionHash", "value": "9f2c1ae6b0d4" } ] } ``` Note the three details that the mapping list calls out: `1000` is 10% rather than `10`, free shipping is 100% off (`10000` permyriad) rather than an `absolute` value of zero, which would reduce the shipping charge by nothing, and the shipping target's discriminator is `shipping` rather than the type name. To reject a code, return the same shape with a rejection in `promotionResult` and a `discounts` array holding whatever should remain, usually empty. An Extension response carries at most **** update actions. The limit counts actions rather than the drafts inside them, so a single Set DirectDiscounts action carrying many [DirectDiscountDrafts](/search.md?urn=ctp:api:type:DirectDiscountDraft) consumes one slot, while each Custom Field action consumes another. Return one Set DirectDiscounts action for the complete set, and keep the remaining actions to the Custom Field writes the storefront needs. This limit can be increased per Project after a performance review. Two price modes silently deactivate Cart Discounts for the item that carries them, and a Direct Discount is a Cart Discount applied to one Cart, so an engine effect targeting such an item produces no reduction and no error: - A [LineItem](/search.md?urn=ctp:api:type:LineItem) with the `ExternalTotal` [LineItemPriceMode](/search.md?urn=ctp:api:type:LineItemPriceMode), despite a matching [CartDiscountLineItemsTarget](/search.md?urn=ctp:api:type:CartDiscountLineItemsTarget) or [MultiBuyLineItemsTarget](/search.md?urn=ctp:api:type:MultiBuyLineItemsTarget). - A [CustomLineItem](/search.md?urn=ctp:api:type:CustomLineItem) with the `External` [CustomLineItemPriceMode](/search.md?urn=ctp:api:type:CustomLineItemPriceMode), despite a matching [CartDiscountCustomLineItemsTarget](/search.md?urn=ctp:api:type:CartDiscountCustomLineItemsTarget), [MultiBuyCustomLineItemsTarget](/search.md?urn=ctp:api:type:MultiBuyCustomLineItemsTarget), or [CartDiscountPatternTarget](/search.md?urn=ctp:api:type:CartDiscountPatternTarget). Target the Cart total for Carts that carry externally priced items, which is common in quoted and business-to-business flows. ### Store the coupon code and its result When the engine owns discounts, the shopper's code lives in a Cart Custom Field. Define a [Type](/api/projects/types.md) for the Cart that holds at least three fields: - The entered code, written by the storefront or backend. - The evaluation result for that code, written by the evaluator, carrying acceptance or a rejection reason that the storefront can display. - The promotion hash from the last evaluation, described in [Skip unchanged evaluations](/guides/promotion-loyalty-integration.md#skip-unchanged-evaluations). Add a field for engine-supplied campaign messaging when the storefront renders prompts such as progress toward a threshold. Creating the Type is not enough on its own. [Set CustomField](/search.md?urn=ctp:api:type:CartSetCustomFieldAction) writes a field that the Cart's existing Custom Fields Type defines, so it fails on a Cart that has no Type assigned, which is every newly created Cart. Assign the Type before the evaluator writes to it, either by setting `custom` on the [CartDraft](/search.md?urn=ctp:api:type:CartDraft) at Cart creation, or by returning [Set CustomType](/search.md?urn=ctp:api:type:CartSetCustomTypeAction) ahead of the Set CustomField actions when the Cart carries no Type yet. The second option keeps the requirement inside the Connector, at the cost of one more update action. The deployment scripts own three resources: the Extension, the Subscription, and the Type. Give all three stable keys, and hold the scripts to this contract: - `postDeploy` fetches by key and updates, rather than creating unconditionally. With a stable key an unconditional create does not duplicate the resource, it fails the redeployment because the key is taken. Duplicates appear only when a script generates a fresh key each run, which leaves a second Extension evaluating every Cart or a second Subscription redelivering every Order. - `postDeploy` reads the Type before writing it, so that a redeployment does not remove fields that already exist. - `preUndeploy` deletes the Extension and the Subscription. An undeployed Connector that leaves them registered points the platform at an endpoint that no longer answers, which fails every Cart write. - `preUndeploy` leaves the Type in place. Custom Field data on existing Carts depends on it. ### Configure the Connector and scopes Declare the applications, their endpoints, and their configuration in `connect.yaml`, in the root of the Connect application. Keep the engine API key in secured configuration, and keep the engine base URL, the region, the outbound timeout, the failure mode, the Custom Field names, and the attribute mapping in standard configuration. A Tax Category identifier is configuration rather than a secret, and its presence signals that the Connector applies discounts as Custom Line Items. ```yaml title="connect.yaml, abbreviated to the settings this guide discusses" deployAs: - name: promotion-evaluator applicationType: service endpoint: /promotions scripts: postDeploy: npm run connector:post-deploy preUndeploy: npm run connector:pre-undeploy configuration: standardConfiguration: - key: ENGINE_BASE_URL description: Base URL of the promotion engine required: true - key: ENGINE_TIMEOUT_MS description: Outbound engine deadline, below the Extension timeout required: true - key: COUPON_CODE_FIELD description: Cart Custom Field holding the entered coupon code required: true - key: EVALUATION_HASH_FIELD description: Cart Custom Field holding the last successful promotion hash required: true - key: FAILURE_MODE description: Behavior on engine error or timeout, fail-open or fail-closed required: true - key: ATTRIBUTE_MAPPING description: Cart and Customer attributes forwarded to the engine required: true - key: DISCOUNT_TAX_CATEGORY_ID description: Tax Category for discount Custom Line Items, unused with Direct Discounts required: false securedConfiguration: - key: ENGINE_API_KEY description: API key for the promotion engine required: true - name: redemption-syncer applicationType: event endpoint: /redemptions scripts: postDeploy: npm run connector:post-deploy preUndeploy: npm run connector:pre-undeploy configuration: standardConfiguration: - key: ENGINE_BASE_URL description: Base URL of the promotion engine required: true - key: ENGINE_TIMEOUT_MS description: Outbound engine deadline, below the 10-second acknowledgment window required: true - key: ROLLBACK_ORDER_STATES description: Order State keys that trigger a rollback required: false securedConfiguration: - key: ENGINE_API_KEY description: API key for the promotion engine required: true inheritAs: apiClient: scopes: - manage_extensions - manage_subscriptions - manage_types - view_orders ``` The `inheritAs.apiClient.scopes` block makes Connect generate a runtime API Client at installation. It is not the only way to authorize the deployment scripts: a Connector can instead take pre-generated commercetools credentials through secured configuration, and the scripts then call the API with those. Prefer the generated client, because it keeps the permissions least-privilege and removes a manual credential-handling step from installation. Use the following scopes only where the described application behavior requires them: - `manage_extensions` for the script that registers the Cart API Extension. - `manage_subscriptions` for the script that registers the Subscription. - `view_orders` for the application that fetches the Order. This scope is broader than its name suggests, because it also grants read access to Carts, Associate Carts, Associate Orders, and Zones. Use the Store-scoped variant where a Store boundary applies. - `manage_types` only when a deployment script creates the Cart Type. Add `view_customers` only when the evaluator forwards Customer attributes to the engine, and `manage_customers` only when the integration mirrors a loyalty balance onto a Customer. Confirm every scope against the canonical [API scopes](/api/scopes.md) reference. A token request naming a scope the API Client does not hold returns a [400 `invalid_scope` error](/learning-developer-essentials/authentication-authorization/scopes.md) at authentication, rather than a permission error at the failing call. Those scopes belong to the runtime client. The API Client that creates the Deployment is separate, and its permissions do not go in `connect.yaml`: it needs `manage_connectors_deployments:{projectKey}`, plus `manage_api_clients:{projectKey}` because the `inheritAs` block makes the Connector generate its own credentials. Without the second scope, deployment fails with an access-denied error. See [Hosts and authorization](/connect/hosts-and-authorization.md#authorization) and [Modify a Connector](/connect/modify-connector.md). Register the Extension destination so that its URL matches the route the application serves. Connect exposes the deployed service base URL for this purpose, and a handler mounted at the root can use it unchanged. When the handler is mounted on a path, append that path to the destination. A destination that does not match the served route causes every platform call to reach a route that does not exist. ## Implement the evaluator ### Condition the trigger Register the Extension on the `cart` resource for `Create` and `Update` actions, and add a [conditional trigger](/api/projects/api-extensions.md#conditional-triggers) so that the engine is called only for Carts worth evaluating. Promotion engines bill and rate-limit per call, and this predicate is the first control over both. A useful starting condition restricts evaluation to an active Cart that contains items. Include `cartState` for a second reason: a Cart with the `Frozen` [CartState](/search.md?urn=ctp:api:type:CartState) and the `HardFreeze` strategy **rejects** Set DirectDiscounts, and under the default `SoftFreeze` the Direct Discounts are added to the Cart without being applied. Evaluating a frozen Cart therefore either fails the operation or produces a discount that changes nothing. Under a carved-out design, where commercetools keeps ownership for most Carts, the condition must also select only the engine-owned Carts. Test whatever marks the carve-out, such as the Store, the Customer Group, or a Custom Field the storefront sets. Without that restriction the evaluator runs for Carts that carry platform-owned Discount Codes, and returning Set DirectDiscounts for one of them breaks the exclusivity rule and fails the Cart operation. Follow the [conditional trigger](/api/projects/api-extensions.md#conditional-triggers) rules when you validate the predicate. In particular, guard optional fields with `is defined` so that a missing value does not fail the Cart operation with a [400 ExtensionPredicateEvaluationFailed](/api/errors.md#extensionpredicateevaluationfailed) error. A Project holds up to **** API Extensions, so confirm that there is room before planning a Cart Extension. ### Skip unchanged evaluations Hash the promotion-relevant fields of the Cart and store the result in a Custom Field. When the next evaluation computes the same hash, return an empty action list without calling the engine. Include every field the engine's rules can match on: Line Items with their quantities and prices, the Customer and Customer Group, the Store, the currency and country, the Shipping Method, and the entered coupon code. A hash that omits a field the engine reads serves one segment's discount to another and produces a discount that is wrong rather than absent. A Cart hash cannot see the engine. Campaign activation, a time window opening or closing, a coupon redeemed on another channel, a shared budget being consumed, and a loyalty tier change all move the engine's answer while the Cart stays identical, so an unconditional skip leaves a stale discount in place until an unrelated Cart change breaks the hash. Three rules keep the skip safe: - **Bound it.** Store an evaluation timestamp, or the decision or campaign-set version token the engine returns, next to the hash. Call the engine anyway once the entry is older than a configured lifetime, or when the token no longer matches. - **Bypass it on coupon actions.** A shopper who submits or removes a code expects a fresh answer. - **Write it only on success.** The stored value records the Cart that the current discounts were calculated from. A hash written alongside an empty discount array holds the Cart at no discounts. Leaving the previous value untouched is not enough either, because a freshness-triggered failure leaves a hash that still matches the Cart; the failure branch must clear the hash or mark its entry stale so that the next evaluation retries. The hash is a cost control, not a correctness mechanism. It does not replace idempotency on the engine side. ### Choose a failure policy This guide uses a **fail-open** policy. When the engine errors or exceeds its timeout, return a successful response that uses Set DirectDiscounts with an empty array. This explicitly removes the existing Direct Discounts, so a promotion outage degrades to a Cart without promotions rather than a Cart that cannot be changed. This is the opposite of the default for a compliance-driven [tax integration](/guides/tax-integration.md), where an untaxed Cart may be unacceptable and blocking is the safer failure. Choose deliberately, state the choice in the Connector documentation, and implement only the branch you chose. A Project that runs both integrations runs two different failure policies on the same Cart, which is correct and worth documenting. Fail-open has a visible consequence. A Cart can persist without the discounts the shopper expected, and a discount can disappear after an unrelated change. The next successful evaluation restores it, but only if the evaluator sends the complete Direct Discount array rather than a delta, and only if the failure branch follows the hash rules in [Skip unchanged evaluations](/guides/promotion-loyalty-integration.md#skip-unchanged-evaluations). Break either and a transient engine error becomes an empty state that persists. The policy covers engine errors and timeouts only. It does not protect the Cart from a malformed response. If the actions the evaluator returns are invalid, the platform returns a [502 ExtensionUpdateActionsFailed](/api/errors.md#extensionupdateactionsfailed) error and the Cart operation fails, however carefully the engine call was guarded. A `giftLineItem` draft carrying a `target`, or a reference to a Product that does not exist, surfaces as a failed Cart write rather than as a missing discount. Validate the drafts before returning them. A percentage sent in the wrong unit is the opposite failure. The draft is well formed, the Cart write succeeds, and the shopper silently receives a discount a hundredth of the intended size. No guard catches it, so cover unit conversion with a test instead. The platform makes one attempt. An API Extension is not retried within an API call, although later API calls reach it again. Size the timeouts against the [API Extension time limits](/api/projects/api-extensions.md#time-limits) rather than against the engine alone. The platform allows 1 second to connect and 2 seconds to respond by default, and the documented target is to answer within 50 ms. A promotion engine call rarely fits that, so raise the Extension timeout with `setTimeoutInMs` up to the 10-second maximum, above which a per-Project increase needs a performance review. Set the outbound engine timeout below whichever Extension timeout you configure, and cancel the call yourself rather than letting the platform time out. A Connector deployed to a sandbox environment can scale to zero, and its documented startup time exceeds the maximum Extension timeout. The first Cart operation after an idle period can therefore fail with a [504 ExtensionNoResponse](/api/errors.md#extensionnoresponse) error while the integration is correct. See [deployment behavior and environments](/connect/deployment-behavior-and-environments.md#environments) for the environment differences. ### Reject a coupon without failing the Cart update A shopper enters an invalid code. An Extension may answer a validation failure with a `400` response carrying an `errors` array, whose `message` and `localizedMessage` do reach the API caller. The problem is not the quality of the message but its cost: a `400` rejects the whole Cart operation, so the shopper's real change, such as adding an item or setting an address, is discarded along with the coupon. A business-rule outcome should not undo an unrelated action. Return `200` instead, apply no discount for the rejected code, and write the rejection and its reason to the Custom Field that the storefront reads. Reserve the `400` response for requests that are genuinely invalid. An HTTP Extension answers with `200` or `201` for a successful response, or `400` for a validation failure. Every other status, **including `202`**, is treated as a failure to respond properly and fails the triggering Cart operation. A successful evaluation that produces no actions is a `200` response with an empty action list. Pin this with a test, because a handler that returns `202` on an accepted request is a plausible mistake that breaks every Cart write. ### Understand what re-triggers the evaluator Two behaviors are commonly confused, and only one of them is a loop. The evaluator's own response does **not** call it again. The Extension runs before the result is persisted, and the actions it returns are applied within that same operation. Writing Set DirectDiscounts in the response is not a new Cart update. A write that your own integration makes through the API **is** a Cart update and does trigger the evaluator. An `event` application or a job that updates the Cart out of band produces one extra evaluation, and one extra engine charge, for a change no shopper made. A loop needs a second write: something in the integration must update the Cart again in response to the first evaluation's effects. Keep out-of-band writes outside the trigger condition, and make sure nothing reacts to a discount change by writing back to the Cart. ### Sequence against other Extensions commercetools triggers all API Extensions registered for the same resource and action concurrently. If a Project runs both a promotion Extension and a tax Extension on the Cart, discounts must be applied before tax is calculated, because tax is calculated on discounted amounts. When the two Extensions run concurrently, the tax calculator can evaluate the Cart state that precedes the discount. Declare the dependency using [Extension Chaining](/api/projects/api-extensions.md#extension-chaining) so that the tax calculator waits for the promotion evaluator and receives the state it produced. Register the dependencies in the deployment scripts alongside the Extension, and stay inside the bounds of a chain: - An Extension declares at most **** direct dependencies. - A chain runs at most **** levels deep. - Circular dependencies are rejected. - Cumulative execution time must stay inside the 60-second limit the platform enforces for an API request. Two chained Extensions that each call an external service consume a large share of that budget. Extension Chaining establishes execution order, but it does not complete an `ExternalAmount` tax calculation by itself. Applying a discount has a financial impact, so it invalidates a previously set total gross price, and `ExternalAmount` requires some tax actions to follow a price-affecting change in a separate request, which a chained Extension cannot issue. Work through the [tax integration guide](/guides/tax-integration.md) before you chain a promotion Extension in front of a tax Extension. It covers that sequencing, the elements that need amounts, and the `shippingKey` rules. ### Keep the mapping testable Keep the Cart-to-request and effects-to-actions mapping in pure functions with no network calls, so that the whole evaluation can be asserted without a deployment, a Cart, or a token. ## Implement the redemption application ### Decide when redemption happens Two redemption points are defensible, and the choice changes what the application subscribes to and what it must roll back. **Redeem when the Order is created.** This matches "the promotion was used" and is the simpler flow. It requires a rollback path, because an Order that is later canceled must release the coupon and reclaim the points. **Redeem when payment is confirmed.** Nothing is consumed for an Order that is never paid, so an abandoned unpaid Order needs no rollback and expires instead. This narrows the rollback duty rather than removing it, because an Order that is canceled or returned after payment still needs its coupon released and its points reclaimed. The cost is that the shopper sees the discount on the Order before the engine has consumed it, and the application subscribes to payment or Order State changes rather than Order creation. Choose one and implement it. Drifting between the two produces both sets of failure modes and neither set of guarantees. ### Subscribe to the right Messages For redemption at Order creation, subscribe to [OrderCreatedMessage](/search.md?urn=ctp:api:type:OrderCreatedMessage). Decide whether Orders created from a [Recurring Order](/search.md?urn=ctp:api:type:RecurringOrder) or through Order Import should also redeem, and subscribe to [OrderCreatedFromRecurringOrderMessage](/search.md?urn=ctp:api:type:OrderCreatedFromRecurringOrderMessage) and [OrderImportedMessage](/search.md?urn=ctp:api:type:OrderImportedMessage) accordingly. Add [OrderStateChangedMessage](/search.md?urn=ctp:api:type:OrderStateChangedMessage) and the return Messages your process uses when rollback is in scope. A Project holds up to **** Subscriptions. ### Redeem idempotently For each delivery, complete this sequence: 1. Validate the delivery and its Message type, and acknowledge deliveries that this application does not act on. 2. Fetch the current Order by `resource.id` rather than treating the delivery payload as the current resource. 3. Decide from the Order you fetched. Skip redemption for an Order that is already canceled, and skip a rollback for an Order that was never redeemed. 4. Redeem in the engine, keyed on a stable identifier derived from the Order ID, so that reprocessing the same Order does not create a second redemption. 5. Treat a response of `already redeemed` from the engine as success rather than an error to retry. 6. Record the outcome for that Order, so that a later delivery for the same Order can be recognized as already handled. 7. Acknowledge only after the engine call succeeded, was already complete, or failed permanently and was recorded for an operator. A Connect event application must acknowledge a delivery within 10 seconds, and Connect treats `102`, `200`, `201`, `202`, and `204` as acknowledgments. Set the outbound engine deadline below that window so that the handler decides the outcome instead of being cut off mid-call. A call that passes the deadline is not a permanent failure: record the redemption as pending under the same Order-derived key, respond so that the delivery is retried, and let the idempotency key collapse the duplicate attempt if the first call did reach the engine. The 7-day retention Connect applies to unacknowledged messages is queue behavior, not the source Subscription's retry window. A Subscription [retries for up to 48 hours](/api/projects/subscriptions.md#at-least-once-delivery) in the `TemporaryError` health status, and for up to 24 hours in a production Project or 1 hour in a development or staging Project in the `ConfigurationError` status, after which notifications are dropped. Treat those windows as the time available to repair a broken redemption path. See [Event application behavior](/connect/deployment-behavior-and-environments.md#event) and [Acknowledge deliberately](/guides/oms-integration.md#acknowledge-deliberately). [Subscriptions provide at-least-once delivery without ordering guarantees](/api/projects/subscriptions.md#delivery-guarantees), so both redelivery and out-of-order delivery are expected. Use the engine's idempotency key when it offers one, monitor Subscription health, and provide a replay or reconciliation procedure for Orders whose delivery is dropped. Missing order is a correctness problem once rollback is in scope, not only a duplicate-delivery problem. A cancellation or return Message can arrive before the `OrderCreated` Message for the same Order. A rollback processed first, followed by a redemption, leaves a canceled Order redeemed in the engine. This is why steps 3 and 6 gate on the Order's current state and on the recorded outcome rather than on the Message that woke the application. Reconcile recorded outcomes against current Order state on a schedule for the cases the engine cannot express, and test out-of-order delivery explicitly. Never redeem from a Cart. Only an Order is a purchase, and redeeming at evaluation time consumes coupons and awards points for Carts that are abandoned. Do not treat commercetools as the ledger for points or balances. Mirroring a balance onto a Customer Custom Field for display is reasonable. Reading that mirror back as authoritative is not, because it diverges from the engine silently. ### Roll back on cancellation or return When rollback is in scope, release the redemption and reclaim the points on the transitions the merchant designates. Drive those transitions from configured lists of Order and State keys rather than from names written into the code, because State keys differ between Projects. Handle a return as a partial rollback where the engine supports one. Where an Order Edit changes the discounted amounts, remember that changing Direct Discounts on an existing Order uses the Order Edit [Set DirectDiscounts](/search.md?urn=ctp:api:type:StagedOrderSetDirectDiscountsAction) action rather than a Cart update. ### Keep the engine session stable An engine tracks a session and a profile. Use the Cart ID as the session key and the Customer ID, or a stable anonymous identifier, as the profile key, and carry the same session key into the redemption call so that the engine can tie the redemption to the evaluations that preceded it. Not every Order has a Cart. The `cart` reference on an [Order](/search.md?urn=ctp:api:type:Order) is optional, and an imported Order has none, so there is no evaluation session to tie the redemption to. Decide what an Order without a Cart reference does: either exclude it from redemption, which is consistent with never subscribing to [OrderImportedMessage](/search.md?urn=ctp:api:type:OrderImportedMessage), or redeem it under the Order ID as the session key and accept that the engine records it as a session with no preceding evaluation. Falling back silently to a different key attributes the redemption to the wrong session. Sign-in can break that mapping unless you handle it. Under `MergeWithExistingCustomerCart` [AnonymousCartSignInMode](/search.md?urn=ctp:api:type:AnonymousCartSignInMode), a different Customer Cart can survive the [Cart merge](/api/carts-orders-overview.md#merge-a-cart). Under `UseAsNewActiveCustomerCart`, the anonymous Cart becomes the active Cart instead. The documented merge rules do not specify whether Cart-level Custom Fields transfer to the surviving Cart. Do not rely on an implicit transfer of the entered coupon code or promotion hash. Test the selected sign-in mode, explicitly re-apply required values to the surviving Cart, re-evaluate under its ID, and close or re-key the abandoned engine session. ## Test the applications Run these tests without a deployed Connector and without engine credentials. Mock the outbound boundary and assert on what the code decided: which endpoint it called, what it sent, and what it did with the answer. Test the following evaluator behavior: - Each engine effect type maps to the expected value and target, including permyriad conversion and the `shipping` discriminator. - The response replaces the complete Direct Discount array rather than sending a delta. - A successful response uses `200` or `201`, and no code path returns `202`. - An invalid coupon produces a `200` response with a rejection in the Custom Field, and a Set DirectDiscounts action carrying the complete remaining set, usually an empty array, so that a discount from a previously accepted code is removed. - A Cart with no Custom Fields Type assigned receives the Type before the first Set CustomField action, rather than failing. - An unchanged promotion hash returns an empty action list and makes no engine call, while a change to any hashed field causes a fresh evaluation. - An expired freshness entry and a submitted coupon code both bypass the skip and call the engine, and a failed evaluation clears the stored hash or marks it stale so that the next update retries. - An engine error and an engine timeout both follow the chosen failure policy. - A large Cart stays within the Extension action limit. - A gift effect for a Product that is absent from the catalog follows the decision you recorded, rather than throwing. Test the following redemption and deployment behavior: - The handler validates the delivery envelope, acknowledges Messages it does not act on, and fetches the Order by `resource.id`. - Redeeming the same Order twice produces one redemption and one point award, and a response of `already redeemed` is treated as success. - A cancellation delivered before the `OrderCreated` delivery for the same Order leaves that Order unredeemed. - An engine call that passes the outbound deadline records a pending redemption and completes on retry without a second redemption. - A Cart that never becomes an Order produces no redemption. - Configured cancellation and return transitions produce the expected rollback. - Running the deployment scripts more than once leaves one API Extension, one Subscription, and one Cart Type registered, with the Type's existing fields intact. - The undeploy script removes the API Extension and the Subscription, so that no platform call is left pointing at the removed endpoint. ## Verify and operate the integration Verify in a non-production Project against an engine test environment, and confirm that a real campaign matches your test Cart before concluding that anything is broken. Work through these checks: 1. **Verify configuration**: confirm that the deployment scripts registered the API Extension against the evaluator endpoint, created the Cart Type, and created the Subscription with the Messages and the destination the redemption application consumes. A Subscription is configured with a destination rather than pointed at an application URL, so check the destination and the application's consumption of it as separate steps. Confirm that the engine credentials are not present in standard configuration or in logs. 2. **Verify evaluation**: add a priced Line Item that an active campaign matches, then read the Cart. `totalPrice` has moved, and the discount is visible in the form the chosen mechanism produces. For Direct Discounts, `directDiscounts` lists the values and targets you sent, while the effect on prices appears in `discountedPricePerQuantity` on the affected Line Items for item targets, and in `discountOnTotalPrice` for a Cart-total target. For negative Custom Line Items, look for the added line. For engine-managed Discount Codes, check `discountCodes` and `discountedPricePerQuantity`. 3. **Verify coupon handling**: enter a valid code and confirm both a discount and an acceptance result. Enter an invalid code and confirm that the Cart update **succeeds** and carries a rejection reason. The acceptance and rejection results live in the Custom Fields only where the evaluator owns the code; for engine-managed Discount Codes, read the platform's own [DiscountCodeState](/search.md?urn=ctp:api:type:DiscountCodeState) instead. 4. **Verify call reduction**: repeat an update that changes nothing the engine reads and confirm that no engine call occurred. Then change a hashed field and confirm that a fresh evaluation ran. 5. **Verify redemption**: place an Order, confirm that the application acknowledged the Message, and confirm through the engine's API that the effects you configured for the selected engine occurred, such as the coupon being consumed, the session being closed, or the points being awarded. Where the engine distinguishes a redemption from an evaluation, confirm that the redemption is recorded as one. 6. **Verify redelivery**: deliver the same Order Message twice and confirm exactly one redemption and one point award. 7. **Verify the abandoned Cart**: evaluate a Cart with a points-earning promotion and abandon it. The engine shows no redemption and no points. 8. **Verify the failure policy**: make the engine unavailable. Under fail-open, the Cart update still succeeds without discounts, and the next successful evaluation restores them. 9. **Verify sign-in**: sign in with an anonymous Cart that already carries an evaluated session and an entered code, and confirm that the code, the discounts, and the engine attribution land where you decided they should. 10. **Verify rollback**: where it is in scope, cancel an Order and confirm one rollback in the engine, then repeat for a return. Monitor evaluation latency, engine errors and timeouts, the ratio of skipped to executed evaluations, Subscription health, and redelivery counts. Use the Cart ID and the Order ID as the trace keys that both applications share, because a Subscription delivery does not carry the correlation ID of the API Extension request that preceded it. Log that correlation ID in the evaluator for the synchronous path, and propagate it to the redemption application only if the evaluator persists it somewhere the Order-driven flow can read. ### Diagnose common failures Several correct behaviors in this domain read as defects. Engine-side symptoms assume that the selected engine exposes the campaign, reporting, idempotency, and session capabilities configured for this guide. Adapt those checks to the selected engine. | Symptom | Likely cause | Resolution | | --- | --- | --- | | Discount Codes stopped affecting Carts after go-live. | Expected. A Cart carrying Direct Discounts ignores matching Cart Discounts, and a Discount Code cannot be added to it. | This is the exclusivity rule working as designed. If both are genuinely required, the ownership decision needs revisiting. | | The engine returns no discount. | Frequently correct. No rule matched, the campaign is not active or not yet started, the budget is exhausted, the coupon is expired or at its usage limit, the Customer is outside the targeted segment, or the Connector points at a different engine environment than the dashboard you are reading. | Confirm in the engine that an active campaign matches the test Cart. An unconditional test campaign separates a wiring fault from a campaign that did not match. | | `directDiscounts` is empty. | The evaluator did not run or returned no actions. The Extension can be unregistered, the trigger condition can be unmatched, or a fail-open branch can have returned no discounts. | Confirm the registration, then the trigger condition, then the engine call and the failure-policy path. An empty array is not evidence that the engine returned zero. | | Every Cart update fails. | The Extension answered with a status other than `200` or `201`, including `202`, or it exceeded its timeout. A `400` validation response also rejects the triggering operation. | Return `200` or `201` for a successful evaluation, and set the engine timeout below the Extension timeout. | | The shopper's unrelated Cart change is lost when a coupon is invalid. | The evaluator answered an invalid code with a `400` validation failure, which fails the whole operation. | Return `200` with a rejection reason in a Custom Field. | | The first Cart operation after an idle period fails with `ExtensionNoResponse`. | A sandbox deployment scaled to zero, and the cold start exceeded the Extension timeout. | Expected for that environment. Verify against a deployment that does not scale to zero. | | A discount disappeared after an unrelated Cart update, then returned. | The fail-open branch ran during an engine error, and the following evaluation restored the complete array. | Expected. A discount that stays missing instead means the evaluator sends deltas rather than the complete array, or the failure branch stored the hash and the Cart now skips the engine. | | A Cart keeps a discount from a campaign that has ended, or misses one that has just started. | The promotion hash is unchanged, so the skip rule suppressed the engine call. A Cart hash cannot observe engine-side changes. | Bound the skip with a freshness lifetime or an engine version token, and bypass it when a coupon code is submitted or removed. | | A discount is applied at a hundredth of its intended size. | An engine percentage was forwarded without conversion to permyriad, so `10` produced 0.1% rather than 10%. | Convert before mapping. 10% is `1000`. | | The Cart operation fails when a shipping discount is applied. | The target used the type name `shippingCost` rather than the `shipping` discriminator, or a fixed amount was mapped to a multi-buy target. | Use `shipping`, and map fixed-amount quantity offers to `pattern`. | | The Cart operation fails with `ExtensionUpdateActionsFailed` although the engine answered. | The returned update actions were invalid, which the fail-open branch does not cover. | Validate the discount drafts before returning them. Check the permyriad unit, a `giftLineItem` carrying a `target`, and references to Products that do not exist. | | An engine discount on specific items changes no price, and no error is returned. | Those Line Items use the `ExternalTotal` price mode, or those Custom Line Items use the `External` price mode, either of which deactivates Cart Discounts for the item. | Target the Cart total for Carts that carry externally priced Line Items or Custom Line Items. | | An engine discount is replaced by a Product Discount on some Line Items. | The Project uses the `BestDeal` discount combination mode, so only one discount type applies per Line Item and the platform selected the lower Cart total. | Expected for that mode. Switch the Project to `Stacking` if both must apply. | | Direct Discounts are set but the totals do not change, or setting them is rejected. | The Cart is frozen. Under `SoftFreeze` they are added without being applied, and under `HardFreeze` they cannot be set. | Exclude frozen Carts from the trigger condition, and re-evaluate after the Cart is unfrozen. | | Tax is calculated on undiscounted amounts. | The promotion and tax Extensions ran concurrently. | Declare the tax Extension's dependency on the promotion evaluator through Extension Chaining. | | The engine records no redemptions although Carts show discounts. | Only evaluation is wired, so no redemption call is ever made. Some engines log evaluations, so a populated dashboard is not evidence that redemption ran. | Confirm that the Subscription is registered and that the redemption application acknowledges deliveries, then check the engine for redemption records specifically. | | The engine holds two redemptions for one Order. | Redemption is not idempotent across redelivery. | Key the redemption on a stable Order-derived identifier and treat `already redeemed` as success. | | A canceled Order is redeemed in the engine, and the rollback ran before it. | Subscriptions do not guarantee order, so the cancellation Message arrived before the `OrderCreated` Message. | Gate both operations on the Order fetched at processing time and on the recorded outcome for that Order, then reconcile on a schedule. | | Coupons are consumed and points are awarded for Carts that were never ordered. | Redemption is happening during evaluation. | Redeem only from the Order-driven application. | | Recurring Orders are never redeemed. | The Subscription covers `OrderCreated` only, which excludes Orders generated by a Recurring Order schedule. | Subscribe to the recurring Order Message, and to the imported Order Message, when those Orders should redeem. | | A coupon disappears when the shopper signs in. | The sign-in flow did not carry the code to the surviving Cart. | Re-apply the code explicitly and re-evaluate under the surviving Cart ID. | | Usage limits or points land on the wrong shopper. | The engine session followed the anonymous Cart, which is no longer the active Cart. | Close or re-key the engine session during the sign-in flow. | | The evaluator runs several times for one shopper action, and engine charges climb. | Another application in the integration writes to the Cart through the API, which triggers the Extension again. | Filter your own writes, or keep them outside the trigger condition. | | `setCustomField` fails on a new Cart. | The Cart has no Custom Fields Type assigned, or the deployment script did not create the Type. | Create the Type in `postDeploy`, reading before writing, and assign it through the CartDraft or a Set CustomType action before the first Set CustomField. | | Deployment fails with an access-denied error. | The deployment client is missing a scope, most often `manage_api_clients` when the Connector generates its own credentials. | Use a deployment client that holds `manage_connectors_deployments` and `manage_api_clients`. These are not declared in `connect.yaml`. | | Every platform call to the Connector returns a not-found response. | The Extension destination points at the service base URL rather than the application endpoint path. | Register the destination with the endpoint path, and mount the route to match. | ## Related pages - [Area overview page with navigation](/guides.md) - [Previous page: Integrate gift cards](/guides/gift-card-integration.md) - [Next page: Integrate email](/guides/connect-email-integration.md) - [Search documentation and API specs](/search.md)