# Tracking plan (analytics.yaml)

The tracking plan is a file in your repository: `analytics.yaml`. It lists every event your code sends, with a description and the props. The file is the source of truth. twelvebit keeps a copy, so the dashboard and the MCP server know your events, and so it can show events that are planned but never sent.

## Full example

~~~yaml
version: 1
project: example.com
integrations: [stripe]
events:
  signup:
    description: A visitor creates an account
    tags: [activation]
    props:
      method:
        type: string
        enum: [email, google, github]
        description: How the account was created
  project_created:
    description: A user creates a project
    tags: [activation]
    props:
      template: { type: string, enum: [blank, blog, store] }
      from_onboarding: { type: boolean, required: false }
  checkout_started:
    description: A user opens the checkout
    tags: [revenue]
    props:
      plan: { type: string, enum: [starter, pro] }
      seats: { type: number, required: false }
  subscription_started:
    description: The first payment of a subscription succeeds (sent by the server)
    tags: [revenue]
    revenue: true
    props:
      plan: { type: string, enum: [starter, pro] }
dashboards:
  funnels:
    - name: Signup to paid
      steps: ["page:/pricing", signup, checkout_started, subscription_started]
~~~

## Schema

The schema is strict. An unknown key is an error, so a typo does not silently do nothing.

| Key | Type | Rules |
|---|---|---|
| `version` | number | Required. Must be `1`. |
| `project` | string | Your domain, for example `example.com`. |
| `integrations` | list | Any of `stripe`, `polar`, `lemonsqueezy`, `paddle`, `dodo`, `shopify`, `other`. |
| `events` | map | Required. Up to 200 events. Key = the event name. |
| `events.<name>.description` | string | Required. Up to 500 characters. |
| `events.<name>.tags` | list of strings | Up to 10. Groups events in the dashboard. |
| `events.<name>.revenue` | boolean | `true` for events that carry money. Send them from the server. |
| `events.<name>.props` | map | Up to 30 props. Key = the prop name. |
| `props.<name>.type` | string | Required. `string`, `number` or `boolean`. |
| `props.<name>.enum` | list of strings | Allowed values. Only for `type: string`. |
| `props.<name>.required` | boolean | Props are optional by default. `true` makes the prop required in the generated types. |
| `props.<name>.description` | string | Up to 500 characters. |
| `dashboards.funnels` | list | Up to 20 funnels. |
| `dashboards.funnels[].name` | string | Required. |
| `dashboards.funnels[].steps` | list | 2 to 8 steps. A step is an event in this plan (`signup` or `goal:signup`), `payment` (twelvebit adds this goal for each payment), or a page (`page:/pricing`; `*` is a wildcard). |

Names of events and props: lowercase snake_case, start with a letter (`^[a-z][a-z0-9_]*$`), at most 64 characters. Two names that give the same camelCase name in generated code (`a_1` and `a1`, both `a1`) are rejected. The names `pageview`, `identify`, `outbound`, `ping` and `payment` are reserved by the script.

## Typed helpers

Generate them from the plan:

~~~sh
npx -y https://twelvebit.app/cli/twelvebit-cli.tgz codegen
~~~

This writes `analytics.gen.ts`, one camelCase function per event on a `track` object, with the props as types, and then pushes the plan (use `--no-push` to skip that). Commit the file. The output looks like this (details can change between CLI versions):

~~~ts
// analytics.gen.ts: generated by `twelvebit codegen` from analytics.yaml. Do not edit by hand.
type Value = string | number | boolean;
type Twelvebit = (name: string, props?: Record<string, Value>) => void;

// No-op on the server and when the script is blocked, so tracking never breaks the app.
function send(name: string, props?: object) {
  if (typeof window === "undefined") return;
  try {
    (window as unknown as { twelvebit?: Twelvebit }).twelvebit?.(name, props as Record<string, Value> | undefined);
  } catch {}
}

export type SignupProps = {
  /** How the account was created */
  method?: "email" | "google" | "github";
};

export const track = {
  /** A visitor creates an account */
  signup: (props?: SignupProps) => send("signup", props),
  // … one function per event: projectCreated, checkoutStarted, subscriptionStarted
};

export function identify(user: { user_id: string; email?: string; name?: string; [trait: string]: Value | undefined }) {
  send("identify", user);
}
~~~

If you do not use codegen, write a small helper by hand:

~~~ts
// lib/track.ts
type Events = {
  signup: { method: "email" | "google" | "github" };
  project_created: { template: "blank" | "blog" | "store"; from_onboarding?: boolean };
  checkout_started: { plan: "starter" | "pro"; seats?: number };
};

declare global {
  interface Window {
    twelvebit?: (name: string, props?: Record<string, string | number | boolean | undefined>) => void;
  }
}

export function track<E extends keyof Events>(name: E, props: Events[E]) {
  if (typeof window !== "undefined") window.twelvebit?.(name, props);
}

// track("signup", { method: "google" });
~~~

## Push the plan

The CLI:

~~~sh
npx -y https://twelvebit.app/cli/twelvebit-cli.tgz push
~~~

Or plain HTTP (YAML or JSON):

~~~sh
curl -sS -X PUT https://twelvebit.app/api/v1/tracking-plan -H "Authorization: Bearer $TWELVEBIT_API_KEY" \
  -H "Content-Type: text/yaml" --data-binary @analytics.yaml
~~~

The response says what changed compared with the plan that twelvebit had: the events that were added, removed and changed, and whether the funnels changed. A plan that does not match the schema is rejected with status 422 (code `invalid_plan`) and a list of errors, each with a path (for example `events.signup.props.plan.enum`) and a message.

Add `?dry_run=1` to validate the plan and see the diff without saving it.

Read the stored plan with `GET /api/v1/tracking-plan` or `npx -y https://twelvebit.app/cli/twelvebit-cli.tgz pull`. It comes back as YAML in a fixed key order, without the comments of your file, so keep the file in git as the source of truth.

## Drift checks

Drift is when the code and the plan disagree. There are two checks.

**In the code**, without network access:

~~~sh
npx -y https://twelvebit.app/cli/twelvebit-cli.tgz check --strict
~~~

It scans your source files for `twelvebit("…")` calls, `track.…()` calls of the generated helpers and `data-tb-goal` attributes, and compares them with `analytics.yaml`. It reports events that the code sends but the plan does not declare, events that the plan declares but the code never sends, and an `analytics.gen.ts` that is older than the plan. With `--strict`, it exits with code 1 when there is drift.

**In production**, the install check lists each planned event and whether twelvebit has received it:

~~~sh
curl -sS https://twelvebit.app/api/v1/install-check -H "Authorization: Bearer $TWELVEBIT_API_KEY"
~~~

## Run the check in CI

~~~yaml
# .github/workflows/analytics.yml
name: analytics
on: [pull_request]
jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npx -y https://twelvebit.app/cli/twelvebit-cli.tgz check --strict
~~~

To push the plan on merge to `main`, add a job that runs `npx -y https://twelvebit.app/cli/twelvebit-cli.tgz push` with `TWELVEBIT_API_KEY` from the repository secrets.

---

Source: https://twelvebit.app/docs/tracking-plan · All docs: https://twelvebit.app/llms.txt
