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
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
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
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
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
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
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
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
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
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
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 – %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
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