Skip to content

Analytics API

Dashboard analytics endpoints for querying tracking data. All endpoints require API key authentication. Most endpoints accept GET only; PATCH is supported for updating a lead's pipeline status (see Update lead status).

Authentication

Every request must include an API key in one of these headers:

Authorization: Bearer <your-api-key>
X-API-Key: <your-api-key>

Missing or invalid key returns 401 Unauthorized:

json
{ "error": "API key required. Use Authorization: Bearer <key> or X-API-Key header." }
json
{ "error": "Invalid API key" }

Setting the API Key

EnvironmentMethod
DevelopmentANALYTICS_API_KEY in wrangler.toml [vars]
Productionwrangler secret put ANALYTICS_API_KEY --env production

Overview

GET /analytics/overview

High-level summary stats across the entire dataset.

Response:

json
{
  "total_sessions": 1234,
  "total_events": 56789,
  "unique_visitors": 890,
  "unique_fingerprints": 750,
  "avg_engagement_score": 42.5,
  "avg_session_duration_s": 185,
  "vpn_suspected_count": 12
}

Daily Sessions

GET /analytics/sessions/daily?days=30

Sessions aggregated by day.

ParamTypeDefaultMaxDescription
daysinteger3090Number of days to look back

Response:

json
[
  {
    "date": "2026-02-22",
    "total_sessions": 45,
    "unique_visitors": 38,
    "avg_duration": 120.5,
    "avg_engagement": 55.2
  }
]

Top Pages

GET /analytics/pages/top?limit=20

Most viewed pages ranked by page_view event count.

ParamTypeDefaultMaxDescription
limitinteger20100Number of pages to return

Response:

json
[
  {
    "page": "https://example.com/home",
    "views": 234,
    "unique_sessions": 180
  }
]

Geo Distribution

GET /analytics/geo

Country-level session distribution.

Response:

json
[
  {
    "country": "US",
    "sessions": 500,
    "unique_visitors": 420
  }
]

Engagement Distribution

GET /analytics/engagement

Engagement score distribution in 5 buckets for completed sessions.

Response:

json
[
  { "bucket": "0-19", "count": 120 },
  { "bucket": "20-39", "count": 85 },
  { "bucket": "40-59", "count": 200 },
  { "bucket": "60-79", "count": 150 },
  { "bucket": "80-100", "count": 45 }
]

Traffic Sources

GET /analytics/sources

Top referrer domains ranked by session count. Direct traffic appears as (direct).

Response:

json
[
  { "referrer_domain": "google.com", "sessions": 300 },
  { "referrer_domain": "(direct)", "sessions": 200 },
  { "referrer_domain": "facebook.com", "sessions": 50 }
]

Realtime

GET /analytics/realtime

Approximate live stats: active sessions (not ended in last hour), events in last hour, and today's visitor/session counts.

Response:

json
{
  "active_sessions": 12,
  "events_last_hour": 456,
  "unique_visitors_today": 89,
  "total_sessions_today": 95
}

Storage

GET /analytics/storage

R2 storage stats: total object count and estimated session count.

Response:

json
{
  "total_objects": 15234,
  "estimated_sessions": 1200
}

Leads List

GET /analytics/leads?limit=25&offset=0&q=search

Paginated lead list for dashboard CRM views.

ParamTypeDefaultMaxDescription
limitinteger25100Number of leads to return
offsetinteger0-Pagination offset
qstring--Search term (name, email, phone, project, visitor_id)
statusstring--Filter by pipeline stage: new, contacted, qualified, proposal, won, lost, fake
has_follow_upsboolean--When true, only return leads that have at least one follow-up submission (returning visitors)
seenboolean--When true, only leads that have been viewed. When false, only leads that need review: never viewed or have a follow-up submitted after last view. Omit for all.
sortstringnewest-newest, oldest, last_follow_up, score_desc, score_asc, risk_desc, risk_asc, name_asc

Response:

