Delivery options

Add shipping to a checkout sets up one flat rate. A store with one warehouse and two shops usually needs more:

  • Shipping priced by region: $9 to the contiguous US, $25 to Alaska and Hawaii.
  • Free shipping on orders over $75.
  • Free in-store pickup at either shop.
  • Scheduled local delivery within 15 km of the warehouse, priced by distance.

You need a test API key with delivery write scope and active Locations with published addresses. The examples use loc_01K4WHSE for the warehouse and loc_01K4SHOPA and loc_01K4SHOPB for the shops. To price rates on your own server instead, see Live delivery rates with a callback.

How the pieces fit#

Every delivery choice a buyer sees is a method. A method has one type (shipment, pickup, or local_delivery), an origin, optional eligibility rules, one pricing strategy, and an optional estimate. The other resources exist so methods can share configuration:

ResourceReferenced byHolds
Delivery profile (dprof_)Product variants and bundle componentsWhich delivery types can fulfill the item
Delivery zone (dzone_)Method eligibility and rate-table ratesA reusable geography: countries, states, postal codes, or a radius
Delivery location set (dls_)Method originsA reusable group of Locations
Delivery method (dmet_)Checkouts, payment links, checkout settingsOne buyer-visible choice
Delivery rate callback (dcb_)Methods with callback pricingYour rate server's URL and signing secret

Rate tables have no endpoint of their own. A rate table is the rate_table pricing strategy inside a method, and its rates get drate_ IDs.

A checkout offers a method when the method's type is in the item's profile allowed_types and the method's origin can serve the items. When you create a checkout, Flint pins the current revision of every assigned method and its dependencies. Later edits apply to new checkouts, not to checkouts that are already open.

1. Decide whether you need a profile#

Unless you set a default profile, Flint creates an active General profile the first time you create a physical item without one. It allows shipment, pickup, and local delivery. If all your items can go every way, skip this step.

Create a profile when some items need different rules. This one is for furniture that can ship or be picked up but is too large for local delivery:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-profiles \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: profile-oversized-1" \
  -d '{
    "name": "Oversized",
    "configuration": {
      "requirement": "required",
      "resolution_mode": "quote",
      "allowed_types": ["shipment", "pickup"],
      "origin_policy": { "type": "method_origin" },
      "combination_policy": "combine_when_compatible",
      "splitting_policy": "whole_line_item"
    }
  }'

Profiles are active when created. Assign it by setting delivery_profile_id on the product variants or bundle components it covers. To make it the default for new physical items, set catalog.default_delivery_profile_id with PATCH /v1/settings. To apply it to existing items that have no profile, call POST /v1/delivery-profiles/{delivery_profile_id}/assign-to-unconfigured with the profile's version as expected_version. Items that already have a profile keep it.

origin_policy decides where items ship from:

  • method_origin: the chosen method's origin. This is what General uses. Each option leaves from its own method's origin, so one quote can offer shipping from the warehouse and pickup at a shop.
  • inventory_routing: Flint picks a stocked Location from the method's origins. Requires tracked inventory.
  • fixed_location: always the profile's location_id.

2. Create zones#

A zone is a reusable geography. Zones accept only country, state, postal_code, and radius conditions, combined with all, any, and not. Each node holds exactly one of these keys. State values are ISO 3166-2 codes such as US-AK. For a US state or Canadian province you can send the two-letter code, such as AK or ON, and Flint stores US-AK or CA-ON. An address matches whether the buyer's state is AK, US-AK, or Alaska. Outside the US and Canada, the buyer's state must be the subdivision code, with or without the country prefix.

Create the contiguous US zone:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-zones \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: zone-contiguous-us-1" \
  -d '{
    "name": "Contiguous US",
    "configuration": {
      "all": [
        { "country": { "values": ["US"] } },
        { "not": { "state": { "values": ["US-AK", "US-HI"] } } }
      ]
    }
  }'

Then create an Alaska and Hawaii zone the same way with {"state": {"values": ["US-AK", "US-HI"]}} as its configuration. Save both delivery_zone_id values.

Create the local delivery zone as a radius around the method's origin:

Response
{
  "name": "Within 15 km",
  "configuration": {
    "radius": {
      "origin": { "method_origin": true },
      "maximum_distance": { "value": 15, "unit": "kilometer" },
      "measurement": "straight_line"
    }
  }
}

Radius conditions measure the straight-line distance from the origin Location to the buyer's geocoded address. Use "origin": {"location_id": "loc_..."} to measure from a fixed Location instead.

Zones are active when created. Put customer conditions (customer_group, customer_verified) and time-of-day conditions (window_time) in a method's eligibility, not in a zone.

3. Ship with a rate table#

