Skip to main content

Form Intercept

Audience: Sales, management, support, QA, engineering · Where in app: Configured at Admin → Identity → Config (capture_website_forms); runs in the embedded widget; results at Admin → Identity → Form Submissions · Plan availability: All plans (super-admin toggle, on by default)

The widget passively watches form submissions on the customer's website and extracts identity fields (name/email/phone/company) for the Identity Graph. It never modifies or blocks the form — it only listens. A first-party form submission is a direct verification trigger: the moment a visitor submits a form on the customer's own domain, the system trusts that identity.

This is why the AI can know a returning visitor's name even though they never typed it into chat — a form captured it on a previous visit.

What it does

  • Listens for form submissions across native HTML forms, SPA submit buttons, and major embedded form providers.
  • Extracts only identity-relevant fields (name, email, phone, company).
  • Sends the captured data to the backend, which resolves/updates the person and marks the session verified.
  • Records an audit row (FormSubmissionModel / the profile's form_submissions[]), and queues CRM sync if HubSpot/GHL is connected.
  • Is invisible to the visitor and never interferes with the form's own submission — even if the destination form fails, the data is still captured.

How it works

What it captures

FieldHow it's recognized
Emailtype="email", or field name/label contains "email", or value matches an email regex
Phonetype="tel", or name/label contains "phone" / "tel" / "mobile" / "cell"
Namefull_name, or first_name + last_name combined
Company / Businessname/label contains "company" / "organization" / "employer" / "workplace" / "business"

What's explicitly skipped (privacy)

  • <input type="password">
  • <input type="hidden">
  • Credit card / CVV / SSN / routing-number fields
  • File uploads
  • Out-of-viewport (decorative / off-screen) fields

Supported form types

ProviderDetection
Native HTML <form>submit event listener
SPA submit buttonsClick on [type=submit] / [role=button] adjacent to form-like fields
HubSpot Forms (hsforms.net)hsFormCallback / onFormSubmit
Calendly (calendly.com)calendly.event_scheduled (the booking itself is also a direct trigger)
GoHighLevel (leadconnectorhq.com, gohighlevel.com, white-label domains)V1/V2 forms, lc-form-submit event, contact-id payloads; dynamic origin discovery handles white-label GHL
Typeform (typeform.com)embed submit messages
Jotform (jotform.com)postMessage events
Mailchimp (list-manage.com)submit interception

Widget pre-chat form (direct trigger)

The widget's own pre-chat contact form is a first-class verification path (not interception): it posts POST /api/intake with source: 'form' (widget/src/stores/widget.ts verifyPreChatContact → backend formSubmissionRoutes.jsFormSubmissionController@store) — a direct trigger (verify_form). The localStorage key kk_pre_chat_done gates showing the form once; on every widget load the saved identity (name/email/phone from the widget's own storage) is re-POSTed to /api/intake so a returning device re-asserts its verification — the backend dedupes. The in-chat contact-info flow writes source: 'chat_form' separately.

Transport (three fallbacks)

When a form is detected the payload is sent over the first available transport:

  1. Widget socket (preferred — already connected)
  2. navigator.sendBeacon (survives page navigation)
  3. fetch with keepalive: true (last resort)

All three use a text/plain JSON body — this avoids ad-blockers that block application/json requests and sidesteps a CORS preflight.

What happens server-side

  1. The identity/verifier pipeline receives the form submission with source: 'form'.
  2. Match keys (sha256 of normalized email/phone) are computed.
  3. PersonResolverService finds or creates a Person, and ensures the PersonProfile for this tenant.
  4. The captured fields update the Person/PersonProfile.
  5. The session is marked verified (verify_form — a direct trigger).
  6. A form-submission record is appended (audit log; deduped by a submission_hash so a widget re-fire of the same submission isn't re-resolved).
  7. CRM sync is queued if HubSpot/GHL is connected.
  8. Lead score is recalculated (a form submission is a scoring signal).

Configuration & options

Super-admin owned:

  • Global: IdentityGraphDefaultsModel.capture_website_forms (default true).
  • Per-company: companies.identity_graph_config.capture_website_forms (when identity_graph_use_custom_config = true).

When off, the form-intercept script doesn't run for that tenant.

For support/QA: captured forms are browsable at Admin → Identity → Form Submissions, filterable by form_type, source, and company_id. Each form carries a form_type (contact_info / pre_call / booking / custom) and a source (chat / call / booking_flow / auto_trigger).

Common support questions

  • "Why does the AI know my returning visitor's name?" — a form was submitted previously on your site; the intercept captured it. The PersonProfile now has identity, so the next verified session shows the name.
  • "Why didn't the intercept catch my custom form?" — likely a heavily SPA-fied form with a non-standard submit. Reach out — detection can be extended.
  • "Will it conflict with my form library?" — no. It's a passive listener; it never modifies or submits anything. Safe alongside HubSpot/Marketo/Pardot/etc.

Behaviors & edge cases

  • Form submit verifies; chat-typed identity does not. A form is a first-party direct trigger (verify_form); an email parsed out of a chat message stays a claim until something stronger confirms it. See Verification Policy.
  • Dedup. A repeated identical submission (widget re-fire, or the same form filled twice within 24h) is collapsed via the submission_hash idempotency key rather than re-resolved or double-counted.
  • Capture survives form failure. Because the intercept is a separate listener, the data is captured even if the site's own form POST errors out.
  • Junk is rejected upstream. The resolver validates field shape (no date-as-phone, no "yes"-as-email) and screens names via app/lib/NameModeration.js (screen() — obscenity + denylists) before persisting; rejected names surface as "Unknown Visitor".
  • Per-tenant isolation. Captured data lands on the PersonProfile for that company only.

Plan & limits

Available on all plans; on by default. No per-form charge. It's an identity signal, not a metered feature. Disable per tenant for compliance reasons via the capture_website_forms override.

Technical implementation

  • Widget: the form-intercept script (passive listeners + the three-transport sender) ships in the embedded widget bundle. (Exact widget source is in the built bundle; behavior here is grounded in the Identity-Graph KB and the server-side resolver — verify widget-internal specifics against the widget source if changing them.)
  • Server: PersonResolverService.resolve({ ..., source: 'form' })_decideTrust() returns verify_form; FormSubmissionSchema on PersonProfileModel stores the audit row with submission_hash.
  • Collections: person_profiles.form_submissions[]; CRM sync via the CRM enrichment/sync services.

See Identity Graph (subsystem) for the resolution chain.

What Nox can tell you

  • Whether form capture is on (globally / for a tenant)
  • Which form sources have produced captures, and which forms most often
  • Recent submissions for a given person