TECHNICAL GUIDE

Real-World MemberPress Hooks to Improve Your Membership Site

Unlock the full potential of MemberPress with these essential hooks. Learn how to extend and automate your membership site with simple, effective code snippets.

PLUGIN MemberPress
HOOKS 12 ITEMS

Running a membership site with MemberPress? You probably already know it makes selling content, courses, or subscriptions straightforward. But here’s the thing — the real game-changer is hooks.

Hooks are like tiny control switches built into MemberPress. They let you tweak your site’s behavior, automate repetitive tasks, and connect with other tools — all without touching a single core file.

In this post, we’ll cover 12 hooks you can actually use today. From automating onboarding to adding custom account fields or syncing payments with other systems, these hooks give you the flexibility to shape your membership site exactly how you want it. No hacks. No messy core edits. Just smart, practical tweaks that save time and make your site work for your business.

1. mepr_signup

Trigger a personalized welcome email the moment a new member completes checkout.

Use case: The built-in MemberPress emails are pretty bare. They confirm the purchase but don't tell the new member what to do next. If someone just paid $97 for your coaching program, you probably want to send them a warm, specific email that says "Welcome to the Pro Plan, here's your first lesson." That kind of personal touch gets people engaged right away instead of leaving them wondering where to start.

mepr_signup.php
add_action('mepr_signup', function($txn) {
    if (!($txn instanceof MeprTransaction) || empty($txn->user_id)) {
        return;
    }

    $user = get_user_by('ID', $txn->user_id);
    if (!$user) {
        return;
    }

    $product = get_the_title($txn->product_id);
    $subject = 'Welcome to Our Community!';
    $message = sprintf(
        "Hi %s,\n\nThanks for joining with the %s plan. Here's a quick start guide...",
        esc_html($user->display_name),
        esc_html($product)
    );

    wp_mail($user->user_email, $subject, $message);
}, 10, 1);
Explanation:

mepr_signup is an action that fires once per completed signup, after the user record and transaction are both saved. It passes a single MeprTransaction object ($txn) with access to $txn->user_id, $txn->product_id, and $txn->total. The snippet validates the object with instanceof, resolves the WP_User via get_user_by(), and uses get_the_title() to pull the human-readable product name. You can extend it by adding PDF attachments, switching the email template by product ID, or queuing the send via wp_schedule_single_event() to avoid blocking the thank-you page.

Welcome Email Onboarding Signup Activation

2. mepr_signup_user_loaded

Capture advertising attribution data and tie it permanently to the member's profile.

Use case: You're spending money on ads and affiliates, but when someone signs up through MemberPress, the connection between "which ad brought them" and "which member they became" disappears. The tracking cookie is sitting right there in the browser, but nobody grabs it. This hook runs early enough in the checkout process, before payment even happens, so you can read that cookie and store it on the user permanently. Now when your boss asks "which Facebook campaign actually made us money?", you have the answer.

mepr_signup_user_loaded.php
add_action('mepr_signup_user_loaded', function($usr) {
    if (!($usr instanceof MeprUser) || empty($usr->ID)) {
        return;
    }

    // Only set tracking once, don't overwrite on repeat signups
    $existing = get_user_meta($usr->ID, '_mepr_tracking_id', true);
    if (!empty($existing)) {
        return;
    }

    $tracking_id = '';
    if (isset($_COOKIE['tracking_id'])) {
        $tracking_id = sanitize_text_field(wp_unslash($_COOKIE['tracking_id']));
    }

    if (empty($tracking_id)) {
        $tracking_id = wp_generate_uuid4();
    }

    update_user_meta($usr->ID, '_mepr_tracking_id', $tracking_id);
}, 10, 1);
Explanation:

mepr_signup_user_loaded is an action that fires during the checkout flow, after the MeprUser object is loaded but before the payment gateway processes the charge. It passes a single MeprUser object ($usr) which extends WP_User. The snippet first checks for an existing meta value to enforce idempotency. If the user already has a tracking ID (e.g., from a previous signup), it bails out. It reads the cookie with wp_unslash() for proper sanitization and falls back to wp_generate_uuid4() instead of uniqid() for collision-safe uniqueness. The meta key _mepr_tracking_id is prefixed with an underscore to hide it from the default WordPress profile editor.

