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:
{ "error": "API key required. Use Authorization: Bearer <key> or X-API-Key header." }{ "error": "Invalid API key" }Setting the API Key
| Environment | Method |
|---|---|
| Development | ANALYTICS_API_KEY in wrangler.toml [vars] |
| Production | wrangler secret put ANALYTICS_API_KEY --env production |
Overview
GET /analytics/overview
High-level summary stats across the entire dataset.
Response:
{
"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.
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
days | integer | 30 | 90 | Number of days to look back |
Response:
[
{
"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.
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 20 | 100 | Number of pages to return |
Response:
[
{
"page": "https://example.com/home",
"views": 234,
"unique_sessions": 180
}
]Geo Distribution
GET /analytics/geo
Country-level session distribution.
Response:
[
{
"country": "US",
"sessions": 500,
"unique_visitors": 420
}
]Engagement Distribution
GET /analytics/engagement
Engagement score distribution in 5 buckets for completed sessions.
Response:
[
{ "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:
[
{ "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:
{
"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:
{
"total_objects": 15234,
"estimated_sessions": 1200
}Leads List
GET /analytics/leads?limit=25&offset=0&q=search
Paginated lead list for dashboard CRM views.
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 25 | 100 | Number of leads to return |
offset | integer | 0 | - | Pagination offset |
q | string | - | - | Search term (name, email, phone, project, visitor_id) |
status | string | - | - | Filter by pipeline stage: new, contacted, qualified, proposal, won, lost, fake |
has_follow_ups | boolean | - | - | When true, only return leads that have at least one follow-up submission (returning visitors) |
seen | boolean | - | - | 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. |
sort | string | newest | - | 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.
{
"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.
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 15 | 50 | Number 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:
{
"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:
{
"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:
{
"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.
| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Max 100 |
offset | integer | 0 | Pagination |
ip or q | string | - | Filter by IP (substring) |
period | string | all | Quick 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:
{ "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):
{ "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:
| Parameter | Description |
|---|---|
period | today | yesterday | range (default: today) |
date_from | For period=range: start date (YYYY-MM-DD or ISO datetime). |
date_to | For period=range: end date (YYYY-MM-DD or ISO datetime). |
q | Search in contact email, phone, project, source, referer, and payload. |
sort | submitted_at | contact_email | project_name | source (default: submitted_at). |
order | asc | desc (default: desc). |
limit | Max rows to return (default 50, max 100). |
offset | Pagination 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" }.
| Endpoint | Description |
|---|---|
GET /analytics/engine/submissions-daily | Submissions per day (array of { day, submissions }) |
GET /analytics/engine/by-project | Submissions by project ({ project_name, submissions }) |
GET /analytics/engine/by-source | Submissions by source ({ source, submissions }) |
GET /analytics/engine/by-engagement | By engagement label + avg engagement score |
GET /analytics/engine/overview | Total submissions, avg suspicion, avg engagement, avg time to submit (seconds) |
GET /analytics/engine/projects | Distinct projects with counts (for filter dropdowns) |
GET /analytics/engine/sources | Distinct sources with counts (for filter dropdowns) |
See Analytics Engine for schema and SQL examples.
Dropdown options (Location & project)
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).
| Endpoint | Method | Description |
|---|---|---|
/analytics/dropdown-options | GET | List all options grouped by kind: { source: [], country: [], project: [], city: [] }. Optional `?kind=source |
/analytics/dropdown-options | POST | Create option. Body: `{ "kind": "source" |
/analytics/dropdown-options/:id | GET | Get one option by id. |
/analytics/dropdown-options/:id | PATCH | Update option. Body: { "value"?, "label"?, "sort_order"? }. |
/analytics/dropdown-options/:id | DELETE | Delete option. |
Dashboard: Settings → Location & project to manage options.
Error Responses
| Status | Meaning |
|---|---|
401 | Missing or invalid API key |
400 | Invalid lead id or request body |
404 | Unknown analytics endpoint or lead not found |
405 | Method not allowed (only GET and PATCH for lead update) |
500 | Analytics query failed |
503 | Analytics Engine not configured (missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_ANALYTICS_ENGINE_API_TOKEN) |