Skip to main content

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.

Input shape

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:
CheckBehaviour 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:
  1. fulfillment_constraints evaluates → blocks (if any) or narrows.
  2. Routing rules then evaluate over the narrowed set.
  3. 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.

Performance considerations

  • 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