Attribution Tracking Affiliate UTM Ad Spend

3. mepr_txn_store

Send a real-time Slack (or Zapier) notification every time a payment completes.

Use case: There's something satisfying about seeing a Slack message pop up the second someone pays. But beyond the dopamine hit, your team actually needs to know. Maybe someone needs to ship a welcome kit, or your Zapier workflow needs to create a login on Teachable. Without a hook like this, you'd be checking the dashboard manually or waiting for a cron job to catch up. This fires instantly and gives you the old and new transaction state so you can tell a real payment apart from a routine database save.

mepr_txn_store.php
define('MY_SLACK_WEBHOOK_URL', 'https://hooks.slack.com/services/XXXXX/XXXXX/XXXXX');

add_action('mepr_txn_store', function($txn, $old_txn) {
    if (!($txn instanceof MeprTransaction)) {
        return;
    }

    // Only fire when the status changes to "complete"
    if ($txn->status !== MeprTransaction::$complete_str) {
        return;
    }
    if (isset($old_txn->status) && $old_txn->status === MeprTransaction::$complete_str) {
        return;
    }

    $user = get_user_by('ID', $txn->user_id);
    $payload = [
        'text' => sprintf(
            'New payment: %s (%s) paid %s for %s',
            $user ? $user->display_name : 'Unknown',
            $user ? $user->user_email : 'N/A',
            MeprAppHelper::format_currency((float) $txn->total),
            get_the_title($txn->product_id)
        ),
    ];

    $response = wp_remote_post(MY_SLACK_WEBHOOK_URL, [
        'body'      => wp_json_encode($payload),
        'headers'   => ['Content-Type' => 'application/json'],
        'timeout'   => 5,
        'blocking'  => false,
    ]);

    if (is_wp_error($response)) {
        error_log('MemberPress Slack webhook failed: ' . $response->get_error_message());
    }
}, 10, 2);
Explanation:

mepr_txn_store is an action that fires every time a MeprTransaction is written to the database. That includes new payments, status updates, refund, or admin edit. It passes two parameters: $txn (the current transaction) and $old_txn (a snapshot of the previous state). The snippet compares $txn->status against MeprTransaction::$complete_str and checks $old_txn->status to ensure we only fire when the status actually transitions to "complete", preventing duplicate triggers. The wp_remote_post() call uses 'blocking' => false so the HTTP request is dispatched without waiting for a response, keeping the checkout flow fast. Swap the Slack webhook URL for any endpoint like Zapier, Make, n8n, or a custom API.

Webhook Slack Zapier Real-Time Notification Payment

4. mepr_sub_created

Automatically assign and clean up WordPress roles when a subscription is created, upgraded, or downgraded.

Use case: Here's a problem that sneaks up on you: a member upgrades from Basic to Pro, but they still have the "basic_member" role sitting on their account. Now they have both roles, and whatever plugin checks those roles might give them weird access, or not enough. The fix is simple in theory (swap the role on upgrade), but doing it manually for every plan change doesn't scale. This hook handles it automatically for new signups, upgrades, and downgrades, and cleans up the old role so you never end up with stale permissions.

mepr_sub_created.php
add_action('mepr_sub_created', function($type, $obj, $context) {
    // $type is "subscription" or "transaction"
    // $context is "new", "upgraded", or "downgraded"
    if (empty($obj->user_id) || empty($obj->product_id)) {
        return;
    }

    $user = get_user_by('ID', $obj->user_id);
    if (!$user) {
        return;
    }

    // Map product IDs to roles, replace with your own
    $role_map = [
        123 => 'premium_member',
        456 => 'vip_member',
    ];

    $product_id = (int) $obj->product_id;

    // Remove all membership-managed roles first to keep state clean
    foreach ($role_map as $pid => $role) {
        if ($pid !== $product_id && in_array($role, $user->roles, true)) {
            $user->remove_role($role);
        }
    }

    // Add the role for the current product
    if (isset($role_map[$product_id]) && !in_array($role_map[$product_id], $user->roles, true)) {
        $user->add_role($role_map[$product_id]);
    }
}, 10, 3);
Explanation:

