DEV Community

seller-mind
seller-mind

Posted on

How I Built a Shopify Post-Purchase Tracking Widget From Scratch

When I first started helping a friend manage their Shopify store, I thought the hard part was getting the first sale. I was completely wrong. The real headache started after the sale.

Every day, my inbox was flooded with "Where is my order?" (WISMO) emails. Customers were anxious, and I was spending hours copy-pasting tracking numbers from carrier websites just to reply. It was exhausting, unscalable, and frankly, a terrible way to treat customers who had just given us their money. The post-purchase experience was broken. So I decided to build something.

The Post-Purchase Black Hole

The "Where is my order?" problem is a massive drain on e-commerce resources. When a customer clicks "buy," they enter a waiting period. If you don't provide a branded, transparent tracking experience, they will naturally check the carrier's website.

Carrier sites are often clunky, slow, and sometimes covered in third-party ads. They look nothing like the beautiful, curated Shopify store the customer just bought from. This jarring transition breaks brand trust.

Worse, every time a customer has a question, it generates a support ticket. For a small team, handling WISMO tickets can consume the majority of your customer support bandwidth. If you ignore the post-purchase experience, you risk frustrating your customers and burning out your support staff. We needed a way to keep the customer engaged and informed on our own terms.

The Technical Approach

To solve this, I needed a way to inject a custom tracking widget directly into the Shopify order status page. Shopify's Online Store 2.0 introduced App Embed blocks, which allow apps to inject code into themes without requiring manual theme edits or breaking when the merchant updates their theme. This was the perfect architectural choice.

Frontend Injection

I created a Liquid snippet that acts as the App Embed block. It safely passes the order context to the frontend and initializes the tracking widget.

{% comment %}
  Renders shipment tracking widget on order status pages
{% endcomment %}

<div id="parcelglance-widget" 
     data-order-id="{{ order.id }}"
     data-order-number="{{ order.name }}"
     data-store-name="{{ shop.name }}"
     style="min-height: 200px;">
</div>

<script>
(function() {
  const script = document.createElement('script');
  script.src = 'https://cdn.parcelglance.com/widget.js';
  script.async = true;
  script.onload = function() {
    Parcelglance.init({
      orderId: document.getElementById('parcelglance-widget').dataset.orderId,
      orderNumber: document.getElementById('parcelglance-widget').dataset.orderNumber,
      storeName: document.getElementById('parcelglance-widget').dataset.storeName,
      container: 'parcelglance-widget',
    });
  };
  document.head.appendChild(script);
})();
</script>
Enter fullscreen mode Exit fullscreen mode

This snippet uses Liquid to grab the order.id, order.name, and shop.name, passing them as data attributes to a container div. Then, an immediately invoked function expression (IIFE) dynamically creates a script tag to load the widget logic.

Using an IIFE ensures we don't pollute the global scope, which is crucial when injecting scripts into a merchant's storefront. Marking the script as async ensures that the widget's JavaScript doesn't block the rendering of the rest of the order status page, keeping performance metrics healthy.

Backend Webhooks and Security

A frontend widget is useless without data. To get real-time tracking updates, I needed to listen to Shopify's order fulfillment webhooks. Security is critical here; you must verify that the webhook actually came from Shopify to prevent malicious actors from spoofing fulfillment data.

const crypto = require('crypto');

function verifyShopifyWebhook(rawBody, hmacHeader, secret) {
  const hash = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('base64');
  return hash === hmacHeader;
}

// Handle fulfillment update webhook
app.post('/webhooks/orders/fulfilled',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const verified = verifyShopifyWebhook(
      req.body,
      req.headers['x-shopify-hmac-sha256'],
      process.env.SHOPIFY_WEBHOOK_SECRET
    );

    if (!verified) return res.status(401).send('Invalid');

    const order = JSON.parse(req.body.toString('utf8'));
    // Store fulfillment data for tracking lookup
    await db.fulfillments.upsert({
      orderId: order.id,
      trackingNumber: order.fulfillments[0]?.tracking_number,
      carrier: order.fulfillments[0]?.tracking_company,
      status: 'shipped',
    });

    res.status(200).send('OK');
  }
);
Enter fullscreen mode Exit fullscreen mode

In this Node.js Express handler, we use the crypto module to verify the x-shopify-hmac-sha256 header against the raw request body using the app's webhook secret. We use express.raw to ensure we are hashing the exact byte payload Shopify sent. If the hash matches, we parse the JSON and upsert the fulfillment data into our database. This ensures our tracking widget always has the latest carrier and tracking number information.

Practical Results and Trade-offs

Building this taught me a lot about Shopify's ecosystem. What worked really well was the App Embed approach. Because it doesn't modify the theme files directly, it survives theme updates and is completely non-destructive. Merchants can toggle it on or off without touching a single line of Liquid in their main templates.

What surprised me was how much of a difference asynchronous loading made for the store's performance metrics. By deferring the widget script, we kept the Lighthouse performance scores high, which is always a win for SEO and user experience.

If I were to do it again, I would implement a more robust polling mechanism for the carriers. Relying solely on the initial fulfillment webhook is great for getting the tracking number, but carriers sometimes update statuses without triggering a new Shopify webhook immediately. Adding a background cron job to periodically sync with carrier APIs would ensure the tracking page reflects the exact real-time status, rather than just the initial shipped state.

Wrapping Up

If you're dealing with WISMO tickets and want to give your customers a better post-purchase experience, I've packaged this solution into a free tool. You can check out the Shipment Tracking Widget at parcelglance.com. It's designed to be easy to install via App Embeds and customizable to match your store's theme.


Full disclosure: I'm the developer of Parcelglance. I built this because I needed it myself.

Top comments (0)