Store bridges for developers
Developer Guide pro Pro featurePro. 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.
$sourceis the bridge (woocommerce,edd,rcp,pmpro,memberpress);$item_typeiscourseormembership_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_typeandsource_reftogether are the key: crediting the same pair again returns the first row and records nothing new. Buildsource_reffrom the order and the item, as above.source_typeis one ofthird_party_sale,affiliateormanual_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 negativemanual_adjustmentrow with a differentsource_reffor the same instructor; it nets against their next earnings, like a refund of a Learnomy sale. - Plans. A
membership_plangrant 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.