mepr_sub_created is an action that fires when MemberPress creates a subscription or transaction through the gateway layer. It passes three parameters: $type (string, either "subscription" or "transaction"), $obj (the MeprSubscription or MeprTransaction object), and $context (string, either "new", "upgraded", or "downgraded"). The snippet casts $obj->product_id to int for strict comparison, iterates the role map to remove any stale roles that don't match the current product, then adds the correct role only if it isn't already assigned. This prevents role duplication when the hook fires multiple times for the same subscription event.

Role Assignment Upgrade Downgrade Access Control

5. mepr_adjusted_price

Implement dynamic pricing strategies like loyalty discounts, wholesale rates, and geographic purchasing-power parity without creating dozens of coupon codes.

Use case: Coupon codes work fine when you have two or three discount scenarios. But what happens when you want existing members to automatically see 15% off a second plan, wholesale partners to see 25% off, AND visitors from certain countries to get purchasing-power-parity rates? That's a lot of coupon codes to manage, and they don't stack or prioritize themselves. This filter gives you one place in code to define all your pricing rules. It runs after MemberPress has already done its math (coupons, proration, etc.), so you get the final word on what the customer actually pays.

mepr_adjusted_price.php
add_filter('mepr_adjusted_price', function($price, $coupon_code, $product) {
    if (!($product instanceof MeprProduct) || !is_user_logged_in()) {
        return $price;
    }

    $price    = (float) $price;
    $user     = new MeprUser(get_current_user_id());
    $discount = 0;

    // 1. Loyalty discount: existing active members get 15% off additional memberships
    $active_subs = $user->active_product_subscriptions('ids');
    if (!empty($active_subs) && is_array($active_subs) && !in_array($product->ID, $active_subs, true)) {
        $discount = 15;
    }

    // 2. Role-based discount: wholesale customers get 25% off
    $wp_user = get_user_by('ID', $user->ID);
    if ($wp_user && in_array('wholesale_customer', (array) $wp_user->roles, true)) {
        $discount = max($discount, 25);
    }

    // 3. Geographic pricing: users in specific regions get a reduced rate
    $region = get_user_meta($user->ID, 'region', true);
    $reduced_regions = ['IN', 'BR', 'PH', 'NG']; // Purchasing power parity regions
    if (in_array($region, $reduced_regions, true)) {
        $discount = max($discount, 40);
    }

    if ($discount > 0) {
        $price = round($price * (1 - ($discount / 100)), 2);
    }

    return max($price, 0);
}, 10, 3);
Explanation:

mepr_adjusted_price is a filter that runs inside MeprProduct::adjusted_price() after coupons and proration have already been applied. It passes three parameters: $price (float, the calculated price), $coupon_code (string or empty), and $product (the MeprProduct object). The snippet layers three discount strategies: loyalty (15%), role-based (25%), and geographic (40%), then applies whichever is highest using max(). The active_product_subscriptions('ids') call checks the user's current active plans so we don't discount the product they already own. The final max($price, 0) guard prevents negative prices. Adjust the $role_map, $reduced_regions array, and percentage values to match your business rules.

Dynamic Pricing Loyalty Discount Geographic Pricing Wholesale PPP

6. mepr_changing_subscription

Intercept plan upgrades and downgrades before they finalize to log the change, store the old plan, and notify your retention team.

Use case: When someone downgrades from your $99/month plan to the $29 one, you probably want to know about it before it happens. Maybe you need to prorate their bonus credits, revoke access on an external platform, or ping your retention team so they can reach out. The catch is that most hooks fire after the switch is done, and by then the old plan data is already overwritten. This one fires right before the change goes through, so you still have both the old and new transaction sitting side by side.

