# Install StepsKit with Google Tag Manager

> Add StepsKit through a GTM Custom HTML tag on All Pages, pass the logged-in user with a Data Layer Variable, and avoid the History Change trigger that double-loads the SDK.

*Source: https://stepskit.com/docs/install/gtm*

For teams who'd rather not ship a code change to add a script. Everything below
happens in the GTM UI.

One caveat worth knowing up front: identifying users through GTM requires your
app to push the user into the data layer, which *is* a code change — a one-line
one. Without it you get tours, but no per-user targeting and no reliable
show-once capping. See step 2.

## Prerequisites

- Your project API key, from the **Integrations** page in the
  [dashboard](/app).
- Edit access to the GTM container that's already installed on your site.

## 1. Add the loader

In your container: **Tags → New → Tag Configuration → Custom HTML**, and paste:

```html
<script>
  (function () {
    var stepskit = window.stepskit = window.stepskit || {};
    stepskit._q = stepskit._q || [];

    // Queue API calls made before the SDK finishes loading.
    var methods = ['identify', 'setUserAttributes', 'track', 'refresh', 'playTour', 'stopTour', 'dismissAnnouncement', 'on', 'off', 'destroy', 'validateEnvironment'];
    methods.forEach(function (method) {
      stepskit[method] = stepskit[method] || function () {
        stepskit._q.push([method].concat([].slice.call(arguments)));
      };
    });

    // Load the StepsKit SDK asynchronously.
    var script = document.createElement('script');
    script.async = true;
    script.src = 'https://cdn.stepskit.com/stepskit.latest.js';
    script.setAttribute('data-api-key', 'YOUR_API_KEY');

    var first = document.getElementsByTagName('script')[0];
    first.parentNode.insertBefore(script, first);
  })();
</script>
```

Then:

- **Trigger:** `All Pages` (or `Initialization - All Pages`).
- **Support document.write:** leave **unchecked**. The snippet uses
  `insertBefore`, not `document.write`.
- Name it something like `StepsKit — loader` and save.

### Do not add a History Change trigger

This is the one thing GTM users get wrong here. The reflex on a single-page app
is to add a `History Change` trigger so the tag fires on client-side
navigation. **Don't** — the tag would run again on every route change and load a
second SDK instance each time.

StepsKit already handles SPA navigation itself: it patches `history.pushState`
and `replaceState` and listens for `popstate`, so it re-evaluates which tours
apply without any help from GTM. `All Pages`, once, is correct.

## 2. Identify the user

**This is half the install, not a nice-to-have.** Without an `id`, show-once
capping degrades to a per-tab check and any tour with a "filtered" audience is
hidden outright.

### Push the user into the data layer

In your app, once you know who's signed in:

```js
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  user_id: user.id,
  user_email: user.email,
  user_plan: user.plan,
});
```

If you already push user data for GA4, reuse it — you don't need new keys.

### Create the variables

**Variables → New → Data Layer Variable**, one per field, named to match:
`user_id`, `user_email`, `user_plan`.

### Add the identify tag

**Tags → New → Custom HTML**, trigger `All Pages`, firing **after** the loader
tag (set the loader as a setup tag under Advanced Settings → Tag Sequencing if
you want to be explicit — see the note below on why you usually don't have to):

```html
<script>
  (function () {
    var id = {{user_id}};
    if (!id) return;

    window.stepskit = window.stepskit || {};
    window.stepskit._q = window.stepskit._q || [];
    window.stepskit._q.push([
      "identify",
      {
        id: String(id),
        email: "{{user_email}}",
        plan: "{{user_plan}}",
      },
    ]);
  })();
</script>
```

**Mind the quotes.** GTM substitutes `{{variable}}` as raw text, not as a
JavaScript value. A bare `{{user_email}}` becomes `user@example.com` — an
undeclared identifier and a syntax error. String values need wrapping quotes;
the numeric-or-missing `user_id` is read unquoted and coerced with `String()`.

### You don't need tag sequencing

Because the loader snippet installs a queue stub synchronously, pushing onto
`window.stepskit._q` works whether or not the SDK has finished downloading —
StepsKit drains the queue on startup. That's why this tag can fire in any order
relative to the loader, as long as the loader tag exists on the page.

## 3. Verify it worked

Use GTM **Preview** mode to confirm both tags fire on a page load, then load the
real site and check:

1. **Dashboard** — the project's Integrations page flips to **Connected**.
2. **Console, on a signed-in page** — run
   `window.stepskit.validateEnvironment()`. You want a non-null `visitorId` and
   a populated `userAttributes`. A null `visitorId` means the identify tag
   didn't fire, or the data layer push happened after it.
3. **Network** — `cdn.stepskit.com/stepskit.latest.js` returns `200`.

`validateEnvironment()` also returns a `toursFiltered` table that says exactly
why any given tour isn't showing.

Remember to **Submit** the container version — Preview mode only affects you.

## GTM notes

**Consent Mode.** If your container runs Consent Mode v2, the StepsKit tag may
be held back until the visitor consents, and tours won't render before then.
Decide deliberately which consent category product tours belong to — for most
B2B products this is functional rather than marketing.

**Content Security Policy.** GTM Custom HTML injects an inline script, so your
`script-src` needs `'unsafe-inline'` unless you've configured GTM's nonce
propagation. StepsKit itself needs:

- `script-src`: `https://cdn.stepskit.com`
- `connect-src`: `https://stepskit.com`
- `style-src`: `'unsafe-inline'` — **required**. The embed injects its entire
  stylesheet as a `<style>` element when the script evaluates. Without this the
  tour engine renders completely unstyled rather than merely off-brand.
- `img-src`: `https://*.supabase.co` — only if your steps use images.

**When to move it into the codebase.** GTM is a fine permanent home. But if you
find yourself fighting consent gating, CSP, or a slow container load before your
tours appear, installing StepsKit directly is a ten-line change — pick your
framework from the [install overview](/docs/install.md).

## Troubleshooting

**Tag fires in Preview but the dashboard says "Not connected".** You haven't
submitted the container version yet.

**SDK loads more than once per page.** You added a History Change trigger, or
the tag is on both `All Pages` and a second trigger. See above.

**Console syntax error from the identify tag.** A `{{variable}}` is missing its
wrapping quotes. See above.

**Connected, but `visitorId` is null.** Either the data layer push runs after
the tag fires, or the variable names in GTM don't match the keys you pushed.
Check both in Preview mode's Data Layer tab.

**Tour renders as unstyled text.** Your CSP is missing
`style-src 'unsafe-inline'`.

## Next steps

- [JavaScript API reference](/docs/api.md) — every method on `window.stepskit`.
- [User attributes](/docs/concepts/user-attributes.md) — what you can target on.
- [Frequency capping](/docs/concepts/frequency-capping.md) — how "show once" works.
