Documentation

Set up nbsanalytics

Everything you need to install the script, track goals, attribute revenue and send data from your server. Stuck? Email [CONTACT EMAIL].

Install the script #

Add a site in the app, then paste its snippet into the <head> of every page. Your site's id (nbs_…) is in Settings.

<script defer data-site="nbs_…" src="https://analytics.myanch.com/js/script.js"></script>

The script is about 1.4 KB compressed and loads with defer, so it never blocks your page. Open your dashboard after adding it: the first visit shows up within seconds.

What's tracked automatically

  • Pageviews, including route changes in single-page apps (via history.pushState and the back button).
  • Outbound link clicks, as the goal outbound_click with the destination in a url property.
  • Clicks on elements with data-nbs-goal (see Goals).

The script doesn't send anything from localhost, from inside iframes, from automated browsers, or from browsers where you've excluded yourself. When it skips an event it says why in the browser console.

Script options

AttributeWhat it does
data-site Required. Your site's public id, nbs_….
data-api Where to send events. Defaults to /api/event on the script's own origin. Set it when you proxy through a different path.
data-cookieless Don't set cookies. See Cookie and cookieless mode. Set the same mode in your site's settings.
data-hash Count changes to the URL hash (#/pricing) as pageviews, for hash-routed apps.
data-localhost Also track on localhost, handy while testing your setup.
data-hostnames Cross-domain tracking: a comma-separated list like example.com,example-shop.com. Links between these domains carry the visitor's ids, so one visit spans them. Cookie mode only.

Events are only accepted from your site's domain and its subdomains, so copying your snippet onto another site doesn't add traffic to your dashboard. To track more domains with the same site, add them under Settings → Other domains; the snippet there then includes data-hostnames.

With data-hostnames, a click from one listed domain to another adds an _nbs parameter holding the random visitor and session ids (nothing else). The script on the other domain removes the parameter from the address bar, and stores the ids in its own cookies only when the click came from a listed domain (it checks the referrer) and the browser has no visitor id there yet, so a shared link can't change who someone is counted as. A visitor already known on both domains keeps two ids.

Goals and the headline metric #

A goal is any event other than a pageview: a signup, a demo request, a download. Send one from JavaScript with a name and optional properties:

nbs("signup", { plan: "pro", source: "pricing_page" })

Or with no code at all, by marking up the element that's clicked:

<button data-nbs-goal="signup" data-nbs-goal-plan="pro">Create account</button>

Every data-nbs-goal-* attribute becomes a property; dashes in the name become underscores (data-nbs-goal-billing-period → billing_period).

To know whether people reach part of a page, mark it with data-nbs-scroll. The goal scroll:pricing is sent once per pageview, when half of the element (or half the screen, for a tall one) is in view:

<section data-nbs-scroll="pricing">…</section>
  • Goal names: lowercase letters, numbers, _, - and :, up to 64 characters.
  • Up to 10 properties per event. Keys follow the same rule (without :); values are stored as text, up to 255 characters.
  • Don't put personal data such as email addresses in goal properties.

Calling nbs() before the script loads

The script is deferred, so nbs may not exist yet when your own code runs. Add this one-liner before the snippet; calls made early are queued and sent once the script loads.

<script>window.nbs = window.nbs || function () { (window.nbs.q = window.nbs.q || []).push(arguments) }</script>

The headline metric

Each site has one number that means "it's working". Connect a payment provider and it's revenue; otherwise choose a goal in Settings → Headline metric. It becomes the last tile in the KPI row, the bars on the main chart, and a conversion column in every breakdown, so you can see which sources, pages and countries actually convert.

Identifying signed-in users

When someone signs in, tell us who they are and their visits — across devices, once they sign in on each — are joined into one journey on the Visitors page:

nbs("identify", { user_id: "u_1842", name: "Ada Lovelace", image: "https://example.com/ada.png" })

user_id is your own id for the user. name and image (an https:// URL) are optional; other string properties are kept as traits (up to 10). Only identify people where your own privacy notice covers it.

Anyone can call the script, so a browser identify is taken as a hint: it fills in a profile's empty fields but never overwrites them, it ignores email, and it adds its browser to the journey without changing which browser server events with only a user_id are credited to. To set an email for payment matching, or to correct a profile, use identify from your server, which overwrites.

Revenue #

Connect Stripe

  1. In Stripe, go to Developers → API keys and create a restricted key.
  2. Give it Read access to Checkout Sessions, Invoices, Refunds and Charges. Nothing else is needed and nothing can be written.
  3. Paste the key (rk_live_…) into your site's Settings → Revenue.

We import completed Checkout Sessions, subscription renewals and refunds every few minutes. The first sync goes back 90 days. Renewals are credited to the source of the customer's first payment; refunds are subtracted.

Attribute payments to visits

To credit a payment to the visit that led to it, pass the visitor and session ids into the Checkout Session's metadata as nbs_vid and nbs_sid. Get them in the browser with nbs("ids") and send them along when you create the session:

// In the browser, e.g. when "Buy" is clicked
const ids = nbs("ids") // { visitor_id: "…", session_id: "…" }

await fetch("/checkout", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(ids),
})
// On your server (Node, with the stripe package)
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: "price_…", quantity: 1 }],
  success_url: "https://example.com/welcome",
  metadata: {
    nbs_vid: req.body.visitor_id ?? "",
    nbs_sid: req.body.session_id ?? "",
  },
})

