Ghost Event Tracking with Meta Pixel | OpsBlu Docs

Ghost Event Tracking with Meta Pixel

Track Ghost member signups, subscriptions, and content engagement with Meta Pixel standard and custom events

Meta Pixel provides standard events for tracking conversions and custom events for Ghost-specific actions. This guide shows how to implement comprehensive event tracking for Ghost memberships, content engagement, and user interactions.

Meta Pixel Standard Events

Meta provides predefined standard events optimized for ad delivery and conversion tracking.

Meta Event Ghost Action Use Case
Lead Member signup (free) Newsletter leads
Subscribe Newsletter subscription Email list growth
Purchase Paid subscription Membership revenue
ViewContent Post/page view Content engagement
CompleteRegistration Account creation User acquisition
InitiateCheckout Portal checkout opened Subscription funnel
AddPaymentInfo Payment details entered Purchase intent
Search Site search usage Content discovery

Implementation Methods

Method 1: Ghost Portal Events (Client-Side)

Track Ghost Portal interactions using event listeners.

Member Signup (Lead Event)

{{!-- In default.hbs, before </body> --}}
<script>
  // Track free member signup
  window.addEventListener('portal-signup', function(event) {
    if (typeof fbq !== 'undefined') {
      fbq('track', 'Lead', {
        content_name: 'Ghost Member Signup',
        content_category: 'Membership',
        {{#member}}
        status: 'free',
        {{/member}}
      });

      // Also track as CompleteRegistration
      fbq('track', 'CompleteRegistration', {
        content_name: 'Member Account Created',
        value: 0,
        currency: 'USD'
      });
    }
  });
</script>

Newsletter Subscribe Event

<script>
  // Track newsletter subscription
  window.addEventListener('portal-subscribe', function(event) {
    if (typeof fbq !== 'undefined') {
      fbq('track', 'Subscribe', {
        content_name: 'Newsletter Subscription',
        predicted_ltv: 5.00, // Estimated lifetime value of email subscriber
        value: 0,
        currency: 'USD'
      });
    }
  });
</script>

Member Login/Logout

<script>
  // Track member login
  window.addEventListener('portal-signin', function(event) {
    if (typeof fbq !== 'undefined') {
      fbq('trackCustom', 'MemberLogin', {
        {{#member}}
        member_type: '{{#if paid}}paid{{else}}free{{/if}}'
        {{/member}}
      });
    }
  });

  // Track member logout
  window.addEventListener('portal-signout', function(event) {
    if (typeof fbq !== 'undefined') {
      fbq('trackCustom', 'MemberLogout', {});
    }
  });
</script>

Portal Opened (Checkout Initiated)

<script>
  // Track when Ghost Portal opens
  window.addEventListener('portal-open', function(event) {
    if (typeof fbq !== 'undefined') {
      fbq('trackCustom', 'PortalOpened', {
        {{#member}}
        member_status: 'logged_in',
        {{/member}}
        {{^member}}
        member_status: 'visitor',
        {{/member}}
      });

      // If portal opens for checkout, track InitiateCheckout
      {{^member}}
      fbq('track', 'InitiateCheckout', {
        content_name: 'Membership Checkout',
        content_category: 'Subscription'
      });
      {{/member}}
    }
  });
</script>

Method 2: Paid Subscription Tracking (Purchase Event)

Track subscription purchases using member context:

{{!-- In default.hbs or post.hbs --}}
{{#if @member.subscriptions}}
<script>
  // Track active paid subscription as Purchase
  if (typeof fbq !== 'undefined') {
    var subscription = {
      id: '{{@member.subscriptions.[0].id}}',
      tier: '{{@member.subscriptions.[0].tier.name}}',
      price: {{@member.subscriptions.[0].tier.monthly_price}},
      currency: '{{@member.subscriptions.[0].tier.currency}}',
      cadence: '{{@member.subscriptions.[0].cadence}}'
    };

    // Only track purchase once per session
    if (!sessionStorage.getItem('ghost_subscription_tracked')) {
      fbq('track', 'Purchase', {
        content_name: subscription.tier,
        content_type: 'subscription',
        content_ids: [subscription.id],
        currency: subscription.currency.toUpperCase(),
        value: subscription.price,
        predicted_ltv: subscription.price * 12, // Annual value
        num_items: 1
      });

      sessionStorage.setItem('ghost_subscription_tracked', 'true');
    }
  }
</script>
{{/if}}

Note: This tracks existing subscriptions. For new subscriptions, use Stripe webhooks (Method 3).

For accurate subscription tracking, use Stripe webhooks with Meta Conversion API.

Stripe Webhook Handler (Node.js)

// stripe-webhook.js
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const crypto = require('crypto');
const axios = require('axios');

const app = express();

app.post('/webhook/stripe', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle subscription events
  switch (event.type) {
    case 'customer.subscription.created':
      await trackSubscriptionPurchase(event.data.object);
      break;
    case 'invoice.payment_succeeded':
      if (event.data.object.billing_reason === 'subscription_cycle') {
        await trackRecurringPayment(event.data.object);
      }
      break;
    case 'customer.subscription.deleted':
      await trackSubscriptionCancellation(event.data.object);
      break;
  }

  res.json({received: true});
});

async function trackSubscriptionPurchase(subscription) {
  const pixelId = process.env.META_PIXEL_ID;
  const accessToken = process.env.META_CAPI_ACCESS_TOKEN;

  // Hash customer email
  const customer = await stripe.customers.retrieve(subscription.customer);
  const hashedEmail = crypto.createHash('sha256')
    .update(customer.email.toLowerCase())
    .digest('hex');

  const payload = {
    data: [{
      event_name: 'Purchase',
      event_time: Math.floor(Date.now() / 1000),
      action_source: 'website',
      user_data: {
        em: hashedEmail,
        client_user_agent: customer.metadata?.user_agent,
        fbc: customer.metadata?.fbc,
        fbp: customer.metadata?.fbp
      },
      custom_data: {
        content_name: subscription.metadata?.tier_name || 'Paid Membership',
        content_type: 'subscription',
        content_ids: [subscription.items.data[0].price.product],
        currency: subscription.currency.toUpperCase(),
        value: (subscription.items.data[0].price.unit_amount / 100),
        predicted_ltv: (subscription.items.data[0].price.unit_amount / 100) * 12
      }
    }]
  };

  try {
    await axios.post(
      `https://graph.facebook.com/v18.0/${pixelId}/events`,
      payload,
      { params: { access_token: accessToken } }
    );
    console.log('Meta CAPI: Purchase tracked');
  } catch (error) {
    console.error('Meta CAPI Error:', error.response?.data);
  }
}

async function trackRecurringPayment(invoice) {
  // Similar implementation for recurring payments
  console.log('Recurring payment tracked:', invoice.id);
}

async function trackSubscriptionCancellation(subscription) {
  // Track cancellation as custom event
  const pixelId = process.env.META_PIXEL_ID;
  const accessToken = process.env.META_CAPI_ACCESS_TOKEN;

  const customer = await stripe.customers.retrieve(subscription.customer);
  const hashedEmail = crypto.createHash('sha256')
    .update(customer.email.toLowerCase())
    .digest('hex');

  const payload = {
    data: [{
      event_name: 'SubscriptionCancelled',
      event_time: Math.floor(Date.now() / 1000),
      action_source: 'website',
      user_data: {
        em: hashedEmail
      },
      custom_data: {
        subscription_id: subscription.id,
        cancellation_reason: subscription.cancellation_details?.reason || 'unknown'
      }
    }]
  };

  try {
    await axios.post(
      `https://graph.facebook.com/v18.0/${pixelId}/events`,
      payload,
      { params: { access_token: accessToken } }
    );
  } catch (error) {
    console.error('Meta CAPI Error:', error.response?.data);
  }
}

app.listen(3000);

Pass Client Data to Stripe

Modify Ghost theme to capture fbp and fbc values:

<script>
  // Capture Facebook click and browser IDs
  function getFBCookie(name) {
    var value = "; " + document.cookie;
    var parts = value.split("; " + name + "=");
    if (parts.length === 2) return parts.pop().split(";").shift();
  }

  window.addEventListener('portal-checkout-start', function() {
    var fbp = getFBCookie('_fbp');
    var fbc = getFBCookie('_fbc');

    // Store in sessionStorage to pass to Stripe
    sessionStorage.setItem('meta_fbp', fbp);
    sessionStorage.setItem('meta_fbc', fbc);

    console.log('Meta tracking IDs:', {fbp, fbc});
  });
</script>

Method 4: Content Engagement Tracking

Track how users interact with Ghost content:

ViewContent Event

{{!-- In post.hbs --}}
{{#post}}
<script>
  // Track post view
  if (typeof fbq !== 'undefined') {
    fbq('track', 'ViewContent', {
      content_name: '{{title}}',
      content_category: '{{primary_tag.name}}',
      content_type: 'article',
      content_ids: ['{{id}}'],
      {{#member}}
      viewer_type: 'member',
      member_status: '{{#if paid}}paid{{else}}free{{/if}}',
      {{/member}}
      {{^member}}
      viewer_type: 'visitor',
      {{/member}}
      visibility: '{{visibility}}'
    });
  }
</script>
{{/post}}

Reading Progress Event

<script>
  {{#post}}
  // Track reading milestones
  var milestones = [25, 50, 75, 100];
  var tracked = [];

  function trackReadingProgress() {
    var article = document.querySelector('article');
    if (!article) return;

    var articleHeight = article.offsetHeight;
    var windowHeight = window.innerHeight;
    var scrollTop = window.pageYOffset;
    var scrollPercent = ((scrollTop + windowHeight) / articleHeight) * 100;

    milestones.forEach(function(milestone) {
      if (scrollPercent >= milestone && tracked.indexOf(milestone) === -1) {
        tracked.push(milestone);

        if (typeof fbq !== 'undefined') {
          fbq('trackCustom', 'ReadingProgress', {
            scroll_depth: milestone,
            post_id: '{{id}}',
            post_title: '{{title}}'
          });
        }
      }
    });
  }

  var scrollTimeout;
  window.addEventListener('scroll', function() {
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(trackReadingProgress, 200);
  });
  {{/post}}
</script>

Member-Only Content Access

{{#has visibility="members,paid"}}
<script>
  {{#member}}
  // Track member accessing gated content
  if (typeof fbq !== 'undefined') {
    fbq('trackCustom', 'MemberContentAccess', {
      content_id: '{{post.id}}',
      content_title: '{{post.title}}',
      content_visibility: '{{post.visibility}}',
      member_tier: '{{#if paid}}paid{{else}}free{{/if}}'
    });
  }
  {{/member}}
</script>
{{/has}}

Method 5: Search Tracking

If Ghost search is enabled:

<script>
  // Track site search
  var searchInput = document.querySelector('.search-input, [data-ghost-search]');

  if (searchInput) {
    searchInput.addEventListener('keyup', function(e) {
      if (e.key === 'Enter' && this.value.length > 0) {
        if (typeof fbq !== 'undefined') {
          fbq('track', 'Search', {
            search_string: this.value,
            content_category: 'Site Search'
          });
        }
      }
    });
  }
</script>

Track when users click external links:

<script>
  document.addEventListener('click', function(e) {
    var link = e.target.closest('a');
    if (!link) return;

    var href = link.getAttribute('href');
    var isExternal = href && href.indexOf('http') === 0 && href.indexOf(location.hostname) === -1;

    if (isExternal && typeof fbq !== 'undefined') {
      fbq('trackCustom', 'OutboundClick', {
        destination_url: href,
        {{#post}}
        source_post: '{{title}}',
        {{/post}}
      });
    }
  });
</script>

Custom Events for Ghost

Create custom events for Ghost-specific actions:

Tier Comparison Viewed

<script>
  // Track when user views pricing/tiers page
  {{#is "page"}}
  {{#has slug="pricing,membership,subscribe"}}
  if (typeof fbq !== 'undefined') {
    fbq('trackCustom', 'ViewPricing', {
      page_name: '{{title}}',
      {{#member}}
      current_tier: '{{#if paid}}paid{{else}}free{{/if}}',
      {{/member}}
    });
  }
  {{/has}}
  {{/is}}
</script>

Comment Submitted

<script>
  // Track comment submission
  document.addEventListener('click', function(e) {
    if (e.target && e.target.matches('.comment-submit, [data-comment-submit]')) {
      if (typeof fbq !== 'undefined') {
        fbq('trackCustom', 'CommentSubmitted', {
          {{#post}}
          post_id: '{{id}}',
          post_title: '{{title}}',
          {{/post}}
          {{#member}}
          member_status: '{{#if paid}}paid{{else}}free{{/if}}'
          {{/member}}
        });
      }
    }
  });
</script>

Member Tier Upgrade

<script>
  window.addEventListener('portal-subscription-created', function(event) {
    {{#member}}
    var previouslyPaid = {{#if paid}}true{{else}}false{{/if}};

    // If previously free, now paid = upgrade
    if (!previouslyPaid && typeof fbq !== 'undefined') {
      fbq('trackCustom', 'TierUpgrade', {
        from_tier: 'free',
        to_tier: 'paid',
        {{#if subscriptions}}
        new_tier_name: '{{subscriptions.[0].tier.name}}',
        subscription_value: {{subscriptions.[0].tier.monthly_price}},
        {{/if}}
      });
    }
    {{/member}}
  });
</script>

Event Parameter Best Practices

Use Consistent Naming

// Good: Consistent parameter names
fbq('track', 'ViewContent', {
  content_name: 'Post Title',
  content_category: 'Technology',
  content_type: 'article'
});

// Bad: Inconsistent parameter names
fbq('track', 'ViewContent', {
  name: 'Post Title',
  category: 'Technology',
  type: 'article'
});

Include Currency for Value Events

// Always include currency with value
fbq('track', 'Purchase', {
  value: 9.99,
  currency: 'USD', // Required
  content_name: 'Monthly Subscription'
});

Use Content IDs for Retargeting

// Include content_ids for dynamic ads
fbq('track', 'ViewContent', {
  content_ids: ['post-123', 'post-456'],
  content_type: 'article'
});

Testing Events

Meta Pixel Helper

  1. Install Meta Pixel Helper Chrome extension
  2. Navigate to your Ghost site
  3. Trigger events (signup, subscribe, etc.)
  4. Check extension popup for fired events
  5. Verify parameters are correct

Test Events Tool

  1. Navigate to Events Manager → Test Events
  2. Copy your test event code
  3. Add to Meta Pixel init:
fbq('init', 'YOUR_PIXEL_ID', {}, {
  agent: 'test_event_code',
  test_event_code: 'TEST12345'
});
  1. Trigger events on your site
  2. Verify events appear in Test Events dashboard

Browser Console

// Check if Meta Pixel loaded
console.log(typeof fbq);
// Expected: "function"

// Manually trigger test event
fbq('track', 'Lead', {
  content_name: 'Test Lead'
});

// Check Meta queue
console.log(window._fbq);

Performance Optimization

Throttle High-Frequency Events

// Throttle scroll tracking
var lastScroll = 0;
var scrollThrottle = 1000; // 1 second

window.addEventListener('scroll', function() {
  var now = Date.now();
  if (now - lastScroll < scrollThrottle) return;

  trackReadingProgress();
  lastScroll = now;
});

Batch Custom Events

// Queue events and send in batches
var eventQueue = [];

function queueEvent(eventName, params) {
  eventQueue.push({name: eventName, params: params});

  if (eventQueue.length >= 5) {
    flushEvents();
  }
}

function flushEvents() {
  eventQueue.forEach(function(event) {
    fbq('trackCustom', event.name, event.params);
  });
  eventQueue = [];
}

// Flush on page unload
window.addEventListener('beforeunload', flushEvents);

Defer Non-Critical Events

// Defer reading progress tracking
window.addEventListener('load', function() {
  setTimeout(function() {
    initReadingProgressTracking();
  }, 2000);
});

Common Issues

Events Not Recording

  • Pixel Not Active - Verify pixel status in Events Manager
  • Ad Blockers - Users with ad blockers won't track
  • Incorrect Event Name - Standard events are case-sensitive
  • Missing Currency - Value events require currency parameter

Duplicate Events

  • Multiple Listeners - Remove duplicate event tracking code
  • Portal Events Fire Twice - Use { once: true } on listeners
  • Code Injection + Theme - Consolidate in one location

iOS 14+ Tracking Issues

  • Domain Verification - Verify domain in Meta Business Suite
  • Aggregated Events - Configure top 8 events in Events Manager
  • Conversion API - Implement server-side tracking for accuracy

Custom Events Not Appearing

  • Event Name Format - Custom events can't match standard event names
  • Parameter Limits - Max 25 custom parameters per event
  • Delay - Custom events may take 24-48 hours to appear in reporting

Next Steps