A rate table checks its rates from the highest priority down and uses the first rate whose when expression matches. If none match, the method is not offered. unmatched_behavior must be unavailable.

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-methods \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: method-standard-shipping-1" \
  -d '{
    "name": "Standard shipping",
    "type": "shipment",
    "configuration": {
      "origin": { "type": "fixed_location", "location_id": "loc_01K4WHSE" },
      "pricing": {
        "type": "rate_table",
        "rate_table": {
          "unmatched_behavior": "unavailable",
          "rates": [
            {
              "priority": 20,
              "when": { "zone": { "delivery_zone_id": "dzone_01K4AKHI" } },
              "currency_options": { "USD": { "amount": 2500, "currency": "USD" } }
            },
            {
              "priority": 10,
              "when": { "zone": { "delivery_zone_id": "dzone_01K4CONUS" } },
              "currency_options": { "USD": { "amount": 900, "currency": "USD" } }
            }
          ]
        }
      },
      "estimate": {
        "type": "transit_time",
        "transit_time": {
          "handling_days": { "minimum": 1, "maximum": 1 },
          "transit_days": { "minimum": 2, "maximum": 5 }
        }
      },
      "public_details": { "service_level": "standard" }
    }
  }'

The response returns each rate with a delivery_rate_id and the method's version. Omitting eligibility offers the method everywhere, so here the rate table alone limits it to the two zones. Amounts are in minor units: 900 is $9.00.

The transit_time estimate counts handling_days and then transit_days as business days in the origin Location's timezone, starting from the day of the quote. Flint skips Saturdays and Sundays, not holidays. Each option in a quote carries the result as calendar dates. For an origin in New York, a quote created on Thursday, October 1, 2026 shows:

Response
"arrival_estimate": {
  "earliest_date": "2026-10-06",
  "latest_date": "2026-10-09",
  "timezone": "America/New_York"
}

Show these dates as they are, for example "Arrives Oct 6 to 9". Converting them to another timezone can move them by a day.

A when expression is a full eligibility expression. It can reference a zone, use country, state, or postal_code directly, or match a customer group.

To change the rates later, send the complete rates array in a method PATCH. Include delivery_rate_id for each rate you keep, omit it for new rates, and leave out rates to remove them. Configuration changes require the method's current version as expected_version:

JSON
{
  "expected_version": 1,
  "configuration": {
    "pricing": {
      "type": "rate_table",
      "rate_table": {
        "unmatched_behavior": "unavailable",
        "rates": [
          { "delivery_rate_id": "drate_01K4AKHI", "priority": 20, "when": { "zone": { "delivery_zone_id": "dzone_01K4AKHI" } }, "currency_options": { "USD": { "amount": 2900, "currency": "USD" } } },
          { "delivery_rate_id": "drate_01K4CONUS", "priority": 10, "when": { "zone": { "delivery_zone_id": "dzone_01K4CONUS" } }, "currency_options": { "USD": { "amount": 900, "currency": "USD" } } }
        ]
      }
    }
  }
}

Free shipping over a threshold#

Use tiered pricing when the price depends on the cart. Bands start at zero, are contiguous, and the last band has no to. from is inclusive and to is exclusive. For money bases, both are in minor units.

JSON
{
  "name": "Free shipping over $75",
  "type": "shipment",
  "configuration": {
    "origin": { "type": "fixed_location", "location_id": "loc_01K4WHSE" },
    "eligibility": { "zone": { "delivery_zone_id": "dzone_01K4CONUS" } },
    "pricing": {
      "type": "tiered",
      "tiered": {
        "basis": "order.merchandise_subtotal_after_line_item_discounts",
        "bands": [
          { "from": 0, "to": 7500, "currency_options": { "USD": { "amount": 900, "currency": "USD" } } },
          { "from": 7500, "currency_options": { "USD": { "amount": 0, "currency": "USD" } } }
        ]
      }
    }
  }
}

order.merchandise_subtotal_after_line_item_discounts is the order's merchandise minus its item discounts: promotions and manual discounts with discount_class: line_item. An $80 cart with a $10 item discount counts as $70, so it pays $9 for shipping. Order-level discounts do not lower it, and neither do discounts on the delivery charge. A promotion is order-level unless you set its discount_class. To ignore discounts entirely, use order.merchandise_subtotal_before_discounts.

The choice_group. subtotals measure only the items fulfilled together. A choice group is the whole order unless profiles split it, and then each group is priced on its own items. Other bases include choice_group.total_weight (send unit with it) and choice_group.fulfillment_quantity.

4. Offer pickup#

