docs/INTEGRATIONS.mdIntegrations
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:
- Homeowner submits the form on
/<clientSlug>/instant-quote. POST /api/quotesvalidates the payload and loads the client + pricing + integrations vialoadClientBySlug.- Server calls
calculateEstimate(...)to compute the band. - Lead row is inserted first into
leads(PII columns). - Quote row is inserted second into
quotes, referencing the lead, with the address, roof inputs, estimate band, and cached monthly payment. Thequote_numberandpublic_tokenare generated by Postgres column defaults so they're atomic with the insert. - 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.
- The API responds with
{ publicToken, quoteNumber, low, mid, high, monthlyPayment }. - 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+quotesBEFORE 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:
- Enable Zapier delivery — checkbox. When off, the system never sends to Zapier even if a URL is set.
- Zapier webhook URL — paste the Catch Hook URL from your
Zap. Must be
https://. Non-https URLs are silently rejected server-side (saved asnull). - Webhook label (optional) — internal name for the Zap. Shown next to the status in admin.
- Test Zapier Webhook — sends a sample payload to the
currently-typed URL (no need to save first). Logs a row to
delivery_attemptswithis_test_payload=trueso the per-submission timeline filters it out.
Creating a Zapier Catch Hook
- In your Zap, choose Webhooks by Zapier → Catch Hook.
- Copy the unique webhook URL Zapier provides.
- Paste into the admin "Zapier webhook URL" field above.
- Hit "Test Zapier Webhook" — Zapier will receive a sample
instant_quote_testpayload it can use to map fields. - 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_attemptsrow withis_manual_retry = true. - Updates
quotes.zapier_status/zapier_last_attempt_atwith the new result. - For test leads, retry is the only way to force a delivery
— automatic delivery for
is_test = trueis 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 deliverytoggle 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. failedwith HTTP 410 Gone — the Zap was paused. Re-enable it in Zapier and retry.failedwith timeout — Zapier is slow; the request was aborted after 5 s. The lead is still safe in the database; retry from the detail page.skippedwithdisabled— the admin's "Enable Zapier delivery" toggle is off. Turn it on, then retry past submissions if you want them backfilled.skippedwithno_url— no webhook URL on theclient_integrationsrow.
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
sentwould 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 bysubmission.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 vianext/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.pushevents.
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
- User picks a Google Places suggestion on step 1.
- The form fires
POST /api/eagleview/prefetchwith the slug + the formatted address + structured address components. - 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.
extractRoofMetrics(raw)sums up roof area across structures and picks the predominant pitch from the largest structure.- The route returns
{ success, source: "eagleview" | "unavailable", roofAreaSqFt, predominantPitch }. - 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-testpage 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:
- 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.
- 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_submittedevent, they'll both count for the same submission. - 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.
- 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.
postWebhookalready 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/quotesto 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.
loadPublicQuoteinlib/quote.jsexplicitly selects only safe columns. Don't add lead fields to that select list. ADMIN_TOKENis 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
fetchcalls when the page navigates. We usewindow.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.