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'sform_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
| Field | How it's recognized |
|---|---|
type="email", or field name/label contains "email", or value matches an email regex | |
| Phone | type="tel", or name/label contains "phone" / "tel" / "mobile" / "cell" |
| Name | full_name, or first_name + last_name combined |
| Company / Business | name/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
| Provider | Detection |
|---|---|
Native HTML <form> | submit event listener |
| SPA submit buttons | Click 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.js → FormSubmissionController@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:
- Widget socket (preferred — already connected)
navigator.sendBeacon(survives page navigation)fetchwithkeepalive: 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
- The identity/verifier pipeline receives the form submission with
source: 'form'. - Match keys (
sha256of normalized email/phone) are computed. PersonResolverServicefinds or creates a Person, and ensures the PersonProfile for this tenant.- The captured fields update the Person/PersonProfile.
- The session is marked verified (
verify_form— a direct trigger). - A form-submission record is appended (audit log; deduped by a
submission_hashso a widget re-fire of the same submission isn't re-resolved). - CRM sync is queued if HubSpot/GHL is connected.
- 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(whenidentity_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_hashidempotency 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()returnsverify_form;FormSubmissionSchemaonPersonProfileModelstores the audit row withsubmission_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
Related
- Verification Policy — why a form verifies instantly
- Identity Graph Overview
- Cross-Session Context — where captured forms appear in the AI prompt
- Person vs Profile — where form data is stored
- CRM Integration
- Identity Graph (engineering subsystem)