Let an external promotion, coupon, or loyalty engine decide the discounts on a Cart, and record the redemption after the Order is placed.
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.
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. Product Discounts are decided separately, by the Project's 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.
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
- 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.
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.Choose a Connector path
view_connectors scope to call the Search Connectors endpoint, filtering on the promotion IntegrationType, which covers promotions and loyalty:GET https://connect.{region}.commercetools.com/connectors/search?integrationTypes=promotion&private=false
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. 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:
- Configure an existing deployable Connector when its evaluation, coupon handling, loyalty, redemption, rollback, and regional capabilities fit.
- 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.
- 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.
- Build both applications when no Connector exists for the engine, which is the usual case for a promotion service that you operate.
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, 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, 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. |
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
serviceapplication registered as a Cart API Extension, which evaluates. - An
eventapplication driven by a Subscription, which redeems.
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.
| 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.
| 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 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 or 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 |
| Free or reduced shipping | relative or absolute | 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 or MultiBuyCustomLineItemsTarget |
Five details in that mapping cause defects that are hard to attribute:
- A
relativevalue is a permyriad, one ten-thousandth. A 10% discount is1000. An engine value of10forwarded 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 namedCartDiscountShippingCostTarget. A draft that sendsshippingCostis 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. AlineItemstarget is not a substitute, because it discounts the matching Line Items as a whole and cannot express the trigger and discounted quantities. - A
giftLineItemvalue must not carry atarget, 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 asupplyChannelwithout theInventorySupplyrole or adistributionChannelwithout theProductDistributionrole. 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.
{
"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"
}
]
}
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.100 update actions. The limit counts actions rather than the drafts inside them, so a single Set DirectDiscounts action carrying many DirectDiscountDrafts 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 with the
ExternalTotalLineItemPriceMode, despite a matching CartDiscountLineItemsTarget or MultiBuyLineItemsTarget. - A CustomLineItem with the
ExternalCustomLineItemPriceMode, despite a matching CartDiscountCustomLineItemsTarget, MultiBuyCustomLineItemsTarget, or 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
- 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.
Add a field for engine-supplied campaign messaging when the storefront renders prompts such as progress toward a threshold.
custom on the CartDraft at Cart creation, or by returning Set CustomType 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:
postDeployfetches 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.postDeployreads the Type before writing it, so that a redeployment does not remove fields that already exist.preUndeploydeletes 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.preUndeployleaves the Type in place. Custom Field data on existing Carts depends on it.
Configure the Connector and scopes
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.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
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_extensionsfor the script that registers the Cart API Extension.manage_subscriptionsfor the script that registers the Subscription.view_ordersfor 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_typesonly when a deployment script creates the Cart Type.
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 reference. A token request naming a scope the API Client does not hold returns a 400 invalid_scope error at authentication, rather than a permission error at the failing call.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 and Modify a Connector.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
cart resource for Create and Update actions, and add a conditional trigger 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.cartState for a second reason: a Cart with the Frozen 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.
is defined so that a missing value does not fail the Cart operation with a 400 ExtensionPredicateEvaluationFailed error.25 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
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.
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.Reject a coupon without failing the Cart update
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.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.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.
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.
- An Extension declares at most
5direct dependencies. - A chain runs at most
3levels 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.
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 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.
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
50 Subscriptions.Redeem idempotently
For each delivery, complete this sequence:
- Validate the delivery and its Message type, and acknowledge deliveries that this application does not act on.
- Fetch the current Order by
resource.idrather than treating the delivery payload as the current resource. - 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.
- 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.
- Treat a response of
already redeemedfrom the engine as success rather than an error to retry. - Record the outcome for that Order, so that a later delivery for the same Order can be recognized as already handled.
- Acknowledge only after the engine call succeeded, was already complete, or failed permanently and was recorded for an operator.
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.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 and Acknowledge deliberately.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.
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.
cart reference on an 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, 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.MergeWithExistingCustomerCart AnonymousCartSignInMode, a different Customer Cart can survive the Cart merge. 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
shippingdiscriminator. - The response replaces the complete Direct Discount array rather than sending a delta.
- A successful response uses
200or201, and no code path returns202. - An invalid coupon produces a
200response 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 redeemedis treated as success. - A cancellation delivered before the
OrderCreateddelivery 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:
- 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.
- Verify evaluation: add a priced Line Item that an active campaign matches, then read the Cart.
totalPricehas moved, and the discount is visible in the form the chosen mechanism produces. For Direct Discounts,directDiscountslists the values and targets you sent, while the effect on prices appears indiscountedPricePerQuantityon the affected Line Items for item targets, and indiscountOnTotalPricefor a Cart-total target. For negative Custom Line Items, look for the added line. For engine-managed Discount Codes, checkdiscountCodesanddiscountedPricePerQuantity. - 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 instead.
- 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.
- 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.
- Verify redelivery: deliver the same Order Message twice and confirm exactly one redemption and one point award.
- Verify the abandoned Cart: evaluate a Cart with a points-earning promotion and abandon it. The engine shows no redemption and no points.
- 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.
- 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.
- 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. |