Instant Quote · Admin

Docs

Project documentation rendered straight from /docs.

docs/INTEGRATIONS.md

Integrations

Last updated: May 19, 2026 at 4:17 PM ET

Third-party services the tool talks to, and the lead-flow plumbing that holds them together.

Related docs: README, ARCHITECTURE.

Lead submission flow

End-to-end:

  1. Homeowner submits the form on /<clientSlug>/instant-quote.
  2. POST /api/quotes validates the payload and loads the client + pricing + integrations via loadClientBySlug.
  3. Server calls calculateEstimate(...) to compute the band.
  4. Lead row is inserted first into leads (PII columns).
  5. Quote row is inserted second into quotes, referencing the lead, with the address, roof inputs, estimate band, and cached monthly payment. The quote_number and public_token are generated by Postgres column defaults so they're atomic with the insert.
  6. After both rows are saved, the server fires the external delivery layer in parallel:
    • Zapier (recommended) — audited, retryable, with per-lead delivery status. Skipped automatically for test leads. See "Zapier lead delivery" below.
    • Legacy webhooks (Forminit / GHL / HubSpot) — fire-and-forget for clients already on them. No audit trail, no retry. See "Legacy webhooks" below.
  7. The API responds with { publicToken, quoteNumber, low, mid, high, monthlyPayment }.
  8. The browser redirects to /<clientSlug>/thank-you?quote=<publicToken>.

The database is the source of truth. A failed delivery never fails the user-facing response — the lead row + quote row are already saved, and the admin can retry Zapier from the submission detail page. If Zapier and the legacy webhooks all go down at the same time, the lead is still safe in /admin/submissions.

Zapier lead delivery

Zapier is the first-class external delivery channel for the instant quote tool. Unlike the legacy fire-and-forget webhooks (Forminit / GHL / HubSpot), every Zapier delivery attempt is audited in the database so the admin can see exactly what happened to each lead and retry failures manually.

Rule of the system

The database is the source of truth. Every lead is saved in leads + quotes BEFORE Zapier delivery is attempted. A failed Zapier delivery NEVER fails the user-facing form submission. The thank-you page and submission record both work either way.

Setup (admin)

In the client edit page → Integrations tab → Zapier lead delivery subsection:

  1. Enable Zapier delivery — checkbox. When off, the system never sends to Zapier even if a URL is set.
  2. Zapier webhook URL — paste the Catch Hook URL from your Zap. Must be https://. Non-https URLs are silently rejected server-side (saved as null).
  3. Webhook label (optional) — internal name for the Zap. Shown next to the status in admin.
  4. Test Zapier Webhook — sends a sample payload to the currently-typed URL (no need to save first). Logs a row to delivery_attempts with is_test_payload=true so the per-submission timeline filters it out.

Creating a Zapier Catch Hook

  1. In your Zap, choose Webhooks by ZapierCatch Hook.
  2. Copy the unique webhook URL Zapier provides.
  3. Paste into the admin "Zapier webhook URL" field above.
  4. Hit "Test Zapier Webhook" — Zapier will receive a sample instant_quote_test payload it can use to map fields.
  5. Build the rest of your Zap from there.

Use one Catch Hook per client unless you intentionally want to route multiple clients into the same Zap.

Payload

Stable JSON sent on every successful real submission. Adding new keys later is safe; removing or renaming is not (downstream Zaps would break).

{
  "event": "instant_quote_submission",
  "client": { "id": "...", "company_name": "...", "slug": "..." },
  "lead":   { "first_name": "...", "last_name": "...", "full_name": "...", "phone": "...", "email": "..." },
  "property": {
    "street_address": "...", "city": "...", "state": "...", "zip": "...",
    "full_property_address": "..."
  },
  "quote": {
    "quote_number": "Q-001234",
    "estimate_low": 18000, "estimate_high": 22000, "estimate_midpoint": 20000,
    "monthly_financing_estimate": 220,
    "roof_square_feet": 2300,
    "selected_material": "asphalt_arch",
    "stories": 2, "roof_age": "10_20", "roof_complexity": "moderate"
  },
  "qualification": { "timeline": "1_3m", "financing_interest": "maybe" },
  "tracking": {
    "source_url": "...", "page_slug": "...", "referrer": "...",
    "utm_source": "...", "utm_medium": "...", "utm_campaign": "...",
    "utm_content": "...", "utm_term": "...",
    "gclid": "...", "fbclid": "..."
  },
  "submission": {
    "id": "...", "submitted_at": "2026-05-19T...", "is_test": false, "status": "new"
  }
}