In cookie mode your server can also read the nbs_vid and nbs_sid cookies straight from the request, since they're set on your own domain. In cookieless mode nbs("ids") returns only a session id, which is enough to attribute the payment to the visit.

Other payment providers

Stripe is the only built-in integration today. For anything else, send payments to the payments API from your webhook handler, with the same ids.

Server-side API #

Create an API key in your site's Settings → API keys. Keys start with nbs_sk_, belong to one site, and go in the Authorization header. Keep them on your server — never in browser code.

StatusMeaning
201 / 202 / 200 Recorded, accepted, or (for payments) already recorded.
401Missing or unknown API key.
422 Invalid payload. The JSON body's error says what's wrong and field names the field to fix, e.g. {"error": "visitor_id must be 8-64 letters, numbers, _ or -", "field": "visitor_id"}. Nothing is recorded.

POST /api/v1/events

Record a goal or a pageview from your backend.

curl -X POST https://analytics.myanch.com/api/v1/events \
  -H "Authorization: Bearer nbs_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "subscription_upgraded",
    "visitor_id": "value of the nbs_vid cookie",
    "url": "https://example.com/account?utm_campaign=spring",
    "referrer": "https://news.ycombinator.com/",
    "ip": "203.0.113.9",
    "user_agent": "Mozilla/5.0 …",
    "props": { "plan": "team" }
  }'

Every event belongs to someone: send a visitor_id, a user_id, or the visitor's ip and user_agent. Without any of them the request is refused.

Field
name Required. A goal name (lowercase a-z 0-9 _ - :, up to 64 characters), or pageview. payment, refund, trial_started, subscription_started, subscription_ended and identify are reserved: use the payments and identify endpoints.
visitor_id Usually the nbs_vid cookie, so the event joins the browser's visits. 8–64 characters: letters, numbers, _ and -. A malformed id is refused, never guessed at.
user_id Your own id for the user (up to 255 characters). Recorded on the event. Without a visitor_id, the event goes to the browser the user was last identified on; for a user never identified, to a visitor id derived from your user id, which joins their journey once a browser is identified too.
session_id Optional. The nbs_sid cookie, same format as visitor_id.
url Optional. Must be on your site's domain. Defaults to your home page. UTM tags and ad click ids in it set the source, as for the script.
referrer Optional. The page the visitor came from, for the source and channel.
ip Optional. The visitor's IP address as your server received it, for the location and, without a visitor or user id, the cookieless visitor hash. Used for the lookup and then discarded: never stored or logged. An invalid address is ignored.
user_agent Optional. The visitor's user agent, for browser and device. Events with a bot's user agent are answered 202 with "status": "ignored" and not counted.
props Optional. Up to 10 properties: keys lowercase a-z 0-9 _ -, values strings up to 255 characters, numbers or booleans.

