SDK reference
The tracker ships as two packages: @wnstn/canopy is the vanilla core — under 2kb, no framework required — and @wnstn/canopy-nextjs wraps it for React with a provider and hooks. Both are tree-shakeable, type-safe, and send events to your own domain with no cookies and no browser storage.
1Installation
npm i @wnstn/canopy # vanilla core npm i @wnstn/canopy-nextjs # React / Next.js wrapper (includes the core)
Using the script tag instead of bundling? Skip this page's install sections — the quick start covers the tag attributes, and everything below about options and hooks still applies via window.Canopy.init(options).
2Initializing
createCanopy (a factory — no new, no side effects at import time) validates your options, starts the tracker, and returns an instance:
import { createCanopy } from "@wnstn/canopy";
const canopy = createCanopy({
siteId: "canopy_example_com",
debug: process.env.NODE_ENV === "development",
});Initialization is idempotent: calling it twice updates the existing instance's options instead of creating a second tracker. If siteId is missing, initialization throws a CanopyError (see error handling).
Options
| Option | Type | Default | Description |
|---|---|---|---|
| siteId | string | required | Your site ID, e.g. canopy_example_com. |
| endpoint | string | /api/canopy/event | Where events are POSTed. Set a full URL for cross-origin installs. |
| env | string | — | Label attached to every event, e.g. production or staging. |
| manualPageview | boolean | false | Suppress the automatic pageview on load. |
| disableOutbound | boolean | false | Disable automatic outbound click tracking. |
| respectDnt | boolean | true | Drop all events when the browser sends Do Not Track. |
| sampleRate | number | 1 | Send only a fraction of events, 0–1. Useful for very high-traffic sites. |
| props | object | — | Default properties merged into every event. |
| debug | boolean | false | Log every send to the console. |
| hooks | object | — | beforeSend / afterSend / onError interceptors. |
3Instance methods
| Method | Description |
|---|---|
| track(name, props?) | Send a custom event with optional properties. |
| pageview(props?) | Send a pageview for the current URL. Automatic unless manualPageview is set. |
| identify(props?) | Send an identify event with user traits stored in props. |
| reset() | Discard the current session ID — the next event starts a new session. |
| setDefaultProps(props) | Merge properties into the defaults attached to every event. |
| getSessionId() | Return the current in-memory session ID. |
| options | Read the current configuration (also assignable to update it). |
Typical lifecycle for a logged-in app:
canopy.identify({ userId: user.id, plan: user.plan }); // after login
canopy.track("feature_used", { name: "export" }); // anywhere
canopy.reset(); // on logout4Module-level functions
The package also exports standalone functions that work before initialization by queueing internally. Once createCanopy runs, the queue replays in order — so import order and init order never matter:
import { track, pageview, identify } from "@wnstn/canopy";
track("signup_started"); // queued
pageview({ path: "/landing" }); // queued
identify({ userId: "123" }); // queued
createCanopy({ siteId: "canopy_example_com" }); // queue replays here| Function | Description |
|---|---|
| track(name, props?) | Queue or send a custom event. |
| pageview(props?) | Queue or send a pageview. |
| identify(props?) | Queue or send an identify event. |
| createCanopy(options) | Initialize the tracker (alias: initCanopy). |
| setDefaultProps(props) | Set defaults on the initialized instance. |
| getSessionId() | Current session ID. |
| flushQueue() | Force-replay queued calls (mainly for tests). |
| isInitialized() | Whether the tracker has started. |
| getOptions() | Current options of the initialized instance. |
| getCanopy() | The live instance, or null before init. |
| CanopyError | The error class used for config, network, and validation failures. |
5Hooks
Hooks intercept every send — including automatic pageviews and outbound clicks — which makes them the place for redaction, filtering, and logging. All three can be async, and an error thrown inside a hook is caught and logged so it never breaks tracking:
| Hook | Signature | Use |
|---|---|---|
| beforeSend | (event) => event | false | Modify the event, or return false to cancel the send. |
| afterSend | (event, response) => void | Runs after a successful send; response may be null. |
| onError | (event, error) => void | Runs when a send failed. |
createCanopy({
siteId: "canopy_example_com",
hooks: {
beforeSend: (event) => {
if (event.n === "outbound") return false; // skip outbound clicks
if (event.props?.email) event.props.email = "[redacted]";
return event; // or a modified event
},
afterSend: (event) => console.debug("sent", event.n),
onError: (event, error) => reportToSentry(error),
},
});6Automatic behavior
After initialization the tracker quietly does the work you would otherwise wire up by hand:
| Behavior | Details |
|---|---|
| Pageviews | One on load, then again on every SPA navigation — pushState, replaceState, popstate, and hashchange are all observed. |
| Outbound clicks | A capture-phase click listener emits an outbound event with the link's href and text (up to 80 chars) whenever the target origin differs from yours. |
| Do Not Track | When the browser signal is on, every event is dropped. Opt out of respecting it with respectDnt: false — not recommended. |
| Bots | Requests from user agents matching common bot patterns are dropped. |
| Local files | Pages loaded from file: are never tracked. |
| Sampling | sampleRate: 0.5 sends roughly half of all events, chosen per event. |
Transport
Events are sent with navigator.sendBeacon when available so they survive page unload, falling back to fetch with keepalive. Requests are made with credentials: "omit" — nothing identifying rides along, and no response handling delays navigation.
Sessions
A session is a random UUID generated in memory when the first event needs it — never written to a cookie, localStorage, or anywhere else. It survives SPA navigations (the page never reloaded) and disappears when the tab closes, which is why Canopy needs neither a consent banner nor a privacy policy exception. reset() discards it on demand, e.g. on logout.
Debugging
Set debug: true to log each send to the console, and check the network tab for a 202 response from your endpoint.
7Error handling
Failures surface as CanopyError — a standard Error with a machine-readable code and typed helpers, so callers can branch on the kind of failure without string matching:
try {
createCanopy({}); // missing siteId
} catch (e) {
if (e instanceof CanopyError && e.isConfigError()) {
// code: "CONFIG_ERROR"
}
}| Code | Helper | Raised when |
|---|---|---|
| CONFIG_ERROR | isConfigError() | Invalid options — e.g. missing siteId. |
| NETWORK_ERROR | isNetworkError() | A send could not reach the endpoint. |
| VALIDATION_ERROR | isValidationError() | An event failed validation before sending. |
| SEND_CANCELLED | isCancelledError() | A beforeSend hook cancelled the send. |
8Next.js wrapper
@wnstn/canopy-nextjs re-exports the entire core API and adds a provider and hooks. Mount CanopyProvider once in your root layout — it initializes the tracker synchronously on the client and keeps options fresh across renders:
import { CanopyProvider } from "@wnstn/canopy-nextjs";
export default function RootLayout({ children }) {
return (
<CanopyProvider siteId={process.env.NEXT_PUBLIC_CANOPY_SITE_ID!}>
{children}
</CanopyProvider>
);
}The provider accepts every core option plus scriptSrc: set it to a URL to additionally render a next/script tag, or false to suppress the script entirely (the default — the provider initializes the bundled tracker itself, so a script tag is usually unnecessary). CanopyScript is also exported standalone if you want just the tag.
Hooks
| Hook | Returns | Notes |
|---|---|---|
| useCanopyTrack() | track(name, props?) | Stable function; falls back to the queue outside a provider. |
| useCanopyPageview() | pageview(props?) | Manual pageview, e.g. for custom routers. |
| useCanopyIdentify() | identify(props) | Attach traits after login. |
| useCanopyReset() | reset() | Start a fresh session, e.g. on logout. |
| useCanopyInstance() | all methods + options | Full instance access in one hook. |
| useCanopy() | instance | null | Raw instance, or null before initialization. |
| useCanopyInitialized() | boolean | Whether the tracker is live. |
| useCanopyOptions() | options | null | Current configuration. |
| useCanopyFlush() | () => void | Flush queued pre-init calls. |
'use client' boundary where you track.9Event payload
For reference — and for building your own ingestion clients — this is exactly what the tracker POSTs. Field meanings and server-side limits are documented in the HTTP API reference:
{
"v": "0.1.0", // SDK version
"s": "canopy_example_com", // site ID
"sid": "3f9c2a1e-...", // in-memory session ID
"n": "pageview", // event name
"u": "https://example.com/pricing", // full URL
"r": "https://google.com", // referrer
"p": "/pricing", // path + query
"t": "Pricing — Example", // page title
"w": 1440, // viewport width
"props": { "utm_source": "newsletter" },
"env": "production",
"ts": 1739755562410
}