Handle partial availability at checkout

Updated August 12, 20268 min read

POST /storefront/delivery-options answers one question per entry: what can be delivered together, in one delivery, from one location, at that entry's fee. When a cart is bigger than any single location can supply, the endpoint tells you so rather than quietly promising a split. This recipe covers how to read that answer and how to ask for every alternative. It then covers when to requote, and what the order allocation engine does with your choice.

For the base walkthrough of the endpoint, start with Show delivery options at checkout.

Scenario

A customer in London is buying a camera and two laptops. The merchant fulfills from a Manchester warehouse (WH-UK-01) and a London store (STORE-UK-LON-01). The camera is only in Manchester; the laptops are only in London. No single location can ship the whole cart, so your checkout has to either sell a partial cart or offer two deliveries.

Prerequisites

  • API credentials: see Getting started for the one-time setup.
  • Inventory management enabled for the tenant, with stock recorded against each product_id. Without it the stock check is skipped and every entry comes back unmarked.
  • At least one delivery option configured for the merchant, with fulfillment locations attached.
  • Each cart line identified by product_id or product_ref. A line that resolves to nothing is tolerated here — it just gets no stock behavior.

Step 1, quote the cart with a stock check

Send the cart, the customer's location, and inventory_check: true. merchant and line_items are required; a request without line items is rejected with 400.

Two request fields are worth setting from the start. customer.coords is what makes radius-covered locations eligible at all, so send it whenever the merchant fulfills from stores. sales_channel lets channel-scoped allocation rules match the same way they will when you create the order.

curl -X POST 'https://api.carriyo.com/storefront/delivery-options' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'tenant-id: YOUR_TENANT_ID' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "merchant": "ACME",
    "sales_channel": "web",
    "inventory_check": true,
    "delivery_methods": ["DELIVERY"],
    "payment": {
      "currency": "GBP",
      "order_total": 2439.97
    },
    "customer": {
      "country": "GB",
      "city": "London",
      "address1": "45 Gloucester Place",
      "postcode": "W1U 8HU",
      "contact_name": "Oliver Bennett",
      "coords": [51.5194, -0.158]
    },
    "line_items": [
      {
        "id": "line-1",
        "product_id": "PDT5N8T3JZ7QWM2XK",
        "quantity": 1,
        "weight": { "value": 0.5, "unit": "kg" }
      },
      {
        "id": "line-2",
        "product_id": "PDT9B4L6YH1PRD8VC",
        "quantity": 2,
        "weight": { "value": 1.5, "unit": "kg" }
      }
    ]
  }'

The stock check runs only when three things hold: inventory_check is true, inventory management is enabled for the tenant, and each line resolves to a product. Miss any one and you get the unfiltered result described in Show delivery options at checkout.

Step 2, read the availability marks

By default each option appears once: the entry with the best coverage. Carriyo picks it by most fully covered items, then most covered quantity, then the option's configured location order.

[
  {
    "id": "f29acd11-34c2-4874-96f6-4a2530a82886",
    "delivery_method": "DELIVERY",
    "code": "EXPRESS_DELIVERY",
    "name": "Express Delivery",
    "carrier_account_id": "1ee1b219-d64e-4e67-8bc4-ff1c8c955457",
    "shipping_fee": { "amount": 9.99, "currency": "GBP" },
    "estimated_arrival_from": "2026-05-08T09:00:00.000Z",
    "estimated_arrival_to": "2026-05-08T18:00:00.000Z",
    "items": [
      { "line_item_id": "line-1", "quantity": 1, "availability": "FULL", "fulfillable_quantity": 1 },
      { "line_item_id": "line-2", "quantity": 2, "availability": "NONE", "fulfillable_quantity": 0 }
    ],
    "fulfillment_locations": [
      {
        "partner_location_id": "d4f5a6b7-1111-4c8d-9e0f-1a2b3c4d5e6f",
        "partner_location_code": "WH-UK-01",
        "partner_location_name": "ACME Manchester Warehouse"
      }
    ],
    "availability": "PARTIAL"
  },
  {
    "id": "012694a1-04af-42fd-ba4d-beeaca2034ed",
    "delivery_method": "DELIVERY",
    "code": "STANDARD_DELIVERY",
    "name": "Standard Delivery",
    "carrier_account_id": "782b9b83-6d95-4468-8998-38f28b5a7c11",
    "shipping_fee": { "amount": 4.99, "currency": "GBP" },
    "estimated_arrival_from": "2026-05-09T09:00:00.000Z",
    "estimated_arrival_to": "2026-05-11T18:00:00.000Z",
    "items": [
      { "line_item_id": "line-1", "quantity": 1, "availability": "NONE", "fulfillable_quantity": 0 },
      { "line_item_id": "line-2", "quantity": 2, "availability": "FULL", "fulfillable_quantity": 2 }
    ],
    "fulfillment_locations": [
      {
        "partner_location_id": "f6a7c8d9-3333-4e0f-1a2b-3c4d5e6f7a8b",
        "partner_location_code": "STORE-UK-LON-01",
        "partner_location_name": "ACME London Store"
      }
    ],
    "availability": "PARTIAL"
  }
]

