Documentation
Integrations

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

text
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.

Don't widen the allowlist
The middleware allowlists /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:

ts
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:

KeyUsed in
email_total / email_outbound / email_inbound / email_automated / email_response_rateEmail & SMS tab — Email card
sms_total / sms_outbound / sms_inbound / sms_automated / sms_response_rateEmail & SMS tab — SMS card
total_emails_sms_sent / total_conversations / calls_totalEmail & SMS tab — Summary
cost_per_lead / cost_per_appointment / cost_per_quote / cost_per_sale / cost_per_acquisitionCalculated KPIs tab + Pipeline
roasPaid Marketing tab — fallback for live calc
avg_ticket_value / lead_to_appointment_rate / lead_to_sale_ratePipeline Overview — fallback for live calc
reviews_total / reviews_5_starsSEO/Local — fallback for GBP reviews
meta_engagement / meta_comments / meta_likes / meta_video_views / followersSocial Media tab
google_ads_avg_cpc / google_ads_cost_per_leadPaid 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).