# Website Analytics

See who visits your website, where they came from, and which ads, links and pages
turn into **sales**. You add one piece of code to your website; if you sell
online, your server also tells us about each payment.

```
 Visitor ──▶ your website (bl.js) ──▶ in.bananaleaf.ai ──▶ Website Analytics
                                              ▲
 Payment ──▶ your server ──(API key)──────────┘   sale credited to the visit that led to it
```

- **Website code:** one `<script>` in your page's `<head>`, with your **site key**
- **Server calls:** `https://in.bananaleaf.ai`, JSON, an **API key** from your server only
- **Where to see it:** Bananaleaf → **Website Analytics** (opens in its own tab)

---

## Contents

1. [Set up in three steps](#1-set-up-in-three-steps)
2. [What is recorded on its own](#2-what-is-recorded-on-its-own)
3. [Record your own events](#3-record-your-own-events)
4. [Record sales from your server](#4-record-sales-from-your-server)
5. [Mobile apps](#5-mobile-apps)
6. [Count visitors who use ad blockers (optional)](#6-count-visitors-who-use-ad-blockers-optional)
7. [Rules for clean numbers](#7-rules-for-clean-numbers)
8. [Errors](#8-errors)
9. [Something is not showing up](#9-something-is-not-showing-up)

---

## 1. Set up in three steps

### Step 1 — Add your website

In Bananaleaf, open **Website Analytics → Install** and add your site: a name
("Main website") and its domain (`yoursite.com`). You get a **site key** that
starts with `bl_pub_`.

The site key is public: it can only **send** visits, never read them, so it is
safe in your page source. Only addresses on your domain (and its subdomains)
may send with it; `localhost` always works, for testing.

### Step 2 — Paste the code on every page

Put this inside `<head>` on every page, with your own site key. If your site
uses one shared layout or template, paste it there once.

```html
<script async src="https://cdn.bananaleaf.ai/v1/bl.js"></script>
<script>
  window.bl = window.bl || { _q: [] };
  ["init", "fire", "identify", "reset", "consent", "getAnonymousId", "getSessionId"].forEach(function (m) {
    bl[m] = bl[m] || function () { bl._q.push([m, arguments]); };
  });
  bl.init("bl_pub_your_site_key");
</script>
```

The second part lets your own code call `bl.fire(...)` before the script has
finished loading — those calls wait and go out once it has.

**Next.js (App Router):** put it in `app/layout.js`, inside `<head>`:

```jsx
import Script from "next/script";

<Script id="bl-init" strategy="beforeInteractive">{`
  window.bl = window.bl || { _q: [] };
  ["init","fire","identify","reset","consent","getAnonymousId","getSessionId"].forEach(function (m) {
    bl[m] = bl[m] || function () { bl._q.push([m, arguments]); };
  });
  bl.init("bl_pub_your_site_key");
`}</Script>
<Script src="https://cdn.bananaleaf.ai/v1/bl.js" strategy="afterInteractive" />
```

**Shopify / WordPress / Webflow:** paste the HTML version into the theme's
"header code" or "custom code in head" box.

### Step 3 — Check it works

Open your website in another tab. On the **Install** page, **Check my install**
shows each page view within a few seconds. That is all a basic setup needs.

> `/v1/bl.js` always serves the latest 1.x version; browsers may keep a copy for
> a few hours. To pin one exact build that never changes, use
> `https://cdn.bananaleaf.ai/v1.3.0/bl.js`.

---

## 2. What is recorded on its own

You write no code for any of this:

| What | Event name | Notes |
|---|---|---|
| A page was opened | `page_view` | Also on page changes in single-page apps (React, Next.js, Vue) |
| Time and scroll on that page | `page_leave` | Sent when the visitor leaves the page |
| Where they came from | on every event | `utm_*` tags, ad click ids (Google, Meta…), the referring site |
| Device, system, browser | on every event | Read from the browser |
| Country, region, city | on every event | Worked out by our network; **the visitor's IP address is never stored** |

Each browser gets an anonymous id (`v_…`) that is kept between visits, so a
visitor who comes back next week is the same visitor. Bots and data-centre
traffic are marked and left out of your reports.

**Consent.** You are responsible for your own cookie or consent banner. If a
visitor declines, call:

```js
bl.consent("denied");   // counted, but not remembered between pages or visits
bl.consent("granted");  // remembered again
```

---

## 3. Record your own events

Anything else you want to count — a button, a form, a step in a booking — you
send with `bl.fire`:

```js
bl.fire("event_name", { any: "details" }, { value, event_id });
//        name           what happened       optional extras
```

**Examples**

```js
// someone opened a product — on the product's own page, with its id
bl.fire("product_viewed", { product_id: "YOGA-01", product_name: "Yoga batch" });

// someone started checkout — with the amount, so the report can show money
bl.fire("checkout_started", { product_id: "YOGA-01" },
        { value: { amount: 1499, currency: "INR" } });

// someone submitted a contact form
bl.fire("lead", { form: "Contact us" });
```

- **Name:** letters, numbers and `_`, starting with a letter, up to 64
  characters. Pick names in the past tense (`signup_completed`) and keep them the
  same forever — a renamed event starts a new line in the reports.
- **Details** (second argument): anything that helps you break the numbers
  down later — product, plan, form name. Never put phone numbers, emails,
  passwords or card details here.
- **`value`**: money, in **rupees, not paise** (₹1,499 → `1499`), with a
  three-letter currency (default `INR`). Only this field is added up as
  revenue — an `amount` inside the details is not.
- **`event_id`**: your own id for this event (8–64 characters), e.g. the order
  id. If the same id arrives twice, it is counted once.

**Products.** Put the same `product_id` (and `product_name`) on every event
about a product — views, checkout, the sale. **What sold** uses it to name the
product, and the page where its *view* events happen becomes the product's page
for **Sold here**, even when the sale itself only carries the id.

**Which events count as sales or leads.** `payment_done`, `purchase`,
`order_completed`, `lead` and `signup` always appear on the **Conversions**
page. You can mark any other event as a conversion on the **Events** page.

### Tell us who the visitor is

When someone signs in or signs up, call `identify` with **your own id** for
that person (your customer or user id — not their phone number). Their earlier
anonymous visits are joined to them, and their later visits on other devices
are too.

```js
bl.identify("cust_812");     // after login or signup
bl.reset();                  // on logout — the browser becomes anonymous again
```

---

## 4. Record sales from your server

A sale fired from the browser is unreliable: the thank-you page gets refreshed
(counted twice), the tab is closed early or an ad blocker stops it (lost), and
anyone can fire it by hand. **Send payments from your server instead**, at the
moment your system marks the order as paid (your payment webhook).

**Get sales right — the checklist**

| | What to send | Why |
|---|---|---|
| 1 | `event_id` = your order id, the same on every retry and from every place that reports it | One order counts once |
| 2 | `value` = the amount paid, **in rupees** | Revenue |
| 3 | `who.anonymous_id` **and** `who.session_id`, saved from the checkout page (4.2) | Credits the sale to the visit, page and ad it came from |
| 4 | `metadata.items` = what was bought: `product_id`, `name`, `url`, `price`, `quantity` (4.3) | **What sold** per product, **Sold here** per page |
| 5 | `who.user_id` = your customer id, if they have one | Joins their visits on every device |

### 4.1 Make a server key

In **Developers → API keys**, create a key and tick **Website events**. It starts
with `bl_live_`. Keep it on your server, in an environment variable — never in
your website or app code. A request that comes from a browser is refused.

A **test key** (`bl_test_…`) checks every request fully and records nothing —
use it while you build.

### 4.2 Send the visitor's id with the order

So that a sale can be credited to the visit (and the ad) that led to it, your
checkout page sends the browser's anonymous id — and this visit's id — along
with the order:

```js
const order = {
  // … your fields …
  bl_anonymous_id: (window.bl && bl.getAnonymousId && bl.getAnonymousId()) || null,
  bl_session_id: (window.bl && bl.getSessionId && bl.getSessionId()) || null,
};
```

The visit id (`bl.getSessionId()`, tag 1.3.0 and later) ties the sale to the
exact visit it came from, even when the payment is confirmed hours later.

Save it on the order. If it is missing (ad blocker), the order must still go
through — the sale is then counted, just not credited to a source.

### 4.3 Send the payment

```bash
curl https://in.bananaleaf.ai/v1/events \
  -H "Authorization: Bearer $BANANALEAF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [{
      "event_name": "payment_done",
      "event_id": "order_8812",
      "timestamp": "2026-10-25T10:42:00Z",
      "who": { "anonymous_id": "v_3k9q…", "session_id": "s_7c1d…", "user_id": "cust_812" },
      "value": { "amount": 1499, "currency": "INR" },
      "metadata": {
        "items": [
          { "product_id": "YOGA-01", "name": "Yoga batch", "url": "/courses/yoga",
            "price": 1499, "quantity": 1 }
        ],
        "coupon": "DIWALI"
      }
    }]
  }'
```

| Field | Required | Meaning |
|---|---|---|
| `event_name` | yes | What happened, e.g. `payment_done`, `refund_issued` |
| `event_id` | recommended | Your order id. The **same id on every retry** — it is counted once |
| `who` | yes | `anonymous_id` (from 4.2), `user_id` (your own customer id), or both. Add `session_id` (from 4.2) to credit the exact visit |
| `timestamp` | no | When it happened (ISO 8601). Default: now |
| `value` | for money | `amount` in rupees (not paise) + `currency` |
| `metadata` | no | Details for breaking reports down. No personal data. `items` = what was bought (below) |
| `site_id` | no | Which of your websites; default is your first one |

Up to 500 events per request. A single event without the `events` wrapper also works.

**What was bought.** List the products in `metadata.items` — each with
`product_id`, `name`, `url` (the product's page), `price` (one unit, rupees) and
`quantity`. Conversions → **What sold** then shows sales per product, and
Pages → **Sold here** credits each sale to its product's page. One product?
Top-level `product_id`, `product_name` and `product_url` work too.

**Reply:** `200 {"ok": true, "accepted": 1, "duplicates": 0, "rejected": []}`.
A `200` means it is stored safely. Events that were not usable are listed in
`rejected` with the reason; the others are kept.

### 4.4 Link the buyer to a person (guest checkout)

When the buyer gives a phone or email at checkout, tell us once, so they show as
a known person and match their Bananaleaf contact:

```bash
curl https://in.bananaleaf.ai/v1/identify \
  -H "Authorization: Bearer $BANANALEAF_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "anonymous_id": "v_3k9q…",
    "user_id": "cust_812",
    "traits": { "phone": "+919876543210", "email": "priya@example.com", "name": "Priya" }
  }'
```

- `user_id` is always **your** customer id, never the phone (phones change hands).
- Phone in international format (`+91…`).
- Phone and email are used only to match the person to their Bananaleaf contact;
  they are never stored with events.
- Do not call it for orders placed on someone else's behalf (a staff member
  booking for a customer) — it would join the staff member's browser to the customer.

### 4.5 Retry safely

Do not make your customer wait for this call: send it in the background, after
your own database write.

| Reply | Do |
|---|---|
| `200` | Done |
| `429`, `5xx`, timeout, no network | Retry with backoff (e.g. 1 s, 4 s, 15 s). Honour `Retry-After` |
| `401`, `403`, `413`, `422` | Do **not** retry — fix the request (see [Errors](#8-errors)) |

Because the same `event_id` is counted once, retrying never double-counts.

---

## 5. Mobile apps

There is **no mobile SDK**, and for most apps you don't need one. A native
Android or iOS app already talks to your own server for everything that matters —
signups, orders, payments, bookings — so **send those from your server** with the
calls in [section 4](#4-record-sales-from-your-server). That works the same for
every platform (Android, iOS, React Native, Flutter) and needs no app update.

### Sales and signups — works fully

Your **server** sends them exactly as in [section 4](#4-record-sales-from-your-server),
with your own `user_id`. It does not matter whether the purchase happened on the
website or in the app: revenue and conversions are counted the same way. Add
`"metadata": { "channel": "app" }` so you can tell them apart.

If the same person also uses your website and calls `bl.identify("cust_812")`
there, their website visits and their app purchases join up under one person.

### A website inside the app (WebView) — works fully

If the app shows web pages in a WebView, put the normal website code on those
pages. They are counted like any website visit.

### Screens and taps inside a native app — only if you need them

Screens and taps happen on the phone, not on your server. Two ways to record them:

- **Through your server.** The app reports them to your own backend, which sends
  them on with `/v1/events` and the person's `user_id`. Simplest, and the API key
  stays on your server — but these rows carry **no device or city**, because the
  request comes from your server, not the phone.
- **Straight from the app**, to the same endpoint the website code uses, with the
  site key. You get device and city, and you do the work the website code would
  do for you:

```http
POST https://in.bananaleaf.ai/e
Content-Type: application/json
User-Agent: MyShop/2.1 (Linux; Android 14; Mobile)

{
  "key": "bl_pub_your_app_site_key",
  "sent_at": "2026-10-25T10:42:05Z",
  "batch": [{
    "event": "screen_view",
    "event_id": "0192f4c6-7b2a-7c1e-9d3f-2b8e5a1c4d70",
    "ts": "2026-10-25T10:42:03Z",
    "anonymous_id": "v_8d2f1c…",
    "session_id": "s_41aa…",
    "user_id": "cust_812",
    "url": "app://myshop/checkout",
    "title": "Checkout",
    "screen": "393x852",
    "language": "en-IN",
    "props": { "app_version": "2.1.0" }
  }]
}
```

- **Add a separate site** for the app (e.g. "Android app") with **no domain**, so
  its numbers stay apart from the website's and its requests are not refused.
- **Make your own anonymous id** on first launch (`v_` + a random UUID), save it
  on the device, and send it with every event. Make a new `session_id` after 30
  minutes without activity.
- **Set a `User-Agent` like the one above.** It is how we know the device:
  `(Linux; Android 14; Mobile)` reads as an Android phone,
  `(iPhone; iOS 17.4; Mobile)` as an iPhone. **Android's default network client
  (`okhttp`, also used by React Native) is treated as a bot** and those events are
  hidden from your reports — so this header is not optional on Android.
- Use `url` for the screen (`app://myshop/checkout`) — it shows as `/checkout` on the **Pages** report.
- `user_id` after the person signs in; leave it out before.
- Send in small batches (up to 100), and keep unsent events on the device to retry
  later, so nothing is lost offline.

### What does not work for apps yet

- **Which ad led to an install.** That needs the Play Store's install referrer
  (Android) or Apple's ad attribution (iOS). Not built — app installs show as
  "direct".
- **Automatic screen and session tracking.** Without an SDK, the app sends
  every event itself.

---

## 6. Count visitors who use ad blockers (optional)

Some ad blockers stop calls to other companies' addresses, so those visitors are
not counted. A developer can fix this by forwarding a path on **your own
domain** to us; the code and the visits then come from your site.

The **Install** page shows your site's path (something like `/k3x9q/`) and
ready-to-paste setups for **Cloudflare Workers, Next.js, Vercel and Nginx**.
After setting it up, use the second code snippet on that page, which loads the
script and sends the visits through your path.

The forwarder must pass on each visitor's address and, where it can, their
location — the setups on the Install page already do. Without that, every
visit looks like it comes from your own server.

---

## 7. Rules for clean numbers

- **Money is `value`, in rupees.** ₹499 is `499`. If your system stores paise,
  divide by 100. Send the sale's real currency.
- **One sale = one `event_id`.** Use the order id, and the same id on every retry.
  If your website still fires the sale too, give it the same `event_id` — the
  server's copy wins and it is counted once.
- **Send the sale from the server, not the thank-you page.**
- **Say what was bought.** `metadata.items` with a `product_id` and the product's
  page `url` — without it, What sold and Sold here stay empty.
- **Send the visit's id.** Save `bl.getSessionId()` with the order at checkout and
  send it as `who.session_id`; the sale is then credited to that exact visit.
- **`user_id` is your customer id.** Never a phone, email or a placeholder like
  `"guest"` or `"0"` (those are ignored).
- **No personal data in event details.** Phones and emails go only in `identify`.
  Never send passwords, OTPs or card details anywhere.
- **Keep event names stable.** Decide them once; renaming splits the reports.
- **Test on a separate site.** `localhost` is always allowed, so tests with your
  real site key land in your real numbers. Add a "Dev" site for local work.

---

## 8. Errors

| Code | Means | Fix |
|---|---|---|
| `401` | The key is missing, wrong or revoked | Check the key and which account it belongs to |
| `403` | Website: the page's domain is not on the site's allowed list. Server: the key lacks **Website events**, or the call came from a browser | Add the domain on the Install page / tick the scope / call from your server |
| `413` | Too large (website: 100 events or 512 KB; server: 500 events or 1 MB) | Send smaller batches |
| `422` | The JSON is not in the expected shape | Read the `detail` in the reply |
| `429` | Too many requests | Wait for `Retry-After`, then retry |
| `503` | We could not store it just now | Retry with backoff |

Reasons you may see in `rejected` (server calls): `bad_event_name`,
`bad_event_id`, `who_required` (no usable `anonymous_id` or `user_id`),
`bad_timestamp`, `bad_value`, `bad_currency`, `bad_metadata`, `metadata_too_large`,
`unknown_site`.

---

## 9. Something is not showing up

- **No page views at all.** Open your site, then the browser's developer tools →
  Network. You should see `bl.js` load (200) and requests to `in.bananaleaf.ai/e`
  return 200. A 403 there means your domain is not on the site's allowed list.
- **Visits show, sales don't.** Check your server got a `200` with `accepted: 1`.
  A sale appears on **Conversions** within about a minute.
- **Sales show with ₹0.** The amount was sent in the details instead of `value`,
  or in paise. See [Rules](#7-rules-for-clean-numbers).
- **Sales show as "unattributed".** The order had no `bl_anonymous_id` and the
  buyer had never been identified. Check step 4.2 on your checkout page.
- **The same sale counted twice.** The browser and the server sent it with
  different `event_id`s. Use the same one, or stop sending it from the browser.
  (A thank-you page that fires on every reload does the same — give it the order id.)
- **What sold says "named no product".** The sale carries no `metadata.items`
  and no `product_id`. See the checklist in section 4.
- **What sold shows an id instead of a name.** Send `name` in `items` (or
  `product_name`), or fire a view event with `product_id` + `product_name`.
- **Sold here is empty, or points at a checkout page.** Send each item's `url`
  (the product page's path, e.g. `/courses/yoga`).
- **App events missing.** Check the `User-Agent` header (see [Mobile apps](#5-mobile-apps)).
