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.
{
"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:
'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:
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:
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:
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:
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:
canopy.identify({ userId: user.id, plan: user.plan });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:
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:
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:
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:
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.