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.
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 |
|---|---|---|---|
| standard | Story start | Audience seed, and the denominator for every drop-off number |
| custom | Reached 50% | Engagement. Tells you whether the ad or the story is the problem |
| custom | Reached 100% | Retargeting people who finished but didn't click |
| standard | Outbound link click | The conversion. Optimise campaigns on this |
| custom | Score reached, or segment assigned | Mid-funnel optimisation once volume allows |
|
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.
⚠️ 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.
<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: truenothing is sent until your consent tool callswindow.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)
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 |
✅ 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
segmentparameter. - 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.
Updated on: 24/09/2026
Thank you!