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.
<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>:
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.jsalways serves the latest 1.x version; browsers may keep a copy for a few hours. To pin one exact build that never changes, usehttps://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:
bl.consent("denied"); // counted, but not remembered between pages or visits
bl.consent("granted"); // remembered again3. Record your own events
Anything else you want to count — a button, a form, a step in a booking — you
send with bl.fire:
bl.fire("event_name", { any: "details" }, { value, event_id });
// name what happened optional extrasExamples
// 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 (defaultINR). Only this field is added up as revenue — anamountinside 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.
bl.identify("cust_812"); // after login or signup
bl.reset(); // on logout — the browser becomes anonymous again4. 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:
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
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:
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_idis 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) |
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. 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,
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/eventsand the person'suser_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:
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 newsession_idafter 30 minutes without activity. - Set a
User-Agentlike 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
urlfor the screen (app://myshop/checkout) — it shows as/checkouton the Pages report. user_idafter 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 is499. 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 sameevent_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.itemswith aproduct_idand the product's pageurl— 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 aswho.session_id; the sale is then credited to that exact visit. user_idis 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.
localhostis 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.jsload (200) and requests toin.bananaleaf.ai/ereturn 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
200withaccepted: 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. - Sales show as "unattributed". The order had no
bl_anonymous_idand 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_ids. 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.itemsand noproduct_id. See the checklist in section 4. - What sold shows an id instead of a name. Send
nameinitems(orproduct_name), or fire a view event withproduct_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-Agentheader (see Mobile apps).