Fulfillment Constraints
fulfillment_constraints functions run at order placement and decide,
per cart line, which fulfillment locations are allowed to ship it.
If a line ends up with zero allowed locations the order is blocked
with a clear error. Non-blocking outputs (every line has at least one
allowed location) are persisted onto the order so the downstream routing
engine can pick the location without re-running the function.
Use it when:
- Some SKUs are only stocked at certain warehouses (single-source items).
- A line is hazmat and must ship from a licensed hub only.
- An express-shipping promise can only be honoured from specific
regions.
- A line carries a made-to-order attribute and must route to the
manufacturer.
- A line is digital and should bypass physical locations entirely.
fulfillment_constraints is the WASM-backed, code-driven counterpart to
order routing rules. Both narrow which
location ships which line; this one can also block the order.
How it works
The function runs at order placement immediately after
order_validation and before the order row is persisted. If you
return an empty allowedLocationIds for any line, the entire order is
rejected — partial blocking is not supported.
Per-shop cap: 5 active fulfillment_constraints apps (matches
order_validation). The dispatcher runs every installed app of this
type and collects their constraints into a single union before
evaluating blocks.
Manifest
{
"handle": "warehouse-routing",
"name": "Warehouse Routing",
"version": "1.0.0",
"extensions": {
"functions": [
{
"type": "fulfillment_constraints",
"handle": "constrain-by-attribute",
"title": "Warehouse routing by attribute",
"entrypoint": "dist/fulfillment-constraints.wasm",
"inputFields": {
"cart": {
"lines": {
"id": true,
"quantity": true,
"merchandise": {
"id": true,
"sku": true,
"attributes": true
}
}
},
"fulfillmentLocations": true,
"shippingAddress": { "country": true, "province": true }
}
}
]
}
}
type must be fulfillment_constraints. entrypoint is the path
inside your app bundle to the compiled WASM. The inputFields
projection (see Input fields) trims the
payload to just the fields you read — recommended for fast dispatch.
interface FulfillmentConstraintsFunctionInput {
cart: {
items: CartLineItem[]; // Note: surfaced as `items`, not `lines`
totalPrice: number;
itemCount: number;
currency: string;
localization?: {
country: string;
language: string;
presentmentCurrencyRate?: number;
};
metafields?: MetafieldBag; // cart-scoped metafields
};
shippingAddress?: {
firstName?: string;
lastName?: string;
address1?: string;
city?: string;
province?: string;
country?: string;
zip?: string;
};
// Catalog of fulfillment locations available to the shop.
// NOTE: in the current build, the dispatcher passes an EMPTY array here
// until the platform's location catalogue ships. Apps that
// need a location list should keep their own map in app metafields or
// hardcode the gid()s they manage.
fulfillmentLocations: Array<{
id: string; // gid://launchmystore/Location/<uuid>
gid?: string;
name?: string;
address?: AddressInput;
capabilities?: string[]; // e.g. ["hazmat", "cold_chain"]
}>;
}
interface CartLineItem {
id: string; // cart line id; pass back in constraints[].lineId
gid?: string; // gid://launchmystore/CartLine/<uuid>
variantId: string;
productId: string;
title: string;
quantity: number;
price: number;
sku?: string;
properties?: Record<string, string>;
metafields?: MetafieldBag;
merchandise?: {
id: string;
productId: string;
metafields?: MetafieldBag;
product?: { id: string; metafields?: MetafieldBag };
};
}
cart.items vs cart.lines. The platform serializes cart contents
as items for back-compat with existing apps; some examples in this doc
use lines (the canonical name in our input-field schema). Treat them
as the same array. If your function uses inputFields to project, list
cart.lines — the projector aliases both.
Output shape
interface FulfillmentConstraintsFunctionOutput {
constraints: Array<{
lineId: string; // must match cart.items[].id
allowedLocationIds: string[]; // empty = block this line
message?: string; // surfaced to the customer when blocking
}>;
}
Rules:
lineId must match a cart.items[].id returned by the platform.
An unknown lineId is silently ignored.
allowedLocationIds is the narrowed set — locations your function
approves. An empty array blocks the order.
message is only displayed when blocking. If omitted, the customer
sees "Line <lineId> cannot be fulfilled from any location".
You only need to return constraints for lines you have an opinion on.
Lines without a constraint entry are unconstrained — the routing engine
can ship them from any location.
Validator
The platform validates output shape strictly:
| Check | Behaviour on failure |
|---|
Output is non-null object with constraints array. | Function result discarded; logged. |
Every constraints[].lineId is a string. | Function result discarded; logged. |
Every constraints[].allowedLocationIds is string[]. | Function result discarded; logged. |
A discarded result behaves as if the function returned no constraints —
the order proceeds. This is intentional: a buggy app must not be able to
take a merchant’s storefront offline.
Worked examples
Example: hazmat → licensed hub only
Preventing flammable / hazardous SKUs from shipping from a regular DC.
// fulfillment-constraints.js — compiled to WASM
export default function main(input) {
// Build the set of locations licensed for hazmat shipment. In this
// build the dispatcher passes an empty `fulfillmentLocations`, so
// fall back to a hard-coded id if no catalogue arrived.
const fromCatalogue = (input.fulfillmentLocations || []).filter(
(l) => Array.isArray(l.capabilities) && l.capabilities.includes('hazmat'),
);
const hazmatLocations = fromCatalogue.length > 0
? fromCatalogue.map((l) => l.id)
: ['gid://launchmystore/Location/hazmat-hub'];
const constraints = (input.cart.items || input.cart.lines || [])
.filter((line) => line.merchandise?.attributes?.hazmat === 'true')
.map((line) => ({
lineId: line.id,
allowedLocationIds: hazmatLocations,
message:
'This item contains hazardous materials and ships from our licensed warehouse only.',
}));
return { constraints };
}
If no hazmat-hub is configured for the shop and the catalogue is
empty, the function returns the hard-coded id and the routing engine
will use it. If the merchant later wires the catalogue, the function
auto-picks up the right ids without redeploy.
Example: oversold lines → block
Prevent a checkout when an in-cart line has zero stock anywhere. This
is the canonical “fail-loud” use case.
export default function main(input) {
const constraints = [];
for (const line of input.cart.items || input.cart.lines || []) {
const eligible = (input.fulfillmentLocations || []).filter((loc) => {
// Read per-variant per-location inventory from a metafield that the
// app populates via its install hook. Schema:
// inventory: { byLocation: { [locationId]: { quantity } } }
const inv = line.merchandise?.metafields?.app_inventory?.by_location?.value;
const atLoc = inv?.[loc.id];
return atLoc && atLoc.quantity >= line.quantity;
});
if (eligible.length === 0) {
constraints.push({
lineId: line.id,
allowedLocationIds: [], // empty → block
message: `${line.title} is out of stock and cannot be shipped right now.`,
});
} else {
constraints.push({
lineId: line.id,
allowedLocationIds: eligible.map((l) => l.id),
});
}
}
return { constraints };
}
When even one line resolves to allowedLocationIds: [], the entire
order is blocked with HTTP 400 and all the function’s messages are
joined into a single human-readable string.
Example: geo-restricted SKUs
Some SKUs (knives, alcohol, electronics with regional certifications)
are only legal to ship within certain regions.
export default function main(input) {
// Country-specific routing for restricted items. Keyed by a sku-prefix
// convention enforced by the merchant's PIM.
const restrictedPrefixes = {
'KNIFE-': { allowed: ['US'], locations: ['gid://launchmystore/Location/us-knife-hub'] },
'ALCO-': { allowed: ['US', 'CA'], locations: ['gid://launchmystore/Location/us-alco-hub'] },
};
const destCountry = input.shippingAddress?.country;
const constraints = [];
for (const line of input.cart.items || input.cart.lines || []) {
const sku = line.merchandise?.sku || line.sku || '';
const match = Object.entries(restrictedPrefixes).find(([prefix]) =>
sku.startsWith(prefix),
);
if (!match) continue; // not restricted
const [, rule] = match;
if (!rule.allowed.includes(destCountry)) {
constraints.push({
lineId: line.id,
allowedLocationIds: [],
message: `${line.title} cannot be shipped to ${destCountry}.`,
});
} else {
constraints.push({
lineId: line.id,
allowedLocationIds: rule.locations,
});
}
}
return { constraints };
}
Example: digital lines bypass physical locations
Digital downloads should not consume a physical-location slot.
const DIGITAL_LOCATION = 'gid://launchmystore/Location/digital-fulfillment';
export default function main(input) {
const constraints = (input.cart.items || input.cart.lines || [])
.filter((line) => line.merchandise?.attributes?.fulfillment_type === 'digital')
.map((line) => ({
lineId: line.id,
allowedLocationIds: [DIGITAL_LOCATION],
}));
return { constraints };
}
Example: combine narrowing + non-blocking pass-through
A common pattern: narrow some lines (regional), block others (oversold),
let the rest fall through unconstrained.
export default function main(input) {
const constraints = [];
const lines = input.cart.items || input.cart.lines || [];
for (const line of lines) {
const attrs = line.merchandise?.attributes || {};
if (attrs.hazmat === 'true') {
constraints.push({
lineId: line.id,
allowedLocationIds: ['gid://launchmystore/Location/hazmat-hub'],
});
} else if (attrs.fulfillment_type === 'digital') {
constraints.push({
lineId: line.id,
allowedLocationIds: ['gid://launchmystore/Location/digital-fulfillment'],
});
} else if (line.merchandise?.metafields?.inventory?.total?.value === 0) {
constraints.push({
lineId: line.id,
allowedLocationIds: [],
message: `${line.title} is out of stock.`,
});
}
// Other lines: no constraint entry → routing engine picks any location.
}
return { constraints };
}
Error behaviour when no eligible location exists
When any constraint resolves to allowedLocationIds: [], the order
is rejected with:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"statusCode": 400,
"message": "error",
"data": null,
"error": "This item ships from our hazmat-licensed warehouse only.; OUT_OF_STOCK_LINE_2 is out of stock.",
"errors": [
{
"cartLineId": "cl_abc...",
"reason": "This item ships from our hazmat-licensed warehouse only.",
"appId": "warehouse-routing"
},
{
"cartLineId": "cl_def...",
"reason": "OUT_OF_STOCK_LINE_2 is out of stock.",
"appId": "warehouse-routing"
}
],
"code": "FulfillmentConstraintsFailed"
}
The customer-facing message is the concatenation of every blocking
constraint’s message, joined with "; ". The structured errors[]
array carries enough metadata for the checkout to surface each
reason inline next to the offending line.
Code is always FulfillmentConstraintsFailed — clients can switch on
this to render a fulfilment-specific error UI instead of a generic
validation error.
Combination with order routing rules
When both fulfillment_constraints and
order routing rules are active, constraints
run first:
fulfillment_constraints evaluates → blocks (if any) or narrows.
- Routing rules then evaluate over the narrowed set.
- The chosen location must be in the constraint’s
allowedLocationIds for that line.
A routing rule that picks a location not in the constraint set is
ignored for that line, and the engine falls back to the highest-priority
rule whose target is in the allowed set. If no rule’s target is in the
allowed set, the engine picks the first id from allowedLocationIds in
insertion order.
This composition lets you split concerns cleanly:
- Use
fulfillment_constraints for hard physical limits (no
inventory; unlicensed location).
- Use routing rules for preferences
(West Coast → Oakland; high-value → expedited).
Persistence
Non-blocking entries are merged onto the order at
additionalFields.fulfillmentConstraints[]:
{
"additionalFields": {
"fulfillmentConstraints": [
{
"lineId": "cl_abc...",
"allowedLocationIds": [
"gid://launchmystore/Location/oakland-dc",
"gid://launchmystore/Location/newark-dc"
],
"appId": "warehouse-routing"
},
{
"lineId": "cl_def...",
"allowedLocationIds": ["gid://launchmystore/Location/hazmat-hub"],
"message": "Hazmat — licensed hub only.",
"appId": "warehouse-routing"
}
]
}
}
The fulfilment dashboard reads this to surface “this line was
constrained to <locations> by app <appId>” when the operations team
opens the order. The downstream routing engine reads it to make the
final location pick.
- Project your input.
fulfillment_constraints runs on every order
placement. A function that reads only cart.lines[].merchandise.attributes
shippingAddress.country should project to just those fields with
inputFields — saves ~80% of dispatch time
on large carts.
- Avoid network when possible. Network access
is gated behind
network_access: true. Each outbound call adds up to
1500 ms to checkout latency. If you need live inventory, cache it on
the merchant side and refresh out of band.
- Return early. If your function only affects a subset of lines, do
one pass over
cart.items, push constraints for lines you have an
opinion on, and ignore the rest. Empty constraints: [] is a valid
output.
See also