Analytics & events
What store events NanoSite sends, to which destinations, and how to integrate when you run your own storefront frontend. This is the "outbound" side referenced from Webhooks & events.
Overview
Your store emits a canonical set of events once, and we fan them out to every destination you enable: Klaviyo, Facebook, Google Tag Manager, Mixpanel, Twilio, or your own Webhook URL. Two delivery paths work together:
- Server-side (the backbone) — authoritative events (orders, fulfillment, customer sign-ups) are emitted by our backend and delivered to each destination's server API. This works no matter what frontend your store uses, and survives ad-blockers.
- Client-side — for storefronts we build for you, a small browser layer sends in-page events (page/product views, funnel steps) directly to your destinations.
Configure destinations per store under Add-ons → Marketing. An event that fires both client- and server-side carries a shared id so it is counted once.
Destinations
| Destination | Server | Client | Config |
|---|---|---|---|
| Klaviyo | Events + Profiles API | Onsite JS | Private API key, public key |
| Facebook (Meta) | Conversions API | Pixel | Pixel ID, CAPI token |
| Google Tag Manager | Server container (optional) | Web container (dataLayer) | Web container ID, server URL |
| Mixpanel | Track API | Browser SDK | Project token, API host |
| Twilio | SMS (transactional/marketing) | — | Account SID, auth token, from number |
| Webhook | Signed JSON POST to your URL |
— | Endpoint URL, signing secret |
Secrets are encrypted at rest and never returned to the browser. Public identifiers (a Mixpanel project token, a Facebook pixel id) are exposed through the storefront public config so a browser SDK can use them.
Events out of the box
These common events are emitted automatically for every store — server-side, from our verified checkout / order / customer code — so they are trustworthy by construction:
| Event | Fires when | Key properties |
|---|---|---|
order_placed |
Payment captured | order id, currency, value, items |
order_fulfilled |
Order marked fulfilled | order id, items |
order_refunded |
Order refunded | order id, value |
order_cancelled |
Order cancelled | order id |
customer_registered |
A shopper creates an account (sign-up) | email, external id, name, method |
customer_identified |
An existing shopper signs in | email, external id, name |
newsletter_subscribed |
Subscribe form submitted | email, list |
customer_registered is your sign-up event. It fires once per new account, whichever way the
account was created. method says which way: password, google, magic_link or otp. Each
destination receives it as its own registration event:
| Destination | Sign-up is sent as |
|---|---|
| Facebook (Meta) | CompleteRegistration |
| Google Tag Manager (GA4) | sign_up (with method) |
| Mixpanel | customer_registered (with method) |
| Klaviyo | Profile created + Registered Account metric |
The following browsing events are also part of the taxonomy and may be sent from a storefront (see
custom-frontend integration): product_viewed, product_added_to_cart, checkout_started,
customer_identified. Authoritative order_* and customer_registered events are server-only
and cannot be sent from a browser (so they can never be spoofed with a public key).
Custom events
Beyond the common taxonomy, a store can send custom events unique to its funnel (for example a
nutrition brand's assessment_completed, formula_generated, or welcome_kit_dispatched). A custom
event carries a customName and a flat props object and flows through the same pipeline to every
enabled destination.
{
"name": "custom",
"customName": "formula_generated",
"props": { "formula_version": "v2", "macro_focus": "energy" },
"profile": { "externalId": "cus_123" },
"consent": { "analytics": true }
}
Webhook destination
Use the Webhook destination to send store events to a system you run yourself: your own backend, a data warehouse loader, or an automation tool such as Zapier, Make or n8n. Requests follow the Standard Webhooks spec, so you can verify them with an off-the-shelf library in most languages.
Enable it under Add-ons → Marketing → Webhook. You only enter one thing:
- Endpoint URL: a public
https://URL. Plainhttp, IP addresses and internal hostnames are rejected, and redirects are not followed.
When you save, we create a signing secret for you (whsec_…). Use Copy to paste it into your
endpoint, or into Zapier, Make or n8n, so it can check that requests come from us. Reveal shows
it again at any time. Regenerate replaces it; the old secret stops working right away, so update
your endpoint at the same time. The secret is encrypted at rest.
Use Send test event to check the endpoint. It POSTs a nanosite_test_event right away.
Which events you receive
Every event that reaches the pipeline is forwarded, but not every event is emitted automatically:
- Always sent:
order_placed,order_fulfilled,order_refunded,order_cancelled,customer_registered,customer_identified(on sign-in) andnewsletter_subscribed. Our backend emits these itself. - Only if your storefront sends them:
product_viewed,product_added_to_cart,checkout_startedandcustom. Only the browser sees these actions, so they reach the webhook only when your storefront reports them withtrackEvent(see Custom frontend integration).
What we send
Each event is a POST with a JSON body:
{
"id": "mevt_01J…",
"type": "order_placed",
"version": 1,
"timestamp": "2026-09-30T12:00:00.000Z",
"data": {
"profile": { "customerId": null, "email": "[email protected]", "phone": null, "name": null },
"consent": { "marketing": true, "analytics": true, "region": null, "source": "checkout" },
"order": { "id": "order_…", "status": "paid", "currencyCode": "USD", "total": 2500, "items": [] }
}
}
data depends on type. Every field of every event type, with a full example, is listed in the
generated Webhook events reference. version is the payload version: it
changes only for a breaking change, so ignore fields you don't recognize (see
Versioning).
Headers:
| Header | Value |
|---|---|
webhook-id |
The event id. It stays the same across retries, so use it to deduplicate. |
webhook-timestamp |
Unix seconds when the request was signed |
webhook-signature |
v1, + base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body> |
Responding and retries
Return any 2xx status to acknowledge an event. A timeout (8 s), a redirect or any other status
counts as a failure, and the event is retried with exponential backoff: 8 attempts over about four
hours. Retries can deliver the same event twice, so deduplicate on webhook-id.
Verifying the signature
Verify against the raw request body, before any JSON parsing. The simplest way is the Standard
Webhooks library (npm install standardwebhooks, also available for Python, Go, Ruby, PHP, Java and
others). It checks the signature and rejects timestamps more than 5 minutes old:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.NANOSITE_WEBHOOK_SECRET); // the "whsec_…" secret you copied
const event = wh.verify(rawBody, {
"webhook-id": req.headers["webhook-id"],
"webhook-timestamp": req.headers["webhook-timestamp"],
"webhook-signature": req.headers["webhook-signature"],
}); // throws if invalid
Without the library:
import { createHmac, timingSafeEqual } from "node:crypto";
const verify = (rawBody: string, headers: Record<string, string>, secret: string): boolean => {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // older than 5 minutes
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
// The header is a space-separated list of "v1,<signature>" entries.
return (headers["webhook-signature"] ?? "").split(" ").some((entry) => {
const [version, sig] = entry.split(",");
return version === "v1" && sig?.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
};
Consent
Webhook delivery is not gated on consent. The endpoint is your own system, and order events often
drive operational work (fulfillment, accounting) that must not depend on a marketing opt-in. Every
event carries the shopper's consent object instead. Check it before using the data for marketing or
passing it on to a third party.
Client vs server
Server is the backbone: our backend emits authoritative events and forwards them to each
destination's server API. It is independent of your frontend and cannot be tampered with by a browser.
Client events (page/product views, in-page funnel steps) are only observable in the browser; for
storefronts we build, we send them for you. If you run your own frontend, send them yourself (below).
A shared eventId deduplicates an action reported from both sides.
Custom frontend integration
If you build your own storefront frontend, we don't control that client — so integrate through the SDK. Send browsing / custom events to us and we forward them to every enabled destination:
import { trackEvent } from "@nanosite/ecommerce-sdk";
// A common browsing event
await trackEvent({
name: "product_added_to_cart",
line: { title: "Tee", quantity: 1, unitAmount: 2500, currencyCode: "USD" },
consent: { analytics: true },
});
// A custom funnel event
await trackEvent({
name: "custom",
customName: "assessment_completed",
props: { assessment_variant: "v1" },
profile: { externalId: customerId },
consent: { analytics: true },
});
Pass a stable eventId if you also fire the same action via a pixel, so it dedups. Authoritative
order_* and customer_registered events are emitted server-side and are rejected here.
To also run a browser SDK yourself (e.g. Mixpanel), read the public token from
GET /store/marketing/destinations and initialize on your side:
const res = await fetch("/api/store/marketing/destinations", {
headers: { "x-publishable-key": PUBLISHABLE_KEY },
});
const { data } = await res.json();
const mp = data.find((d) => d.id === "mixpanel");
if (mp?.enabled) mixpanel.init(mp.config.projectToken, { api_host: mp.config.apiHost });
Consent
Every event carries a consent signal (marketing / analytics). Consent is enforced
server-side: a destination that requires marketing consent (e.g. Klaviyo) or analytics consent (e.g.
Mixpanel, GTM) is skipped when that consent is absent — regardless of what a client sends. The
Webhook destination is the exception. It receives every event along with its
consent object, and your endpoint applies it. Capture the
shopper's consent at checkout / your consent banner and pass it with each event.
Sign-up and sign-in are no exception. The register, Google, magic-link and one-time-code endpoints
accept an optional consent object, which is attached to the customer_registered /
customer_identified event. If it is omitted, the event is recorded with consent denied, so any
destination that requires consent skips it. Creating an account does not count as consent by itself.
await registerCustomer({
email,
password,
consent: { marketing: bannerState.marketing, analytics: bannerState.analytics, source: "signup" },
});