Trackr for Craft CMS

Usage

Four ways in, one way through

Tracking arrives by hand, by CSV, over the API or from a console command. All four land in the same method, so the same tracking number sent twice updates one shipment rather than creating a second — which matters, because fulfilment services retry and merchants re-upload spreadsheets.

A shipment is identified by its carrier and its tracking number together. Send the same pair again and you have edited the shipment you already had.

By hand

Trackr adds a panel to Commerce's own order edit screen: carrier, tracking number, ship date, delivery status, note. On Pro the panel also lets you say which line items and how many of each are in the box.

By CSV

Trackr → Import. Upload the file. Trackr reads the header row, works out which column is which, resolves every order and every carrier, and shows you exactly what it would do — new shipments, updates to existing ones, and rows it could not resolve. Nothing is written until you confirm.

Headers are matched loosely, so a supplier's Tracking No, tracking_number and TRACKING-NUMBER are the same column. The only required column is the order number.

Order Number,Tracking Number,Carrier,Ship Date,Service,Status
CT-1042,1Z999AA10123456784,UPS,2026-08-14,Ground,in_transit
CT-1043,9400111899223197428490,USPS,2026-08-14,Priority Mail,delivered

php craft trackr/import/sample prints a file with every column Trackr understands.

On Pro, php craft trackr/import/watch imports everything in the watched folder and moves the files aside. Point a supplier's SFTP drop at it and put the command on cron.

By API (Pro)

curl -X POST https://example.com/actions/trackr/api/shipments \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{
        "order_number": "CT-1042",
        "tracking_number": "1Z999AA10123456784",
        "carrier": "ups",
        "service": "Ground",
        "ship_date": "2026-08-14"
      }'

201 for a new shipment, 200 when it updated one that already existed — so a service that retries on timeout cannot double-ship an order.

EndpointWhat it does
POST /actions/trackr/api/shipmentsRecord or update a shipment
GET /actions/trackr/api/shipments?order_number=Read tracking back
POST /actions/trackr/api/statusMove a shipment's delivery status
GET /actions/trackr/api/carriersThe carrier handles Trackr understands

Apache commonly strips the Authorization header, so X-Trackr-Token and a trackr_token query parameter are accepted too. Not token — that is Craft's own preview-token parameter, and Craft rejects the request before Trackr sees it.

From the command line

php craft trackr/shipments/add CT-1042 1Z999AA10123456784 --carrier=ups
php craft trackr/shipments/show CT-1042
php craft trackr/shipments/list
php craft trackr/shipments/set-status 1Z999AA10123456784 delivered
php craft trackr/shipments/recalculate CT-1042
php craft trackr/import/csv tracking.csv --dry-run
php craft trackr/import/watch          # Pro
php craft trackr/import/sample
php craft trackr/log/prune             # Pro

recalculate rebuilds the fulfilment rollup from the shipment rows. You should never need it — the rollup is recalculated on every write — but it is there for after a direct database edit.

The widget

{{ craft.trackr.widget(order) }}

One line in your Commerce order email template, or on an account page, or both. It renders as table markup with inline styles, because its main home is an email client that strips <style> blocks and will not load an external sheet.

Colours, layout and what it shows are all settings, with a live preview on the settings screen.

To replace the markup entirely, point widgetTemplate at a site template. It receives the same variables the built-in one does: order, shipments, settings and progress.

If you would rather build your own from scratch:

{% for shipment in craft.trackr.shipments(order) %}
    <li>
        {{ shipment.getProviderLabel() }} — {{ shipment.trackingNumber }}
        {% if shipment.getTrackingUrl() %}
            <a href="{{ shipment.getTrackingUrl() }}">Track</a>
        {% endif %}
        <span>{{ shipment.getStatusLabel() }}</span>
    </li>
{% endfor %}

The tracking page

Trackr serves a customer-facing page at /track. A customer enters their order number and the email address on the order; a link from the widget carries the order's UID and skips the form.

No account is needed, which is the point — most Commerce stores take guest orders, and a guest who cannot log in still wants to know where their parcel is.

Failed lookups are throttled per IP and every failure answers identically. See Configuration for why you should leave the email requirement on.

{{ craft.trackr.trackingPageUrl(order) }} gives you the direct link for an order.

Carriers

Trackr → Carriers lists the 130 built in, across North America, Europe, Asia-Pacific, South America, Africa and the Middle East, plus LTL freight, print-on-demand and the aggregators.

Switch off the ones you never use so the picker stays short. On Pro you can also:

  • Rename one — your customers may know DHL eCommerce by a different name.
  • Point it at a different URL — some carriers run a different tracking host per region.
  • Give it a logo for the widget.
  • Add your own, including a white-labelled Our delivery van with no URL at all, which records a number without pretending it links anywhere.

Merchant changes are stored as overrides rather than copies, so if a built-in carrier's URL is wrong and gets corrected in a Trackr release, the correction still reaches you when all you did was rename it.

Tracking URLs are templates. {tracking_number} is the one every carrier uses; where a carrier needs more, {postal_code}, {phone}, {country} and {ship_date} are available.

A carrier Trackr does not recognise is still recorded — the number just shows as plain text. A link that 404s is worse than no link.

Item-level tracking (Pro)

A shipment can name line items and quantities instead of covering the whole order. Trackr counts shippable units — digital goods are excluded, or an order with a download in it could never reach fully shipped — and holds the order at partially shipped until every unit is accounted for.

Three rules decide what a shipment counts for:

  • An item-level shipment counts the items it names.
  • A shipment flagged partial counts nothing, so the order stays open.
  • Anything else counts whatever has not shipped yet — which is what a merchant means by pasting one tracking number onto an order.

The rollup is recalculated from the shipment rows on every write, never incremented, so deleting or editing a shipment can never leave an order counting units that are not there.

Twig API

{{ craft.trackr.widget(order) }}          {# the tracking widget #}
{{ craft.trackr.shipments(order) }}       {# every shipment, newest first #}
{{ craft.trackr.latest(order) }}          {# the most recent one, or null #}
{{ craft.trackr.hasShipped(order) }}
{{ craft.trackr.isFullyShipped(order) }}
{{ craft.trackr.isDelivered(order) }}
{{ craft.trackr.progress(order) }}        {# {shipped, total, remaining, percent} #}
{{ craft.trackr.trackingPageUrl(order) }}
{{ craft.trackr.trackingUrl('ups', '1Z999AA10123456784') }}
{{ craft.trackr.providers() }}
{{ craft.trackr.detect('1Z999AA10123456784').name }}   {# Pro #}

trackingUrl() is the only place a tracking URL is ever built — the CP link, the widget, the tracking page and this all resolve through it, so a customer cannot be shown three different links for one parcel.

Events

use justinholtweb\trackr\services\Shipments;
use justinholtweb\trackr\events\ShipmentEvent;
use yii\base\Event;

Event::on(Shipments::class, Shipments::EVENT_AFTER_SAVE_SHIPMENT, function(ShipmentEvent $e) {
    // $e->shipment, $e->order, $e->isNew
});

Event::on(Shipments::class, Shipments::EVENT_AFTER_UPDATE_STATUS, function(ShipmentEvent $e) {
    // fires when a shipment reaches delivered, among others
});

Both fire wherever the shipment came from — order screen, CSV, API or console — because all four go through the same method.