Override IAP subscription status and active-product resolution

Goal

Make MAM Suite treat a user as subscribed — and resolve which product they’re subscribed to — based on something other than the default “walk the user’s WooCommerce orders for a future expiration_date” logic.

You’ll use this when:

  • Entitlements live outside WooCommerce (a SaaS license server, a CRM, a partner integration, a staff comp list) and should still unlock the app.
  • Several SKUs should collapse to one canonical “tier” the rest of your code switches on.
  • You want to refine the default order-walk — for example, to ignore refunded orders, or to pick the newest subscription instead of the first one WooCommerce returns.

There are two related but distinct decisions, and this article covers both because they are easy to confuse:

Decision Hook Question it answers
Active-product resolution mam_iap_active_subscriber_product Is this user subscribed, and to which product (SKU)?
Paywall on/off mam_iap_require_iap Should the paywall be shown / purchase be required for this request?

If you only need to flip the paywall off for a user or role (a comp account, a free-week promo), you may only need mam_iap_require_iap — see Hook: mam_iap_require_iap and Recipe: Bypass IAP for a specific user. If you need the rest of the system to know which entitlement a user holds, you need mam_iap_active_subscriber_product. Many external-entitlement integrations subscribe to both, so the two decisions stay consistent.


Prerequisites

  • MAM Suite with mam-main and mam-inapp-purchase-manager (v2.0+) activated.
  • A place to put PHP: a site-specific plugin or a mu-plugin. Do not edit mam-inapp-purchase-manager directly — it’s an updatable suite plugin.
  • A way to determine entitlement for a given user (the external source you’re integrating, or the user meta / order data you’ll consult).
  • PHP 8.1+ and familiarity with WordPress filters and priorities.

The default mam_iap_active_subscriber_product callback resolves the user with the mam_user_id() helper — which internally fires do_action( 'mam_update_current_user' ) and returns MAM_Current_Request::get_user_id() — and ignores the $user_id filter argument entirely. In your own callback, prefer the passed $user_id when it’s non-zero and fall back to mam_user_id() (which self-resolves the current app user, so you don’t need to fire mam_update_current_user yourself). The examples below do both.


How resolution works by default

Before overriding it, understand what you’re replacing. The plugin registers its callback at priority 10:

apply_filters( 'mam_iap_active_subscriber_product', string $default_product, int $user_id = 0 );

The default callback (mam_in_app_purchase_manager::mam_iap_active_subscriber_product()):

  1. Resolves the user via mam_user_id() (the $user_id argument is ignored).
  2. Calls wc_get_orders( [ 'customer_id' => mam_user_id(), 'status' => 'any' ] ).
  3. Walks each order and reads its expiration_date post meta.
  4. On the first order whose expiration_date is in the future, returns the SKU of the first line item’s product.
  5. Otherwise returns $default_product unchanged.

That expiration_date meta is written upstream by the purchase pipeline (mam_iap_purchase) from the product’s duration attribute. Two consequences worth knowing:

  • Status any includes refunded and cancelled orders. A refunded order with a future expiration_date still counts as active unless you back-date or clear that meta.
  • First match wins. With overlapping subscriptions, you get whichever order WooCommerce returns first — not necessarily the newest or longest-running.

Your override either runs before priority 10 to short-circuit the order walk entirely, or after it to refine or fall back on the plugin’s answer.


Steps

1. Decide where your override sits in the chain

Pick a priority based on intent:

  • Priority < 10 — your source is authoritative. Return your SKU and the plugin’s order walk never overrides it. But if you return $default_product (yielding), the priority-10 callback runs next and may overwrite your “no entitlement” answer with a real WooCommerce order. That’s usually fine — it means “check my source first, fall back to WooCommerce.”
  • Priority > 10 — refine the plugin’s result. You see the SKU the order walk resolved (or the fallback) and can normalize it, or fill in an external entitlement only when WooCommerce found nothing.

2. Resolve from an external entitlement source (runs first)

Return your own SKU when the external source says the user is entitled; otherwise yield so the default WooCommerce path still gets a chance.

add_filter( 'mam_iap_active_subscriber_product', 'my_app_external_entitlement', 5, 2 );

function my_app_external_entitlement( $default_product, $user_id = 0 ) {
    $user_id = $user_id ?: mam_user_id();
    if ( $user_id <= 0 ) {
        return $default_product;
    }

    // Replace with your real entitlement lookup.
    $license_expires = (int) get_user_meta( $user_id, 'b2b_license_expires', true );
    if ( $license_expires > time() ) {
        return 'b2b_premium';   // canonical SKU your code switches on
    }

    return $default_product;    // yield to the default WooCommerce walk
}