A pickup method's origin is a pickup_location_collection: the Locations the buyer can choose from. Create a location set when several methods share the same Locations:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-location-sets \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: location-set-shops-1" \
  -d '{
    "name": "Shops",
    "configuration": { "location_ids": ["loc_01K4SHOPA", "loc_01K4SHOPB"] }
  }'

Then create the pickup method. public_details.pickup_mode is required for pickup: in_store, curbside, locker, or other.

Response
{
  "name": "Pick up in store",
  "type": "pickup",
  "configuration": {
    "origin": {
      "type": "pickup_location_collection",
      "delivery_location_set_id": "dls_01K4SHOPS"
    },
    "pricing": {
      "type": "fixed",
      "fixed": { "currency_options": { "USD": { "amount": 0, "currency": "USD" } } }
    },
    "public_details": {
      "pickup_mode": "in_store",
      "instructions": "Bring your order number to the front counter."
    }
  }
}

Instead of a set, you can list up to 1,000 Locations inline in origin.location_ids.

At checkout, the buyer has to choose a store before the method can be priced. Query which stores can fulfill the cart, nearest first:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-previews \
  -H "X-Checkout-Session-ID: cs_123" \
  -H "X-Checkout-Session-Secret: CHECKOUT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "pickup_locations",
    "checkout_session_id": "cs_123",
    "expected_delivery_selection_id": null,
    "buyer_location": { "type": "address", "address": { "postal_code": "11249", "country": "US" } }
  }'

Send the checkout's current expected_delivery_selection_id, or null before the buyer has chosen. After a buyer picks a store, search again with that selection's ID to offer other stores; the search does not change the choice.

Each result in locations has the store's location, distance_meters, compatible_methods, and the quantities it can cover. A postal code is enough to sort: Flint measures from the postal code's center, and from the exact point when you send a coordinate. The query returns at most 25 stores, the nearest when Flint can place the buyer, and does not reserve stock. Create the quote with the chosen store's ID as pickup_location_id. Quote creation checks the store and inventory again.

A pickup store does not need inventory allocation. Items with tracked inventory are picked up from the store's own stock, so for them a store needs inventory.allocation_status: "active" and stock on hand. A store without allocation is still listed, with an unavailable outcome and inventory_insufficient, and merchant-authenticated searches name it in merchant_diagnostics with pickup_location_inventory_unavailable. The quote still offers methods that leave from other Locations, such as warehouse shipping. If the chosen store lacks stock, only the pickup option becomes unavailable, with inventory_unavailable.

5. Offer scheduled local delivery#

This method delivers within the radius zone, charges $5 plus $1 per kilometer, and lets the buyer pick a two-hour window:

JSON
{
  "name": "Local delivery",
  "type": "local_delivery",
  "configuration": {
    "origin": { "type": "fixed_location", "location_id": "loc_01K4WHSE" },
    "eligibility": { "zone": { "delivery_zone_id": "dzone_01K4LOCAL" } },
    "pricing": {
      "type": "calculated",
      "calculated": {
        "base_fee_currency_options": { "USD": { "amount": 500, "currency": "USD" } },
        "distance": {
          "unit_quantity": 1,
          "unit": "kilometer",
          "currency_options": { "USD": { "amount": 100, "currency": "USD" } }
        },
        "minimum_fee_currency_options": { "USD": { "amount": 500, "currency": "USD" } },
        "maximum_fee_currency_options": { "USD": { "amount": 2000, "currency": "USD" } }
      }
    },
    "minimum_option_lifetime_seconds": 300,
    "estimate": {
      "type": "schedule_window",
      "schedule_window": {
        "availability": {
          "timezone": "America/New_York",
          "weekly_intervals": [
            { "start_weekday": 2, "start_minute": 600, "end_weekday": 2, "end_minute": 720 },
            { "start_weekday": 2, "start_minute": 840, "end_weekday": 2, "end_minute": 960 },
            { "start_weekday": 4, "start_minute": 600, "end_weekday": 4, "end_minute": 720 },
            { "start_weekday": 4, "start_minute": 840, "end_weekday": 4, "end_minute": 960 }
          ],
          "preparation_lead_time_seconds": 7200,
          "maximum_scheduling_horizon_days": 7
        }
      }
    },
    "public_details": { "service_level": "scheduled" }
  }
}

