Skip to content
System nominalUTC --:--:--Articles 041

Building Order Tracking in WooCommerce Without a Tracking Subscription

Most post-purchase support tickets are "where is my order". Here is the plugin architecture that answers them without shipping customer data to a tracking service.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Topic
WOO
Section
WooCommerce & Storefront
Published
06 OCT 2026
Reading time
7 MIN
Words
1977W
Technical cover graphic for Building Order Tracking in WooCommerce Without a Tracking Subscription
Generated schematic — WOO series, REV 2.6.

The customer has paid, the label is printed, the parcel is somewhere between your warehouse and their door, and they want to know where. On the stores I have worked on, that question arrives as a support ticket more often than every other post-purchase issue combined. It is also the cheapest ticket in the queue to eliminate, which makes the decision about how to answer it worth taking seriously.

A tracking page inside the store answers it. The default answer for most teams is to rent that page from a tracking service: you install an app, it charges a few cents per order, and it hands you a widget. The integration takes an afternoon and the invoice scales with your success. The cost that does not show up on the invoice is the data. Depending on how the app authenticates, your customer's email address, delivery address and order contents may be sitting in somebody else's database, and their privacy policy, not yours, governs what happens next.

Building it yourself is not free either. It is a smaller bill, paid in a different currency. What follows is the architecture I would choose today, the parts of it that are genuinely simple, and the one place where it will break.

A courier is data, not code

Start with the entity that everything else hangs off. A courier has a name, a URL template containing the tracking number, and a logo. That is the whole record. Register it as a custom post type with three pieces of post meta, or as a row in a table of your own if you would rather not have couriers showing up in the admin search. The post type is faster to build and gives you an editing screen without writing one.

The URL template is the part that matters. Store the courier's own tracking page with a placeholder where the number goes, so the store can link out without copying the courier's status data:

// Stored as post meta on the courier record.
// A real courier URL, not an API endpoint.
update_post_meta(
    $courier_id,
    '_courier_tracking_url',
    'https://www.courier-example.com/track?awb={tracking_id}&locale=en'
);

Substitution is then a string replacement, and it is worth writing it defensively because the substituted value ends up in a link:

function wpsifero_tracking_link( string $tracking_id, int $courier_id ): string {
    $template = (string) get_post_meta( $courier_id, '_courier_tracking_url', true );

    if ( '' === $template ) {
        return '';
    }

    $url = str_replace(
        '{tracking_id}',
        rawurlencode( $tracking_id ),
        $template
    );

    return esc_url_raw( $url );
}

rawurlencode() matters more than it looks. Courier tracking numbers are usually alphanumeric, but every store eventually ships through a carrier whose references contain a space or a slash, and those break a URL that looked fine in testing.

The tracking ID is not the order number

It is tempting to use the order number as the tracking identifier and skip a column. Do not. The order number is sequential and often visible to the customer in their account area already, so a public lookup keyed on it turns every neighbouring order into a guessable URL. Increment a number, get somebody else's delivery status.

Generate a separate identifier instead, somewhere random enough to be unguessable but short enough to read over the phone. A hash of the order key is fine on WooCommerce 8.x, and so is a version 4 UUID trimmed to sixteen characters.

function wpsifero_generate_tracking_id(): string {
    // 8 random bytes, hex encoded, uppercase. Short and unguessable.
    return strtoupper( bin2hex( random_bytes( 8 ) ) );
}

Generate it at a status transition, not at checkout. Until the order is actually handed to a carrier there is nothing to track, and issuing identifiers on pending orders means abandoned checkouts consume the same namespace as real shipments. On a store doing 5,000 orders a month on WooCommerce 8.2, PHP 8.2 with a persistent object cache, hooking the generation to the transition into the packed or completed status produced a few hundred IDs a day and no measurable effect on checkout response time.

Store the ID as order meta so HPOS cannot lose it

WooCommerce High-Performance Order Storage moved order data out of wp_posts and wp_postmeta and into dedicated tables. The migration was gradual, which is why plugins that read order meta with a raw get_post_meta() call work fine on some stores and return empty strings on others. On a store that has finished the migration, the order meta you wrote is in wc_orders_meta, and get_post_meta() will not find it.

The fix is not to learn the new table layout. It is to stop reaching past the API. Generate the ID, write it through the order object, and read it back the same way. The order object decides where the row physically lives.

add_action( 'woocommerce_order_status_changed', function ( $order_id, $from, $to, $order ) {
    // Everything after this line works on whichever storage backend is active.
    if ( ! $order instanceof WC_Order ) {
        $order = wc_get_order( $order_id );
    }

    if ( ! $order || $order->get_meta( '_wpsifero_tracking_id' ) ) {
        return;
    }

    $order->update_meta_data( '_wpsifero_tracking_id', wpsifero_generate_tracking_id() );
    $order->save();
}, 10, 4 );

The save() call is not optional. update_meta_data() only stages the change on the in-memory object; nothing reaches the database until the object is saved, and a hook that forgets this looks like it works until the request ends.

The public lookup: a rewrite rule with a shortcode fallback

Two entry points are worth registering. A rewrite rule gives you a clean path such as /track/ABC123/, and a shortcode gives you something to drop into a page when the rewrite rules are not yours to manage, which is the normal situation on a managed host or a site where another plugin owns the permalink structure.

add_action( 'init', function () {
    add_rewrite_rule(
        '^track/([A-Z0-9]{8,32})/?$',
        'index.php?wpsifero_tracking_id=$matches[1]',
        'top'
    );
} );