The test-button payload is identical but with event: "instant_quote_test" and submission.is_test: true so Zaps can filter it out.

Status semantics

quotes.zapier_status is the latest snapshot; the full audit log lives in delivery_attempts.

Status What it means
null (Not configured) No delivery attempted yet — Zapier disabled, no URL, or migration 016 not applied.
sent Zapier accepted the latest attempt (HTTP 2xx).
failed Latest attempt got a non-2xx response or a network error. Retry from the detail page.
skipped Attempt was intentionally not sent. skip_reason is one of test_lead, disabled, no_url.

The Submissions list (/admin/submissions) has a Zapier filter dropdown (Any / Sent / Failed / Skipped / Not configured) and shows a small Zap: … chip on each row.

Retry behaviour

From the submission detail page, click Retry delivery in the Zapier section. The retry:

  • Uses the CURRENTLY-SAVED client webhook URL — so if the admin fixed a typo in the URL between attempts, the retry uses the new URL.
  • Creates a NEW delivery_attempts row with is_manual_retry = true.
  • Updates quotes.zapier_status / zapier_last_attempt_at with the new result.
  • For test leads, retry is the only way to force a delivery — automatic delivery for is_test = true is always skipped.

The retry button is hidden when the latest status is sent (no duplicate sends unless you intentionally retry from a sent state — in which case the UI still surfaces the button as long as Zapier is configured and the admin can re-fire if needed).

Test-lead skip

Submissions with is_test = true are never delivered automatically. They get a skipped attempt row with skip_reason = 'test_lead'. The submission detail page surfaces a note:

"This is a test submission. External delivery is skipped by default."

To force a test lead through (e.g. to validate a Zap end-to-end), use the retry button — it passes isManualRetry=true which bypasses the test-lead skip.

Troubleshooting

  • "Not configured" — verify the Enable Zapier delivery toggle is on AND the webhook URL is filled in. Also verify migration 016 has been applied; until then the system can't persist the new fields.
  • failed with HTTP 410 Gone — the Zap was paused. Re-enable it in Zapier and retry.
  • failed with timeout — Zapier is slow; the request was aborted after 5 s. The lead is still safe in the database; retry from the detail page.
  • skipped with disabled — the admin's "Enable Zapier delivery" toggle is off. Turn it on, then retry past submissions if you want them backfilled.
  • skipped with no_url — no webhook URL on the client_integrations row.

Duplicate-delivery warnings

  • Don't run Zapier AND a legacy webhook (Forminit / GHL / HubSpot) for the same downstream CRM — the lead will land twice.
  • Hitting Retry delivery on a submission whose latest status is sent would create a second send. The UI hides the button in that case to prevent accidental re-sends, but the backend doesn't enforce idempotency. If you intentionally need to re-send, ask Zapier (or the downstream system) to dedupe by submission.id.

Recommended path: Zapier

Zapier is the recommended lead-delivery channel for every new client. See the previous section for setup, payload, retry, and status behaviour. The legacy fields below are kept for clients that were set up before Zapier landed; we don't remove them because they're still active in the live flow — but they should not be combined with Zapier on the same client.

Legacy webhooks (still active, deprecated for new setups)

Per-client legacy webhook fields live on the client_integrations row. The admin Integrations tab surfaces them under a collapsed "Legacy webhooks" section with a deprecation banner; the collapse auto-opens when any URL is set so existing setups stay visible.