POST /api/v1/identify

curl -X POST https://analytics.myanch.com/api/v1/identify \
  -H "Authorization: Bearer nbs_sk_…" \
  -H "Content-Type: application/json" \
  -d '{ "visitor_id": "…", "user_id": "u_1842", "name": "Ada Lovelace", "email": "ada@example.com", "company": "Analytical Engines" }'

user_id is required. With a visitor_id the browser is linked to the user; without one the user's profile is updated (or started) from your server alone. name, image (an https:// URL) and email are optional, plus up to 10 other traits with lowercase keys and string, number or boolean values. The email is kept only as a keyed hash, used to credit payments that carry the same email; it is never stored or shown. Values sent here overwrite the profile's, and the browser in visitor_id becomes the one server events with only a user_id are credited to.

POST /api/v1/payments

curl -X POST https://analytics.myanch.com/api/v1/payments \
  -H "Authorization: Bearer nbs_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "transaction_id": "ord_10442",
    "amount": 49.00,
    "currency": "GBP",
    "visitor_id": "…",
    "session_id": "…",
    "user_id": "u_1842",
    "email": "ada@example.com",
    "customer_id": "cus_8812"
  }'
Field
transaction_id Required. Your id for the payment. Sending the same id twice records it once.
amount or amount_cents Required, except for the subscription kinds below. amount in major units (49.00), or amount_cents as an integer in minor units.
currency Required. A three-letter ISO code, e.g. GBP.
kind Optional. payment (default), refund (subtracted), renewal (counted as revenue, credited to the customer's first payment), or trial_started, subscription_started, subscription_ended, which are shown as conversions and carry no revenue. "refund": true still works as "kind": "refund".
visitor_id, session_id Optional, but the surest way to credit the payment to a source. From the cookies or nbs("ids"). Malformed ids are refused.
user_id Optional. Without ids, the payment is credited to the latest visit of the user you identified with this id.
email Optional. Without ids or a known user, the payment is credited to the latest visit of the user identified with this email. Compared as a keyed hash, never stored.
customer_id Optional. Later payments from the same customer without ids are credited to their first payment's source.
timestampOptional. ISO 8601. Defaults to now.

A payment is credited to a visit by, in order: session_id, visitor_id, user_id, email, then customer_id. Renewals check customer_id first. Payments matching none are shown as Unattributed.

Proxy through your own domain #

Some ad and privacy blockers block analytics requests to third-party domains. Serving the script and the event endpoint from your own domain avoids that. Proxy two paths to https://analytics.myanch.com:

  • GET /js/script.js — the script.
  • POST /api/event — where events go.

Your proxy must forward the visitor's IP address in X-Forwarded-For (we read the first address in it) and pass the User-Agent header through. Without the IP every visit appears to come from your server's location, and cookieless visitor counts collapse. Then point the snippet at your own domain:

<script defer data-site="nbs_…" src="/js/script.js"></script>

If /js/script.js or /api/event are already taken on your site, proxy any other paths and tell the script where events go with data-api="/your/path".

Caddy

example.com {
	handle /js/script.js {
		reverse_proxy https://analytics.myanch.com {
			header_up Host {upstream_hostport}
		}
	}
	handle /api/event {
		reverse_proxy https://analytics.myanch.com {
			header_up Host {upstream_hostport}
			header_up X-Forwarded-For {remote_host}
		}
	}

	# ...the rest of your site
}

nginx

location = /js/script.js {
    proxy_pass https://analytics.myanch.com/js/script.js;
    proxy_set_header Host analytics.myanch.com;
    proxy_ssl_server_name on;
}

location = /api/event {
    proxy_pass https://analytics.myanch.com/api/event;
    proxy_set_header Host analytics.myanch.com;
    proxy_ssl_server_name on;
    proxy_set_header X-Forwarded-For $remote_addr;
}

Phoenix (behind kamal-proxy or similar)

A small plug in your endpoint, placed before Plug.Static and Plug.Parsers. Behind a reverse proxy, conn.remote_ip is the proxy, so it takes the client from the first X-Forwarded-For hop the proxy added.