mepr_changing_subscription.php
add_action('mepr_changing_subscription', function($latest_txn, $evt_txn) {
    if (!($latest_txn instanceof MeprTransaction) || !($evt_txn instanceof MeprTransaction)) {
        return;
    }

    $old_product = (int) $latest_txn->product_id;
    $new_product = (int) $evt_txn->product_id;
    $user_id     = (int) $latest_txn->user_id;

    if ($old_product === $new_product || empty($user_id)) {
        return;
    }

    $old_name = get_the_title($old_product);
    $new_name = get_the_title($new_product);
    $direction = ($evt_txn->total > $latest_txn->total) ? 'upgrade' : 'downgrade';

    // Log the plan change for analytics
    error_log(sprintf(
        '[MemberPress] User %d %s: "%s" (ID %d) → "%s" (ID %d) at %s',
        $user_id, $direction, $old_name, $old_product, $new_name, $new_product, current_time('mysql')
    ));

    // Store old plan for later reference (e.g., for prorating perks)
    update_user_meta($user_id, '_mepr_previous_product_id', $old_product);
    update_user_meta($user_id, '_mepr_plan_change_direction', $direction);
}, 10, 2);
Explanation:

mepr_changing_subscription is an action that fires inside MeprSubscription and MeprTransaction models when a plan switch is detected, before the new transaction replaces the old one. It passes two MeprTransaction objects: $latest_txn (the current/old transaction) and $evt_txn (the incoming one). The snippet compares $evt_txn->total against $latest_txn->total to determine the direction (upgrade vs. downgrade), logs a structured entry via error_log(), and writes both the old product ID and the direction to user meta. These meta values can then be read by downstream hooks like mepr_sub_created or mepr_subscription_transition_status to trigger follow-up logic.

Plan Change Upgrade Downgrade Retention Proration

7. mepr_subscription_transition_status

Send a clear, human-readable email every time a subscription status changes so members aren't left confused when access stops working.

Use case: You know those support tickets that say "my account stopped working, what happened?" Nine times out of ten, a credit card expired or a payment bounced. MemberPress flips the subscription status behind the scenes, but nobody tells the member. So they get confused, can't access their content, and email you. This hook catches every status change (active to suspended, pending to active, and so on) and lets you send a plain-English email that explains what happened and what they should do. Fewer confused members means fewer tickets in your inbox.

mepr_subscription_transition_status.php
add_action('mepr_subscription_transition_status', function($old_status, $new_status, $subscription) {
    if ($old_status === $new_status) {
        return;
    }

    if (!($subscription instanceof MeprSubscription) || empty($subscription->user_id)) {
        return;
    }

    $user = get_user_by('ID', $subscription->user_id);
    if (!$user) {
        return;
    }

    $product = get_the_title($subscription->product_id);

    $status_labels = [
        MeprSubscription::$active_str    => 'Active',
        MeprSubscription::$cancelled_str => 'Cancelled',
        MeprSubscription::$suspended_str => 'Suspended',
        MeprSubscription::$pending_str   => 'Pending',
    ];

    $old_label = $status_labels[$old_status] ?? ucfirst(sanitize_text_field($old_status));
    $new_label = $status_labels[$new_status] ?? ucfirst(sanitize_text_field($new_status));

    $subject = sprintf('Your %s Membership Status Has Changed', esc_html($product));
    $message = sprintf(
        "Hi %s,\n\nYour %s membership status changed from %s to %s.\n\nIf you have questions, reply to this email.",
        esc_html($user->display_name),
        esc_html($product),
        $old_label,
        $new_label
    );

    wp_mail($user->user_email, $subject, $message);
}, 10, 3);
Explanation:

mepr_subscription_transition_status is an action that fires inside MeprSubscription::store() whenever the subscription status value changes in the database. It passes three parameters: $old_status (string), $new_status (string), and $subscription (the full MeprSubscription object). The snippet uses MemberPress class constants (MeprSubscription::$active_str, $cancelled_str, $suspended_str, $pending_str) instead of hardcoded strings for reliable comparison. If the status didn't actually change, the function exits immediately to avoid false triggers. The email uses sprintf() with esc_html() to safely interpolate user data into the message body.

Status Change Cancellation Suspension Member Communication

8. mepr_account_is_inactive

Automatically revoke access on external platforms (Teachable, Circle, Discord) the moment a membership becomes inactive.