Field Status Purpose
forminit_webhook_url Active Forminit endpoint (or any generic webhook). Fire-and-forget.
ghl_webhook_url Active GoHighLevel inbound webhook. Fire-and-forget.
hubspot_webhook_url Active HubSpot inbound webhook. Fire-and-forget.
notification_email Inactive Stored but no code path consumes it. Reserved for a future email notifier.
notification_phone Inactive Stored but no code path consumes it. Reserved for a future SMS notifier.

How the active URLs fire

When a quote is saved, forwardToIntegrations POSTs JSON

{
  "client": { "id": "...", "slug": "...", "name": "..." },
  "lead":   { /* full leads row, includes PII */ },
  "quote":  { /* full quotes row */ }
}

to every configured legacy URL in parallel with the Zapier delivery. Each request has a 5-second timeout. Non-2xx responses and timeouts are logged but swallowed — same fire-and-forget behaviour as before Zapier landed. No audit trail. No retry. No delivery status per submission. Code lives at app/api/quotes/route.js#forwardToIntegrations / #postWebhook.

Duplicate-delivery risk with Zapier

If a client has a legacy ghl_webhook_url set AND a Zapier Zap that also pushes to GHL, the lead lands in GHL twice — once from each pipeline. Neither side dedupes by submission.id. Pick one delivery path per downstream system:

  • Recommended: clear the legacy URL, send through Zapier only. Tracked, retryable.
  • Or keep the legacy URL and disable Zapier for that downstream. You lose the audit trail but avoid the double-send.

The Integrations tab's legacy section calls this out explicitly with an amber warning whenever the section is expanded.

FORMINIT_WEBHOOK_URL env var

The original single-tenant build also documented a top-level FORMINIT_WEBHOOK_URL env var. That env var is no longer used by the current quote route — the route reads webhook URLs from the client's row instead. Safe to remove from your deployment config.

Notification email / phone

Stored on the row but currently consumed by nothing. The admin form labels them as such ("Stored but not sent anywhere today."). When we add an email/SMS notifier later, the natural hook is right after the quote insert in app/api/quotes/route.js, alongside forwardToIntegrations / deliverZapier.

Google Tag Manager

GTM is the only client-side analytics integration baked into the template. Setup:

  • Admin enters the container ID (GTM-XXXXXXX) in Integrations → GTM Container ID.
  • Validated server-side against the regex ^GTM-[A-Z0-9]+$ and normalised to uppercase before saving.
  • Rendered by components/GoogleTagManager.jsx, which emits the standard GTM bootstrap inline (not via next/script) so it runs during HTML parsing — that's what Tag Assistant looks for.

The component renders nothing when the ID is missing or invalid.

Meta Pixel, Google Ads, etc.

The template does not ship Meta Pixel or Google Ads conversion tags directly. The intended path is always GTM:

  • Add the Meta Pixel base code as a GTM tag in the client's container.
  • Add the Google Ads conversion tag the same way.
  • Set up triggers off the standard page-view events fired automatically, or off custom dataLayer.push events.

The form does not currently push custom events to dataLayer. If you add one (e.g. quote_submitted), push it after the /api/quotes response succeeds and before window.location.assign(...) in components/QuoteForm.jsx#submit. Be aware that the thank-you page is a separate full-page load — a tag firing on quote_submitted and a separate tag firing on the thank-you page's pageview can both fire for the same conversion. See Duplicate conversions below.

CRM and email

There is no first-party CRM / email integration. Two paths exist:

  • Push to the contractor's CRM via webhook. The most common path — GoHighLevel and HubSpot already have inbound webhook endpoints configured per-client.
  • Forminit forwarding. Generic webhook URL that re-fans-out to email + CRM on the contractor's side.

