Fetching latest headlinesโ€ฆ
Building a Plugin-Free Newsletter Popup on WordPress: Custom REST Endpoint Mailchimp API v3
NORTH AMERICA
๐Ÿ‡บ๐Ÿ‡ธ United Statesโ€ขJuly 7, 2026

Building a Plugin-Free Newsletter Popup on WordPress: Custom REST Endpoint Mailchimp API v3

0 views0 likes0 comments
Originally published byDev.to

Building a Plugin-Free Newsletter Popup on WordPress: Custom REST Endpoint โ†’ Mailchimp API v3

A client wanted a newsletter popup on their WooCommerce/Divi site to hand out a lead-magnet PDF and grow their Mailchimp audience. The obvious paths โ€” a popup plugin, or Mailchimp's own hosted popup โ€” both lose on the things I actually care about: page weight, brand control, and not loading someone else's JavaScript on every pageview.

So I built it as a custom component: a modal that posts to my own WordPress REST endpoint, which talks to Mailchimp server-side. No plugin, no Mailchimp front-end script, no reCAPTCHA. Here's the whole thing, including a CSS bug that cost me ten confusing minutes.

Why not a plugin or the native Mailchimp popup

Three reasons I don't reach for either on a performance-sensitive site:

  • The hosted Mailchimp popup injects their JS and styles into every page. On a site already fighting for Core Web Vitals under Divi, that's one more render-blocking dependency and an off-brand UI I can't fully control.
  • Popup plugins carry the same weight problem plus a settings surface, update cadence, and attack surface I don't need for what is, fundamentally, one form and one API call.
  • I was going to own the API integration regardless โ€” the site's footer signup wants the same endpoint eventually. Build it once, reuse it. The tradeoff is that you write the code. For a single form talking to a well-documented API, that's a few hours, and you get exactly the component you want.

Architecture

Three moving parts:

  1. A REST endpoint (namespace/v1/subscribe) registered in the theme. It receives the email, validates it, and does the Mailchimp call server-side so the API key never touches the browser.
  2. A modal rendered once in wp_footer, gated by page conditionals, with the API key handed to the front end as nothing โ€” only the endpoint URL and a nonce.
  3. A small vanilla-JS controller for triggers, the frequency cap, and the fetch. Everything lives in the child theme. No mu-plugin, no external assets beyond one CSS and one JS file.

The server side: a REST endpoint that owns the Mailchimp call

The key architectural decision: the browser never sees the API key. The front end posts an email to your endpoint; your endpoint authenticates to Mailchimp. This is the whole reason not to use Mailchimp's client-side embed.

Register the route and read your credentials from wp-config.php constants (never hard-code them in theme files that live in version control or the DB):

add_action( 'rest_api_init', function () {
    register_rest_route( 'namespace/v1', '/subscribe', array(
        'methods'             => 'POST',
        'permission_callback' => '__return_true', // public; guarded by nonce + honeypot
        'args' => array(
            'email' => array(
                'required'          => true,
                'sanitize_callback' => 'sanitize_email',
                'validate_callback' => 'is_email',
            ),
            'hp' => array( 'sanitize_callback' => 'sanitize_text_field' ),
        ),
        'callback' => 'np_subscribe',
    ) );
} );

The Mailchimp v3 subscribe is an upsert: PUT /lists/{list_id}/members/{subscriber_hash}, where the hash is the MD5 of the lowercased email. Using PUT (not POST) means re-submits don't error โ€” they update the existing member instead of throwing "already subscribed."

