KPI rollout playbook
Use this sequence after you have Postgres, Google SSO, and the Skyfall dashboard running locally or on Vercel. The dashboard is Facebook-first: live spend and traffic from Meta, with GHL powering qualified and won counts for CPL, CPQL, and CPA.
What the dashboard shows
Open /dashboard after sign-in. If Facebook or GoHighLevel is not connected, a Get started banner walks you through setup. Pick a date range (or use 7d / 14d / 30d presets) — live tiles refresh from the APIs.
- Hero KPI strip — Spend, Leads, Qualified, Won, CPL, CPQL, and CPA at a glance (hover for definitions).
- Facebook ad spend — total spend, impressions, and clicks from the Meta Marketing API.
- Traffic metrics (expand) — clicks, CTR, CPC, and CPM from Insights.
- CPL — Facebook spend ÷ Meta lead actions (
lead/onsite_conversion.lead_grouped). - CPQL — spend ÷ GHL opportunities from
LEAD QUALIFIEDthrough your closed stage in the selected pipeline. - CPA — spend ÷ contacts in your configured win pipeline stages (set in Connections when you connect GHL).
- Conversion funnel (expand) — lead → qualified → won counts with cost per step.
- Campaign drill-down — campaigns → ad sets → ads with CPL, CPQL, and CPA per row. A ~ prefix means CPQL/CPA was estimated from campaign-level GHL data.
- Tracker — /dashboard/tracker for pixel, postback, GHL webhook, and affiliate events (optional; not required for top-level KPI tiles).
Sync Facebook stores a Meta snapshot in Postgres (sync history). Resync CRM refreshes GHL qualified/won counts. Live tiles work without Postgres; only sync history and tracker events need the database.
Connect Facebook & GHL
On the KPI dashboard, open Connections (right column on desktop). Complete both integrations — KPIs stay empty until Facebook and GoHighLevel are connected.
- Facebook — click Connect Facebook, authorize Meta, then pick your ad account. Reconnect from Connections if you see a token expiry banner.
- GoHighLevel — paste your Private Integration token, Location ID, pipeline name (e.g.
FIRST PIPELINE), and optional CPA win stages (comma-separated stage names). - Optional: expand Skyfall tracker in Connections for pixel, postback, and GHL webhook URLs. Optional: copy Meta URL params for sharper per-ad CPQL/CPA in drill-down.
Environment variables
Copy .env.example → .env.local in the project root.
AUTH_SECRET— random string, ≥ 32 characters (NextAuth signing).AUTH_URL—http://localhost:3000locally; production URL on Vercel.AUTH_GOOGLE_ID/AUTH_GOOGLE_SECRET— Google OAuth web client.DATABASE_URL— Postgres (local Docker or hosted). Rundocker compose up -d && npm run db:pushfor local.CRON_SECRET— bearer token forGET /api/cron/sync.META_ACCESS_TOKEN— long-lived token withads_read.META_AD_ACCOUNT_ID— ad account id (123456789oract_123456789).GHL_PRIVATE_INTEGRATION_TOKEN— GHL Private Integration token (required for CPQL/CPA).GHL_LOCATION_ID— sub-account Location ID.GHL_CLOSED_DEAL_TAG(optional) — comma-separated won tags; defaults towon (completed),won (closed).
Google SSO
- Google Cloud Console → Credentials → Create OAuth client ID → Web application.
- Authorized JavaScript origins:
http://localhost:3000and your production origin. - Redirect URIs:
/api/auth/callback/googleon each origin. - Only @esntlsystems.com workspace accounts can sign in (enforced in
src/auth.ts).
Meta Ads (Facebook KPIs)
- In the dashboard, use Connections → Connect Facebook (OAuth). Pick the ad account that matches the campaigns you report on.
- For local dev or server-side env fallback, set
META_APP_ID,META_APP_SECRET, and connect via the UI — tokens are stored per user in Postgres. - Live range tiles call Meta Insights with your From/To dates. If tiles show a token error, use Reconnect Facebook in Connections or the expiry banner's refresh action.
- Production runs a daily cron at
/api/cron/meta-tokensto refresh long-lived tokens before they expire.
GoHighLevel (CPQL & CPA)
- GHL → Settings → Integrations → Private Integration → copy token into Connections (or
GHL_PRIVATE_INTEGRATION_TOKENfor env-based dev). - Copy your sub-account Location ID and pipeline name (e.g.
FIRST PIPELINE). - The token needs scopes for contacts search, opportunities, and tags.
- CPQL counts opportunities from stage
LEAD QUALIFIEDthrough your closed/won stage in the configured pipeline, filtered by the dashboard date range. - CPA counts opportunities in your CPA win stages — enter comma-separated stage names in Connections (e.g.
Won, LEAD VEHICLE PICKED UP WITH CLAIM #). If unset, Skyfall uses default win-stage name matching. - Use Resync CRM on the dashboard after bulk GHL stage changes. Per-ad CPQL/CPA in drill-down improves when landing URLs include Meta
campaign_id/ad_idparams (copy templates in Connections).
Skyfall tracker (optional)
Tracker ingest is optional — top-level CPL, CPQL, and CPA use Meta + GHL directly. Use the tracker when you want a server-side event log or affiliate/postback attribution.
- In Connections → Skyfall tracker, copy your pixel snippet, track API URL, postback URL, and GHL workflow webhook URL.
- View events on /dashboard/tracker — filter by date, event type, and source; expand rows for full payloads.
- Postback and GHL webhook events can improve per-ad CPQL/CPA in campaign drill-down when GHL contact attribution includes
campaign_id/ad_id.
Cron on production
GET /api/cron/sync— hourly Meta snapshot to Postgres. Header:Authorization: Bearer CRON_SECRET.GET /api/cron/meta-tokens— daily Facebook token refresh. SameCRON_SECRETheader.- Never expose
CRON_SECRETin the browser or client-side code. - Local test:
curl -sS -H "Authorization: Bearer $CRON_SECRET" "http://localhost:3000/api/cron/sync"
Deploy to skyfall.agency (Vercel + Cloudflare)
Skyfall is one Next.js app: marketing at skyfall.agency, dashboard at dashboard.skyfall.agency. Keep Cloudflare for DNS on your domain; host the app on Vercel (API routes, Google login, Prisma, hourly cron). Do not use Docker on Vercel — use hosted Postgres instead.
Before deploy, run locally: npm run verify:production (checks env vars; production DATABASE_URL must not be localhost).
A. Production database (Neon)
- Create a project at neon.tech and copy the Postgres connection string (
sslmode=require). - One-time schema push from your Mac:
DATABASE_URL="postgresql://…neon…" npx prisma db push - Set the same
DATABASE_URLin Vercel → Settings → Environment Variables.
B. Vercel project
- Push this repo to GitHub (root folder:
dashboard-skyfallif monorepo). - vercel.com → Import repo → set Root Directory to
dashboard-skyfallif needed. - Add all env vars from
.env.localfor Production (never commit secrets). SetAUTH_URLtohttps://dashboard.skyfall.agency. - Include your long-lived
META_ACCESS_TOKEN— not App Secret (App Secret stays local fornpm run meta:long-tokenonly).
C. Google OAuth (both domains)
- Authorized JavaScript origins:
https://skyfall.agency,https://dashboard.skyfall.agency - Redirect URIs:
https://skyfall.agency/api/auth/callback/google,https://dashboard.skyfall.agency/api/auth/callback/google
D. Cloudflare DNS → Vercel
In Cloudflare for skyfall.agency, add domains in Vercel first, then create records:
- skyfall.agency — CNAME
@or use Vercel's A record → target shown in Vercel Domains (oftencname.vercel-dns.com) - dashboard.skyfall.agency — CNAME
dashboard→ same Vercel project - SSL/TLS mode: Full (not Flexible) when proxying to Vercel
Cloudflare Workers/Pages can stay for other skyfall.agency assets later; this app runs on Vercel because it needs Next.js server routes, NextAuth, Prisma, and cron.
E. Verify production
https://skyfall.agency— marketing site loadshttps://dashboard.skyfall.agency/login— Google sign-in workshttps://dashboard.skyfall.agency/dashboard— KPI dashboard shows spend after connecting Facebook- Vercel → Cron Jobs →
/api/cron/sync(hourly) and/api/cron/meta-tokens(daily) return 200