DEV Community

seller-mind
seller-mind

Posted on

Architecting a Headless Shopify Tracking Widget for Post-Purchase

When I first started helping a friend manage their Shopify store, I thought the hard part was getting sales. I was wrong. The real nightmare started after the checkout. Every day, my inbox flooded with "Where is my order?" (WISMO) emails. Customers were anxious, and I was spending hours copy-pasting tracking numbers from carrier websites.

I tried a few off-the-shelf apps, but they either bloated the page load time with heavy third-party scripts or locked the branding behind expensive paywalls. I just wanted a lightweight, customizable way to show real-time tracking without compromising the store's performance. So I decided to build something.

The Hidden Cost of WISMO

WISMO isn't just an annoyance; it's a silent conversion killer. When a customer buys something, the post-purchase experience is the only touchpoint you have until the package arrives. If that experience is a generic carrier page or a blank "check back later" screen, anxiety builds. Anxious customers email support. Support costs money and time. More importantly, a poor post-purchase experience destroys repeat purchase rates.

From a technical standpoint, embedding heavy, iframe-based tracking widgets from legacy apps introduces massive layout shifts and blocks the main thread. We needed an architecture that was fast, secure, and seamlessly integrated with Shopify's native theme structure without relying on clunky iframes.

Architectural Decisions: Ditching Iframes

The core architectural decision was avoiding iframes entirely. Iframes are terrible for performance and make styling a nightmare. Instead, I opted for Shopify’s native App Embed blocks, which inject lightweight JavaScript directly into the theme.

To keep the frontend blazing fast, the tracking widget itself doesn't talk to carrier APIs directly. That would expose API keys and introduce latency. Instead, I built a backend event-driven pipeline. When an order is fulfilled, Shopify fires a webhook to our server.

Here is the webhook handler that processes fulfillment updates:

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

The HMAC verification is non-negotiable for security. Once verified, we upsert the tracking state into a fast datastore. This means our frontend never has to wait for slow carrier API responses during a page load.

Then, on the frontend, the App Embed block loads. Here is the Liquid and JavaScript snippet that initializes the 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

By passing the order context via data attributes, the script securely fetches the pre-processed tracking state from our API. Because the script is loaded asynchronously, it keeps the main thread unblocked, preserving the store's Core Web Vitals.

Practical Results and Trade-offs

Moving away from iframes to native App Embed blocks was the most impactful decision. The performance metrics, specifically Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS), remained completely unaffected, which is rare for third-party widgets.

One surprise was the complexity of carrier API normalization. Every carrier formats their tracking events differently. I had to build a robust adapter pattern on the backend to normalize statuses into a consistent state machine. For example, mapping "Out for Delivery" from one carrier and "On the truck" from another to a single out_for_delivery enum.

If I were to do it again, I would implement a more aggressive caching layer for the tracking lookups. Carrier APIs can sometimes be slow to respond, and we don't need to hit them on every single page load if the status hasn't changed. Implementing a time-to-live (TTL) cache based on the last known status would reduce backend load significantly.

Wrapping Up

I ended up packaging this architecture into a tool called Parcelglance. It’s a Shopify app that adds a branded, lightweight tracking page to your store, pulling real-time status from major carriers while matching your store's theme. It's designed specifically to reduce those WISMO tickets by giving customers a seamless post-purchase experience.

If you're dealing with WISMO tickets and want a performant tracking widget, you can check out the free Shipment Tracking Widget at parcelglance.com.


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

Top comments (0)