Show a delivery promise on product listings

Updated September 2, 20265 min read

A delivery promise on a product listing ("Get it in 45 minutes", "Free next-day delivery") turns a browsing shopper into a buyer. POST /storefront/product-delivery-options returns the delivery options for each product in a list, so you can render the fastest one under each card.

Unlike the cart endpoint, which evaluates one basket as a whole, this endpoint evaluates each product on its own, from its own fulfillment location. This recipe walks the three-step flow: find the nearest eligible locations for the customer, pick each product's location from your own stock data, then fetch the per-product options.

Scenario

A customer in London is browsing a product listing page with two products: a t-shirt and a pair of running shoes. You want a delivery promise under each card. You first find the nearest stores that can fulfill for this customer, decide which store each product ships from, then ask Carriyo for the delivery options per product and render the fastest.

Prerequisites

  • API credentials: see Getting started for the one-time setup.
  • Locations configured for the merchant, with fulfillment coverage (zones or radius) that reaches the customer.
  • At least one delivery option configured for the merchant. Options priced PASS_THROUGH (a live carrier quote) are not returned by this endpoint.
  • Your own stock data to decide which location each product ships from.

Step 1, find the nearest eligible locations

Call POST /storefront/locations with the customer's location to get the stores or warehouses that can serve them, nearest first. Supply customer.coords so Carriyo can compute distance and select the nearest.

curl -X POST 'https://api.carriyo.com/storefront/locations' \
  -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",
    "functions": ["FULFILLMENT"],
    "location_type": "STORE",
    "customer": {
      "country": "GB",
      "city": "London",
      "area": "Marylebone",
      "coords": [51.5194, -0.1547]
    },
    "max_distance": { "value": 25, "unit": "km" },
    "max_locations": 10
  }'

The response is a capped array of eligible locations, nearest first, with a distance on each:

[
  {
    "location_id": "loc-uk-marylebone",
    "location_code": "STORE-UK-LON-01",
    "location_name": "ACME Marylebone",
    "location_type": "STORE",
    "functions": ["FULFILLMENT", "COLLECTION"],
    "address1": "12 Marylebone High Street",
    "city": "London",
    "country": "GB",
    "postcode": "W1U 4RY",
    "coords": [51.5194, -0.1547],
    "distance": { "value": 0.3, "unit": "km" }
  },
  {
    "location_id": "loc-uk-camden",
    "location_code": "STORE-UK-LON-02",
    "location_name": "ACME Camden",
    "location_type": "STORE",
    "functions": ["FULFILLMENT"],
    "address1": "5 Camden High Street",
    "city": "London",
    "country": "GB",
    "postcode": "NW1 7JE",
    "coords": [51.5346, -0.1416],
    "distance": { "value": 2.1, "unit": "km" }
  }
]

For the eligibility rules (zones versus radius) and the sorting and paging options, see Find eligible locations.

Step 2, pick each product's fulfillment location

Carriyo returned the locations that can serve this customer. Now decide, from your own stock data, which location each product ships from. This is your call, not Carriyo's. You know your inventory.

Say the Marylebone store has the t-shirt in stock and Camden has the running shoes. You pair each product with its partner_location_code:

ProductFulfillment location
PDT7K2M9QX4RWJ8ZT (t-shirt)STORE-UK-LON-01 (Marylebone)
PDT3M6R9WK2QDXV5H (shoes)STORE-UK-LON-02 (Camden)

Those are Carriyo's own product_id values, as returned by the Products API. You can send your own product_ref instead — see below.

Step 3, fetch the per-product delivery options

Call POST /storefront/product-delivery-options with the products and the location each ships from. Set the location per product under products[].fulfillment_locations; anything you set at the top level applies as the default. Set inventory_check to true to have each option marked with an availability verdict and a fulfillable_quantity.

A product missing from Carriyo does not fail the page

Identify each product by product_id or product_ref — one of the two is required. A product that is not in Carriyo's catalog yet does not fail the request: the other products are answered normally and the missing one comes back as an error row — "delivery_options": [] plus "error": { "code": "PRODUCT_NOT_FOUND", ... } — so only that product loses its promise.

curl -X POST 'https://api.carriyo.com/storefront/product-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",
    "currency": "GBP",
    "inventory_check": true,
    "sort_by": "SPEED",
    "sort_direction": "ASC",
    "customer": {
      "country": "GB",
      "city": "London",
      "area": "Marylebone",
      "coords": [51.5194, -0.1547]
    },
    "products": [
      {
        "product_id": "PDT7K2M9QX4RWJ8ZT",
        "quantity": 1,
        "unit_price": 24.99,
        "category": "apparel",
        "fulfillment_locations": [
          { "partner_location_code": "STORE-UK-LON-01" }
        ]
      },
      {
        "product_id": "PDT3M6R9WK2QDXV5H",
        "quantity": 1,
        "unit_price": 89.99,
        "category": "footwear",
        "weight": { "value": 0.9, "unit": "kg" },
        "fulfillment_locations": [
          { "partner_location_code": "STORE-UK-LON-02" }
        ]
      }
    ]
  }'

The response is a bare array, one entry per requested product, carrying the requested quantity and that product's delivery_options. Each option appears once, as a single delivery fact: one availability verdict, one delivery window, and the fulfillment_locations that can each on their own provide exactly that:

[
  {
    "product_id": "PDT7K2M9QX4RWJ8ZT",
    "quantity": 1,
    "delivery_options": [
      {
        "id": "opt-express-lon",
        "delivery_method": "DELIVERY",
        "code": "EXPRESS",
        "name": "Express 45 min",
        "description": "Delivered within the hour",
        "image_url": "https://cdn.example.com/icons/express.png",
        "carrier_account_id": "e7f8a9b0-1234-4c5d-8e6f-7a8b9c0d1e2f",
        "shipping_fee": { "amount": 4.99, "currency": "GBP" },
        "estimated_arrival_from": "2026-05-05T14:00:00.000Z",
        "estimated_arrival_to": "2026-05-05T14:45:00.000Z",
        "availability": "FULL",
        "fulfillable_quantity": 1,
        "fulfillment_locations": [
          {
            "partner_location_id": "loc-uk-marylebone",
            "partner_location_code": "STORE-UK-LON-01",
            "partner_location_name": "ACME Marylebone"
          }
        ]
      },
      {
        "id": "opt-std-lon",
        "delivery_method": "DELIVERY",
        "code": "STD",
        "name": "Standard Delivery",
        "description": "Delivered in 2-4 working days",
        "carrier_account_id": "d6e7f8a9-5678-4b0c-9d1e-2f3a4b5c6d7e",
        // A free option carries no `currency` unless the option configures a default one
        "shipping_fee": { "amount": 0 },
        "estimated_arrival_from": "2026-05-07T09:00:00.000Z",
        "estimated_arrival_to": "2026-05-09T18:00:00.000Z",
        "availability": "FULL",
        "fulfillable_quantity": 1,
        "fulfillment_locations": [
          {
            "partner_location_id": "loc-uk-marylebone",
            "partner_location_code": "STORE-UK-LON-01",
            "partner_location_name": "ACME Marylebone"
          }
        ]
      }
    ]
  },
  {
    "product_id": "PDT3M6R9WK2QDXV5H",
    "quantity": 1,
    "delivery_options": [
      {
        "id": "opt-std-lon",
        "delivery_method": "DELIVERY",
        "code": "STD",
        "name": "Standard Delivery",
        "description": "Delivered in 2-4 working days",
        "carrier_account_id": "d6e7f8a9-5678-4b0c-9d1e-2f3a4b5c6d7e",
        "shipping_fee": { "amount": 3.99, "currency": "GBP" },
        "estimated_arrival_from": "2026-05-07T09:00:00.000Z",
        "estimated_arrival_to": "2026-05-09T18:00:00.000Z",
        "availability": "FULL",
        "fulfillable_quantity": 1,
        "fulfillment_locations": [
          {
            "partner_location_id": "loc-uk-camden",
            "partner_location_code": "STORE-UK-LON-02",
            "partner_location_name": "ACME Camden"
          }
        ]
      }
    ]
  }
]

Absolute stock counts are never returned. fulfillable_quantity is capped at the quantity you asked for, so FULL with "quantity": 1 means "at least one", never "exactly one left".

Asking by your own reference instead

Swap product_id for product_ref and Carriyo resolves it against the catalog first, so the options come back identical:

"products": [
  {
    "product_ref": "TSHIRT-BLK-M",
    "quantity": 1,
    "unit_price": 24.99,
    "category": "apparel",
    "fulfillment_locations": [{ "partner_location_code": "STORE-UK-LON-01" }]
  }
]

Each response row then carries both — the resolved product_id and the product_ref you sent — so you can map rows back to your own catalog.

If a product_ref (or product_id) matches nothing in Carriyo, the request still succeeds. That product's row keeps its place with no options and an error, and has no product_id when nothing could be resolved:

{
  "product_ref": "HOODIE-GRY-L",
  "quantity": 1,
  "delivery_options": [],
  "error": { "code": "PRODUCT_NOT_FOUND", "message": "No product found for product_ref HOODIE-GRY-L" }
}

Render such a row as "no promise available" — it was not evaluated, so it is not out of stock.

Step 4, render the fastest option per product

Each product's delivery_options is availability-banded first — FULL, then PARTIAL, then UNKNOWN, then NONE — with sort_by: SPEED ordering within each band. So [i].delivery_options[0] is the fastest option the product can actually be delivered by, not merely the fastest option on paper. That is the delivery promise. For the t-shirt it's Express 45 min; for the shoes it's standard delivery.

// Delivery promise per product card
"PDT7K2M9QX4RWJ8ZT" → "Get it in 45 minutes"   // delivery_options[0] = Express 45 min
"PDT3M6R9WK2QDXV5H" → "Delivery in 2-4 days"   // delivery_options[0] = Standard Delivery

The value each product carries into option conditions is unit_price × quantity. Because the banding puts deliverable options first, reading [0] is safe on its own; filter on availability only if you also want to exclude PARTIAL entries from the promise.

Pitfalls

  • A missing product is an error row, not a 400 and not NONE. One of product_id or product_ref is required. A product Carriyo's catalog does not hold comes back on its own row with "delivery_options": [] and error.code: PRODUCT_NOT_FOUND, while every other product is answered. Check error before reading delivery_options[0], and never render an error row as out of stock.
  • Locations are your decision. Carriyo returns the eligible locations; you pick which one each product ships from. This endpoint does not choose a location for you.
  • Out-of-stock options are marked, not dropped. An option that can't meet the requested quantity still appears, marked availability: NONE with fulfillable_quantity: 0. An option is absent only when it doesn't match the product's conditions or serves none of its fulfillment locations.
  • Products are kept even when empty. A product no option matches comes back with "delivery_options": [], so every requested product is represented. Don't assume every entry has an option.
  • Availability marks need an effective stock check. availability and fulfillable_quantity appear only when inventory_check is true and inventory management is enabled for the tenant. Absent marks mean "not evaluated", never "unavailable" — and UNKNOWN means the stock read failed, which must never be rendered as out of stock.
  • PASS_THROUGH options are omitted. Options priced from a live carrier quote don't appear on this endpoint. Use the cart delivery-options endpoint when you need those.
  • Caps apply. At most 50 products per request and 5 locations per product. A product may not be requested twice.