Use case: Your membership site probably isn't just WordPress. Pro members might also get into a Teachable course, a Circle community, or a private Discord. When their subscription lapses, MemberPress locks them out of your WordPress content, but Teachable and Discord have no clue. So cancelled members keep accessing paid stuff on those platforms until someone notices. This hook fires the instant an account goes inactive and gives you the chance to hit each platform's API and clean things up automatically.

mepr_account_is_inactive.php
add_action('mepr_account_is_inactive', function($txn) {
    if (!($txn instanceof MeprTransaction) || empty($txn->user_id)) {
        return;
    }

    $user = get_user_by('ID', $txn->user_id);
    if (!$user) {
        return;
    }

    $product_id = (int) $txn->product_id;

    // Map product IDs to external services that should be revoked
    $service_map = [
        123 => 'teachable',  // Replace with your product IDs
        456 => 'circle',
    ];

    if (!isset($service_map[$product_id])) {
        return;
    }

    $service = $service_map[$product_id];

    // Revoke access via an external API
    $response = wp_remote_request('https://api.example.com/users/' . rawurlencode($user->user_email) . '/access', [
        'method'  => 'DELETE',
        'headers' => [
            'Authorization' => 'Bearer ' . MY_EXTERNAL_API_TOKEN,
            'Content-Type'  => 'application/json',
        ],
        'body'    => wp_json_encode(['service' => $service, 'product_id' => $product_id]),
        'timeout' => 10,
    ]);

    if (is_wp_error($response)) {
        error_log(sprintf(
            '[MemberPress] Failed to revoke %s access for user %d: %s',
            $service, $txn->user_id, $response->get_error_message()
        ));
    }
}, 10, 1);
Explanation:

mepr_account_is_inactive is an action fired by MeprActiveInactiveHooksCtrl when a transaction's status is no longer active or has expired. It passes a single MeprTransaction object ($txn). The snippet maps product IDs to external service names, then sends an authenticated DELETE request via wp_remote_request() to revoke the user's access on the corresponding platform. The rawurlencode() call ensures the email is safely embedded in the URL path. Error responses are logged with error_log() including the service name and user ID for easy debugging. You can extend the $service_map array to cover as many platforms as needed since each product ID maps to one external service.

Offboarding Churn Access Revocation External Integration

9. mepr_validate_signup

Block disposable email domains and enforce IP-based rate limiting to prevent spam registrations and brute-force signups.

Use case: If your registration page is public, sooner or later you'll see a wave of fake signups from throwaway emails like 10minutemail.com. CAPTCHA helps, but it doesn't stop someone from using a fresh disposable address every time. And if a bot finds your form, it can hammer it hundreds of times from the same IP. This filter runs before anything is written to the database, so you can block junk domains and throttle repeat attempts before they pollute your member list or mess up your email metrics.

mepr_validate_signup.php
add_filter('mepr_validate_signup', function($errors) {
    $email = sanitize_email(wp_unslash($_POST['user_email'] ?? ''));

    // 1. Domain Enforcement
    $blocked_domains = ['disposable.com', 'competitor.com', '10minutemail.com'];
    $domain = substr(strrchr($email, '@'), 1);

    if (in_array(strtolower($domain), $blocked_domains, true)) {
        $errors[] = 'Please use a valid company email address to register.';
    }

    // 2. Adaptive Rate Limiting
    $ip = sanitize_text_field($_SERVER['REMOTE_ADDR'] ?? '');
    if (!empty($ip)) {
        $cache_key = 'mepr_reg_limit_' . md5($ip);
        $attempts  = (int) get_transient($cache_key);

        if ($attempts > 3) {
            $errors[] = 'Too many registration attempts. Please wait 15 minutes.';
        } else {
            set_transient($cache_key, $attempts + 1, 900);
        }
    }

    return $errors;
}, 10, 1);
Explanation:

mepr_validate_signup is a filter that runs inside the checkout controller before any user or transaction records are created. It receives and must return the $errors array. Any strings you push into it will block the signup and display as validation messages. The snippet extracts the email domain with strrchr() and checks it against a blocklist array using strict in_array(). For rate limiting, it uses WordPress transients keyed by an MD5 hash of the IP address, with a 900-second (15-minute) TTL. The transient counter increments on every attempt and blocks at 3+. You can easily extend the $blocked_domains array or replace the transient approach with a database table for more granular analytics.