function np_subscribe( WP_REST_Request $req ) {
    // Honeypot: any value means bot. Fake a success so it doesn't retry.
    if ( ! empty( $req->get_param( 'hp' ) ) ) {
        return new WP_REST_Response( array( 'ok' => true ), 200 );
    }

    if ( ! defined( 'MC_API_KEY' ) || ! defined( 'MC_SERVER' ) || ! defined( 'MC_LIST_ID' ) ) {
        return new WP_REST_Response(
            array( 'ok' => false, 'message' => 'Newsletter is not configured yet.' ),
            503
        );
    }

    $email  = sanitize_email( $req->get_param( 'email' ) );
    $hash   = md5( strtolower( $email ) );
    $auth   = 'Basic ' . base64_encode( 'user:' . MC_API_KEY );
    $status = 'subscribed'; // or 'pending' for double opt-in โ€” see below

    $res = wp_remote_request(
        "https://" . MC_SERVER . ".api.mailchimp.com/3.0/lists/" . MC_LIST_ID . "/members/{$hash}",
        array(
            'method'  => 'PUT',
            'timeout' => 15,
            'headers' => array(
                'Authorization' => $auth,
                'Content-Type'  => 'application/json',
            ),
            'body' => wp_json_encode( array(
                'email_address' => $email,
                'status_if_new' => $status,
            ) ),
        )
    );

    if ( is_wp_error( $res ) ) {
        return new WP_REST_Response( array( 'ok' => false, 'message' => 'Try again shortly.' ), 502 );
    }

    $code = wp_remote_retrieve_response_code( $res );
    if ( $code >= 400 ) {
        // Log the real Mailchimp reason server-side; keep the user message clean.
        error_log( 'Mailchimp ' . $code . ': ' . wp_remote_retrieve_body( $res ) );
        return new WP_REST_Response( array( 'ok' => false, 'message' => 'Couldn\'t add you right now.' ), 502 );
    }

    // Tag for segmentation โ€” fire-and-forget, don't block success on it.
    wp_remote_post(
        "https://" . MC_SERVER . ".api.mailchimp.com/3.0/lists/" . MC_LIST_ID . "/members/{$hash}/tags",
        array(
            'timeout' => 10,
            'headers' => array( 'Authorization' => $auth, 'Content-Type' => 'application/json' ),
            'body'    => wp_json_encode( array(
                'tags' => array( array( 'name' => 'homepage-popup', 'status' => 'active' ) ),
            ) ),
        )
    );

    return new WP_REST_Response( array( 'ok' => true ), 200 );
}

Two details worth calling out:

  • base64_encode('user:' . $key) โ€” Mailchimp uses HTTP Basic auth where the username is literally any string and the password is the API key.
  • The server prefix (us8, us21, etc.) is the suffix on your API key after the dash, and it's part of the API host. Store it as its own constant so the base URL is composed, not hard-coded. The tag call is deliberately fire-and-forget. If tagging fails, the subscribe already succeeded โ€” I don't want to fail the user's request over a segmentation nicety. Log it and move on.

Single vs double opt-in changes more than a status string

This is the detail that bites people. Mailchimp audiences are configured for single or double opt-in, and your status_if_new has to match:

  • Double opt-in โ†’ send status: pending. Mailchimp emails a confirmation link; the member isn't subscribed until they click. If you send pending on a single-opt-in audience, you strand contacts in limbo โ€” no confirmation email is sent, so they sit pending forever.
  • Single opt-in โ†’ send status: subscribed. They're live immediately.
    But it's not just the API value โ€” it changes your success copy. My first success message said "check your inbox to confirm." On a single-opt-in audience, no confirmation email exists, so that copy is a lie. Match the message to the mode:

  • Double: "Almost there โ€” confirm via the email we just sent."

  • Single: "You're on the list. Grab your guide below."
    Check the audience setting before you write a single line of success-state copy. Single opt-in trades a confirmation step for higher capture at the cost of more typo/bot noise in your list โ€” which is exactly why the next section matters.

Anti-spam: a honeypot, not reCAPTCHA

For a plain email capture, reCAPTCHA is the wrong tool. It adds a third-party script (CWV hit), a privacy footprint, and occasional user friction โ€” all to guard a single input. A honeypot catches the bots that matter here with zero weight and zero UX cost.

Add a field that's invisible to humans and irresistible to naive bots:

<input class="np-hp" type="text" tabindex="-1" autocomplete="off"
       aria-hidden="true" data-np-hp>
.np-hp {
    position: absolute !important;
    left: -9999px !important;
    width: 1px; height: 1px;
    opacity: 0;
}

If it comes back filled, it's a bot โ€” return a fake 200 so it doesn't retry against a real error path. Real users never see or touch it. tabindex="-1" and aria-hidden keep it out of keyboard and screen-reader flow.

This won't stop a determined targeted attack, but for newsletter spam it filters the overwhelming majority with none of reCAPTCHA's costs. If abuse ever escalates, you add rate-limiting server-side before you reach for a CAPTCHA.

The front end: triggers and a frequency cap

The modal renders once in wp_footer, gated to the surfaces that should show it and never to logged-in users:

function np_should_render() {
    if ( is_user_logged_in() ) return false; // members don't need the lead magnet
    return is_front_page() || is_home() || is_singular( 'post' );
}