Three rules make this readable.

  • Every cart line is listed. Items an entry cannot supply come back as NONE with fulfillable_quantity: 0, never dropped. quantity is what you asked for; fulfillable_quantity is what this entry can give you.
  • availability on the entry is the verdict for the whole cartFULL, PARTIAL, or UNKNOWN. Both entries above read PARTIAL: each can ship one line and not the other.
  • Every location listed can do the job on its own. fulfillment_locations is never a union of locations that each cover a different piece. That is what makes each entry a single delivery.

Absence means something specific. An absent availability means the line or the entry was not evaluated — never that it is unavailable. UNKNOWN means the check ran and could not answer, because an inventory read failed. The option is returned rather than hidden, so treat UNKNOWN as "no answer" and never as out of stock.

Entries are always availability-banded before anything else: entries that cover the whole cart come first, then partial entries ordered by how many lines they can at least partly supply. Within a band, sort_by applies (SPEED by default, COST for cheapest first). Render the list in the order Carriyo returns it. Nothing here is FULL, so nothing is promoted, and SPEED puts Express first.

Step 3, ask for every variation

The default response hid something: Standard Delivery can also ship the camera from Manchester. Set include_variations: true to get every maximal single-delivery variation of each option as its own entry — same code, different items and fulfillment_locations.

curl -X POST 'https://api.carriyo.com/storefront/delivery-options' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'tenant-id: YOUR_TENANT_ID' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "merchant": "ACME",
    "sales_channel": "web",
    "inventory_check": true,
    "include_variations": true,
    "delivery_methods": ["DELIVERY"],
    "payment": { "currency": "GBP", "order_total": 2439.97 },
    "customer": {
      "country": "GB",
      "city": "London",
      "address1": "45 Gloucester Place",
      "postcode": "W1U 8HU",
      "contact_name": "Oliver Bennett",
      "coords": [51.5194, -0.158]
    },
    "line_items": [
      { "id": "line-1", "product_id": "PDT5N8T3JZ7QWM2XK", "quantity": 1, "weight": { "value": 0.5, "unit": "kg" } },
      { "id": "line-2", "product_id": "PDT9B4L6YH1PRD8VC", "quantity": 2, "weight": { "value": 1.5, "unit": "kg" } }
    ]
  }'

Standard Delivery now returns twice. The Express entry is unchanged, so it is abbreviated here:

[
  { "code": "EXPRESS_DELIVERY", "availability": "PARTIAL" /* camera from WH-UK-01, as above */ },
  {
    "id": "012694a1-04af-42fd-ba4d-beeaca2034ed",
    "delivery_method": "DELIVERY",
    "code": "STANDARD_DELIVERY",
    "name": "Standard Delivery",
    "shipping_fee": { "amount": 4.99, "currency": "GBP" },
    "estimated_arrival_from": "2026-05-09T09:00:00.000Z",
    "estimated_arrival_to": "2026-05-11T18:00:00.000Z",
    "items": [
      { "line_item_id": "line-1", "quantity": 1, "availability": "NONE", "fulfillable_quantity": 0 },
      { "line_item_id": "line-2", "quantity": 2, "availability": "FULL", "fulfillable_quantity": 2 }
    ],
    "fulfillment_locations": [
      {
        "partner_location_id": "f6a7c8d9-3333-4e0f-1a2b-3c4d5e6f7a8b",
        "partner_location_code": "STORE-UK-LON-01",
        "partner_location_name": "ACME London Store"
      }
    ],
    "availability": "PARTIAL"
  },
  {
    "id": "012694a1-04af-42fd-ba4d-beeaca2034ed",
    "delivery_method": "DELIVERY",
    "code": "STANDARD_DELIVERY",
    "name": "Standard Delivery",
    "shipping_fee": { "amount": 4.99, "currency": "GBP" },
    "estimated_arrival_from": "2026-05-09T09:00:00.000Z",
    "estimated_arrival_to": "2026-05-11T18:00:00.000Z",
    "items": [
      { "line_item_id": "line-1", "quantity": 1, "availability": "FULL", "fulfillable_quantity": 1 },
      { "line_item_id": "line-2", "quantity": 2, "availability": "NONE", "fulfillable_quantity": 0 }
    ],
    "fulfillment_locations": [
      {
        "partner_location_id": "d4f5a6b7-1111-4c8d-9e0f-1a2b3c4d5e6f",
        "partner_location_code": "WH-UK-01",
        "partner_location_name": "ACME Manchester Warehouse"
      }
    ],
    "availability": "PARTIAL"
  }
]

Two entries sharing a code are not duplicates. They are distinct single-delivery alternatives of the same option. Group them by code in your UI and present them as delivery groups: "laptops arrive 11 May from London, camera arrives 11 May from Manchester". Entries whose coverage is contained in another entry's are dropped, so you never have to de-duplicate them yourself. There is at most one entry per candidate location.

Composing several entries into a multi-delivery checkout is your explicit act. Carriyo never returns an entry that silently spans two locations.

Step 4, requote each delivery the shopper accepted

A quote is priced against the cart you sent. Once the shopper accepts a split, each consignment is a smaller cart. Requote each subset with its own payment to get the fee you will actually charge. Send only that subset's line items and pin the location with fulfillment_locations.

curl -X POST 'https://api.carriyo.com/storefront/delivery-options' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'tenant-id: YOUR_TENANT_ID' \
  -H 'x-api-key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "merchant": "ACME",
    "sales_channel": "web",
    "inventory_check": true,
    "delivery_methods": ["DELIVERY"],
    "payment": { "currency": "GBP", "order_total": 489.99 },
    "customer": {
      "country": "GB",
      "city": "London",
      "address1": "45 Gloucester Place",
      "postcode": "W1U 8HU",
      "contact_name": "Oliver Bennett",
      "coords": [51.5194, -0.158]
    },
    "fulfillment_locations": [{ "partner_location_code": "WH-UK-01" }],
    "line_items": [
      { "id": "line-1", "product_id": "PDT5N8T3JZ7QWM2XK", "quantity": 1, "weight": { "value": 0.5, "unit": "kg" } }
    ]
  }'

The camera-only cart is worth £489.99, so a tiered fee lands on a different tier than the full basket did:

[
  {
    "code": "STANDARD_DELIVERY",
    "name": "Standard Delivery",
    "shipping_fee": { "amount": 6.99, "currency": "GBP" },
    "items": [
      { "line_item_id": "line-1", "quantity": 1, "availability": "FULL", "fulfillable_quantity": 1 }
    ],
    "fulfillment_locations": [
      {
        "partner_location_id": "d4f5a6b7-1111-4c8d-9e0f-1a2b3c4d5e6f",
        "partner_location_code": "WH-UK-01",
        "partner_location_name": "ACME Manchester Warehouse"
      }
    ],
    "availability": "FULL"
  }
]

Repeat for the laptop consignment. Beyond splits, requote whenever the answer could have moved:

  • The cart changed: an item added, removed, or its quantity edited.
  • The delivery address changed.
  • Time passed on the checkout page. A quote reads stock, it does not reserve it, and fees can depend on working days and blackout days.