How the parts behave:

  • Distance pricing is prorated to the straight-line distance and rounded half up. The minimum and maximum clamp the result, so this method always charges between $5 and $20.
  • minimum_option_lifetime_seconds is required for calculated, callback, and caller_supplied pricing, and must be below 900. Quotes for these methods last up to 15 minutes. Flint does not offer the method unless the quote has at least this many seconds left.
  • Each weekly interval becomes one window on every matching day. Weekdays run from 0 (Sunday) to 6 (Saturday), and minutes count from local midnight, so 600 to 720 is 10:00 to 12:00. A buyer can choose a window until preparation_lead_time_seconds before it starts, and quotes offer only windows that can still be chosen, so a window already under way is not offered. To take orders through a long period, such as store hours, split it into shorter windows. Add same_day_cutoff_minute to stop same-day windows after a set time, and blackout_intervals for holidays.
  • service_level for local delivery is on_demand, same_day, or scheduled. Shipment accepts economy, standard, expedited, express, overnight, and same_day.

In a quote, the option lists its offered_windows and has window_selection: "offered". Each window's expires_at is the last moment the buyer can choose it. The buyer's choice goes in the selection as choices[].input.delivery_window_id. Build your own checkout covers quotes and selections.

6. Activate the methods#

Methods are created inactive. Activate each one with a status-only PATCH. It cannot be combined with other changes:

cURL
curl -X PATCH https://api.withflintpay.com/v1/delivery-methods/dmet_01K4STANDARD \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: activate-standard-1" \
  -d '{ "status": "active" }'

You can also send "status": "active" on create when every zone, location set, and callback the method references is active. An active method reaches buyers only once you assign it to a checkout in step 8.

7. Preview the options#

POST /v1/delivery-previews with mode: "delivery_options" evaluates a cart that has no order. It requires commerce.delivery.read and creates no checkout, selection, or inventory hold:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-previews \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "delivery_options",
    "currency": "USD",
    "delivery_method_ids": ["dmet_01K4STANDARD", "dmet_01K4FREE75", "dmet_01K4LOCAL"],
    "line_items": [
      { "variant_id": "var_01K4MUG", "quantity": 2 }
    ],
    "destination_address": {
      "line1": "120 Kent Avenue",
      "city": "Brooklyn",
      "state": "NY",
      "postal_code": "11249",
      "country": "US"
    }
  }'

Each entry in choice_groups lists priced options and a candidate_outcomes map keyed by method ID. An unavailable outcome carries an unavailable_reason, such as eligibility_no_match or destination_not_served. An input_required outcome lists the missing fields in input_requirements. merchant_diagnostics explains each method that was not offered. Try one address per zone and a cart on each side of the $75 threshold.

8. Offer the methods at checkout#

Offer the methods on every checkout by default:

Response
{
  "checkout": {
    "default_delivery_method_ids": ["dmet_01K4STANDARD", "dmet_01K4FREE75", "dmet_01K4PICKUP", "dmet_01K4LOCAL"]
  }
}

Send that to PATCH /v1/settings. Setting delivery_method_ids on a checkout session or payment link replaces the default for that checkout. A payment link without its own methods and an invoice checkout use the default when the order has items to deliver. Hosted checkout collects the address, shows the options each buyer qualifies for, and records the choice.

When both shipping methods qualify, the buyer sees both. Order the list with display_position, and mark one as recommended with recommendation_priority.

Change configuration safely#

Every configuration resource has a version. Send it as expected_version when a PATCH changes configuration, or a method's name, description, display position, or recommendation priority. A status-only PATCH does not need it.

DELETE archives a resource. Flint blocks it while an active resource depends on it, for example a zone that an active method references. Deactivate or update the dependent methods first.

Endpoint map#

ResourceEndpoints
Delivery profilesPOST, GET list, GET, PATCH, DELETE on /v1/delivery-profiles; POST /v1/delivery-profiles/{id}/assign-to-unconfigured
Delivery zonesPOST, GET list, GET, PATCH, DELETE on /v1/delivery-zones
Delivery location setsPOST, GET list, GET, PATCH, DELETE on /v1/delivery-location-sets
Delivery methodsPOST, GET list, GET, PATCH, DELETE on /v1/delivery-methods
Delivery rate callbacksPOST, GET list, GET, PATCH, DELETE on /v1/delivery-rate-callbacks; POST .../{id}/rotate-secret, .../{id}/test-deliveries, .../{id}/check-connection
RevocationsPOST /v1/delivery-revocations, GET /v1/delivery-revocations/{id}
Delivery previewsPOST /v1/delivery-previews with mode: "delivery_options" or mode: "pickup_locations"
Checkout quotesPOST and GET on /v1/checkout-sessions/{id}/delivery-quotes, GET /v1/delivery-quotes
Checkout selectionsPOST /v1/checkout-sessions/{id}/delivery-selections; GET and DELETE on .../delivery-selections/current; GET .../delivery-selections/{id}; GET /v1/orders/{id}/delivery-selections/current

List endpoints filter by related resources, for example GET /v1/delivery-methods?delivery_zone_id=dzone_... finds every method that uses a zone. Delivery configuration documents every field.

Was this helpful?