Skip to content

Shipping Label Providers

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.

A cashier ships a special order or a converted pickup straight from the register and prints the carrier label at the counter. Magento creates the shipment through the POS's own pack-and-print flow; the carrier label for that shipment is your bridge's job. Given an existing Magento shipment, your provider fetches (or generates) the label from the carrier's API and returns a payload the POS routes directly to the right printer.

Registering a provider is also what switches the feature on: the pool surfaces its provider codes in the POS config payload, so the app only shows carrier-label printing when at least one bridge is installed.

None of the shipped vendor bridges implements this provider type yet, so there is no reference implementation to clone. The interface is three methods, and this page contains a complete worked example instead.

The Interface

Implement Hyva\Pos\Api\ShippingLabelProviderInterface:

public function getProviderCode(): string;

public function canHandle(ShipmentInterface $shipment): bool;

/** @throws \Magento\Framework\Exception\LocalizedException */
public function getLabel(ShipmentInterface $shipment): ShipmentLabelInterface;

getProviderCode() returns a stable machine code for the provider (for example postnl). canHandle() says whether this provider can produce a label for a given shipment. getLabel() does the actual work.

The Label Payload

getLabel() returns a Hyva\Pos\Api\Data\ShipmentLabelInterface whose labelData is the base64-encoded payload and whose labelFormat is one of the ShipmentLabelInterface::FORMAT_* constants. The format code selects the print pipeline the POS feeds the decoded bytes into:

Constant Value Pipeline
FORMAT_ESCPOS escpos ESC/POS bytes to a thermal label printer
FORMAT_PDF pdf PDF to a PDF-capable printer
FORMAT_ZPL zpl Raw ZPL to a Zebra label printer, untouched
FORMAT_TEXT text Plain text to any text-mode printer

An unrecognized labelFormat does not raise an error: the client degrades silently to the plain-text pipeline, so a wrong format code prints garbage instead of the label. Always emit one of the four declared constants.

Set the tracking number on the label object when the carrier returns one.

First-Match Resolution

Providers register on Hyva\Pos\Model\ShippingLabelProviderPool. When the cashier prints a label, the core walks the registered providers in di.xml declaration order and hands the shipment to the first one whose canHandle() returns true. Later providers are never consulted for that shipment, and there is no admin selection to break a tie - carrier bridges are per-carrier, so overlap should not arise in the first place. If two of your own providers could claim the same shipment, declare the more specific one first in the providers array.

Contract Rules

Sanitize errors. Your provider talks to a remote carrier API, and raw failures carry HTTP bodies, stack frames and credential fragments. The core enforces a boundary around every getLabel() call:

  • Throw a LocalizedException only for a cashier-actionable failure (carrier not configured, the carrier rejected the shipment). Its message reaches the cashier verbatim - map the carrier's reason into your own plain wording, and never copy raw remote-API detail into it.
  • Let everything else escape as-is. Any other throwable is caught by the core ShippingLabelService, logged in full (provider code, message, trace), and replaced with a generic "could not be produced" message for the register. Do not pre-wrap transport errors in LocalizedException "to be safe" - that defeats the boundary and leaks the raw payload to the cashier.

Scope canHandle() to your carrier. A broad claim shadows every provider declared after yours in the pool and silently swallows other carriers' shipments. Match on the exact shipping method your carrier owns, or on the carrier's own linkage tables - never a general "any parcel" test.

Build It

Module scaffolding, the registration.php guard and install checks are covered in Building a Bridge; this section covers only what is specific to shipping labels.

1. Implement the Provider

A complete provider for a fictional Acme Express carrier:

src/Model/ShippingLabelProvider.php
<?php
declare(strict_types=1);

namespace Acme\PosBridge\Model;

use Acme\Shipping\Api\LabelClientInterface;
use Hyva\Pos\Api\Data\ShipmentLabelInterface;
use Hyva\Pos\Api\Data\ShipmentLabelInterfaceFactory;
use Hyva\Pos\Api\ShippingLabelProviderInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\Data\ShipmentInterface;
use Magento\Sales\Api\OrderRepositoryInterface;

class ShippingLabelProvider implements ShippingLabelProviderInterface
{
    public function __construct(
        private readonly LabelClientInterface $labelClient,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly ShipmentLabelInterfaceFactory $labelFactory
    ) {
    }

    public function getProviderCode(): string
    {
        return 'acme_express';
    }

    public function canHandle(ShipmentInterface $shipment): bool
    {
        // Narrow claim: only shipments whose order ships with this
        // carrier. Shipping method is "<carrier>_<method>".
        $order = $this->orderRepository->get((int) $shipment->getOrderId());

        return str_starts_with((string) $order->getShippingMethod(), 'acmeexpress_');
    }