Always declare 2 accepted args (add_filter( ..., 5, 2 )) so you receive $user_id, and always return a string — returning null/false or omitting the return strips the filter chain and the contract downstream is string-typed.

3. Or: only fill in when WooCommerce found nothing (runs after)

If WooCommerce should remain the primary source and the external source is just a fallback, register after priority 10 and inspect the already-resolved value:

add_filter( 'mam_iap_active_subscriber_product', 'my_app_fallback_entitlement', 20, 2 );

function my_app_fallback_entitlement( $resolved, $user_id = 0 ) {
    // Empty/null means the plugin found no active WooCommerce subscription.
    if ( $resolved !== '' && $resolved !== null ) {
        return $resolved;
    }
    $user_id = $user_id ?: mam_user_id();
    return my_app_user_has_b2b_license( $user_id ) ? 'b2b_premium' : $resolved;
}

Note “no subscription” surfaces as the empty $default_product the caller passed in (often ''), not a missing value — handle empty string, not a null/unset key.

4. Or: collapse several SKUs to one tier

If the rest of your code only cares “premium or not,” normalize the resolved SKU. Run after the plugin (priority > 10) so you see its answer:

add_filter( 'mam_iap_active_subscriber_product', 'my_app_normalize_tier', 20 );

function my_app_normalize_tier( $sku ) {
    $premium = [ 'premium_monthly', 'premium_yearly', 'premium_lifetime' ];
    return in_array( $sku, $premium, true ) ? 'premium' : $sku;
}

5. Keep the paywall decision consistent

mam_iap_active_subscriber_product answers “which product,” but it does not by itself turn the paywall off. The paywall is decided separately by mam_iap_require_iap, which filters the inapp_has_iap and inapp_is_required flags sent to the mobile client. If your external source unlocks the app, mirror that decision there too:

add_filter( 'mam_iap_require_iap', 'my_app_external_unlocks_paywall' );

function my_app_external_unlocks_paywall( $has_iap ) {
    $user_id = mam_user_id();   // fires mam_update_current_user internally
    if ( $user_id > 0 && my_app_user_has_b2b_license( $user_id ) ) {
        return 'no';   // hide the paywall for entitled users
    }
    return $has_iap;
}

Two things to remember about mam_iap_require_iap (covered fully in its own article):

  • It runs twice in a row — once for inapp_has_iap, once for inapp_is_required — with no signal distinguishing the two. A plain 'no' sets both, which is usually what you want.
  • It must return the string 'yes' or 'no'.

6. Test with a non-admin user

Administrators are unconditionally exempted from the paywall after mam_iap_require_iap runs (the admin block in manage_phone_data() forces both flags to 'no'). So an admin account will always look “unlocked” regardless of your filters — verify with a real non-admin test user instead.


Gotchas

  • Return a string, every time. Both filters strip the chain on null/false/no-return. The active-product filter is string-typed (a SKU or token); the paywall filter must be exactly 'yes' or 'no'.
  • The $user_id arg is decorative on the default active-product callback. It resolves the user via mam_user_id(), ignoring the argument. If you need to ask about a specific other user, do it in your own higher-priority callback using the passed $user_id.
  • Refunded/cancelled orders count by default. The default walk uses status: 'any'. If you refund subscriptions, either back-date the expiration_date meta or filter out non-wc-completed orders in your own callback.
  • First-match-wins on overlaps. If you care which of several active subscriptions is returned, you must implement that ordering yourself in a higher-priority callback.
  • No early exit on the paywall filter. Returning 'no' doesn’t stop later subscribers; a higher-priority callback can flip it back. If your override must win, use a high priority and accept it locks out everyone after you.
  • Don’t edit the plugin. All of this is reachable from filters in your own plugin/mu-plugin. Editing mam-inapp-purchase-manager will be lost on the next suite update.

  • Hook: mam_iap_active_subscriber_product — full reference for the resolution filter.
  • Hook: mam_iap_require_iap — full reference for the paywall on/off filter.
  • Hook: mam_iap_purchase — how expiration_date meta (the value the default resolution reads) gets written.
  • Recipe: Bypass IAP for a specific user — admin-side, user-meta-driven equivalent built on mam_iap_require_iap.
  • Plugin overview: mam-inapp-purchase-manager

What’s next

Once your override is in place, confirm the two decisions agree: a user your external source entitles should resolve to your canonical SKU via mam_iap_active_subscriber_product and see the paywall off via mam_iap_require_iap. If they diverge (product resolved but paywall still up, or vice versa), you’ve subscribed to one filter but not the other.

Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!