> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://docs.intractive.app/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# Meta - Adding a Meta pixel without Tag Manager

# Adding a Meta pixel without Tag Manager

Every project has a **Footer scripts** field, which injects raw script tags at the end of the public page. That is enough to run a complete Meta pixel — base code, story events, scoring, consent — with no Tag Manager container at all.

This article covers when that is the right call, and gives you a snippet that only needs four lines changed per client.

| 💡 In one sentence: paste one script into Footer scripts, change the pixel ID, and you get the same five events Tag Manager would send — at the cost of debuggability and client self-service.

# Which route to choose
| Consideration | Tag Manager | Footer script |
| ---- | ---- | ---- |
| Time to first event | Slower — the client must provision container access | Minutes, if you have admin on the project |
| Who can change it later | The client's own marketers | Only an Intractive admin |
| Debugging | Tag Assistant shows every tag and why it did or didn't fire | Browser console only |
| Several destinations | Add a tag | Edit code |
| Versioning and rollback | Built in | None |

**Use Tag Manager** when the client wants to own their tracking, when Analytics is in scope too, or when the campaign will run long enough for someone to want changes.

**Use the footer script** when the client has no container and no appetite to get one, when the campaign is short and the events are already agreed, or when you need something live this week.

⚠️ **Never run both at once.** If a project has Meta tags in Tag Manager *and* this script, every event is sent twice to the same pixel. There is no `event_id` on either, so Meta counts both as real. This was measured: a single story start produced two `ViewContent` events. Pick one route and remove the other.

# The five events
Every Intractive story sends the same five. Keeping the vocabulary fixed matters — all our stories share one domain, and Meta budgets configurable events **per domain**, not per client. Inventing new event names per project burns a shared allowance and makes accounts inconsistent.
| Event | Meta type | Fires on | What it's for |
| ---- | ---- | ---- | ---- |
| `ViewContent` | standard | Story start | Audience seed, and the denominator for every drop-off number |
| `StoryHalfway` | custom | Reached 50% | Engagement. Tells you whether the ad or the story is the problem |
| `StoryComplete` | custom | Reached 100% | Retargeting people who finished but didn't click |
| `Lead` | standard | Outbound link click | **The conversion.** Optimise campaigns on this |
| `QualifiedCandidate` | custom | Score reached, or segment assigned | Mid-funnel optimisation once volume allows |
| `ViewContent` and `Lead` are Meta **standard** events, selectable as campaign objectives with no extra setup. The other three are custom and need a **Custom Conversion** in Events Manager before you can optimise toward them. |  |  |  |

|| ⚠️ Lead means "clicked through to the destination", not "applied". The actual application happens in the client's ATS, out of reach of a pixel. Be precise about this when reporting, or you will promise a client something the number does not support.

# Two ways to profile people
Both are optional. Configure neither and you get four clean events.

**Scoring** — for "how interested is this person?". The story adds points as people answer; when the total crosses a threshold, `QualifiedCandidate` fires and the score travels as `value`, which is what makes value-based lookalikes possible later.
**Segmentation** — for "which role fits this person?". The story writes a category into a variable; that category rides along as a `segment` parameter on every event after it is known.

| 💡 Send categories as a parameter, never as event names. Resist FitEngineering, FitWarehouse, and so on. One event carrying segment: "engineering" gives you unlimited segments inside the fixed schema — build one Custom Conversion per segment in Events Manager and each becomes its own audience.


⚠️ **Keep segment values to neutral role labels.** `engineering`, `warehouse`, `customer-support`. Never age, nationality, parental status, salary expectation, language or accessibility needs. A segment is inferred personal data about an individual, sent to an advertising platform, in a recruitment context — encoding anything that proxies a protected characteristic is a problem both under Meta's terms and under employment law.

# Installing it
**Project settings** → **Tracking & scripts** → **Footer scripts**. Admin only.
Paste the code below, wrapped in a pair of script tags, and change the config block at the top. Do **not** also paste the base pixel code Meta offers you in Events Manager — the bootstrap is already included here, and pasting both initialises the pixel twice.
You can skip Meta's noscript image fallback too. It exists for visitors with JavaScript disabled, and a story is a JavaScript app — those visitors see nothing to track.

| 💡 Only the four lines inside CONFIG change per client. Everything below it is identical for every story we ever ship.



