Skip to content

Shipping

litecommerce has two distinct shipping surfaces:

  1. Checkout shipping — zones and flat rates that a storefront reads while pricing a cart.
  2. Post-order fulfillment — package planning, carrier selection, label purchase, physical handoff, cancellation/recovery, and warehouse output for an existing order.

Keeping those surfaces separate matters. Reading a checkout rate, package recommendation, quote, or warehouse row never buys postage or marks goods as shipped. Examples reuse the api() helper.

Checkout zones and rates

Zones (merchant)

Reads are open to any member; writes require settings:manage. Owner/Admin retain broad access, and Staff needs that group.

const zone = await api("/merchant/shipping/zones", { method: "POST", token,
  body: JSON.stringify({
    name: "United States",        // required, 1–200
    countries: ["US"],            // required; uppercase alpha-2; [] is allowed
    sortOrder: 0,                 // optional; defaults to 0
  }) });

PATCH /merchant/shipping/zones/:id updates a zone; sending countries replaces the list. Zones archive/restore rather than hard-delete:

await api(`/merchant/shipping/zones/${zone.id}/archive`, { method: "POST", token });
await api(`/merchant/shipping/zones/${zone.id}/restore`, { method: "POST", token });

An archived zone disappears from the public read immediately.

countries: [] is valid configuration, but it matches no destination. Neither that zone's flat-rate configuration nor its live-rate eligibility applies anywhere until at least one country is added.

Rates (merchant)

Rates hang off a zone. A rate is a flat per-shipment fee with an optional minimum-subtotal gate.

await api(`/merchant/shipping/zones/${zone.id}/rates`, { method: "POST", token,
  body: JSON.stringify({
    name: "Standard (5–7 days)",             // required, 1–200
    priceInCents: 599,                       // required, flat fee
    minimumSubtotalInCents: 0,               // optional; defaults to 0
    estimatedDelivery: "5–7 business days", // optional checkout copy
    sortOrder: 0,                            // optional; defaults to 0
  }) });

PATCH /merchant/shipping/rates/:rateId updates a rate. Rates archive and restore through POST …/rates/:rateId/archive and /restore.

Read at the cart (public)

GET /api/v1/public/shipping/zones returns only active zones and rates, ordered by sortOrder then name:

const zones = await api("/public/shipping/zones");
// → [{ name, countries, liveRatesEnabled, rates: [{ name, priceInCents, … }] }]

An empty rates array does not mean the zone cannot ship. Read liveRatesEnabled with it — that pairing is the whole point of the field:

liveRatesEnabled is zone-level eligibility, not current availability. It tells you how the zone is configured; it cannot tell you a price will come back.

ratesliveRatesEnabledWhat it means
[]falseNo active flat rate is published; live-rate eligibility is paused.
[]trueNo active flat rate is published; the zone is eligible for live carrier rating.
non-emptyfalseActive flat-rate configuration only.
non-emptytrueActive flat-rate configuration and live-rate eligibility.

Both flat-rate configuration and live-rate eligibility are scoped by countries. A zone with countries: [] matches no destination, so both apply nowhere however the flag reads. For a country no active zone lists, no zone configuration applies.

true is necessary but not sufficient for a carrier option. Address, cart, tenant/platform, origin/carrier, and quote conditions that are not represented on this read still apply. Likewise, each flat rate is subject to its own minimumSubtotalInCents floor, so a published rate need not apply to the current cart.

So present carrier shipping as "calculated at checkout" rather than as a promise. liveRatesEnabled is also a pause control rather than an opt-in: a zone the merchant has never touched reads true.

This zone read does not resolve a cart-specific option set. For an ordinary checkout, call POST /api/v1/public/checkout/sessions/:token/reprice and read availableShippingOptions from its response. Physical subscription checkout has no public equivalent for resolving its current shipping options, so do not infer its checkout outcome from this endpoint.

An empty top-level array means the tenant has no active zones, including when every configured zone is archived. Shipping is therefore unconfigured, not unrestricted. That is a configuration statement; it does not establish a checkout outcome.

Taxes are a separate checkout concern. The shipping schema carries no tax fields. Taxes can be computed through the checkout-session bind flow when a tax provider is enabled; shipping rates remain their own configuration and preview surface.

Processing time and the order cutoff (public)

A shipping-policy page usually needs two facts that are not per-zone or per-rate: how many business days the merchant takes to process an order, and the local time after which an order rolls to the next day. Those are tenant-wide, so they live on the site-config read rather than the zone read:

const { orderProcessing } = await api("/public/storefront/site-config");
// → { mode, timezone, processingDays, cutoffTime }

Read mode first. It is the difference between "configured" and "not configured", and reading past it is the mistake this field exists to prevent:

