Blog / Engineering / No.008

Detecting a contact form without breaking anything

A Door is a usable public contact route. Finding one on an arbitrary website is platform archaeology, a scoring function, and two guards that have to fail closed.

Mohamad · KnockwireJune 16, 202612 min readEngineering

Contact form detection sounds like a solved problem until you point it at a thousand company websites. The failures are not exotic. A form is there and the parser cannot see it. The parser sees nine inputs and writes the message into the wrong one. The guard that was supposed to stop us on a protected form reads a property that does not exist, gets undefined back, and waves everything through. We call a usable public contact route a Door, and this is what finding one actually involves.

01Contact form detection starts with the platform

The good news first: contact forms are not a long tail. Across the companies we read, five things account for most of what turns up. HubSpot, ActiveCampaign, WPForms, Typeform, and a plain HTML form posting back to the site it lives on. Identify the platform first, then run the extractor written for it. A generic pass that collects every input on the page gets at least three of the five wrong, and gets them wrong without raising anything.

  • HubSpot: the page carries a portal id and a form guid, and nothing you submit goes to the page you are looking at. Submission is a JSON POST to a regional endpoint keyed on the portal, and the region is part of the address rather than a header, so a European portal submitted to the default host is accepted by nobody. The field list comes from the form definition, not from the DOM.
  • ActiveCampaign: the embed is a script tag, and the markup lives inside a JavaScript string, double escaped. Angle brackets arrive as unicode escapes and every quote is backslashed. An HTML parser pointed at that page finds no form at all, because at that moment there is not one. Unescape the string, parse the result, then extract from the result.
  • WPForms: names are positional. An input is called wpforms[fields][3] and the 3 is a row id in somebody else's WordPress database, so the name carries no meaning. The label text and the field type attribute carry it instead.
  • Typeform: there is no form on the page. There is a hosted flow described as JSON blocks, and the honest read is that this is not a Door we can use.
  • Plain HTML: an action, a method, and usually a hidden token that has to be echoed back alongside the session cookie that issued it. Fetch and submit have to share a cookie jar or the post is rejected as a forgery, which is the correct behaviour on the site's part.

Each of those is a submission contract rather than a rendering quirk. The extractor for one is not a lightly tuned version of the extractor for another, and pretending otherwise produces a detector that reports success on nearly every page and a delivery rate that does not agree with it.

02Field mapping is where it gets hard

Say the form is found and parsed. You now hold somewhere between four and eleven inputs and one message to place. Which box is the message?

We score every input across name, id, placeholder, associated label text, aria-label, element type and maxlength, then take the best score above a threshold. A textarea is the strongest single signal and is still not decisive: plenty of forms use a textarea for a postal address, and some use a single line input with a maxlength of 500 for the message. Subject and message are the pair that go wrong most often. Both are text, they sit next to each other, and swapping them gives the recipient a subject line four paragraphs long.

Then there are the fields that must be left alone. A honeypot is an input hidden from a person and left visible to a naive script, and filling it is precisely how the site learns you are a script. We flag an input as a honeypot on any of: hidden through display or visibility, positioned off screen, zero opacity, a negative tabindex, aria-hidden, or a name from the usual set (url, website, hp, _gotcha, or comment where a comment field already exists). A flagged input is submitted exactly as we found it. That is not a bypass of anything. It is filling the form the way the browser in front of a person would fill it.

Below the threshold we do not guess. There is no Door, the read is parked with the reason written down, and it appears in the refusal ledger as turned down rather than as a quiet attempt. Posting a paragraph into the field labelled "How did you hear about us" is worse than posting nothing, because it spends the attention of a stranger inside a form they cannot reply to.

A detector that cannot report "I do not know" will always find a door, including on the sites that do not have one.

03Two bugs that were quiet in exactly the wrong way

The first is the worst thing we have shipped. The detector returns a nested result and the CAPTCHA finding sits at result.form.captcha. The guard read result.captcha. That property is undefined, undefined is falsy, and the guard therefore concluded that no form anywhere had a CAPTCHA. Nothing threw. Nothing logged. Protected forms went through for most of an afternoon, and it surfaced only because a manual check against a form we knew was protected came back clean and nobody believed the result.

The fix is a rule rather than a corrected property path. The guard now requires an explicit false in order to proceed. Anything else parks the job: undefined, null, a missing object, a string, a truthy value. We assert the shape of the detector result at the boundary instead of reading through it optimistically, and there is a test that hands the guard a result with the flag moved one level deeper and expects a park. It fails against the old code, which is the only useful definition of a regression test.

MethodGuards fail closed. A safety check that reads an absent value has to treat absence as the dangerous case, because there are two ways to get an absent value: a site with no CAPTCHA, and a detector you have broken. Only one of those is safe, and the check cannot tell them apart.

The second bug is smaller and cost more hours. Field matching allowed a suffix match, so an input named contact[email] and an input named contact[secondary_email] both satisfied a rule looking for an email field, and the winner was whichever came last in document order. On a form where the secondary address was optional and unvalidated, we mapped a required field onto the optional one. The post succeeded, the site returned a thank you page, and the message landed in a field nobody reads.

Three rules settle it now, applied in order. An exact name match beats a suffix match and ends the search. Required beats optional, read from the required attribute, from aria-required, and from the platform field definition where the platform provides one. Ties go to the earlier position in the document. The resulting mapping is written into the audit record, so the person approving the Draft can see which input each value will land in before approving rather than after.

04Where we stop

We never solve or bypass a CAPTCHA. Not with a solver service, not with a browser tuned to look more human, and not by hunting for the unprotected legacy endpoint sitting behind the protected one, which is the same act performed with better manners. When the detector reports a CAPTCHA the job moves to needs_review and stays there until a person opens it. Most of the time nobody does, and that is a result rather than a failure.

A CAPTCHA on a contact form is a stated preference. The owner has said, in close to the only vocabulary available for saying it to software, that they want a person on the other end. We are in the business of asking strangers to consider buying something, and that conversation does not open well by demonstrating that their preferences do not bind us.

A few other things fail to qualify as Doors. A form behind a login is not public. A support ticket form is the wrong audience, because it is a queue staffed for paying customers and a sales message in it costs somebody their afternoon. A newsletter signup is not a contact route. Each of those is recorded as a turned down read with a written reason, the same treatment a company gets for failing a fit gate.

Delivery today goes through the target's own public contact form and through nothing else. Email sending is planned for Q4 2026 and is not built, so there is no fallback route when a Door is missing and no second channel when the first one is protected. That absence is what keeps the rule honest. If an easy alternative existed, parking the job would cost nothing, and a rule that costs nothing tells you nothing.

None of this is clever. It is a platform detector, a scoring function, two guards that fail closed, and a written reason attached to every read we did not act on. The clever version of this system would report a higher Knock count and would be worse.

Knockwire reads the internet, throws out the companies that will never buy from you, and knocks on the doors of the ones that will. Run it on your own domain and read your own refusals.

Run it on my siteAll posts