Liferay API constraints
OData Filtering & API Constraints¶
To ensure maximum compatibility across Liferay's diverse Headless API implementations (specifically verified on DXP 2025.Q1), the following OData and filtering patterns must be strictly followed:
1. The "Filter-In-Memory" Mandate¶
Empirical testing confirms that Liferay's REST and GraphQL engines are inconsistent when handling complex filters.
- The Rule: NEVER use complex OData filters (e.g.,
ne,not, or deeporconditions) for discovery. - The Pattern: Fetch all relevant items using a simple, stable filter (like
catalogId eq 123or no filter at all) and perform all exclusions, prefix matching (AICA-), and UUID pattern verification strictly in JavaScript memory. - Rationale: This bypasses "400 Bad Request" errors on unstable fields (like
name) and prevents the "Fatal GraphQL death filter" bug.
2. Implementation Caveats & Mandatory Patterns¶
- Operator Ban (
swand "startswith"): NEVER usesworstartswith()operators for prefix filtering. These operators are inconsistently supported and frequently triggerDataFetchingException: null(500 error) in Liferay's Headless GraphQL fetchers, particularly forheadlessAdminUser. - FATAL: GraphQL Filter Bug: Empirical testing confirms that ANY complex filter on the
headlessAdminUsernamespace (e.g.,id eq ... or id eq ...) can trigger a fatalnullexception in Liferay's data fetchers. - Regional Metadata Fallbacks: Liferay's Headless API for Addresses strictly validates the
addressRegionfield. Providing placeholder strings like "N/A" will result in a400 Bad Request. Always providenullif a region cannot be determined.
Indirect Relationship Glue (Liferay Commerce 2025.Q1)¶
In newer Liferay Commerce versions, Catalogs and Channels are decoupled. For a store to function, the "Glue" must be explicitly established via indirect relationships:
- Product Visibility: Every product must be linked to a channel. Without this, products will not appear in the storefront.
- Inventory Visibility: Every warehouse must be linked to a channel via the
/warehouse-channelsAPI. Without this, stock levels will remain at zero in the checkout, regardless of warehouse items. - ERC-First Resilience: Always use the
by-externalReferenceCodepath for establishing these links to bypass search index lag.
Writing a product-to-channel link¶
/product-channels is read and delete only — products/{id}/product-channels and its by-externalReferenceCode twin expose GET, and product-channels/{id} exposes GET and DELETE. There is no POST. The association can only be written through the product itself, whose DTO carries productChannels as a nested collection, so either:
- send
productChannelswhen creating the product (create-productsdoes this for every channel the run names), or PATCHthe product withproductChannelsafterwards (link-product-channelsdoes this to attach a later run's channel to an existing catalogue).
The PATCH replaces, it does not append. ProductResourceImpl's update path calls deleteCommerceChannelRels for the whole product and then re-adds exactly what the payload names, so a PATCH must always carry the union of the channels the product already has and the ones being added. Read the current set from GET .../product-channels first.
productChannelFilter is a separate field from productChannels, and adding channels does not turn it on. It defaults to false on create, and Liferay keeps the existing value when the field is absent from a PATCH. While it is false, CPDefinition.channelFilterEnabled is false and the channel rels do not restrict anything — see Channel eligibility is not enforced on the admin-order path.
Channel eligibility is not enforced on the admin-order path¶
Liferay does have a channel-eligibility check for order items — VisibilityCommerceOrderValidatorImpl._isChannelEnabled, which looks up a CommerceChannelRel keyed on the CPDefinition and the order's channel, and fails with one-or-more-products-are-no-longer-available. It is gated twice, and AICA's generation path clears both gates:
- The check short-circuits to
trueunlessCPDefinition.channelFilterEnabledis set, and AICA never sendsproductChannelFilter. OrderItemUtil.addCommerceOrderItemandaddOrUpdateCommerceOrderIteminheadless-commerce-admin-orderboth callExportImportThreadLocal.setPortletImportInProcess(true)as their first instruction, andCommerceOrderItemLocalServiceImpl._validateskips the entireCommerceOrderValidatorRegistrychain while an import is in process.POST /orderswith nestedorderItemsgoes through the same helper.
The only product-side validation the admin-order API performs is that the SKU id or ERC resolves to a CPInstance in the same company — not the catalog, and not the channel.
headless-commerce-delivery-cart (CartItemResourceImpl) does not set the import flag, so the validator chain does run for a real shopper adding to a cart.
Verified against dxp-2026.q1.7-lts bytecode. The practical consequence: an unlinked product produces orders Liferay accepts but a storefront cannot browse, so missing links are a visibility defect on the generation path rather than an order-creation failure.
---## Strict DTO Hardening
Liferay's newer Headless APIs (2024.Qx+) enforce strict metadata validation for nested relationships:
- Full Metadata Objects: Many DTOs (e.g.,
Specification) require a Full Parent Object instead of a flat ID. - Correct:
"optionCategory": { "id": 123, "key": "spec-group", "title": { "en_US": "Specs" } } - Incorrect:
"optionCategoryId": 123 - Indexing Heartbeats: Implement a 2-3 second delay between linking a child to a parent (e.g., Options to Product) and performing dependent operations (e.g., creating SKUs or Inventory). This allows Liferay's internal relationship mapping to settle.
- Pricing Resilience: Pricing V2.0 strictly requires the
discountDiscoveryboolean in thePriceEntryDTO. Omitting it will cause a backendNullPointerException.
neverExpire defaults the opposite way for products and SKUs¶
An omitted neverExpire does not mean the same thing on both halves of a product payload, and the SKU half is the dangerous one:
| Payload | Read as | Effect of omitting it |
|---|---|---|
Product |
GetterUtil.getBoolean(product.getNeverExpire(), true) (ProductResourceImpl:739) |
Never expires. |
Sku |
GetterUtil.get(sku.getNeverExpire(), false) (SkuUtil:232) |
Expires. With no expirationDate alongside it, DateConfig.toExpirationDateConfig sets one exactly one month ahead. |
The SKU is Approved and purchasable on the day it is created — expirationDate.before(date) is false, so nothing is visibly wrong. About thirty days later CheckCPInstanceSchedulerJobConfiguration runs _checkCPInstancesByExpirationDate (CPInstanceLocalServiceImpl:1617-1641), the SKU becomes STATUS_EXPIRED, and the storefront stops listing it (SkuResourceImpl:213-222 only reads Approved instances) while the product above it stays Approved and complete-looking.
- The rule: send
neverExpireexplicitly on everyProductandSkupayload.neverExpire: trueleaves the column null (CPInstanceLocalServiceImpl:159-164and:1052-1057only compute an expiry when!neverExpire), and a null expiry can never match the sweeper's finder. - Updates do not merge it. Both resources read the field from the payload with the same defaults on update as on create, so a PATCH or UPSERT that omits
neverExpireresets it — a product meant to expire is silently un-expired by any later write that leaves the field out. - It is write-only.
SkuDTOConverternever setsneverExpireon read, so a GET will not echo it back. ReadexpirationDateinstead: null means it never expires. - Configured, not hardcoded: the
catalog-expiry-configentry (neverExpire,expiryDays) decides this per environment; a missing entry resolves toneverExpire: true. Seeutils/catalogExpiry.cjs.
Verified against liferay-portal 7.4.3.141 (cb125431de).
---## Liferay v2.0 Pricing & Batch APIs (Engineering Rules)
Extensive empirical testing against Liferay DXP (2025.Q1) revealed strict constraints regarding the v2.0 Headless Pricing API and the Headless Batch Engine:
1. Batch Endpoints: POST vs PUT¶
- Rule: Liferay's Headless Batch Engine endpoints (e.g.,
/v2.0/price-lists/price-entries/batch) strictly expect the HTTPPOSTmethod for batch creation operations. - The Pitfall: Attempting to use
PUTfor UPSERT behavior on these endpoints will result in a405 Method Not Allowed. - The Implication: Since
POSTstrictly performs aCREATEoperation, sending a batch payload containing ERCs that already exist in the database will immediately trigger a400 Bad Request("This external reference code is already in use"). You must clean/delete prior entries before generating new ones with the same ERCs.
2. Batch Tracking Query Parameter Collision¶
- Rule: When using a
/batchendpoint, Liferay intercepts theexternalReferenceCodeURL query parameter and assigns it to the Batch Import Task itself (not the target entity). - The Pitfall: If you incorrectly pass a target entity ERC (like
AICA-PL-GENERALfor a Price List) in the query string (?externalReferenceCode=AICA-PL-GENERAL), Liferay will attempt to assign the Price List's ERC to the newly created Batch Task. This causes an immediate400 Bad Requestcollision. - The Pattern: Always pass a dynamically generated, unique
batchERC(e.g.,AICA-BATCH-12345) in the query parameter to allow tracking via WebHooks, and define the target relationships strictly inside the JSON payload items.
3. Strict Pricing DTO Schemas¶
Liferay's Java deserializer for PriceEntry is extremely unforgiving. The JSON payload MUST exactly match the expected Object structure:
- Nested SKU Object: The
skuproperty MUST be a nested object wrapper (e.g.,"sku": { "externalReferenceCode": "..." }or"sku": { "id": 123 }). Sending a flat string (e.g.,"sku": "SKU-123") will trigger a Java constructor exception (no String-argument constructor/factory method to deserialize from String value). - Required Booleans: The
hasTierPriceboolean MUST be explicitly provided. - Extraneous Fields: Do NOT send internal microservice state flags (like
bulkPricingordiscountDiscovery) in the payload, as the strict DTO validation will reject unknown properties.
4. Recursive ERC Deduplication¶
Liferay evaluates batch payloads recursively. If a single payload contains nested arrays (like tierPrices inside priceEntries), all ERCs within that nested array must be mathematically unique across the entire payload.
- The Pitfall: If the AI hallucinates two duplicate
tierPrices(e.g., two entries for "minimum quantity: 10"), generating an identicalexternalReferenceCodefor both, the entire batch will fail with "already in use", even on a clean database. - The Pattern: Aggressively deduplicate nested properties (e.g., using a
SetonminimumQuantity) in memory before assembling the Liferay DTO.
5. Sku.price is a price entry in the catalog's base price list¶
A catalog is never priceless and never has room for a second standard list. Creating one runs CommerceBasePriceListHelper.addCatalogBaseCommercePriceList, which adds <catalog> Base Price List (type price-list) and <catalog> Base Promotion (type promotion). Liferay resolves "the catalog's base list" by flag and type, never by name — CommercePriceListLocalServiceImpl.fetchCatalogBaseCommercePriceListByType is fetchByG_C_T(groupId, catalogBasePriceList = true, type).
Sku.price is not a column that sits quietly on the SKU:
ProductResourceImpl(nestedskus) andSkuResourceImplboth callSkuUtil.updateCommercePriceEntriesafter writing theCPInstance.- Under pricing calculation V2.0 that writes
priceinto the base price-list andpromoPriceinto the base promotion, as an upsert keyed on (price list,CPInstanceUuid, unit of measure). -
It fires unconditionally. Omitting
pricedoes not skip it;GetterUtil.get(sku.getPrice(), cpInstance.getPrice())just files the SKU's current value instead. There is no payload that leaves the base list untouched. -
The consequence: a price list AICA creates for standard prices is always a second home for prices Liferay has already filed elsewhere, and neither list then holds the whole picture. Adopt the catalog's base list per type instead of creating a competing pair.
- The duplicate trap: the entry
SkuUtilcreates has no external reference code, andPriceEntryResourceImpl._addOrUpdateCommercePriceEntrymatches onpriceEntryIdfirst and the ERC second — never on (list, SKU). Posting an ERC-bearing entry for a SKU that already has Liferay's ERC-less one therefore adds a second row in the same list; there is no unique index to stop it. Read the list's entries, and sendpriceEntryIdfor a SKU that already has one. - The read is index-backed:
GET /price-lists/{id}/price-entriesgoes throughSearchUtil.search, so it inherits the usual indexing lag. Reconciliation must degrade to "post a new entry" rather than fail. - A run may skip pricing entirely: because the write is unconditional and
create-product-skussends a price on every SKU, a run with Generate Price Lists off still leaves every generated SKU with a resolvable price in<catalog> Base Price List. The toggle governs AICA's own price-entry writes (create-price-lists, and the bulk and tier steps nested under it), not whether the catalogue is priced.update-catalog-configstays outside the toggle: it is the only step that moves thecatalogBasePriceListflag back onto the catalog's own list when an earlier run left it on an AICA one, and that flag is what decides whereSku.pricelands.
CommercePriceEntry read DTOs carry priceEntryId, skuId and skuExternalReferenceCode, which is enough to index a list by SKU.
Verified against liferay-portal modules/apps/commerce (commerce-price-list-service, headless-commerce-admin-catalog-impl, headless-commerce-admin-pricing-impl).
Last Reviewed: 2026-09-08