defmodule MyAppWeb.AnalyticsProxy do
  @behaviour Plug
  import Plug.Conn

  @upstream "https://analytics.myanch.com"

  def init(opts), do: opts

  def call(%Plug.Conn{method: "GET", request_path: "/js/script.js"} = conn, _opts) do
    case Req.get(@upstream <> "/js/script.js", retry: false) do
      {:ok, %{status: 200, body: body}} ->
        conn
        |> put_resp_content_type("application/javascript")
        |> put_resp_header("cache-control", "public, max-age=3600")
        |> send_resp(200, body)
        |> halt()

      _ ->
        conn |> send_resp(502, "") |> halt()
    end
  end

  def call(%Plug.Conn{method: "POST", request_path: "/api/event"} = conn, _opts) do
    {:ok, body, conn} = read_body(conn, length: 16_384)

    status =
      case Req.post(@upstream <> "/api/event",
             body: body,
             retry: false,
             headers: [
               {"content-type", "text/plain"},
               {"user-agent", conn |> get_req_header("user-agent") |> List.first("")},
               {"x-forwarded-for", client_ip(conn)}
             ]
           ) do
        {:ok, %{status: status}} -> status
        _ -> 202
      end

    conn |> send_resp(status, "") |> halt()
  end

  def call(conn, _opts), do: conn

  defp client_ip(conn) do
    case get_req_header(conn, "x-forwarded-for") do
      [value | _] -> value |> String.split(",") |> hd() |> String.trim()
      [] -> conn.remote_ip |> :inet.ntoa() |> to_string()
    end
  end
end

# lib/my_app_web/endpoint.ex, above Plug.Static:
plug MyAppWeb.AnalyticsProxy

Next.js

// next.config.js
module.exports = {
  async rewrites() {
    return [
      { source: "/js/script.js", destination: "https://analytics.myanch.com/js/script.js" },
      { source: "/api/event", destination: "https://analytics.myanch.com/api/event" },
    ]
  },
}

If you already have an API route at /api/event, use another path and set data-api. Check on your host that the visitor's IP reaches us in X-Forwarded-For: the Realtime view should show your own city, not your hosting provider's.

Cookie and cookieless mode #

Choose the mode in your site's Settings. The install snippet there includes data-cookieless when you pick cookieless, so the script and the server agree.

Cookie modeCookieless mode
Stored on the device First-party cookies on your domain: nbs_vid (1 year) and nbs_sid (30 minutes, renewed on activity) Nothing persistent. A per-tab session id in sessionStorage, gone when the tab closes
A returning visitor is The same visitor for up to a year A new visitor each day
Revenue attribution Across days and weeks: a visit in March can earn a payment in April Within the same visit
Consent banner Likely needed in the UK and EU (see Cookies) Usually not needed

In cookieless mode the visitor id is a hash of your site, the visitor's IP address and user agent, and a random salt that changes every day. Old salts are deleted, so ids can't be linked across days or reversed to an IP address. Multi-day visitor counts in cookieless mode count a person once per day they visit.

Exclude your own visits #

Open your site, then run this in the browser's developer console:

localStorage.nbs_ignore = "1"

That browser stops sending events from your site; the console shows [nbs] not tracking: ignored by owner. Undo it with localStorage.removeItem("nbs_ignore"). The setting is per browser and per origin, so repeat it on each device and each subdomain you use.

Data and privacy #

For each event we store:

  • The page's hostname and path, and any utm_* campaign tags. Other query parameters are dropped.
  • The referrer, and the source and channel we classify from it.
  • Browser, operating system and device type from the user agent, and the screen width.
  • Country, region and city, looked up from the IP address on our own servers using a local copy of the DB-IP database.
  • The goal name and properties you send, and the visitor and session ids.

We never store raw IP addresses: the IP is used for the location lookup (and, in cookieless mode, the daily hash) and then discarded. Data is stored in the EU on Hetzner servers. We act as your processor for your visitors' data — see the Data Processing Agreement, subprocessors and privacy policy.

Owners and admins can download every raw event, and the payments, as CSV from the site's settings under Export data. Deleting a site deletes its events. Events older than three years are deleted automatically.