Each lead includes follow_ups_count, last_follow_up_at, status (pipeline stage for kanban), seen_at (Unix ms when first viewed, or null if not yet seen), and needs_review (1 when the lead has never been viewed or has a follow-up submitted after the last view). Leads also include contact_ip (IP at first submission) and is_ip_blocked (1 when that IP is in the block list; see Blocked IPs). Use sort=last_follow_up to put leads with recent follow-ups first. Opening lead detail (GET /analytics/leads/:id) marks the lead as seen (sets SeenAt) so it no longer needs review until a new follow-up arrives.

json
{
  "leads": [
    {
      "lead_id": 42,
      "enquiry_datetime": "2026-02-23 11:15:00",
      "contact_name": "Rahul Sharma",
      "contact_email": "rahul@example.com",
      "contact_phone": "9876543210",
      "project_name": "Sky Towers",
      "project_location": "Pune",
      "project_source": "WebSite",
      "contact_country_name": "IN",
      "contact_city": "Pune",
      "visitor_id": "abc123...",
      "session_id": "session_123",
      "engagement_score": 74,
      "engagement_label": "Very Hot",
      "form_submit_count": 2,
      "suspicion_score": 5,
      "follow_ups_count": 1,
      "last_follow_up_at": "2026-02-24 09:30:00",
      "status": "contacted",
      "seen_at": 1740280500000,
      "needs_review": 0
    }
  ],
  "total": 187
}

Recent submissions

GET /analytics/leads/recent-submissions?limit=15

Returns the most recent form submissions (primary and follow-up) across all leads. Use this to surface "customer submitted again" or "came back" without opening each lead.

ParamTypeDefaultMaxDescription
limitinteger1550Number of submissions to return

Response:

Array of objects with submission_id, lead_id, submitted_at, contact_name, contact_email, contact_phone, project_name, is_primary_lead (1 = primary, 0 = follow-up), and is_follow_up (boolean). Names are derived from PayloadJson when available.


Lead Detail

GET /analytics/leads/:id

Returns complete lead fields plus linked session activity and submission history.

Response:

json
{
  "lead": {
    "lead_id": 42,
    "contact_name": "Rahul Sharma",
    "contact_email": "rahul@example.com",
    "session_id": "session_123",
    "engagement_score": 74,
    "engagement_label": "Very Hot"
  },
  "sessions": [
    {
      "session_id": "session_123",
      "visitor_id": "abc123...",
      "entry_page": "https://example.com/sky-towers",
      "exit_page": "https://example.com/thank-you",
      "country": "IN",
      "started_at": 1740280000000,
      "ended_at": 1740280080000,
      "duration": 80000,
      "engagement_score": 74,
      "event_count": 19
    }
  ],
  "submissions": [
    {
      "submission_id": 501,
      "lead_id": 42,
      "submitted_at": "2026-02-23 11:15:00",
      "project_name": "Sky Towers",
      "visitor_id": "abc123...",
      "session_id": "session_123",
      "contact_email": "rahul@example.com",
      "contact_phone": "9876543210",
      "contact_ip": "198.51.100.20",
      "source": "WebSite",
      "referer": "https://example.com/sky-towers",
      "cta_name": "hero-form",
      "is_primary_lead": 1,
      "is_duplicate_window": 0,
      "payload_json": "{\"FIRSTNAME\":\"Rahul Sharma\",\"EMAIL\":\"rahul@example.com\",\"PHONE\":\"9876543210\",\"PROJECT\":\"Sky Towers\"}"
    }
  ]
}

Each submission may include payload_json: the raw form payload at submit time (e.g. FIRSTNAME, EMAIL, PHONE, PROJECT). The dashboard uses this to show follow-up history — the exact data submitted in each form submit so admins can see changes over time (e.g. name or phone updates). The lead object also includes status (pipeline stage: new, contacted, qualified, proposal, won, lost, fake), contact_ip (IP at first submission), and is_ip_blocked (1 when that IP is in the block list; see Blocked IPs). Each item in submissions includes contact_ip and is_ip_blocked (1 when that submission’s IP is blocked).


Blocked IPs

Blocked IPs are stored in the BlockedIPs table (migrations 024 and 025). Leads and submissions whose IP is in this list are marked with is_ip_blocked: 1 in list/detail responses; the dashboard shows them with distinct styling (red border/background, “IP blocked” badge). Honeypot (bot) leads have their IP blocked automatically with reason "Honeypot" and optional country/referer stored.