The JS fires on whichever trigger hits first: exit-intent, scroll depth, or a time fallback. Exit-intent converts best and annoys least; the timer is just a ceiling.

var timer = setTimeout(open, cfg.delayMs);          // e.g. 15000
document.addEventListener('mouseout', function (e) {  // exit-intent
    if (e.clientY <= 0) open();
});
window.addEventListener('scroll', function () {       // scroll depth
    var h = document.documentElement;
    if ((h.scrollTop) / (h.scrollHeight - h.clientHeight) * 100 >= cfg.scrollPct) open();
}, { passive: true });

The frequency cap uses localStorage โ€” once someone sees or submits, don't show it again for N days:

var CAP_MS = cfg.capDays * 864e5;
function recentlySeen() {
    try {
        var t = parseInt(localStorage.getItem('np_seen'), 10);
        return t && (Date.now() - t) < CAP_MS;
    } catch (e) { return false; }
}

Wrap storage access in try/catch โ€” private-mode and storage-disabled browsers throw, and a newsletter popup should never take down the page.

Mobile: dodge the intrusive-interstitial penalty

Google penalizes intrusive interstitials on mobile that block content on load. A full-screen modal that fires immediately is exactly what they target. Two mitigations:

  • Don't fire instantly โ€” the delay/scroll/exit triggers already push the popup past the initial load.
  • On mobile, render a bottom sheet, not a full-screen takeover. It's dismissible, doesn't obscure the whole viewport, and reads as a native mobile pattern.
@media (max-width: 600px) {
    .np { align-items: flex-end; padding: 0; }
    .np__card {
        max-width: 100%;
        max-height: 92vh;      /* never taller than the viewport */
        overflow-y: auto;      /* scroll instead of clipping the CTA */
        border-radius: 12px 12px 0 0;
    }
}

I also dropped the guide-cover image entirely on mobile. In a bottom sheet it fought for vertical space it didn't earn, and a text-only sheet converts fine. Desktop keeps the two-column layout with the cover.

The bug that made it look broken: [hidden] vs display

Here's the one that cost me. On submit, the controller hides the form view and shows the success view:

root.querySelector('[data-np-view="form"]').hidden = true;
root.querySelector('[data-np-view="success"]').hidden = false;

After wiring everything up, submitting showed the success message and the form at the same time, with the button stuck on "Sendingโ€ฆ". The API had returned 200 โ€” the integration was fine. The bug was pure CSS.

The form view was a two-column flex container:

.np__body--split { display: flex; }

The hidden attribute works by applying display: none โ€” but at the user-agent stylesheet's specificity, which is effectively zero. My .np__body--split { display: flex } rule outranks it. So setting .hidden = true added the attribute, but display: flex kept winning and the element stayed visible.

The fix is a one-liner that lets [hidden] beat the flex rule:

.np__body--split[hidden] { display: none; }

The lesson: any time you set an explicit display on an element you also toggle with the hidden attribute (or a hidden class), you have to re-assert display: none at matching-or-higher specificity. This is a silent, common trap with flex/grid components that get shown and hidden in JS.

Deploy notes

A few things specific to shipping this on managed WordPress hosting:

  • File-only deploys. The popup is entirely code + one image asset โ€” no database rows. So promotion from staging to production is a file push plus two hand-edits (functions.php include line, wp-config.php constants). Never push the staging DB for a change like this; you'll clobber live orders and comments.
  • Config per environment. The Mailchimp constants live in each environment's wp-config.php separately. They don't ride along on a file push, and that's correct โ€” you may point staging at a test audience.
  • Purge the cache. The modal markup is in wp_footer, so a full-page cache (Varnish/Breeze/Cloudflare) will serve a stale page without it. Purge after deploy and test in a private window.

    Takeaways

  • Keep the API key server-side. A custom REST endpoint is a few lines more than the hosted embed and removes an entire class of exposure and page-weight problems.

  • Match status_if_new to the audience's opt-in mode โ€” and match your success copy to it too.

  • Honeypot over reCAPTCHA for simple captures. Add rate-limiting before you ever add a CAPTCHA.

  • [hidden] loses to any explicit display. Re-assert display: none on the hidden state of flex/grid components.

  • Design mobile as a bottom sheet to stay clear of Google's interstitial penalty.
    The whole thing is one PHP file, one CSS file, and one JS file in the child theme. No plugin, no third-party script, full brand control โ€” and the data lands in the same Mailchimp audience it would have anyway.

Comments (0)

Sign in to join the discussion

Be the first to comment!