Skip to content
Learnomy
Get Learnomy Pro

Store bridges for developers

Pro feature

Pro. The Store and membership bridges module. Owner guides: WooCommerce, Easy Digital Downloads, Restrict Content Pro, Paid Memberships Pro, MemberPress.

A store bridge maps a store's or membership plugin's own events to Learnomy access. It never handles money: no transaction, receipt, commission or payment event is written. Everything goes through Free's Learnomy\Services\Access_Grant_Service, which is idempotent on the order reference, keeps an owner's removal, and leaves access from other sources alone.

Write a bridge#

Extend Learnomy_Pro\Extensions\Store_Bridges\Bridge and add the class on learnomy_pro_store_bridges:

add_filter( 'learnomy_pro_store_bridges', function ( array $classes ) {
	$classes[] = My_Store_Bridge::class;
	return $classes;
} );
Method You implement What it answers
source() yes Source slug on every grant and in the Access Log, 20 characters at most (woocommerce, edd).
label() yes The platform's name for owners (WooCommerce).
platform_active() yes class_exists() / function_exists() for the platform. Nothing heavier.
platform_version() yes The platform's version, or ''.
minimum_version() yes Oldest supported version. Older: the bridge does not load and owners see one dismissible line.
register() yes Hook the platform's grant, revoke and editor events. Runs only when active and new enough.
carries_learnomy_checkout() optional True when Learnomy's own checkout also sells through this platform (WooCommerce), so review() refuses a course sold both ways.
grant( $request ) call it On "paid / active". Request: user_id or email (+ first_name, last_name), items, source_item, external_ref. More than 3 items run in the background. Refusals land in the Access Log.
revoke( $external_ref, $reason ) call it On "refunded / cancelled / expired". Ends every active grant of that reference.
can_map() call it May the current user change a mapping? Site owners (manage_options) only. Check it where the picker renders and where you save.
register_meta_owner_only( $object_type, $subtype ) call it Registers Bridge::META with an owner-only auth_callback, hidden from REST, so generic meta writes refuse non-owners. A platform path that writes meta directly (WooCommerce's REST API and CSV import) also needs its own guard, like WooCommerce_Bridge::keep_mapping_owner_only().
picker( $id, $name, $value, $help ) call it Prints the shared access picker. Call enqueue_picker() from admin_enqueue_scripts on that screen.
items( $stored ) call it Stored type:id keys back to items.
review( $items, $sell_url, $before ) call it Before storing picks: refuses or notes courses already sold at Learnomy's checkout, links a "sold elsewhere" course to $sell_url. Returns the keys to store.

external_ref is the order or membership id at the platform; use the same value in grant() and revoke(). Store the picked keys under Bridge::META with the platform's own API (product CRUD, download meta, level meta), from the field Bridge::FIELD. The WooCommerce bridge, includes/extensions/store-bridges/class-woo-commerce-bridge.php, is the reference.

The bridges that ship#

Bridge source() external_ref Grants on Revokes on Picker and save
WooCommerce 8.2+ woocommerce order id woocommerce_order_status_completed woocommerce_order_fully_refunded, status refunded or cancelled; WooCommerce Subscriptions cancelled or expired (first order) Product data tab; woocommerce_admin_process_product_object
Easy Digital Downloads 3.0+ edd EDD order id edd_complete_purchase (sale orders, not renewals) edd_transition_order_status to refunded or revoked (partially refunded keeps access); EDD Recurring edd_subscription_cancelled / _expired on the parent order, once the subscription is no longer active Box after Download Details (add_meta_boxes_download); edd_save_download
Restrict Content Pro 3.0+ rcp RCP membership id rcp_membership_post_activate, rcp_transition_membership_status_active, rcp_membership_post_renew (only while RCP says the membership is active) rcp_transition_membership_status_expired, rcp_membership_post_disable (upgrade, downgrade, delete); rcp_membership_post_cancel only once RCP says it has ended Form-table row (rcp_add_subscription_form, rcp_edit_subscription_form); rcp_add_subscription, rcp_edit_subscription_level

An RCP membership keeps its id across renewals, so a renewal finds its grant already active and adds no row, and an owner's removal holds for that membership. An RCP upgrade is a new membership: the old one is disabled (its grant ends) and the new one is granted.

The shipped bridges#

Bridge Source Grants on Takes back on external_ref Mapping stored in
WooCommerce_Bridge woocommerce woocommerce_order_status_completed (renewal orders skipped) full refund, refunded or cancelled order, Subscriptions cancelled or expired order id (a subscription's parent order) product meta _learnomy_pro_access, product CRUD
PMPro_Bridge pmpro a level newly held after pmpro_after_change_membership_level a level no longer held after that hook (cancel, expiry, admin removal, level change), pmpro_order_status_refunded the pmpro_memberships_users row id that started the membership level meta _learnomy_pro_access
MemberPress_Bridge memberpress mepr-event-transaction-completed (renewals skipped: txn_count > 1) mepr-event-transaction-refunded, mepr-event-subscription-stopped, mepr-event-transaction-expired (one-time, or a cancelled subscription), mepr_changing_subscription sub-{id} for a subscription, txn-{id} for a one-time payment membership post meta _learnomy_pro_access

PMPro fires no hook for the level a group change replaces, so its bridge notes the member's levels on pmpro_before_change_membership_level and compares after: a level gained grants, a level lost revokes, a level kept (a renewal checkout, which PMPro writes as a new row) does nothing. A revoke finds the level's refs in the ledger (the member's active pmpro grants whose source_item is the level), since the current row may be a renewal's.

Only a site owner (manage_options) changes a mapping, on every write path the platform has: WooCommerce's REST API and CSV import keep the stored value for anyone else (keep_mapping_owner_only()); MemberPress's meta is registered with an owner-only auth_callback, so core's REST, XML-RPC and Custom Fields writes are refused; PMPro has no REST field, import or copy that writes level meta, so its level save is the one path and checks the owner itself.

Pay an instructor from a store sale#

A store sale earns nobody anything through Learnomy, and Learnomy ships no setting for it. A site that wants to pay a commission or a bonus on such a sale adds a few lines of its own. The credit lands as a normal pending earning, so it shows in the instructor's balance, nets with their other earnings and refund adjustments, and goes out through the same withdrawal or Stripe Connect payout.

Listen to learnomy_access_grant_created. It fires once per grant, with the platform's order id as $external_ref, and a retried event does not fire it again. Then credit the course's main author with Commission::create(). Only the main author is ever paid on a course; co-instructors are never given a share.

// WooCommerce: pay the course's author 20% of the order total.
add_action( 'learnomy_access_grant_created', function ( $grant_id, $user_id, $item_type, $item_id, $source, $external_ref ) {
	if ( 'woocommerce' !== $source || 'course' !== $item_type ) {
		return;
	}

	$order  = wc_get_order( (int) $external_ref );
	$author = \Learnomy\Models\Course::main_author_id( (int) $item_id );
	if ( ! $order || $author <= 0 ) {
		return;
	}

	\Learnomy\Models\Commission::create(
		array(
			'transaction_id' => 0,
			'recipient_type' => 'instructor',
			'recipient_id'   => $author,
			'amount'         => round( (float) $order->get_total() * 0.20, 2 ),
			'currency'       => $order->get_currency(),
			'rate_applied'   => 20,
			'source_type'    => 'third_party_sale',
			'source_ref'     => 'woo-' . $order->get_id() . '-course-' . (int) $item_id,
		)
	);
}, 10, 6 );

What to know before you adapt it:

  • Conditions are yours. The snippet decides who is paid, how much and for which products: a flat bonus, a percentage, a different rate per course, only some orders. $source is the bridge (woocommerce, edd, rcp, pmpro, memberpress); $item_type is course or membership_plan.
  • One order, several items. The hook fires once per item the order grants. Work out each item's share of the order yourself, or the same total is credited once per item.
  • Pay once. source_type and source_ref together are the key: crediting the same pair again returns the first row and records nothing new. Build source_ref from the order and the item, as above. source_type is one of third_party_sale, affiliate or manual_adjustment; any other value is dropped and the money is still recorded.
  • Refunds are yours too. A store refund takes access back and fires learnomy_access_grant_revoked, but your credit stays until you reverse it. Book a negative manual_adjustment row with a different source_ref for the same instructor; it nets against their next earnings, like a refund of a Learnomy sale.
  • Plans. A membership_plan grant has no author to pay. Share it between the authors of the plan's courses yourself, or leave it unpaid.

See Commissions and Earnings for how a Learnomy sale is split, and the Hooks Reference for the hook arguments.

The picker#

Learnomy\Admin\Access_Picker::render( $args ) (Free) prints the picker in a meta box, a form-table row or a card: a kind-of-access select, a paged search, chips grouped by kind, a summary line and an overlap note. It stores type:id keys in one hidden input and never prints the catalog. Read a submitted picker with Access_Picker::from_request( $name ) after your own nonce and capability checks.

It reads two admin routes (manage_options):

  • GET /learnomy/v1/admin/access-grants/search?type=course&search=intro&page=1: 20 a page, { data: [ { type, id, title } ], meta: { total, has_more, page, per_page } }.
  • GET /learnomy/v1/admin/access-grants/resolve?ids=course:12,membership_plan:3: { data: { items: [ { key, type, id, label, title, missing } ], overlaps: [ { course_id, title, items } ] } }.

An item type is offered in the picker when its learnomy_access_grant_types entry has a search callable (Access_Grant_Service::title_search( Model::class, $where ) builds one) and, for the overlap note, a courses callable. See the Hooks Reference.