Tom Sailors
Guide · Fulfillment

Local pickup date scheduling and daily order limits on Shopify

Published 2026-08-31 · Updated 2026-08-31

To take local-pickup orders on chosen dates and cap how many pickups a single day can hold, you need a scheduling flow separate from shipping and a per-day limit enforced at checkout. Here is how to build both, drawn from a real build for a store that takes pickup and mail orders side by side.

Keep pickup and shipping on separate paths

A cart that mixes a pickup item and a mail-order item holds two fulfillment paths at once, and each path has its own date rules. That means tracking the path per line and showing more than one calendar in a single cart.

Model the cart as one path instead. Have the shopper choose pickup or shipping up front, and write that choice to a cart attribute:

// cart attribute, set when the shopper picks a path
cart.attributes.shopping_mode = "pickup"   // or "mail"

Removing mixed carts is what made the four fulfillment calendars in this build tractable: with one channel per cart, the channel is a single cart attribute rather than per-line state.

Need help?

Collect pickup dates in the cart

Put the date picker in the cart. Write the shopper’s choice to cart attributes that carry through onto the order:

pickup_date     = "2026-09-14"
pickup_time     = "10:00"
pickup_location = "downtown"

Mail orders carry no shopper-chosen date. Compute an estimated ship date instead of asking a shipping customer to pick a day.

Keep a pickup schedule tied to one path. A clear-cart call keeps cart attributes, so a date chosen before the shopper switched from pickup to shipping can ride into a mail order by accident. Strip the pickup fields when the order is not a pickup order.

Write the bookable window as a clock

A checkout validation function is pure and has no clock of its own. Give it one: write the current date and the bookable window into shop metafields on a schedule.

shop.metafields.schedule.today_date    = "2026-08-31"
shop.metafields.schedule.pickup_window = { "min": "2026-09-02", "max": "2026-09-21" }

The rule in this build: order by 2pm local time Monday through Friday for next-day pickup, otherwise the day after the next weekday, up to a set number of days ahead. Run the cron at boot, every five minutes, and precisely at the daily cutoff time and midnight.

Enforce the daily limit: count, compare, reject

Build the cap from three pieces.

Keep the count as plain JSON, keyed by location then date:

shop.metafields.schedule.day_counts = {
  "_today": "2026-08-31",
  "downtown": { "2026-09-14": 12, "2026-09-15": 4 }
}

Have the recompute job tally the store’s pickup and mixed orders, skip any order that has a cancellation timestamp, and skip dates before today. That cancellation skip is how a canceled order frees its slot: it drops out of the next count and the day reopens. No cancellation webhook is needed.

In the function, read the count, the cap, and the shopper’s chosen date, and refuse a day that is full:

const cap = caps[location];              // e.g. 12
if (typeof cap === "number" && cap > 0) {
  const taken = (counts[location] || {})[date] || 0;
  if (taken >= cap) {
    errors.push({ message:
      "That pickup day is fully booked — please choose another date in your cart." });
  }
}

Keep the count in its own metafield rather than bundling it with other config. A metafield over 10,000 bytes is silently dropped from a function’s input, and the count grows with order volume in a busy season. A validation function’s input query also has a hard complexity budget, and every metafield read spends against it. Bundle several small config fields into one JSON object to stay under that ceiling, because fields inside one object are free once the object is read.

Need help?

Reconcile tags after Shopify Flow, with retries

If a Shopify Flow tags pickup orders with a hardcoded location while your own webhook stamps the location the shopper chose, an order can end up with two contradictory location tags at once.

Fix it in two moves. First, read the order’s live tags from the Admin API when your webhook reconciles them, not the tag snapshot in the webhook payload. The payload is frozen at the moment the webhook fired, usually before Flow has run, so it never contains the tag you mean to remove.

Second, handle the race. Flow’s tag can land a few seconds after yours, so a single reconcile pass can miss it. Run the reconcile more than once on a short detached timer, and do not make the webhook response wait on the later passes:

await reconcile("immediate");                       // in-line
setTimeout(() => reconcile("t+15s"), 15000);        // after Flow’s window
setTimeout(() => reconcile("t+45s"), 45000);        // safety net

Whichever order the two writers finish in, one location tag survives. On the orders that exposed this, Flow’s stamp arrived two to eight seconds after the webhook’s.

Seasonal items: show them, block the checkout

The older model published and unpublished seasonal products by a scheduled job. It has two flaws. Publication is a whole-product setting, so you cannot say an item is available for shipping now but pickup in two weeks. And if the job stops, an item is left live past its date with nobody watching.

Use a steadier model. Keep seasonal products published year-round and move availability onto a product metafield keyed by fulfillment path. Off-window, show a coming soon panel with no add button, and have the checkout function re-check availability for the cart’s path so nobody can buy an out-of-season item through a saved cart.

Pickup orders outrunning your kitchen?

Tell me what you need and I'll tell you what it takes.