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
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.
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
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.
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.
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.
Example: digital lines bypass physical locations
Digital downloads should not consume a physical-location slot.
Example: combine narrowing + non-blocking pass-through
A common pattern: narrow some lines (regional), block others (oversold),
let the rest fall through unconstrained.
Error behaviour when no eligible location exists
When any constraint resolves to allowedLocationIds: [], the order
is rejected with:
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[]:
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