Security Validation Bot Prevention Rate Limiting

10. mepr_save_account

Sync member profile data and active membership tiers to your CRM (HubSpot, ActiveCampaign, Salesforce) every time they update their account.

Use case: Your marketing team doesn't check the WordPress admin to see who's on which plan. They live in HubSpot or ActiveCampaign. But if a member updates their name or switches plans and nobody tells the CRM, you end up sending emails to "Hey null" or targeting people with the wrong membership tier. This hook fires every time a member hits "Save" on their account page, so you can push their latest info (name, email, active plans) straight to your CRM. No more stale contacts, no more manual exports.

mepr_save_account.php
define('MY_HUBSPOT_API_TOKEN', 'YOUR_HUBSPOT_TOKEN');

add_action('mepr_save_account', function($mepr_current_user) {
    if (!($mepr_current_user instanceof MeprUser) || empty($mepr_current_user->ID)) {
        return;
    }

    $email = sanitize_email($mepr_current_user->user_email);
    if (empty($email)) {
        return;
    }

    // Gather the user's active memberships for CRM segmentation
    $active_subs = $mepr_current_user->active_product_subscriptions('products');
    $plan_names  = [];
    if (!empty($active_subs) && is_array($active_subs)) {
        foreach ($active_subs as $product) {
            if (!empty($product->post_title)) {
                $plan_names[] = sanitize_text_field($product->post_title);
            }
        }
    }

    $properties = [
        ['property' => 'email',      'value' => $email],
        ['property' => 'firstname',  'value' => sanitize_text_field($mepr_current_user->first_name)],
        ['property' => 'lastname',   'value' => sanitize_text_field($mepr_current_user->last_name)],
        ['property' => 'membership', 'value' => implode(', ', $plan_names)],
    ];

    $response = wp_remote_post(
        'https://api.hubapi.com/contacts/v1/contact/createOrUpdate/email/' . rawurlencode($email),
        [
            'body'     => wp_json_encode(['properties' => $properties]),
            'headers'  => [
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . MY_HUBSPOT_API_TOKEN,
            ],
            'timeout'  => 10,
            'blocking' => false,
        ]
    );

    if (is_wp_error($response)) {
        error_log('HubSpot sync failed for user ' . $mepr_current_user->ID . ': ' . $response->get_error_message());
    }
}, 10, 1);
Explanation:

mepr_save_account is an action that fires in MeprAccountCtrl after the front-end account form is validated and saved. It passes a single MeprUser object ($mepr_current_user), which extends WP_User and includes MemberPress-specific methods like active_product_subscriptions(). The snippet calls active_product_subscriptions('products') to get an array of MeprProduct objects representing all active plans, then maps their titles into a comma-separated string for the CRM "membership" property. The wp_remote_post() call uses 'blocking' => false so the user doesn't wait for HubSpot to respond. Replace the endpoint and property names to match any CRM API like ActiveCampaign, Salesforce, or Pipedrive.

CRM Sync HubSpot Marketing Automation Segmentation

11. mepr_unauthorized_content

Replace the generic "You don't have access" wall with a dynamic paywall that shows live pricing and direct signup links for the required memberships.

Use case: Someone lands on your locked blog post, and MemberPress shows them a generic "you don't have access" message. That's it. No pricing, no plan names, no signup link. They bounce. You just lost a potential customer to a lazy default message. This filter lets you swap that dead end for something useful: a mini paywall that automatically pulls in which plans unlock that specific page, shows the actual price, and links straight to the checkout. Every locked page becomes a little sales pitch instead of a brick wall.