List blocked IPs

GET /analytics/blocked-ips?ip=...&limit=50&offset=0

Returns blocked IPs with optional pagination. Query ip or q filters by IP (substring match). limit (default 50, max 100) and offset (default 0) support paged navigation.

Response: { "blocked_ips": [ { "ip", "blocked_at", "reason", "country", "referer" }, ... ], "total": number }

Block an IP

POST /analytics/blocked-ips

Add an IP to the block list (or update if already present). Re-adding updates BlockedAt, Reason, Country, and Referer.

Request body:

json
{
  "ip": "198.51.100.20",
  "reason": "Spam",
  "country": "US",
  "referer": "https://example.com/page"
}

Only ip is required; reason, country, and referer are optional.

Response (201): { "blocked": true }

Errors (400): If the IP is already whitelisted, the API returns 400 with message: "IP cannot be both whitelisted and blocked. Remove it from the whitelist first."

Unblock an IP

DELETE /analytics/blocked-ips/:ip

Removes the IP from the block list. The IP must be URL-encoded in the path (e.g. %3A%3A1 for ::1).

Response: { "unblocked": true }


Whitelisted IPs

Whitelisted IPs are stored in the WhitelistedIPs table (migration 027). Requests from these IPs can skip certain checks (e.g. rate limit, block); use isIpWhitelisted(env, ip) in the worker where the bypass is implemented.

List whitelisted IPs

GET /analytics/whitelisted-ips?ip=...&limit=50&offset=0

Returns whitelisted IPs with optional pagination. Query ip or q filters by IP (substring match). limit (default 50, max 100) and offset (default 0) support paged navigation.

Response: { "whitelisted_ips": [ { "ip", "whitelisted_at", "note", "usage" }, ... ], "total": number }. usage is "test" (testing lead sending) or "internal" (actual lead sending from internal system).

Add an IP to the whitelist

POST /analytics/whitelisted-ips

Add an IP to the whitelist (or update if already present). Re-adding updates WhitelistedAt, Note, and Usage.

Request body:

json
{
  "ip": "203.0.113.10",
  "note": "Internal office",
  "usage": "internal"
}

Only ip is required. note is optional. usage is optional and must be "test" or "internal" (default "internal").

Response (201): { "whitelisted": true }

Errors (400): If the IP is already blocked, the API returns 400 with message: "IP cannot be both whitelisted and blocked. Unblock it first."

Remove an IP from the whitelist

DELETE /analytics/whitelisted-ips/:ip

Removes the IP from the whitelist. The IP must be URL-encoded in the path.

Response: { "removed": true }


Blocked IP leads (rejected at capture)

GET /analytics/blocked-ip-leads?limit=50&offset=0&ip=&period=all

Returns leads that were rejected at /lead.capture because the request IP was in BlockedIPs. Not stored in Leads/LeadSubmissions and not sent to Make.com; stored in BlockedIPLeads for audit.

ParamTypeDefaultDescription
limitinteger50Max 100
offsetinteger0Pagination
ip or qstring-Filter by IP (substring)
periodstringallQuick range: today, yesterday, past_7_days, past_14_days, past_30_days, all (filters by ReceivedAt)

Response: { "blocked_ip_leads": [ { "id", "ip", "received_at", "contact_email", "contact_name", "contact_phone", "contact_country_code", "project_name", "source", "referer", "payload_json", "site_key", "form_id" }, ... ], "total": number }. Each lead includes contact_country_code (e.g. +91) when the form submitted COUNTRYCODE so the dashboard can display phone with country code.

POST /analytics/blocked-ip-leads/bulk-delete

Soft-delete selected blocked-IP lead records by id (sets DeletedAt; rows are hidden from list but kept in the DB for analysis). Request body: { "ids": [ number, ... ] }. Maximum 100 ids per request. Response: { "deleted": number }.


Testing leads (from IP whitelisted with usage "test")

GET /analytics/testing-leads?limit=50&offset=0&ip=&period=all

