> For the complete documentation index, see [llms.txt](https://docs.insurgrid.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.insurgrid.com/intake-forms/tracking-and-analytics/custom-tracking-script.md).

# Building a custom tracking script

This page is for the developer or marketing agency writing the snippet that goes in a form's **Custom tracking HTML** box. The non-technical side — where that box lives and who can edit it — is in [Tracking & analytics](/intake-forms/tracking-and-analytics.md).

***

## Do you actually need one?

If the tool you are wiring up is **Meta Pixel** or **Google Tag Manager**, you almost certainly don't. Both have their own field in the Tracking panel, and InsurGrid loads and fires them for you — Meta gets a `PageView` on open and a `Lead` on submission; a GTM container receives all three events below, pushed to the standard `window.dataLayer` under their `insurgrid_` names, ready to hang Custom Event triggers on.

A custom script is for everything else: a call-tracking product, an ad network that isn't Meta, your own analytics endpoint.

{% hint style="info" %}
If a GTM container is already configured on the form, prefer doing the work inside GTM. A tag triggered on `insurgrid_form_completed` in your own container is easier to change later than a snippet pasted into the form.
{% endhint %}

***

## What the box accepts

Worth reading before you write anything — these limits shape the snippet:

* **`<script>` tags only**, with either a `src` or inline JavaScript. At least one script is required.
* A `src` must be **HTTPS**.
* Allowed script attributes: `async`, `crossorigin`, `defer`, `id`, `integrity`, `referrerpolicy`, `src`, `type`. Everything else — including every `on*` handler — is rejected.
* **10,000 bytes** total.

Inline JavaScript is parsed (never executed) and refused if it uses `eval`, `Function`, `setTimeout`, `setInterval`, `atob`, `document.write`, `document.writeln`, `Worker`, `WebAssembly`, `with`, or `debugger`. Most vendor snippets pass. A few that self-schedule with `setTimeout` do not — load those from a `src` URL instead of inlining them.

Validation happens on save: a rejected snippet fails the whole save with an error naming what was refused, and nothing is partially applied.

**Load order is guaranteed.** A `<script src>` with neither `async` nor `defer` finishes loading before the next script is added, so a second script can rely on what the first one defined. Scripts marked `async` or `defer` get no such guarantee.

{% hint style="warning" %}
**`<noscript>` fallback pixels are not supported.** Many vendor snippets come in two halves — a `<script>` and a `<noscript><img></noscript>` tracking pixel for visitors without JavaScript. Paste only the `<script>` half. The `<noscript>` half will not run, and a pixel there records nothing.

That is not a limitation worth working around: the form is a JavaScript application and does not render at all with JavaScript disabled, so the only situation a `<noscript>` pixel exists for is one where nobody reaches the form in the first place.
{% endhint %}

{% hint style="warning" %}
This code runs on a page where consumers type names, addresses and policy details, and it has full access to that page. Only add HTML from a vendor you trust — the validation above is a guardrail, not an audit.
{% endhint %}

***

## The events you can hook into

The form publishes its lifecycle to a plain array at `window.insurgridDataLayer`. Three events, each **at most once per form, per browser session**:

| Event                      | Fires when                                                                                                            |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `insurgrid_form_view`      | The public form page loads.                                                                                           |
| `insurgrid_form_started`   | The consumer completes the first step and it saves successfully. A step that fails to save does not count as a start. |
| `insurgrid_form_completed` | The final step is accepted by the server — a real submission, not an attempt.                                         |

Every entry has the same two fields:

```json
{
  "event": "insurgrid_form_completed",
  "form_public_id": "kQ8vN2rT7xLpZ3mWyB6dCa"
}
```

Only the event name and the form's public ID — the same identifier that appears in the form's public URL, and the same for every visitor of that form. **Answers are never published**: no names, addresses, or uploaded files. If you need the submission data itself, that is server-to-server — the [`form-submission` webhook](/api-reference/webhook-events.md) or the [Submissions API](/intake-forms/submissions.md).

On forms that hand the consumer to a carrier login, `insurgrid_form_completed` fires on the confirmation page they return to, not at the moment they leave.

***

## Writing the script

The one thing to get right: **an event may already have fired before your script runs.** `insurgrid_form_view` is published in the same tick that installs your snippet, so anything fetched over the network arrives too late to witness it. A listener that only watches for future pushes will silently miss the view event on every single visit.

So do both — replay what is already in the array, then intercept later pushes:

```html
<script>
  (function () {
    var layer = (window.insurgridDataLayer = window.insurgridDataLayer || []);

    function handle(entry) {
      if (!entry || !entry.event) return;

      // Your code here.
      console.log('InsurGrid:', entry.event, entry.form_public_id);
    }

    layer.forEach(handle); // events that already fired

    var push = layer.push; // events still to come
    layer.push = function () {
      var result = push.apply(layer, arguments);
      for (var i = 0; i < arguments.length; i++) handle(arguments[i]);
      return result;
    };
  })();
</script>
```

That wrapper is the whole pattern. Everything below is what you put inside `handle`.

{% hint style="info" %}
When a GTM container is configured, the completion push carries an `eventCallback` with a 500ms `eventTimeout`, so the form holds navigation briefly for a tag that acknowledges it. A `sendBeacon` or an image ping has time to leave the browser; a slow `fetch` may be cut off by the page change. Don't put a long request on the completion path.
{% endhint %}

***

## A complete example

Two scripts: the vendor's tag, then your wiring. Because the first has a `src` and no `async`/`defer`, it is finished loading before the second is added — so `window.exampleTracker` exists by then. The guard is there for the case where the vendor's CDN fails, which lets loading continue rather than stalling the chain.

```html
<script src="https://cdn.example.com/tracker.js"></script>
<script>
  (function () {
    var layer = (window.insurgridDataLayer = window.insurgridDataLayer || []);

    function handle(entry) {
      if (!entry || !entry.event) return;
      if (!window.exampleTracker) return; // vendor script failed to load

      if (entry.event === 'insurgrid_form_view') {
        window.exampleTracker.page({ form: entry.form_public_id });
      }

      if (entry.event === 'insurgrid_form_completed') {
        window.exampleTracker.conversion({
          type: 'quote_request',
          form: entry.form_public_id,
        });
      }
    }

    layer.forEach(handle);

    var push = layer.push;
    layer.push = function () {
      var result = push.apply(layer, arguments);
      for (var i = 0; i < arguments.length; i++) handle(arguments[i]);
      return result;
    };
  })();
</script>
```

Posting a conversion to your own endpoint instead, with no vendor script at all:

```html
<script>
  (function () {
    var layer = (window.insurgridDataLayer = window.insurgridDataLayer || []);

    function handle(entry) {
      if (entry && entry.event === 'insurgrid_form_completed') {
        navigator.sendBeacon(
          'https://analytics.example.com/collect',
          JSON.stringify({ type: 'quote_request', form: entry.form_public_id }),
        );
      }
    }

    layer.forEach(handle);

    var push = layer.push;
    layer.push = function () {
      var result = push.apply(layer, arguments);
      for (var i = 0; i < arguments.length; i++) handle(arguments[i]);
      return result;
    };
  })();
</script>
```

Note there is no `setTimeout` anywhere in either example — it would be rejected on save. If your vendor's snippet retries or defers on a timer, host that logic in the file you load over `src` rather than inlining it.

***

## Testing

1. Publish the form and open its public link with the browser console open.
2. Inspect `window.insurgridDataLayer` — it lists what has fired so far.
3. Step through the form and submit it. You should see exactly one entry per event, and your own handler firing on each.

If an event never reaches your handler, the usual cause is the timing problem above: confirm your snippet replays the existing array rather than only wrapping `push`.

Because events deduplicate per session, a second run in the same window produces nothing. Use a **new incognito window** for each test pass, not a page refresh.

***

## Related

* [Tracking & analytics](/intake-forms/tracking-and-analytics.md) — setting tracking up, and the Meta and GTM fields.
* [Webhook events & payloads](/api-reference/webhook-events.md) — the `form-submission` event, which carries the answers.
* [Submissions API](/intake-forms/submissions.md) — pull submissions on demand.
* [Embed a form](/intake-forms/b-embed-form.md) — putting the form in your own app.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.insurgrid.com/intake-forms/tracking-and-analytics/custom-tracking-script.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