mepr_unauthorized_content.php
add_filter('mepr_unauthorized_content', function($content, $post) {
    if (!is_object($post) || empty($post->ID)) {
        return $content;
    }

    $rules = MeprRule::get_rules($post);
    if (empty($rules)) {
        return $content;
    }

    $product_ids = [];
    foreach ($rules as $rule) {
        if (isset($rule->mepr_type) && $rule->mepr_type === 'memberships' && !empty($rule->mepr_value)) {
            $product_ids = array_merge($product_ids, (array) $rule->mepr_value);
        }
    }

    $product_ids = array_unique(array_filter(array_map('intval', $product_ids)));
    if (empty($product_ids)) {
        return $content;
    }

    $links = [];
    foreach ($product_ids as $pid) {
        $product = new MeprProduct($pid);
        if (empty($product->ID) || $product->post_status !== 'publish') {
            continue;
        }
        $links[] = sprintf(
            '<li><a href="%s" class="mepr-paywall-link">%s &ndash; %s</a></li>',
            esc_url($product->url()),
            esc_html($product->post_title),
            MeprAppHelper::format_currency((float) $product->price)
        );
    }

    if (empty($links)) {
        return $content;
    }

    return sprintf(
        '<div class="mepr-custom-paywall"><h3>%s</h3><p>%s</p><ul>%s</ul></div>',
        esc_html__('This content requires a membership', 'my-mepr-customizations'),
        esc_html__('Choose a plan to unlock this content:', 'my-mepr-customizations'),
        implode('', $links)
    );
}, 10, 2);
Explanation:

mepr_unauthorized_content is a filter applied in MeprRulesCtrl when a visitor doesn't have permission to view protected content. It passes $content (the default unauthorized HTML) and $post (the WordPress post object). The snippet calls MeprRule::get_rules($post) to find all rules protecting this post, then extracts product IDs from rules where mepr_type === 'memberships'. Each product ID is validated by instantiating a MeprProduct and checking that it's published. The final output uses $product->url() for the registration link and MeprAppHelper::format_currency() to display the price in the site's configured currency format. All output is escaped with esc_url() and esc_html().

Paywall Conversion Upsell Content Gating Dynamic Pricing

12. mepr_before_send_email

Log every outgoing MemberPress email for auditing, compliance, and support troubleshooting.

Use case: A member writes in saying they never got their receipt. Did the email actually go out? Was it sent to the right address? WordPress doesn't log outgoing mail, and neither do most hosts, so you're basically guessing. This hook fires right before every email MemberPress sends and gives you the full picture: who it's going to, the subject line, the body, everything. Log it to your error log, a database table, or a service like Logtail. Next time someone says "I never got that email," you can actually check.

mepr_before_send_email.php
add_action('mepr_before_send_email', function($recipients, $subject, $message, $headers, $attachments) {
    $log_entry = sprintf(
        '[MemberPress Email] To: %s | Subject: %s | Time: %s',
        is_array($recipients) ? implode(', ', $recipients) : $recipients,
        sanitize_text_field($subject),
        current_time('mysql')
    );
    error_log($log_entry);
}, 10, 5);
Explanation:

mepr_before_send_email is an action (not a filter) that fires in MeprUtils::wp_mail() immediately before the actual wp_mail() call. It passes five parameters: $recipients (string or array), $subject (string), $message (string, the email body), $headers (string or array), and $attachments (array). Since it's an action, you can't modify the email content through it. Use it strictly for observation, logging, or triggering side effects. The snippet formats a single-line log entry with sprintf() and writes it via error_log(). For production, you can replace error_log() with a custom database insert or an API call to a log aggregation service like Logtail or Datadog.

Email Audit Logging Compliance Debugging
KNOWLEDGE BASE

Common Questions

You can add the code to a small site-specific plugin or to your child theme’s functions.php. No core files are touched.
Yes. Hooks run outside the core plugin, so updates won’t overwrite your code.
MemberPress Expertise

Need a custom MemberPress solution?

Contact our team for a free audit and let us build the hooks that power your membership site.

// TECH STACK

Tools We Know
Inside Out.

Starting with WordPress at the core, we use a curated stack of powerful tools to build fast, stable, and scalable systems for your business.

WordPress

WordPress Core

WooCommerce

WooCommerce

MemberPress

MemberPress

LearnDash

LearnDash

BuddyBoss
TutorLMS
Ottokit
Easy Digital Downloads
WPML
SearchWP
Gravity Forms
Shopmagic
WP Rocket
AffiliateWP
Uncanny Automator
Laravel
WPForms
Stripe
GiveWP