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.pushStateand the back button). -
Outbound link clicks, as the goal
outbound_clickwith the destination in aurlproperty. -
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
| Attribute | What 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
- In Stripe, go to Developers → API keys and create a restricted key.
- Give it Read access to Checkout Sessions, Invoices, Refunds and Charges. Nothing else is needed and nothing can be written.
-
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.
| Status | Meaning |
|---|---|
201 / 202 / 200 | Recorded, accepted, or (for payments) already recorded. |
401 | Missing 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. |
timestamp | Optional. 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 mode | Cookieless 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.