Sitemap
Urban Institute

The Urban Institute is the trusted source for unbiased, authoritative insights that inform consequential choices about the well-being of people and places.

Illustration by Alysheia Shaw-Dansby

How We Integrated FormAssembly into Drupal and Curtailed Spam Submissions

6 min readSep 17, 2025

--

During a recent redesign of Urban.org, we refreshed our newsletter sign-up form by embedding it directly into the site using FormAssembly. This change allowed our newsletter sign-ups to integrate directly into our content resource manager, making it easy to collect and use data right away. Embedding forms also smoothed user experience and increased the chance that visitors will subscribe.

But implementing this new integration brought a surprisingly tricky challenge: embedding the same form multiple times on a single page. FormAssembly doesn’t recommend doing so for performance reasons, but our new design required it.

In this post, we detail the problems we encountered when integrating FormAssembly into Drupal and the step-by-step process we took to find a solution. Though our example uses a Drupal site, the same approach can help anyone working with FormAssembly in their web applications when they need to embed a form in more than one place.

Problem 1: One form, many places (design and technical challenge)

Our new design called for the newsletter form to appear in multiple high-traffic areas on our site so users could find it easily. That meant embedding the form in the header, footer, the utility menu that shows on scroll or mobile, as well as in a block on the homepage designed to catch attention as people browse.

Yet, these components don’t share code under the hood, and we only had one newsletter form to include. FormAssembly recommends against using the same form multiple times on one page because its forms rely on unique IDs and JavaScript listeners that can get confused when the form loads more than once. So our challenge became: How do we safely reuse the same form in multiple places on a single Drupal page?

Problem 2: Fighting spam (security and validation challenge)

Once we embedded and connected the form to FormAssembly, a new challenge arose: We were getting thousands of spam submissions per minute. Bots attacked us in two ways:

  1. On the site itself: Some bots programmatically filled out the form and bypassed FormAssembly’s built-in client-side validation. For example, they stripped the disabled attribute, which lead to a spike in junk submissions.

2. Directly at the endpoint: Other bots skipped our site entirely and hammered FormAssembly’s submission URL. These requests had no referrer header, which meant they bypassed every validation layer we had in place.

Normally, we would implement server-side validation to fix the problem. But here’s the catch: FormAssembly doesn’t accept POST requests from servers. Their forms are protected by Cloudflare and only allow submissions directly from the browser. That creates a problem for Drupal’s Form API. The form submission bypasses Drupal entierly if we use:

<form method=”post” action=”https://external-form-assembly-submission-url">

That means validateForm() and submitForm() won’t run, so we can’t implement any meaningful server-side logic.

Step 1. Render the form once using Drupal’s Form API

To start, we didn’t worry about FormAssembly’s limitations. We simply built a form the way our web application would render it. For our Drupal site, we built the form using Drupal’s Form API, which gave us full control over structure, styling, and validation. That way, the form can run through our validation checks before going to FormAssembly’s third-party endpoint.