If you ever want to send an email directly from this app (e.g. a confirmation email to the homeowner), pick a provider, drop the API call alongside forwardToIntegrations, and key the credentials off an env var (so it's not per-client) or a new client_integrations column (if you want per-client credentials).

EagleView Property Data

EagleView Property Data v2 is used to prefetch the roof square footage while the user is filling out the form. It is server-only; the browser never talks to EagleView.

Flow

  1. User picks a Google Places suggestion on step 1.
  2. The form fires POST /api/eagleview/prefetch with the slug + the formatted address + structured address components.
  3. The route calls lib/eagleview.js#fetchPropertyData, which:
    • exchanges client_id/client_secret for an OAuth token (cached),
    • POSTs the address to {apiBase}{propertyPath},
    • polls the result URL every 2 seconds, up to a 25-second budget, until it gets a 200 with the finished payload.
  4. extractRoofMetrics(raw) sums up roof area across structures and picks the predominant pitch from the largest structure.
  5. The route returns { success, source: "eagleview" | "unavailable", roofAreaSqFt, predominantPitch }.
  6. The form fills the roof-area input and shows the "measuring" UI for at least 2.2 seconds (so it feels intentional). At 25 seconds it gives up and lets the user type manually.

A user who edits the auto-filled number gets roofAreaSource: "fallback" sent on submit, so we know the EagleView number was overridden.

Config (env vars)

See README for the full list. The defaults target the EagleView v2 sandbox host (geographically limited, e.g. Omaha). Flip EAGLEVIEW_API_BASE to the production host when ready.

Sandbox-only test endpoints

  • POST /api/eagleview/test — bearer-token-gated (EAGLEVIEW_TEST_SECRET). Standalone proof-of-concept, used in development.
  • POST /api/admin/eagleview-test — admin-token-gated wrapper around the same logic, used by the /admin/eagleview-test page so admins can inspect raw responses without sharing the standalone secret.

Logging raw responses

Set EAGLEVIEW_LOG_RESPONSES=1 to persist raw EagleView responses to the eagleview_property_lookups table (migration 006). Useful when debugging "why is roof area null for this address". Off by default.

Duplicate conversions — where they can happen

A few places to watch:

  1. Webhook + GTM. If a contractor configures both a CRM webhook and a GTM tag that pushes the same lead to their CRM, you'll double-count. Pick one source per CRM.
  2. Thank-you page pageviews. Every page load fires GTM's default pageview. If you set up a Google Ads conversion that triggers on the thank-you URL and a separate conversion that fires on a custom quote_submitted event, they'll both count for the same submission.
  3. Form retries. If a user submits and gets a non-2xx, then retries, both attempts hit the webhook. The server doesn't deduplicate. If your CRM doesn't have idempotency on its inbound webhook, you can get duplicate contacts.
  4. Multiple webhooks configured per client. Forminit + GHL + HubSpot all configured means three downstream copies of every lead. That's usually intentional, but be explicit with the contractor.

Important warnings — don't break lead flow

  • Lead and quote rows are inserted before any webhook fires. This is non-negotiable. If you refactor app/api/quotes/route.js, keep that order: validate → calculate → insert lead → insert quote → fan-out integrations.
  • Webhook failures must never throw. postWebhook already swallows every error path; keep it that way. Future webhooks (e.g. a Slack ping) should go through the same helper or follow the same pattern.
  • The 5-second webhook timeout is deliberate. If a webhook hangs, the user's browser is still waiting on /api/quotes to respond. Don't raise the timeout without thinking about UX.
  • Lead PII must never appear on the thank-you page. The thank-you URL is reachable by anyone with the link. loadPublicQuote in lib/quote.js explicitly selects only safe columns. Don't add lead fields to that select list.
  • ADMIN_TOKEN is the only thing standing between random visitors and the admin pages. Keep it long, keep it secret, and rotate it if anyone leaves the team.
  • Don't introduce client-side state that depends on the redirect completing. Browsers can drop in-flight fetch calls when the page navigates. We use window.location.assign(...) and wait for the server response before redirecting; if you change this to client-side navigation, make sure the response (and any tracking events) have flushed first.
  • Server logs go to Vercel. Sensitive payloads (Forminit body, EagleView raw response) get logged in some places. Audit log lines before adding new ones.