add_filter( 'query_vars', function ( array $vars ): array {
    $vars[] = 'wpsifero_tracking_id';
    return $vars;
} );

add_shortcode( 'wpsifero_tracking', function (): string {
    $id = get_query_var( 'wpsifero_tracking_id' );

    if ( ! $id ) {
        // On the shortcode path the ID arrives as a query argument.
        $id = isset( $_GET['tracking_id'] )
            ? sanitize_text_field( wp_unslash( $_GET['tracking_id'] ) )
            : '';
    }

    return wpsifero_render_tracking_page( $id );
} );

Flush rewrite rules once on activation and never on a request. A site that flushes on every page load writes the rewrite_rules option constantly and pays for it on every subsequent request, which is a performance problem that has nothing to do with tracking and everything to do with where you put the flush call.

Status history belongs in its own table, read with a prepared query

Order meta is the wrong home for a history. It is one row per key, so a history grows by rewriting a serialised blob, and sorting or paginating it means unserialising everything the customer ever received. Keep the current status on the order and put the events in a table of your own.

CREATE TABLE wp_wpsifero_tracking_history (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  tracking_id VARCHAR(32) NOT NULL,
  status VARCHAR(32) NOT NULL,
  note TEXT NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id),
  KEY tracking_id_created_at (tracking_id, created_at)
);

The created_at column is doing real work. Without it you can tell a customer their parcel is out for delivery, but not that it has been out for delivery since Tuesday, which is the actual answer to the question they asked. The composite key on (tracking_id, created_at) is what keeps the lookup ordered without a filesort once the table has a few million rows in it.

Reading it is where careless code causes damage. The tracking ID comes from a URL, so it is user input, and it must go through $wpdb->prepare() before it reaches the query.

function wpsifero_get_history( string $tracking_id ): array {
    global $wpdb;

    $table = $wpdb->prefix . 'wpsifero_tracking_history';

    // %s holds the placeholder; prepare() quotes the value.
    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT status, note, created_at
             FROM {$table}
             WHERE tracking_id = %s
             ORDER BY created_at ASC
             LIMIT 50",
            $tracking_id
        )
    );

    return is_array( $rows ) ? $rows : array();
}

Note the table name. It is built from $wpdb->prefix and a hardcoded suffix, so no user input touches it. Never interpolate a value into the SQL string with a placeholder sitting next to it; prepare() protects the arguments, not the identifiers.

Data exposure

A public tracking page keyed on the tracking ID will show history to anyone holding that ID. Treat the table as public: keep internal notes, staff names and internal status codes out of it, or hold those in order notes, which require a logged-in session and a capability check.

Self-hosted against a tracking subscription

The comparison is not close on every axis, and pretending otherwise leads teams to the wrong decision. The cost column is where self-hosting wins early, and maintenance is where it loses.

AxisSelf-hostedTracking subscription
Cost at 200 orders/month Build time, then hosting you already pay for. A per-order fee that is small but never zero.
Cost at 8,000 orders/month Unchanged, until the history table needs archiving. Scales with volume, which is the wrong direction.
Data residency Address and order data stay in your database. Depends on the vendor's retention and subprocessors.
Customisation Any status vocabulary, any template, any markup. Whatever the vendor's widget settings expose.
Maintenance Yours: templates, schema, courier changes, upgrades. Theirs, within the limits of their support queue.
Failure mode A courier changes the URL format and your links 404. An outage you cannot debug and a renewal you can refuse.

The limit: you now own every courier integration

The template approach has one failure that no amount of code review prevents. A courier changes its tracking URL format, and every link your store generates for that courier starts returning a 404 or, worse, a working page for a different shipment. Nothing warns you. There is no deprecation window, no email to developers, and the store owner finds out from a customer.

I would budget for this rather than pretend it will not happen. A weekly check that requests one known tracking number through each courier's template, stores the response code, and emails when it changes catches the break in a day rather than a month. It is roughly forty lines of code and it is the difference between owning your tracking and being surprised by it.

The architecture also assumes something about your ambitions. If you only ever need to link out to the courier's own page, this is a weekend of work. The moment you want live status, you are writing per-carrier API clients, handling their authentication and rate limits, and reconciling their events with your order statuses. That is a different project, and at that point the honest question is whether the couriers you use already offer webhooks, because polling them on a schedule will get you rate limited long before it gets you accurate data.

One more thing this does not solve: caching. If a full-page cache serves the tracking page, a page cached under a URL that carries the tracking ID can be handed to the wrong visitor. Mark the route as uncacheable, or pass the ID as a query argument and configure the cache to vary on it.

What to do on Monday

  1. Register the courier entity and fill in URL templates for the three carriers that carry most of your volume. Nothing else can be tested until those templates exist.
  2. Add the tracking ID generator on the status transition into packed, and write it through $order->update_meta_data() followed by $order->save().
  3. Register the rewrite rule and the shortcode, flush rewrite rules once on activation, and verify one real order end to end with the object cache flushed between requests.
  4. Create the history table on activation with a version option, so a later schema change has somewhere to hook.
  5. Write down what the public page is allowed to show before you build it. Retro-fitting that decision after launch means auditing rows you have already collected.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Covers the WooCommerce order lifecycle: checkout, HPOS, session handling, transactional email and the post-purchase layer where most storefront complaints actually originate.

Continue

Related in WooCommerce & Storefront