modeprocessingDayscutoffTimeWhat to render
"description"nullnullNothing. The merchant has not configured structured timing, so the only timing the platform publishes is the free-text estimatedDelivery on each rate.
"structured"e.g. 2"16:00""Orders placed before 4:00 PM timezone start processing the same business day; processing takes 2 business days."
"structured"e.g. 1nullThe same sentence without the cutoff clause. null means no cutoff applies, so orders are never rolled to the next day by one.
"structured"0nullSame-business-day dispatch with no cutoff. Uncommon — it means the stored cutoff is unusable — but valid, so render it.
"structured"nullnullNothing, and check timezone: it is null, so the stored zone is not one this read publishes and no interpretable timing is available. See the three reasons below — they are not equivalent.

Three things worth knowing before you wire copy to it:

  • processingDays is the effective value, not the stored one. A structured estimate configured with no cutoff cannot honestly promise same-business-day dispatch, so the platform floors processing to one business day in that case. A 0 therefore normally arrives with a cutoff — but 0 with cutoffTime: null is reachable when the merchant's stored cutoff is in a form the platform cannot apply, and it means what it says: same-business-day dispatch, no cutoff. Render it rather than treating it as impossible.

  • timezone gates the other two. A bare HH:mm with no zone is not renderable, so when timezone is null both processingDays and cutoffTime are null as well. Read timezone before the two timing fields.

    The value is usually an IANA name, but the gate is what Intl.DateTimeFormat accepts as a timeZone — a superset of the IANA database. A tenant storing a fixed offset like +05:00 has it published verbatim, so treat this as a string you can hand to Intl, not as a name you can look up in the IANA database.

    null means the stored zone is not one this read will publish, for one of three distinct reasons:

    1. The platform cannot resolve it at all (blank, or not a zone). Checkout computes no dates either.
    2. It is process-relative (local, system). The date engine accepts it, but it means "whatever machine evaluates it", so it would resolve to your runtime zone rather than the merchant's.
    3. It is a deterministic offset in a spelling Intl rejects (UTC+5). This one is unambiguous — the same +05:00 for everybody — and is withheld only because of the spelling. Do not treat it as ambiguous the way local is. Note the asymmetry: +05:00 is published, UTC+5 is not.

    Do not read null as "checkout shows no dates." In cases 2 and 3 checkout still computes them. Both cases also require a row written outside the platform API, which validates this column with the same rule this read applies — so neither is reachable for a tenant configured normally.

  • "Same day" always means same BUSINESS day. Business days are Mon–Fri, and the platform rolls a weekend ship date forward to the next weekday — so a Saturday order with processingDays: 0 ships Monday, not Saturday. Do not render unqualified "ships today" copy from a 0. There is also no holiday calendar, so a configured holiday is not skipped.

These are the same inputs that produce shipByDate and the delivery window on a checkout session, so a policy page wired to this object tracks what checkout promises the shopper. That is the point: hand-typed processing copy has no way to notice when the configuration behind it changes.

One documented exception, so you are not surprised by it: for a tenant whose stored zone is process-relative (local, system) or a deterministic offset in a spelling Intl rejects (UTC+5), this object publishes no timing while checkout still computes dates. The page then under-reports rather than contradicting — it says nothing where checkout says something. Neither case is reachable through the platform write API, which validates that column with the same rule this read applies, so both mean a row written around it.

Native post-order fulfillment

The safe physical-shipment sequence is explicit:

select lines → plan/confirm package → quote exact service → buy label (READY)
             → print/pack → confirm carrier custody (SHIPPED)

Provider connection, box presets, package and delivery-promise planning, quoting, label purchase/reconciliation, and prepared-shipment cancellation/recovery are Owner/Admin-only. Direct fulfillment, physical handoff, canonical cancellation through /merchant/commerce when no current or ambiguous postage exists, and warehouse outputs require orders:operate; Owner/Admin retain broad access, while Staff needs that delegated group.

Each package owns exact original-order line quantities. READY means postage and package evidence are prepared; it contributes no handed-off quantity, does not consume inventory, and does not send a shipped notification. Only the later handoff command records carrier custody.

Connect a shipping provider

The merchant provider surface uses these exact routes:

  • GET /merchant/shipping-provider returns connection status, sandbox/disabled state, a masked key fingerprint, verification timestamps, and the cached carrier list. It never returns the API key.
  • PUT /merchant/shipping-provider connects or replaces a ShipEngine key after a live verification. The request is a high-risk action with a required reason; the key is write-only and encrypted at rest. A TEST_ key is identified as sandbox mode.
  • GET /merchant/shipping-provider/services lists rateable services from the connected carriers.
  • POST /merchant/shipping-provider/test rechecks the stored key and refreshes the carrier snapshot.
  • POST /merchant/shipping-provider/disconnect is reasoned and removes the stored credential. It fails closed while a label purchase is in flight.

Provider connection and test routes can make live provider reads. They do not buy labels or add funds.

Save boxes and plan a loaded package

/merchant/shipping/box-presets stores revisioned reusable box facts: usable inside dimensions, carrier-facing outside dimensions, tare weight, and an optional maximum loaded weight. Presets can be activated/deactivated and one active preset can be the merchant default. A default is a preference, not an automatic fit claim.

