n8n Webhook
The catch-all push pipe for metrics that don't have a clean direct integration.
Why we have it
Some metrics make more sense to compute in n8n and push, rather than pull live every time the dashboard loads:
- Email and SMS counts (n8n already has the GHL conversation API workflow).
- Aggregated cost-per-lead / cost-per-sale calculations that join data from multiple sources.
- Social organic engagement — Meta and Instagram do not give us page-level metrics through the existing OAuth scope.
- Anything that requires a third-party scrape or non-OAuth API key.
The push endpoint
POST /api/metrics/external
Authorization: Bearer <N8N_WEBHOOK_SECRET>
Content-Type: application/json
{
"client_id": "uuid",
"metrics": {
"email_total": { "value": 1234, "formatted": "1,234", "change": 8.4 },
"sms_outbound": { "value": 312, "formatted": "312" },
"cost_per_lead": { "value": 18.5, "formatted": "$18.50" },
"roas": { "value": 4.2, "formatted": "4.2x" }
}
}The handler upserts each { key, value, formatted, change } into external_metrics keyed on (client_id, key).
Auth
The endpoint is not Supabase-authenticated — n8n cannot easily hold a Supabase session. Instead we use a shared secret in N8N_WEBHOOK_SECRET, sent as a bearer token. This secret must be rotated if it leaks.
/api/metrics/external for unauthenticated POSTs. Every byte of business logic in this route must check the bearer token before doing anything. The route also does not trust the client_id in the payload — it requires that client_id to exist in clients before upserting.Reading the cache
The dashboard reads via GET /api/metrics/external/[clientId]. This route returns the entire keyspace for that client, which is then merged with live n8n pull data and exposed to UI components via the ext() helper:
function ext(key: string): string | null {
return externalData?.[key]?.formatted ?? null
}
// Used in MetricCard:
{ name: 'Cost / Lead', value: ext('cost_per_lead'), source: 'Calc', loading: externalLoading }Standard keys
The dashboard reads from a fixed set of keys. Send these exact strings from n8n:
| Key | Used in |
|---|---|
| email_total / email_outbound / email_inbound / email_automated / email_response_rate | Email & SMS tab — Email card |
| sms_total / sms_outbound / sms_inbound / sms_automated / sms_response_rate | Email & SMS tab — SMS card |
| total_emails_sms_sent / total_conversations / calls_total | Email & SMS tab — Summary |
| cost_per_lead / cost_per_appointment / cost_per_quote / cost_per_sale / cost_per_acquisition | Calculated KPIs tab + Pipeline |
| roas | Paid Marketing tab — fallback for live calc |
| avg_ticket_value / lead_to_appointment_rate / lead_to_sale_rate | Pipeline Overview — fallback for live calc |
| reviews_total / reviews_5_stars | SEO/Local — fallback for GBP reviews |
| meta_engagement / meta_comments / meta_likes / meta_video_views / followers | Social Media tab |
| google_ads_avg_cpc / google_ads_cost_per_lead | Paid Marketing tab — fallback for live Google Ads |
Status pending
- The endpoint and cache table exist and are stable.
- Denver pushes a small subset of keys; most clients push nothing yet.
- The next milestone is wiring the Email/SMS counts for all active clients (deps on n8n workflow templates).