The endpoint is stateless, so requoting is free of side effects. Call it as often as your checkout needs.

What allocation decides, and what it does not

The quote is a read. It tells you what could ship together right now. Assigning items to locations happens later, when you create the order and the allocation engine runs.

Persist the chosen entry's code on the order, plus its fulfillment_locations, so allocation is constrained to a set you already know holds stock:

{
  "merchant": "ACME",
  "partner_order_reference": "YOUR_ORDER_REF",
  "sales_channel": "web",
  "delivery_option": { "code": "STANDARD_DELIVERY" },
  "fulfillment_locations": [
    { "partner_location_id": "f6a7c8d9-3333-4e0f-1a2b-3c4d5e6f7a8b" }
  ]
  // ...rest of the order create payload
}

Two boundaries matter:

  • Order splitting is an allocation setting, not a quote field. Whether Carriyo may split an order across locations is configured for the merchant in the Dashboard. It never changes what this endpoint returns. Variations are alternatives you compose; splitting is what allocation does after the order exists.
  • allocation_check previews the routing rules. It defaults to the value of inventory_check, so omitting it changes nothing: with inventory_check: true the merchant's allocation rules are already applied on top of the stock read, and the cart sees the same routing decisions the order will. Set allocation_check: false alongside inventory_check: true for a pure stock answer with no routing policy. Sending allocation_check: true without inventory_check: true is rejected with 400 — the rules operate on the stock read, so the combination has no meaning.

Send sales_channel on both calls, and set it to the same value. Channel-scoped allocation rules do not match when it is omitted, which is how a cart and its order end up disagreeing.

Items an option is not allowed to deliver

An option can be scoped per item with an item_product_categories condition — a chilled same-day option that only carries chilled and frozen goods, for example. Items outside the scope are marked availability: NONE with fulfillable_quantity: 0 in every entry of that option.

This is the one exception to "absent means not evaluated". The mark appears without any stock read, even when the stock check is off, and it overrides UNKNOWN. Excluded items count toward the verdict, so a scoped option on a mixed cart reads PARTIAL. An option whose condition excludes every item in the cart is not returned at all.

Configure the condition on the delivery option itself; see Configure delivery options.

Narrow the answer to one location

When your storefront already knows where the order should be sourced, pass a single entry in fulfillment_locations. It narrows the whole evaluation: the stock read, the returned location lists, and the location used for the fee and the arrival estimate all consider only what you asked for. The option's presence in the response then means exactly one thing — that location can serve this cart.

"fulfillment_locations": [{ "partner_location_code": "STORE-UK-LON-01" }]

Use partner_location_code (your own code) rather than partner_location_id (Carriyo's internal ID); codes are stable across environments. An entry that references a location Carriyo cannot resolve is rejected with 400. The message names the offending entry, for example fulfillment_locations[1] ('XYZ') does not match any location.

Pitfalls

  • NONE and absent are different answers. NONE means evaluated and unavailable. An absent availability means not evaluated. Code that treats absence as unavailable will hide every option from tenants that don't run inventory management.
  • UNKNOWN is not out of stock. An inventory read failed. The option is returned rather than dropped precisely so you can still sell it; hiding it converts a transient failure into lost revenue.
  • Fees are quoted against the request you sent. A tiered fee on a PARTIAL entry is looked up against the whole cart's payment.order_total, not the value of the subset that entry can supply. Requote each subset before you charge.
  • An empty shipping_fee ({}) is not free shipping. It means no price could be resolved — no tier matched, no pricing entry matched the currency, or a carrier rate lookup failed. Treat it as "no price available".
  • Radius-covered locations need customer.coords. Without coordinates such a location is never eligible, and its stock is invisible to this endpoint.
  • Options that can supply nothing are omitted entirely. They never come back with an all-NONE item list. The exception is a failed inventory read, which returns the option marked UNKNOWN.
  • include_variations needs an effective stock check. Without one, each option yields a single entry anyway and the flag does nothing.