Install
Every site has its own key. Find yours on the site's setup page in the app, where the snippet below already has the key filled in. Paste it in the <head> of every page, as high as you can.
<script async src="https://mapintact.com/mi.js" data-key="YOUR_SITE_KEY"></script>The script is served from https://mapintact.com/mi.js with a one hour cache. It loads async, never blocks rendering, and sends data with navigator.sendBeacon so it does not slow the page or hold up navigation.
Google Tag Manager
Create a Custom HTML tag with the snippet below and fire it on All Pages. Fire it once per page: do not add it to a history-change trigger, the script handles SPA route changes on its own.
<!-- MapIntact base script (fire on All Pages, once per page) -->
<script async src="https://mapintact.com/mi.js" data-key="YOUR_SITE_KEY"></script>If you use Google's OAuth in the Setup Assistant, the app creates this tag, the trigger and a published container version for you.
Single-page apps
React, Vue, Next.js, Nuxt, Webflow with a router, and similar apps change the URL without a page load. The script patches history.pushState and history.replaceState and listens for popstate, so route changes become page views without any extra work. Setting data-spa just records that fact for the Setup Assistant.
<script async src="https://mapintact.com/mi.js" data-key="YOUR_SITE_KEY" data-spa="true"></script>If your router does not use the History API, call mi('page') after each navigation to record a page view by hand.
Turning it off
Set window.miDisable = true before the script loads (for example on staging, or for your own team) and every call becomes a no-op. To exclude your office traffic on production instead, add your IP on the site's setup page.
What is tracked automatically
Each row is an event type you can pick for an Action node on the canvas. Names and props are what you match against.
| Type | When | Name | Props |
|---|---|---|---|
| pageview | Page load and every SPA route change (pushState, replaceState, back and forward). | none | title, referrer on the first view |
| click | Any click on a, button, [role=button], .btn, .mi-track or [data-mi-event]. | data-mi-event, else the visible text (80 chars), else aria-label, else the href | href, text, id, classes, outbound, download, tel, mailto |
| form | Any form submit. If the form has a filled email field, an identify event is sent too (email and name only). | form id, else name, else the action path | id, action |
| video | First play of an HTML5 video, a YouTube embed with enablejsapi=1, a Vimeo embed, or a Wistia player. | the video src | provider, src |
| scroll | Once per page when the viewport passes 10, 25, 50, 75 and 90 percent of the document. | 10, 25, 50, 75 or 90 | depth |
| custom | Once per page after 10 seconds with the tab visible. | engaged | none |
Give any element a data-mi-event="Book a call" attribute to control the click name, or add the mi-track class to track clicks on elements that are not links or buttons. Outbound links, downloads (pdf, zip, docx, xlsx, csv and similar), tel: and mailto: links carry a flag in their props so you can filter on them.
Every event also carries the page URL (without the hash), the page title and a timestamp. Events are batched and sent after 800 ms, when ten are waiting, or when the page is hidden or closed.
Custom events
Call mi('event', name, props) anywhere, even before the script has loaded: calls queue up and replay once it is in. Props can be nested; the server flattens them two levels deep (product.sku), keeps up to 40 keys and trims strings to 500 characters.
mi('event', 'Add to Cart', { productName: 'Cool Snowboard', productPrice: 999, currency: 'USD', quantity: 1 });If you cannot be sure the script tag runs first, define the queue stub yourself. It is the same pattern Google Analytics uses.
window.mi = window.mi || function () { (window.mi.q = window.mi.q || []).push(arguments); };
fi('event', 'Started quiz', { step: 1 });Identify
Tie the anonymous profile to a person. Email is the join key: later sessions on other devices that identify with the same email show up as one person in the People view, and server-side conversions posted with that email land on the right profile.
mi('identify', { email: '[email protected]', name: 'Jane Doe' });Extra keys (plan, company, whatever you like) are stored as traits on the profile. Form submits with an email field call identify for you, sending only the email and the name field.
Purchases
Record revenue on the thank-you page. Pass value in dollars (or your currency's main unit) or valueCents as an integer. Everything else becomes props. Purchase nodes on the canvas match every purchase event; use the label or order id to split them if you sell more than one thing.
mi('purchase', { value: 497, currency: 'USD', orderId: 'ORD-1001', label: 'Signature program' });Front-end purchase calls are easy to miss when a customer closes the tab early. For anything that pays the bills, also post the order from your server with the webhook; duplicates are dropped on the order id.
Consent
By default the script starts as soon as it loads. If your consent tool should decide, add data-consent="wait" to the script tag. Events are then held in memory (no cookies, no requests) until you call:
mi('consent', 'granted'); // sends what was held and continues
fi('consent', 'denied'); // drops what was held and stops for this pageWire this to your CMP's callback (Cookiebot, OneTrust, CookieYes, Klaro and most others expose one). The Setup Assistant detects common CMPs and shows the exact hook to use.
Cross-domain
When the journey hops between domains you own (a marketing site and a checkout on another domain, for example), list the other domains in data-domains on every site. Links to those hosts get a short _mi parameter added the moment someone clicks or taps, the next site reads it, adopts the same profile and session, and removes it from the address bar.
<script async src="https://mapintact.com/mi.js" data-key="YOUR_SITE_KEY" data-domains="shop.example.com,pay.example.com"></script>Subdomains of one registrable domain (www.example.com and shop.example.com) share the cookie already and need no setup. The identifier cookie is set on the registrable domain with SameSite=Lax and lasts 400 days; the session cookie renews after 30 minutes of inactivity.
Funnelytics compatibility
Switching from Funnelytics is a script swap. The tracker defines window.funnelytics.events.trigger so existing calls in your pages, GTM tags and checkout templates keep working. The __commerce_action__ event becomes a purchase, with __total_in_cents__, __order__, __currency__, __label__ and __sku__ mapped to the purchase fields and every other key kept as a prop. A name or email key on any event also identifies the person, as it does there.
// Funnelytics-style calls keep working unchanged:
window.funnelytics.events.trigger('Add to Cart', { productName: 'Cool Snowboard' });
window.funnelytics.events.trigger('__commerce_action__', { __total_in_cents__: 49700, __order__: 'ORD-1001', __currency__: 'USD', __label__: 'Signature program' });If the original Funnelytics script is still on the page, the shim steps aside and leaves theirs in place.
Server-side webhook
Post conversions from your server, Zapier, Make or any checkout that can call a URL. Each site has its own webhook URL with a secret token; copy it from the site's setup page in the app. Send JSON with Content-Type: application/json.
{
"event": "purchase",
"email": "[email protected]",
"value": 497,
"currency": "USD",
"orderId": "ORD-1001",
"label": "Signature program",
"props": {
"sku": "sig-01",
"coupon": "LAUNCH"
}
}eventis required.purchase,orderandsalebecome purchase events; anything else is a custom event with that name.emailattaches the event to the most recent profile with that email on the site. If nobody has identified with it yet, a new profile is created and later browser sessions that identify with the same email join it.value(main unit) orvalueCentssets the revenue.currency,orderId,labeland everything inpropsare stored as props.externalId(or, failing that,orderId) makes the call idempotent: a repeat returns{ "ok": true, "duplicate": true }and changes nothing.- The event joins the person's latest session when it is under 24 hours old, otherwise it gets its own offline session so channel attribution stays honest.
Responses: 200 with { "ok": true }, 400 for an invalid body, 401 for a bad token, 404 for an unknown site.
Privacy
- IP addresses are never stored. They are hashed with a salt on arrival, and the hash is only used to honour your IP exclusions.
- No fingerprinting. Identity is a random id in a first-party cookie plus a localStorage mirror, nothing derived from the device.
- Country comes from the request edge, not from a lookup on the address. Nothing more precise is kept.
- Form tracking sends only the email and name fields, and only when the form has an email field. Passwords, cards and free text never leave the page.
- Known bots and headless browsers are dropped before anything is written.
- Consent mode holds everything in memory until your CMP says yes, and a denied choice sends nothing.
- People can be deleted from the People view for GDPR requests, which removes their profile, sessions and events.
Questions the docs do not answer? Reply to any email from us or open the Setup Assistant in the app; every red state links to the fix.