DEV Community

seller-mind
seller-mind

Posted on

Building a Secure Shopify Tracking Widget from Scratch

When I first started helping a friend manage their Shopify store, I thought the hardest part would be getting the initial sales. I was wrong. The real nightmare started after checkout.

Within a week of launching, our support inbox was drowning. It wasn't complaints about product quality or broken links. It was a relentless flood of "Where is my order?" (WISMO) emails. Customers were anxious, and we were spending hours copy-pasting tracking numbers from carrier websites.

The post-purchase experience had become a black hole. Once a customer clicked "pay," they were handed off to a generic carrier email that often ended up in the spam folder. The anxiety was real, and the cost to our time was even worse.

So I decided to build something.

The Problem with Post-Purchase

I needed a way to keep customers engaged after checkout without requiring merchants to hire a developer to edit their theme code. Native Shopify tracking often just redirects to a generic carrier page, losing the branded experience entirely. Building a custom tracking page from scratch for every store isn't scalable, and relying on third-party apps that inject heavy scripts can ruin page load speeds.

The goal was clear: build a tracking widget that was secure, performant, and incredibly easy to install, while providing a fully branded, real-time tracking experience to reduce those endless WISMO tickets.

Technical Approach and Architecture

Here is the architecture I settled on to solve this without compromising store performance or security.

1. Frictionless Installation via App Embeds

Early on, I realized that asking merchants to paste Liquid code into their theme.liquid file was a recipe for disaster. It breaks when they change themes, and it causes support headaches. Instead, I leaned heavily into Shopify’s App Embed blocks. This allows the widget to be injected dynamically without touching the core theme files.

Here is what the embed block looks like under the hood:

{% 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.id and order.name via data attributes, the client-side script can securely request the tracking state without needing to parse the DOM for hidden elements or rely on fragile CSS selectors.

2. Webhook-Driven State and Security

My initial mistake was trying to query carrier APIs directly from the client-side widget. It was slow, hit rate limits, and exposed API keys. I pivoted to a webhook-driven architecture.

When an order is fulfilled in Shopify, a webhook fires to our backend. We verify the HMAC signature to ensure the request is genuinely from Shopify, then cache the fulfillment data. The frontend widget simply requests this cached state.

Here is the backend handler for the fulfillment webhook:

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 verifyShopifyWebhook function is critical. Shopify sends an x-shopify-hmac-sha256 header. By hashing the raw request body with our app's webhook secret and comparing it to the header, we guarantee the payload hasn't been tampered with. Only after verification do we parse the JSON and upsert the tracking data into our database.

3. Customizable UI via CSS Variables

A tracking page that clashes with a store's theme is a tracking page that gets uninstalled. Instead of building a rigid UI, I designed the widget to inherit CSS variables from the merchant's theme, while allowing specific overrides for the tracking timeline. This keeps the bundle size small since we aren't shipping massive CSS frameworks.

Practical Results

Moving to this architecture solved our immediate problems. The App Embed approach meant merchants could install the widget in seconds, and it survived theme updates seamlessly. The webhook-driven backend kept our API calls to carriers strictly rate-limited and predictable, while the frontend remained blazing fast since it only fetched a small JSON payload of cached tracking events.

What surprised me most was how much the post-purchase experience impacted customer perception. Even with identical shipping times, a branded, real-time tracking status made customers feel in control. The generic carrier emails were replaced by a cohesive experience that matched the store's branding, which naturally helped reduce support tickets.

If I were to start over, I would change how I initially handled carrier edge cases. I originally tried to normalize all the different carrier status strings in the frontend JavaScript. It bloated the widget bundle and made debugging a nightmare. I moved that normalization logic entirely to the backend webhook handler, keeping the client-side script strictly focused on rendering.

Wrapping Up

Building this solved the WISMO problem for my friend's store, and I realized other merchants were facing the exact same headache. I packaged the architecture into a free tool called Parcelglance.

It adds a branded, real-time tracking page to your Shopify store, integrates seamlessly via App Embed blocks, and helps reduce those endless support tickets. 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)