For selected order lines, call:

const packagePlan = await api(
  `/merchant/orders/${orderId}/shipping-package/recommendation`,
  { method: "POST", token, body: JSON.stringify({ lines }) },
);

This read-only planner returns RECOMMENDED only when a bounded placement witness proves every selected unit fits an active preset. Otherwise it returns NO_RECOMMENDATION with an explicit reason instead of guessing. The merchant still confirms the actual loaded dimensions and weight used for rating and purchase.

Quote an exact carrier selection

Before funded label purchase, quote the exact lines, loaded parcel, carrier, and service:

const quote = await api(`/merchant/orders/${orderId}/shipping-label/quote`, {
  method: "POST",
  token,
  body: JSON.stringify({ lines, package: loadedPackage, carrierId, serviceCode }),
});

The response includes the cost, currency, selection classification, funding preflight, any acknowledgement/reason requirements, and an opaque selectionQuoteFingerprint. The quote can make a non-funded provider rating request, but it does not allocate lines, buy postage, mutate fulfillment or inventory, or notify the customer.

When the order carries a delivery promise, POST /merchant/orders/:orderId/delivery-promise-recommendations can compare the original service with same-or-faster and meets-promise candidates. Missing or incompatible evidence fails closed; the comparison never selects or purchases a label.

Buy a label and prepare the shipment

POST /merchant/orders/:orderId/shipping-label is the high-risk, money-moving step. Send a stable x-idempotency-key, the exact reviewed selection, its selectionQuoteFingerprint, the required high-risk reason, and any separate selection overrideReason requested by the quote.

The server recomputes and re-preflights the selection before claiming spend. It never automatically adds ShipEngine funds. A successful purchase returns a durable label and a prepared shipment in READY; printing or reprinting that label still does not mark carrier handoff.

If the provider outcome is uncertain, the API returns a conflict and preserves the claim for reconciliation. Do not start a second purchase. Read GET /merchant/orders/:orderId/shipping-label/:purchaseId/reconciliation-status and, when its server-derived action permits, use POST /merchant/orders/:orderId/shipping-label/reconcile as Owner/Admin with the exact existing purchaseId, a stable x-idempotency-key, and the required high-risk reason. Reconciliation adopts only exact provider evidence and is idempotent; a first provider 404 does not immediately prove that no label was created.

Record physical carrier handoff

After the carrier has the package:

await api(
  `/merchant/commerce/orders/${recordId}/fulfillments/${fulfillmentId}/handoff`,
  {
    method: "POST",
    token,
    headers: { "x-idempotency-key": operationKey },
    body: JSON.stringify({ notify: true }),
  },
);

The handoff moves that exact READY shipment to SHIPPED, consumes its line reservations once, updates fulfillment rollups, and durably records SEND or SUPPRESS for the shipment notification. Retry with the same idempotency key.

For a direct/manual shipment that does not need native postage, POST /merchant/commerce/orders/:recordId/fulfillments creates an explicit line-managed SHIPPED fulfillment instead; see Orders & fulfillment.

Cancel or recover a prepared shipment

Never delete a prepared package or simply rewind its status:

  • POST /merchant/orders/:orderId/fulfillments/:fulfillmentId/cancel-prepared-shipment is an Owner/Admin-only, reasoned command with a stable x-idempotency-key. It cancels label-free work immediately. For current paid outbound postage it records one cancellation intent and queues the exact carrier void; the fulfillment stays READY and handoff remains blocked until authoritative void evidence arrives. A provider-approved void does not promise a carrier credit or a customer refund.
  • POST /merchant/orders/:orderId/fulfillments/:fulfillmentId/cancel-prepared-shipment/retry-carrier-check is an Owner/Admin-only, reasoned recovery with a stable x-idempotency-key, available after automatic cancellation checks are exhausted. It performs one GET for the frozen label; it never issues another void or buys postage.
  • POST /merchant/commerce/orders/:recordId/fulfillments/:fulfillmentId/acknowledge-external-label-retirement is an Owner/Admin-only, reasoned acknowledgement with a stable x-idempotency-key; its body must set carrierResponsibilityAccepted: true. It records responsibility for already-retired external/legacy postage, is not provider proof, and cannot bypass current or ambiguous native postage.

Every path is package-local and replay-safe. SHIPPED or DELIVERED goods move through Returns rather than prepared-shipment cancellation.

Warehouse output without new postage

GET /merchant/shipping/warehouse-outputs lists bounded shipment rows (READY by default) with packing-slip data and server-derived label eligibility. For 1–50 selected fulfillment IDs:

  • POST /merchant/shipping/warehouse-outputs/csv exports rows in request order and neutralizes spreadsheet-formula prefixes.
  • POST /merchant/shipping/warehouse-outputs/labels combines already-purchased PDF artifacts. It makes no authenticated ShipEngine API request and buys no postage; any missing, voided, invalid, oversized, or unavailable artifact fails the whole request instead of silently omitting a label.