← 목록

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

devto 2026-07-07 원문 보기 ↗


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:

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:

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:

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:

@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: