Skip to content

Fulfillment and Shipping

Hyvä POS is in closed beta

Hyvä POS is currently in a closed beta (pilot phase) with a small group of merchants. It is not yet generally available: the App Store release follows the pilot, and features and configuration may still change - possibly in backwards-incompatible ways - before the general release. Want to take part? Sign up at hyva.io/pos.

These endpoints back the Pickups view (in-store pickup handoffs) and ship-to-customer fulfillment (rates, carriers, labels). All of them require the Hyva_Pos::locations ACL resource.

Method Route Purpose
GET /V1/pos/locations/:locationId/pickups Classified pickup queue for a location
POST /V1/pos/orders/:orderId/pickup-completion Record the physical handoff
POST /V1/pos/orders/:orderId/pickup-cancellation Cancel an uncollected pickup
GET /V1/pos/products/:childSku/bundle-parents Reverse lookup: bundles containing a product
POST /V1/pos/shipping-rates Live rates for a customer address
GET /V1/pos/shipping-config Offline-cacheable flat-rate shipping methods
GET /V1/pos/carriers Active tracking carriers for dispatch
POST /V1/pos/orders/:orderId/ship-with-label Create a shipment and return printable label data
GET /V1/pos/shipments/:shipmentId/shipping-label Carrier label for an existing shipment

List Pickups

GET /V1/pos/locations/:locationId/pickups?filter=ready

Single backing query for the Pickups view. Returns every open order with an instore_pickup_* shipping method that has not shipped yet, scoped to the location's MSI source. Both storefront-placed pickups and POS-placed backorders surface here, distinguished by origin.

filter is one of ready, awaiting_stock, stale, or all (the default). Classification per order:

  • ready - every physical line is coverable from the on-hand quantity at the bound MSI source.
  • awaiting_stock - at least one line cannot be covered yet.
  • stale - not ready and older than the stale threshold (14 days), overriding awaiting_stock.

The response is deliberately thin - the endpoint earns its keep through joins searchCriteria cannot express (orders × shipments × source items × age), not through data shape. The app hydrates each visible row via the stock GET /V1/orders/:id:

[
  {
    "order_id": 812,
    "order_increment_id": "000000812",
    "origin": "online",
    "classification": "ready",
    "source_code": "amsterdam_store",
    "created_at": "2026-08-10 09:15:00",
    "expected_at": null
  }
]

origin is online or pos_backorder. expected_at is a best-guess readiness ETA, null when the row is already ready or no projection is possible. A server-side cron re-checks backorder pickups every five minutes so rows flip to ready as stock arrives.

Complete a Pickup

POST /V1/pos/orders/:orderId/pickup-completion

Adobe's in-store-pickup module has no "picked up" concept - shipment creation is its de-facto fulfillment event. This endpoint records the physical handoff atomically: it creates the shipment if none exists (deducting from the pickup location's MSI source), inserts the hyva_pos_pickup_completion audit row, appends a status-history comment (Picked up by {name}. {comment}), and optionally flips the order status to the location's configured post-pickup status.

{
  "locationId": 1,
  "pickedUpBy": "J. de Vries",
  "comment": "ID checked",
  "sendNotification": true
}

pickedUpBy is required. sendNotification (default true) controls the customer-facing pickup-confirmation email; the audit row and comment are committed before the email fires, so a mail transport failure never rolls back the handoff record.

Error behavior is deliberately specific so the cashier gets something actionable instead of Magento's generic "Could not save a shipment". All are 400 LocalizedException responses:

  • Pickup completion requires a collector name.
  • Order %1 is not a pickup order (shipping method: %2).
  • Order %1 has already been picked up. - duplicate completion.
  • Stock not assigned at %1 for: %2. Add stock at this location first, then complete the pickup. - one or more SKUs have no source-item row at the pickup source; %2 lists them.
  • Insufficient stock at %1 for: %2. - the source-item row exists but quantity is short; %2 lists each SKU with (need N, have M).
  • Could not create the shipment for order %1: %2 - catch-all that surfaces the deepest inner exception message.

Orders pre-shipped by admin's "Notify Order is Ready for Pickup" button are tolerated - the shipment step is skipped and only the completion record and comment are written.

Cancel a Pickup

POST /V1/pos/orders/:orderId/pickup-cancellation