Returns leads stored in TestingLeads (submissions from IPs whitelisted with usage "test"). Same query params and response shape as blocked-ip-leads (replace blocked_ip_leads with testing_leads).

POST /analytics/testing-leads/bulk-delete

Soft-delete selected testing lead records by id (sets DeletedAt; rows are hidden from list but kept in the DB for analysis). Request body: { "ids": [ number, ... ] }. Maximum 100 ids per request. Response: { "deleted": number }.


Update lead status

PATCH /analytics/leads/:id

Update a lead's pipeline status (for kanban). Requires API key.

Request body:

json
{ "status": "contacted" }

Allowed values: new, contacted, qualified, proposal, won, lost, fake (case-insensitive). Use fake to mark spam or fake leads; the dashboard "Fake / Spam" view shows only these.

Response (success):

json
{ "updated": true }

Errors: 400 if body is invalid or status missing; 404 if lead not found or status value invalid.


GET /analytics/leads/submissions (Lead submissions list for marketing)

Returns all rows from the D1 LeadSubmissions table, filtered by date. Used by the dashboard "Lead submissions" section so marketing can see how many leads were received for a day, yesterday, or within a range.

Query parameters:

ParameterDescription
periodtoday | yesterday | range (default: today)
date_fromFor period=range: start date (YYYY-MM-DD or ISO datetime).
date_toFor period=range: end date (YYYY-MM-DD or ISO datetime).
qSearch in contact email, phone, project, source, referer, and payload.
sortsubmitted_at | contact_email | project_name | source (default: submitted_at).
orderasc | desc (default: desc).
limitMax rows to return (default 50, max 100).
offsetPagination offset (default 0).

Response: { "submissions": [ { "submission_id", "lead_id", "submitted_at", "contact_name", "contact_email", "contact_phone", "project_name", "source", "referer", "cta_name", "is_primary_lead", "is_follow_up", "contact_ip", "is_ip_blocked" }, ... ], "total": number }. Each submission includes contact_ip and is_ip_blocked (1 when that submission’s IP is in the block list; see Blocked IPs).

Dates use UTC for "today" and "yesterday" boundaries.


Analytics Engine (lead submissions)

When CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_ENGINE_API_TOKEN are set on the Worker, the following endpoints query the Workers Analytics Engine lead_submissions dataset. All accept optional query params: days (default 30), project, source, engagement_label. If the Engine is not configured, each returns 503 with { "error": "Analytics Engine not configured" }.

EndpointDescription
GET /analytics/engine/submissions-dailySubmissions per day (array of { day, submissions })
GET /analytics/engine/by-projectSubmissions by project ({ project_name, submissions })
GET /analytics/engine/by-sourceSubmissions by source ({ source, submissions })
GET /analytics/engine/by-engagementBy engagement label + avg engagement score
GET /analytics/engine/overviewTotal submissions, avg suspicion, avg engagement, avg time to submit (seconds)
GET /analytics/engine/projectsDistinct projects with counts (for filter dropdowns)
GET /analytics/engine/sourcesDistinct sources with counts (for filter dropdowns)

See Analytics Engine for schema and SQL examples.


Configurable dropdown values for Source (e.g. Website, JustLead, Whatsapp), Country, Project, and City/Location. Used in form configuration and lead filtering. Admin API key only (marketing key returns 403).

EndpointMethodDescription
/analytics/dropdown-optionsGETList all options grouped by kind: { source: [], country: [], project: [], city: [] }. Optional `?kind=source
/analytics/dropdown-optionsPOSTCreate option. Body: `{ "kind": "source"
/analytics/dropdown-options/:idGETGet one option by id.
/analytics/dropdown-options/:idPATCHUpdate option. Body: { "value"?, "label"?, "sort_order"? }.
/analytics/dropdown-options/:idDELETEDelete option.

Dashboard: Settings → Location & project to manage options.


Error Responses

StatusMeaning
401Missing or invalid API key
400Invalid lead id or request body
404Unknown analytics endpoint or lead not found
405Method not allowed (only GET and PATCH for lead update)
500Analytics query failed
503Analytics Engine not configured (missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_ANALYTICS_ENGINE_API_TOKEN)