```
<script>(function () {

  /* CONFIG - edit this block only */

  var CONFIG = {
    pixelId:  '000000000000000',                            // client's pixel ID
    currency: 'EUR',
    score:    { variable: 'intent_score', threshold: 60 },  // or variable: null
    segment:  { variable: null },                           // or e.g. 'best_fit'
    trackHalfway:   true,
    requireConsent: false
  };

  /* END CONFIG - nothing below needs editing */

  var ctx = { story_id: null, session_id: null },
      fired = {}, started = false, score = null,
      segment = null, enabled = true,
      consent = !CONFIG.requireConsent;

  function readVar(vars, name) {
    if (!vars || !name) return undefined;
    return vars['@' + name];        // story variables carry an @ prefix
  }

  function send(key, name, type, extra) {
    if (fired[key] || !enabled || !consent) return;
    if (typeof fbq !== 'function') return;
    fired[key] = true;
    var p = { story_id: ctx.story_id, session_id: ctx.session_id };
    if (segment) p.segment = segment;
    if (extra) for (var k in extra) p[k] = extra[k];
    fbq(type, name, p);
  }

  function qualify() {
    var byScore = CONFIG.score && CONFIG.score.variable &&
                  score !== null && score >= CONFIG.score.threshold,
        bySeg   = CONFIG.segment && CONFIG.segment.variable && segment;
    if (!byScore && !bySeg) return;
    send('qualified', 'QualifiedCandidate', 'trackCustom',
         score === null ? null : { value: score, currency: CONFIG.currency });
  }

  function handle(o) {
    if (!o || !o.event) return;
    if (o.event === 'disableTracking') { enabled = false; return; }
    if (o.event === 'enableTracking')  { enabled = true;  return; }

    if (o.storyId)   ctx.story_id   = o.storyId;
    if (o.sessionId) ctx.session_id = o.sessionId;

    if (o.event === 'intractiveVariablesUpdated') {
      var s = Number(readVar(o.variables, CONFIG.score && CONFIG.score.variable));
      if (!isNaN(s)) score = s;
      var g = readVar(o.variables, CONFIG.segment && CONFIG.segment.variable);
      if (g) segment = String(g);
      if (started) qualify();
      return;
    }

    if (o.event === 'intractiveStoryProgress') {
      var pr = Number(o.progress);
      // A genuine run always opens at progress 0. A resumed session jumps
      // straight to its saved position - ignore it, or every reload books
      // another completion.
      if (pr === 0) { started = true; send('view', 'ViewContent', 'track'); return; }
      if (!started) return;
      if (CONFIG.trackHalfway && pr >= 50) send('half', 'StoryHalfway',  'trackCustom');
      if (pr >= 100)                       send('done', 'StoryComplete', 'trackCustom');
      return;
    }

    if (o.event === 'intractiveOutboundLink' && started) {
      send('lead', 'Lead', 'track', {
        link_url: o.url,
        value:    score === null ? 0 : score,
        currency: CONFIG.currency
      });
    }
  }

  if (!window.fbq) {
    !function (f, b, e, v, n, t, s) {
      if (f.fbq) return; n = f.fbq = function () {
        n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
      };
      if (!f._fbq) f._fbq = n; n.push = n; n.loaded = !0; n.version = '2.0';
      n.queue = []; t = b.createElement(e); t.async = !0; t.src = v;
      s = b.getElementsByTagName(e)[0]; s.parentNode.insertBefore(t, s);
    }(window, document, 'script', '<https://connect.facebook.net/en_US/fbevents.js>');
  }

  fbq('init', CONFIG.pixelId);
  if (consent) fbq('track', 'PageView');

  window.intractiveMetaConsent = function () {
    if (consent) return;
    consent = true;
    fbq('track', 'PageView');
    for (var i = 0; i < dl.length; i++) handle(dl[i]);
  };

  var dl = window.dataLayer = window.dataLayer || [];
  for (var i = 0; i < dl.length; i++) handle(dl[i]);
  var push = dl.push;
  dl.push = function () {
    var r = push.apply(dl, arguments);
    for (var j = 0; j < arguments.length; j++) handle(arguments[j]);
    return r;
  };

})(); </script>

```
# What the snippet already handles
* **Resumed sessions.** Reloading a finished story sends only `PageView`. See *Why completions get over-counted* for why this matters.
* **Once per page.** Each event fires at most once per load.
* **The story's own tracking toggle.** If tracking is disabled in story settings, the script stops sending.
* **Late consent.** With `requireConsent: true` nothing is sent until your consent tool calls `window.intractiveMetaConsent()`. At that point everything the visitor already earned is replayed rather than lost.

# Verifying it
Open the story in a **private window** — not a story you have already finished — and run this in the browser console:
```
performance.getEntriesByType('resource')
  .filter(e => e.name.includes('/tr'))
  .map(e => new URL(e.name).searchParams.get('ev'))

```

On a fresh start you should see `["PageView", "ViewContent"]`, each exactly once. Anything appearing twice means the pixel is loaded twice — check whether Tag Manager is also running Meta tags on this project.

To confirm the right pixel is live:
```
Object.keys(window.fbq.instance.pixelsByID)

```

| 💡 This console check is the source of truth, not Events Manager. It tells you what the browser actually sent, with no reporting delay in between. Use it first, every time.

# Reading Events Manager without being misled
Events Manager is slow, and its default view hides today. Three traps, all of which have cost us time:
| Trap | What to do |
| ---- | ---- |
| **The date range ends yesterday** | The default range excludes today. A brand-new pixel has only today's data, so it always looks dead. Set the range to **Today** before concluding anything |
| **New events take hours to register** | Meta says 30 minutes. For an event name or a dataset it has never seen, it has taken several hours, then everything appeared at once. Verify on the wire and wait |
| **The overview can contradict itself** | We have seen the table report no activity while its own chart plotted dozens of events. When they disagree, trust neither — go back to the console check |

|| ⚠️ "Finish setting up Meta Pixel · 0% complete" is onboarding UI, not a diagnosis. It stays at 0% until Meta processes your first events. It is not telling you the pixel is broken.


✅ **Quick checklist**
* One route only — Tag Manager or footer script, never both.
* Change the pixel ID; leave everything below CONFIG alone.
* Don't paste Meta's base code as well.
* Use the fixed five-event schema; send categories as a `segment` parameter.
* Neutral role labels only.
* Test from a private window and check the console, not Events Manager.
* Set the Events Manager date range to Today before judging anything.