The code below creates a single text field (#type set to textfield) that’s required and a submit button labeled ‘Sign up.’ Drupal’s Form API uses render arrays to define every part of a form, so we can attach custom HTML attributes, validation rules, and styling directly in the code.

Step 2. Reuse the same form in different ways

Because FormAssembly forms rely on unique IDs and JavaScript listeners, embedding the same form multiple times on a page causes those IDs and listeners to clash. To fix this, we built the form once, using Drupal’s Form API and reused that single definition everywhere it needed to appear. Instead of embedding separate copies, we rendered the same Drupal form differently depending on the location:

· Header and utility menu: We loaded the form inside a popover using an iframe. These areas only appear on scroll or on mobile, so using iframes helped isolate behavior and avoid DOM duplication issues.

(Screenshots of popover/ iframe mockup)

· Footer and homepage block: Since Drupal uses the Twig templating system, we used direct Twig embeds with drupal_form() function from the twig_tweak module to render the form, letting us drop it cleanly into HTML Twig templates.

{{ drupal_form(‘Drupal\\custom_blocks\\Form\\SimpleSignupForm’) }}

(Screenshots of embedded version)

By rendering each form iteration differently, all the places on the page point back to the same underlying form, so the IDs and JavaScript only need to exist once.

Step 3. Add reCAPTCHA in Drupal (didn’t help)

With the form embedded across the site and connected to FormAssembly, the bigger issue became spam. Thousands of junk submissions hit the form every minute, so we first tried a common defense: Google reCAPTCHA in Drupal. However, this solution failed
because the form posts directly to FormAssembly, meaning Drupal never saw the submission and the validation was skipped.

Step 4. Turn on reCAPTCHA in FormAssembly (did help)

Next, we enabled FormAssembly’s built-in Google reCAPTCHA. This solution made an immediate difference, especially against the no-referrer spam that was directly hammering FormAssembly’s endpoint. With reCAPTCHA running on FormAssembly’s side, bots couldn’t bypass validation by skipping Drupal.

Step 5. Enhance with Drupal Form API validation and Honeypot

Turning on FormAssembly’s reCAPTCHA helped, but it wasn’t enough. We wanted more control over the data before it left Drupal. That’s where Drupal’s Form API validation came in.

We added custom rules in validateForm() to ensure:

· the email field must not be empty and

· the format must match valid email patterns.

Passing through Drupal validation helps prevent malformed or blank submissions from reaching FormAssembly. We also added:

1. Honeypot protection, using both a hidden field and a minimum submission time and

2. JavaScript validation to block bots that strip or remove required attributes in the DOM.

This layered approach helped catch bots that were slipping through simple client-side checks.

Step 6: Intercept and forward submissions to FormAssembly

The final challenge was bringing everything together. We needed to connect our Drupal form to FormAssembly and keep our validation layer in place. FormAssembly doesn’t allow direct server-to-server POSTs, so we had to get creative.

Here’s how our submission flow works:

1. User submits the form: We removed the method=”post” and action=”external-url” attributes from the <form> element so the data pass through Drupal’s validateForm() and submitForm() methods.

2. Server-side validation runs: We check for required fields like email, validate formatting, and apply Honeypot and time-based protections.

3. Capture clean values: Instead of submitting directly to FormAssembly, we store the validated and sanitized values in the form state and tell Drupal to rebuild the form with $form_state->setRebuild(TRUE).

4. Rebuild and forward: On the next page load, Drupal rebuilds the form, injects a hidden form with the submitted values, and automatically submits the hidden form with Javascript to FormAssembly’s endpoint

Press enter or click to view image in full size

Looking foward

Even though our setup works great now, it’s not totally set-and-forget. The HTML-based embeds (via Form API) still need to be manually updated if FormAssembly’s form structure changes, and FormAssembly doesn’t officially support this use case, meaning future changes may require additional tweaks.

Additionally, this solution presented some unexpected benefits and extra considerations:

· Accessibility: Building the form through your application’s framework (Drupal in our case, but the same applies elsewhere) helps maintain consistent accessibility across all placements. You’re not juggling multiple embed codes, just one form that follows the same accessibility patterns.

· Performance and caching: Protections like Honeypot or reCAPTCHA often rely on session-specific or JavaScript-driven behavior. That can make a page uncacheable, since the system treats it as dynamic. At Urban.org, we have multiple caching layers, so this drawback hasn’t been an issue. But if your site or application depends heavily on full-page caching, it’s worth testing carefully before implementing these protections.

Ultimately, this work allowed us to prioritize the design and user experience we wanted for the Urban.org refresh while better integrating the data from FormAssembly into our other workflows.

-Anand Janardhanan

-Farnoosh Johnson

Want to learn more? Sign up for the Data@Urban newsletter.

--

--

Urban Institute
Urban Institute

Published in Urban Institute

The Urban Institute is the trusted source for unbiased, authoritative insights that inform consequential choices about the well-being of people and places.

Data@Urban
Data@Urban

Written by Data@Urban

Data@Urban is a place to explore the code, data, products, and processes that bring Urban Institute research to life.

No responses yet