    public function getLabel(ShipmentInterface $shipment): ShipmentLabelInterface
    {
        // your carrier API call - fetch or create the label for this shipment
        $response = $this->labelClient->createLabel($shipment->getIncrementId());

        if ($response->isRejected()) {
            // Cashier-actionable: this message reaches the register verbatim.
            // Use your own wording, never the carrier's raw error body.
            throw new LocalizedException(__(
                'Acme Express rejected shipment %1: the order has no parcel weight.',
                $shipment->getIncrementId()
            ));
        }

        return $this->labelFactory->create()
            ->setOrderId((int) $shipment->getOrderId())
            ->setShipmentId((int) $shipment->getEntityId())
            ->setShipmentIncrementId((string) $shipment->getIncrementId())
            ->setLabelFormat(ShipmentLabelInterface::FORMAT_PDF)
            ->setLabelData(base64_encode($response->getLabelPdf()))
            ->setTrackingNumber($response->getTrackingNumber());
    }
}

ShipmentLabelInterfaceFactory is code-generated from the interface preference the core module ships; inject it rather than constructing the data object yourself. Note what getLabel() does not catch: a timeout or an HTTP 500 from createLabel() escapes unwrapped, exactly as the error boundary expects.

2. Register It in the Pool

src/etc/di.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Hyva\Pos\Model\ShippingLabelProviderPool">
        <arguments>
            <argument name="providers" xsi:type="array">
                <item name="acme_express" xsi:type="object">Acme\PosBridge\Model\ShippingLabelProvider</item>
            </argument>
        </arguments>
    </type>
</config>

Unlike the credit pool, this pool takes constructed instances: use xsi:type="object", not string. There is no lazy string resolution here, so the guard in your registration.php on a real carrier vendor class (see Building a Bridge) is what keeps setup:di:compile green when the carrier module is absent.

3. Verify at the Register

After bin/magento setup:upgrade && bin/magento setup:di:compile, walk three cases from the POS app:

  • Ship an order that uses your carrier and print its shipping label. The real carrier label comes out of the right printer, in the format you declared.
  • Print a label for a shipment of a different carrier (with no other bridge installed). The POS reports that no shipping label is available for that shipment - proof your canHandle() is not over-claiming.
  • Break the carrier credentials on purpose and print again. The cashier sees only the generic could-not-produce message; the full error, provider code and trace are in the Magento log under Hyva_Pos: shipping-label provider failed.

Prove It

There is no conformance abstract for shipping-label providers - the kit under Hyva\Pos\TestFramework\Conformance\ covers credit, RMA, customer validator and price resolver providers. First-match resolution and the sanitized-error boundary are pinned by the core module's own unit tests, so what remains to prove is your side of the contract. Unit-test two things: canHandle() scoping and error hygiene.

tests/unit/ShippingLabelProviderTest.php
public function testClaimsOnlyOwnCarriersShipments(): void
{
    $order = $this->createConfiguredMock(Order::class, ['getShippingMethod' => 'ups_ground']);
    $this->orderRepository->method('get')->willReturn($order);

    self::assertFalse($this->provider->canHandle($this->shipment));
}

public function testTransportFailuresEscapeUnwrapped(): void
{
    $this->labelClient->method('createLabel')
        ->willThrowException(new \RuntimeException('401 {"api_key":"sk_live_..."}'));

    $this->expectException(\RuntimeException::class); // not LocalizedException

    $this->provider->getLabel($this->shipment);
}

The second test is the one that matters most: if it starts expecting LocalizedException, your provider is wrapping raw carrier errors and the credential fragment in that message would reach the cashier.

Gotchas

  • object, not string. This pool's di.xml items use xsi:type="object". A string item puts a class name where the pool expects an instance, and every label print fails.
  • A wrong labelFormat prints garbage, not an error. Unrecognized codes fall through to the text pipeline on the client. Stick to the four FORMAT_* constants.
  • A broad canHandle() fails silently. It shadows every provider declared after yours, and the symptom is another carrier's labels coming out wrong - nothing errors. Match your carrier exactly.
  • Do not wrap transport errors. A LocalizedException message goes to the cashier verbatim. Reserve it for messages you wrote yourself; let raw carrier failures escape so the core can log and sanitize them.
  • The provider never creates the shipment. getLabel() runs against an existing shipment created by the POS pack-and-print flow (ShipWithLabelServiceInterface). Fetch or generate the label at the carrier, but leave the Magento shipment untouched.