Custom events

Pageviews and outbound clicks are tracked for you. Custom events are how you answer the questions specific to your product — which plans people preview, where signups come from, which features actually get used. An event is one call in the browser or one POST from your server, and every property you attach is stored as JSON in your own database.

1How an event is stored

Every event — pageview, outbound click, or custom — becomes one row. The tracker fills in the context automatically so you never have to: the current URL, path, referrer, page title, and viewport width are captured at send time, along with a random in-memory session ID that stitches the visit together. Your call only contributes two things: the event name and its properties.

one event, one rowjsonb props
{
  "name": "checkout_completed",
  "path": "/checkout",
  "referrer": "https://news.ycombinator.com",
  "props": { "plan": "pro", "value": 49, "currency": "USD" },
  "session_id": "3f9c2a1e-...",
  "country": "US",
  "created_at": "2026-10-17T09:14:22.410Z"
}

Properties are completely free-form. There is no schema to register, no event to create in a dashboard first — send what you need and it is queryable immediately.

2Naming events

Names are plain strings, up to 64 characters. Canopy reserves three built-in names — pageview, outbound, and identify — and sends those itself; everything else is yours. A few conventions keep the event log pleasant to read months later:

Name the action, not the UI element: signup_completed survives a redesign, header_button_2 does not. Keep one namespace with snake_case so events group naturally in the log, and put variable detail in properties rather than the name — checkout_completed with { plan: "pro" } gives you one event to analyze, while checkout_pro_completed and checkout_team_completed multiply your event list with every plan you add.

3Tracking from the browser

React / Next.js

In client components, the useCanopyTrack hook returns a stable track function. It works even if the provider has not finished initializing — calls fall through to the module-level queue and replay automatically:

reactuse client
'use client';
import { useCanopyTrack } from "@wnstn/canopy-nextjs";

export function UpgradeButton() {
  const { track } = useCanopyTrack();

  return (
    <button onClick={() => track("checkout_started", { plan: "pro", value: 49 })}>
      Upgrade
    </button>
  );
}

Vanilla

With the core package, keep the instance createCanopy returns and call methods on it. The module-level track function also works from anywhere — before or after initialization:

vanilla@wnstn/canopy
import { createCanopy } from "@wnstn/canopy";

const canopy = createCanopy({ siteId: "canopy_example_com" });

canopy.track("signup_started", { source: "header" });

Script tag installs

If you installed via the script tag and don't bundle your own JavaScript, the initialized tracker is exposed globally. Call it as a function or use the named methods:

browser console or inline script
window.canopy("checkout_started", { plan: "pro" });
window.canopy.track("checkout_started", { plan: "pro" });

4Default props

Values you want on every event — environment, release version, A/B variant — belong in default props rather than repeated at every call site. Pass them once at initialization and Canopy merges them into each event; per-call properties win on conflicts:

defaultsmerged into every event
const canopy = createCanopy({
  siteId: "canopy_example_com",
  props: { app_version: "2.4.1", variant: "b" },
});

canopy.track("signup_started", { source: "header" });
// props: { app_version: "2.4.1", variant: "b", source: "header" }

Defaults can also be set or extended later in the session with setDefaultProps:

runtime defaults
canopy.setDefaultProps({ experiment: "pricing_v3" });

5Identifying users

identify attaches traits to the current session as a regular event with whatever properties you pass. Canopy does not build user profiles or store identities in a separate table — the trait object lands in props exactly like any custom event, which keeps the privacy story simple:

identifyafter login
canopy.identify({ userId: user.id, plan: user.plan });
privacy
Only send traits you are comfortable storing in your own database. Identifiers like account IDs are pseudonymous; emails are usually better left out. See Privacy & compliance.

Because sessions live in memory only, a reset() — for example on logout — simply discards the current session ID and the next event starts a fresh one.

6Scrubbing properties before send

The beforeSend hook sees every event just before it leaves the browser. Return a modified event to redact properties, or false to cancel the send entirely — useful for enforcing a house rule that no PII is ever collected, even by accident:

hooksbeforeSend
createCanopy({
  siteId: "canopy_example_com",
  hooks: {
    beforeSend: (event) => {
      if (event.props?.email) event.props.email = "[redacted]";
      return event;
    },
  },
});

Hooks run for automatic events too — pageviews and outbound clicks pass through the same pipeline — and an error thrown inside a hook is caught and logged rather than breaking tracking.

7Server-side events

Anything that can POST JSON can send an event — no auth, no SDK. Server-side tracking is the right tool for webhooks, background jobs, and anything that happens without a browser:

server-sidePOST /api/canopy/event
curl -X POST https://canopy.your-app.com/api/canopy/event \
  -H "Content-Type: application/json" \
  -d '{
    "s": "canopy_example_com",
    "n": "invoice_paid",
    "props": { "amount": 49, "plan": "pro" }
  }'

Reuse the visitor's session ID (it is included on every browser event as sid) when you want the server event to land in the same visit. The full field reference is in the HTTP API docs.

8Recipes

Capture UTM parameters

Store campaign parameters as default props on first load and every event in the session carries them:

utm
const params = new URLSearchParams(location.search);
const utm = Object.fromEntries(
  [...params].filter(([key]) => key.startsWith("utm_"))
);
if (Object.keys(utm).length) canopy.setDefaultProps(utm);

Scroll depth

Fire once when a reader reaches the end of an article — a better engagement signal than time on page:

scroll depth
let fired = false;
window.addEventListener("scroll", () => {
  const max = document.body.scrollHeight - window.innerHeight;
  if (!fired && max > 0 && window.scrollY / max >= 0.9) {
    fired = true;
    canopy.track("read_complete", { path: location.pathname });
  }
}, { passive: true });

9Viewing events

The event log in the dashboard shows raw events with session IDs, countries, and properties, filterable by event name and time range. For programmatic access, the same data is available over HTTP — the events endpoint the log uses, plus CSV/JSON export — all documented in the HTTP API reference.

Start tracking what matters

Two built-in events come free. Everything else is one call away.