For pickups the customer did not (or will not) collect. Routes through Magento's OrderManagementInterface::cancel, which handles the state transition and restores MSI reservations, then stamps a status-history comment: Pickup cancelled at {location}. Reason: {reason}.

{
  "locationId": 1,
  "reason": "Customer no-show"
}

reason is required. Refused with a 400 when the order is not a pickup order, has already been picked up (issue a refund instead), or has already shipped (process a refund/return instead) - shipped goods need the return flow, not a cancel.

Bundle Parent Lookup

GET /V1/pos/products/:childSku/bundle-parents returns the bundle products that contain :childSku as a selection, so the app can warn about "Ship Together" bundles before MSI validation rejects a partial fulfillment. Magento core has no equivalent REST surface.

Shipping Rates

POST /V1/pos/shipping-rates

Live rates for ship-to-customer lines. The POS keeps cart state locally and places orders directly, so there is no Magento cart to estimate against - the service builds a transient quote, runs the standard rate collector (table rates, UPS, FedEx, any configured carrier), and discards the quote.

{
  "customerId": 42,
  "addressId": 118,
  "itemsJson": "[{\"sku\":\"WSH12\",\"qty\":1,\"price\":45.0}]"
}

addressId must be an address entity id belonging to the customer. The response is an array of rate rows; carrier_code + method_code compose the carrierCode_methodCode value the order placement stamps onto its shipping assignment:

[
  {
    "carrier_code": "ups",
    "method_code": "GROUND",
    "carrier_title": "United Parcel Service",
    "method_title": "Ground",
    "amount": 12.5,
    "currency": "EUR",
    "estimated_days": 3,
    "tax_rate": 21.0,
    "tax_amount": 2.63,
    "tax_class_id": 4
  }
]

Tax fields are resolved server-side against tax/classes/shipping_tax_class and the destination tax zone, honoring tax/calculation/shipping_includes_tax, so the client never reproduces tax math. They are null only on module versions that pre-date them.

Shipping Config and Carriers

GET /V1/pos/shipping-config returns the offline-cacheable shipping-method list: flat-shape carriers (flatrate, freeshipping) with carrier_code, method_code, titles, amount, currency, and the same server-resolved tax_rate / tax_class_id. The app replaces its local settings.shippingCarriers list from this on every Settings → Shipping open; live rates from pos/shipping-rates override it per checkout.

GET /V1/pos/carriers returns the dispatch-side list - carriers activated under Stores → Configuration → Sales → Delivery Methods, as {"code": "dhl", "title": "DHL", "sort_order": 10} rows sorted by sort_order. These are the values written to shipment_track.carrier_code when shipping with tracking; carriers without a configured title are filtered out. PRO installs auto-sync this list and lock the on-device editor.

Ship With Label

POST /V1/pos/orders/:orderId/ship-with-label

Pack-and-print action for the Pickups view. Creates a Magento shipment for the selected items and returns label data in the same round-trip, so the app can route the print job immediately.

{
  "itemsJson": "[{\"order_item_id\":12,\"qty\":1}]",
  "trackingNumber": "3STEST1234567",
  "carrierCode": "postnl",
  "trackingTitle": "PostNL Pakket"
}

An empty itemsJson array means "ship everything still shippable". carrierCode and trackingTitle land on the shipment_track row; when omitted they fall back to the order's shipping method and description - pass them explicitly when recording real carrier data. Response:

{
  "order_id": 812,
  "shipment_id": 402,
  "shipment_increment_id": "000000402",
  "label_format": "escpos",
  "label_data": "G0AbYQEuLi4=…(base64)",
  "tracking_number": "3STEST1234567"
}

label_format declares the print pipeline: escpos (thermal printer bytes - the default packing-slip-style label with order barcode), pdf (carrier-API label documents), text, or zpl (raw Zebra). label_data is always base64-encoded. Real carrier-API labels come from carrier integration modules that override the label generation.

Shipping Label for an Existing Shipment

GET /V1/pos/shipments/:shipmentId/shipping-label is read-only - it never creates shipments. Resolution is delegated to the registered shipping-label provider pool, so it returns 404 (NotFoundException) until a carrier bridge module is installed, and 404 (NoSuchEntityException) when the shipment does not exist. The response shape is the same label object as ship-with-label.