WooCommerce Subscriptions
Overview
WooCommerce Subscriptions (the official premium plugin) adds subscription product types — simple subscriptions and variable subscriptions — with configurable billing periods (daily, weekly, monthly, yearly), free trials, sign-up fees, and prorated upgrades/downgrades. It integrates with Stripe, PayPal Reference Transactions, and other gateways that support automated recurring billing. Custom logic hooks into the subscription lifecycle via a rich set of WordPress actions and filters.
When to Use This Skill
- When selling products or services on a recurring billing schedule (SaaS, memberships, box subscriptions)
- When implementing subscription upgrades, downgrades, or plan switching
- When building custom renewal logic or adding business rules around failed payment retry
- When integrating subscription status with access control (e.g., membership site content gates)
- When extending subscription emails or admin reporting with custom data
- When creating subscription add-ons or per-unit quantity scaling
Core Instructions
Create a subscription product programmatically
<?php
// Create a simple subscription product via code (e.g., in a migration script)
$product = new WC_Product_Subscription();
$product->set_name('Monthly Pro Plan');
$product->set_status('publish');
$product->set_regular_price('29.99');
// Save first so the product gets an ID
$product->save();
// Subscription-specific meta (product must be saved before setting post meta)
update_post_meta($product->get_id(), '_subscription_price', '29.99');
update_post_meta($product->get_id(), '_subscription_period', 'month');
update_post_meta($product->get_id(), '_subscription_period_interval', '1');
update_post_meta($product->get_id(), '_subscription_length', '0'); // 0 = forever
update_post_meta($product->get_id(), '_subscription_trial_length', '14');
update_post_meta($product->get_id(), '_subscription_trial_period', 'day');
update_post_meta($product->get_id(), '_subscription_sign_up_fee', '0');
Hook into subscription lifecycle events
WooCommerce Subscriptions fires specific actions at each lifecycle stage:
<?php
// When a new subscription is created (after successful first payment)
add_action('woocommerce_subscription_status_active', function ($subscription) {
$user_id = $subscription->get_user_id();
$plan = $subscription->get_items(); // Array of WC_Order_Item_Product
// Grant access — e.g., set user role or update capabilities
$user = new WP_User($user_id);
$user->add_role('subscriber_member');
// Track in analytics
error_log("Subscription {$subscription->get_id()} activated for user {$user_id}");
});
// When a renewal payment succeeds
add_action('woocommerce_subscription_renewal_payment_complete', function ($subscription, $last_order) {
$user_id = $subscription->get_user_id();
// Extend access, send renewal receipt, update CRM
do_action('my_plugin_renewal_processed', $user_id, $subscription);
}, 10, 2);
// When a renewal payment fails
add_action('woocommerce_subscription_payment_failed', function ($subscription, $last_order) {
$user_id = $subscription->get_user_id();
$retry_count = $subscription->get_failed_payment_count();
// Notify user and optionally pause access
if ($retry_count >= 2) {
$user = new WP_User($user_id);
$user->remove_role('subscriber_member');
// Send dunning email
$subscription->update_status('on-hold');
}
}, 10, 2);
// When subscription is cancelled
add_action('woocommerce_subscription_status_cancelled', function ($subscription) {
$user_id = $subscription->get_user_id();
$user = new WP_User($user_id);
$user->remove_role('subscriber_member');
});
Query subscriptions programmatically
<?php
// Get all active subscriptions for a user
function get_user_active_subscriptions(int $user_id): array {
return wcs_get_users_subscriptions($user_id, ['active']);
}
// Check if a user has an active subscription to a specific product
function user_has_active_subscription_to_product(int $user_id, int $product_id): bool {
$subscriptions = wcs_get_subscriptions_for_product($product_id, 'any', ['customer_id' => $user_id]);
foreach ($subscriptions as $subscription) {
if ($subscription->has_status('active')) {
return true;
}
}
return false;
}
// Get subscription by ID
function get_subscription_details(int $subscription_id): ?WC_Subscription {
$subscription = wcs_get_subscription($subscription_id);
if (!$subscription) return null;
return $subscription;
}
// Usage
$subscription = get_subscription_details(1234);
if ($subscription) {
echo $subscription->get_status(); // 'active', 'on-hold', 'cancelled', etc.
echo $subscription->get_next_payment_date(); // ISO 8601 date
echo $subscription->get_total(); // Current billing amount
}
Handle plan upgrades and downgrades
<?php
// Add proration logic for plan switches
add_filter('woocommerce_subscriptions_switch_proration', function ($proration_amount, $subscription, $new_order, $product, $switch_cart_item) {
// Custom proration: charge/credit based on days remaining in billing period
$next_payment = strtotime($subscription->get_next_payment_date());
$last_payment = $subscription->get_date('last_payment');
$billing_period_days = ($next_payment - strtotime($last_payment)) / DAY_IN_SECONDS;
$days_remaining = ($next_payment - time()) / DAY_IN_SECONDS;
$old_daily_rate = (float)$subscription->get_total() / $billing_period_days;
$new_product_price = (float)$switch_cart_item['data']->get_price();
$new_daily_rate = $new_product_price / $billing_period_days;
// Credit remaining days at old rate, charge at new rate
$proration_amount = ($new_daily_rate - $old_daily_rate) * $days_remaining;
return round($proration_amount, 2);
}, 10, 5);
// Trigger a plan switch programmatically
function switch_subscription_plan(int $subscription_id, int $new_product_id): bool {
$subscription = wcs_get_subscription($subscription_id);
if (!$subscription) return false;
// Remove existing item and add new product
foreach ($subscription->get_items() as $item_id => $item) {
$subscription->remove_item($item_id);
}
$item = new WC_Order_Item_Product();
$item->set_product(wc_get_product($new_product_id));
$item->set_quantity(1);
$subscription->add_item($item);
// Recalculate totals
$subscription->calculate_totals();
$subscription->save();
return true;
}
Retry failed payments and synchronize billing dates
<?php
// Trigger an immediate payment retry (e.g., from an admin action)
function retry_failed_subscription_payment(int $subscription_id): bool {
$subscription = wcs_get_subscription($subscription_id);
if (!$subscription || !$subscription->has_status('on-hold')) {
return false;
}
// Create a renewal order and attempt payment
$renewal_order = wcs_create_renewal_order($subscription);
if (is_wp_error($renewal_order)) {
return false;
}
// Process payment using the subscription's payment method
$payment_gateway = wc_get_payment_gateway_by_order($subscription);
if ($payment_gateway && method_exists($payment_gateway, 'scheduled_subscription_payment')) {
$payment_gateway->scheduled_subscription_payment(
$renewal_order->get_total(),
$renewal_order
);
}
return true;
}
// Synchronize all active subscriptions to renew on the 1st of the month
add_filter('woocommerce_subscriptions_synced_next_payment_date', function ($next_payment_date, $product, $from_timestamp, $trial_end_timestamp) {
if ($product->get_meta('_subscription_period') === 'month') {
$next_month = date('Y-m-01', strtotime('+1 month'));
return strtotime($next_month);
}
return $next_payment_date;
}, 10, 4);
Examples
Suspension and reinstatement flow
<?php
// Admin-triggered suspension with reason logging
function suspend_subscription_with_reason(int $subscription_id, string $reason): void {
$subscription = wcs_get_subscription($subscription_id);
if (!$subscription) return;
// Store suspension reason as meta before status change
$subscription->update_meta_data('_suspension_reason', $reason);
$subscription->update_meta_data('_suspension_date', current_time('mysql'));
$subscription->save();
// Change status to on-hold (fires woocommerce_subscription_status_on-hold action)
$subscription->update_status('on-hold', sprintf('Suspended: %s', $reason));
}
// Reinstate and reset payment date
function reinstate_subscription(int $subscription_id): void {
$subscription = wcs_get_subscription($subscription_id);
if (!$subscription || !$subscription->has_status('on-hold')) return;
// Reset next payment date to avoid immediately triggering a renewal
$subscription->set_date('next_payment', strtotime('+1 month'));
// Activate subscription
$subscription->update_status('active', 'Reinstated by admin');
$subscription->save();
}
Custom subscription email
<?php
// Add a custom "Renewal Reminder" email 7 days before renewal
add_filter('woocommerce_email_classes', function ($email_classes) {
require_once plugin_dir_path(__FILE__) . 'class-renewal-reminder-email.php';
$email_classes['My_Renewal_Reminder_Email'] = new My_Renewal_Reminder_Email();
return $email_classes;
});
// Schedule the emails via WooCommerce action scheduler
add_action('woocommerce_scheduled_subscription_payment', function ($subscription_id) {
$subscription = wcs_get_subscription($subscription_id);
if (!$subscription) return;
// Schedule reminder 7 days before next payment
$next_payment = strtotime($subscription->get_next_payment_date()) - (7 * DAY_IN_SECONDS);
if ($next_payment > time()) {
as_schedule_single_action(
$next_payment,
'my_plugin_send_renewal_reminder',
[['subscription_id' => $subscription_id]]
);
}
}, 10);
Best Practices
- Use Action Scheduler (bundled with WooCommerce) for all async subscription tasks — never rely on WP cron for payment-critical operations
- Always use
wcs_get_subscription() with null-checks — subscriptions may be deleted or missing in edge cases (manual deletions, import failures)
- Test payment gateway token handling — subscription renewals require a stored payment token; test that the gateway correctly charges the original card on file during renewal
- Handle failed payment dunning explicitly — the default retry schedule is 1, 4, and 7 days after failure; customize via
wcs_retry_rules filter to match your business policy
- Log all subscription status changes — store status change history in order notes or a custom table for auditing and customer support
- Respect proration on plan switches — don't force customers to pay double for the same period; use built-in proration or implement custom logic
- Test upgrade/downgrade edge cases — especially when a trial is active or when the billing period changes (monthly to annual)
- Use
wcs_user_has_subscription() for access control — it's more reliable than checking user roles alone
Common Pitfalls
| Problem |
Solution |
| Renewal payments not processing |
Check that the payment gateway has supports[] = subscription in its capabilities; gateways must explicitly declare subscription support |
| Subscription status stays "pending" after successful payment |
The woocommerce_payment_complete action must fire — verify the payment gateway calls $order->payment_complete() on success |
| Proration results in negative charge |
Implement minimum floor of 0.00 in the proration filter return value; negative amounts cause gateway errors on most processors |
wcs_get_subscriptions_for_product returns empty |
Pass the product's parent ID for variations — WCS stores the parent product ID, not the variation ID, on subscription items |
| Trial ends but renewal isn't charged |
Ensure _subscription_trial_length meta is set to a number > 0 AND the payment gateway token is stored correctly from the initial order |
| Cancellation emails sent on admin status changes |
Add remove_action for woocommerce_subscription_status_cancelled before programmatic cancellations and re-add after if you need to suppress emails |
Related Skills
- @woocommerce-plugin-development
- @woocommerce-rest-api
- @subscription-billing
- @stripe-integration
- @woocommerce-blocks
1---2name: woocommerce-subscriptions3description: Add subscription products to WooCommerce with automatic recurring billing, renewal notifications, and subscriber self-service management4---56# WooCommerce Subscriptions78## Overview910WooCommerce Subscriptions (the official premium plugin) adds subscription product types — simple subscriptions and variable subscriptions — with configurable billing periods (daily, weekly, monthly, yearly), free trials, sign-up fees, and prorated upgrades/downgrades. It integrates with Stripe, PayPal Reference Transactions, and other gateways that support automated recurring billing. Custom logic hooks into the subscription lifecycle via a rich set of WordPress actions and filters.1112## When to Use This Skill1314- When selling products or services on a recurring billing schedule (SaaS, memberships, box subscriptions)15- When implementing subscription upgrades, downgrades, or plan switching16- When building custom renewal logic or adding business rules around failed payment retry17- When integrating subscription status with access control (e.g., membership site content gates)18- When extending subscription emails or admin reporting with custom data19- When creating subscription add-ons or per-unit quantity scaling2021## Core Instructions22231. **Create a subscription product programmatically**2425 ```php26 <?php27 // Create a simple subscription product via code (e.g., in a migration script)28 $product = new WC_Product_Subscription();29 $product->set_name('Monthly Pro Plan');30 $product->set_status('publish');31 $product->set_regular_price('29.99');3233 // Save first so the product gets an ID34 $product->save();3536 // Subscription-specific meta (product must be saved before setting post meta)37 update_post_meta($product->get_id(), '_subscription_price', '29.99');38 update_post_meta($product->get_id(), '_subscription_period', 'month');39 update_post_meta($product->get_id(), '_subscription_period_interval', '1');40 update_post_meta($product->get_id(), '_subscription_length', '0'); // 0 = forever41 update_post_meta($product->get_id(), '_subscription_trial_length', '14');42 update_post_meta($product->get_id(), '_subscription_trial_period', 'day');43 update_post_meta($product->get_id(), '_subscription_sign_up_fee', '0');44 ```45462. **Hook into subscription lifecycle events**4748 WooCommerce Subscriptions fires specific actions at each lifecycle stage:4950 ```php51 <?php5253 // When a new subscription is created (after successful first payment)54 add_action('woocommerce_subscription_status_active', function ($subscription) {55 $user_id = $subscription->get_user_id();56 $plan = $subscription->get_items(); // Array of WC_Order_Item_Product5758 // Grant access — e.g., set user role or update capabilities59 $user = new WP_User($user_id);60 $user->add_role('subscriber_member');6162 // Track in analytics63 error_log("Subscription {$subscription->get_id()} activated for user {$user_id}");64 });6566 // When a renewal payment succeeds67 add_action('woocommerce_subscription_renewal_payment_complete', function ($subscription, $last_order) {68 $user_id = $subscription->get_user_id();69 // Extend access, send renewal receipt, update CRM70 do_action('my_plugin_renewal_processed', $user_id, $subscription);71 }, 10, 2);7273 // When a renewal payment fails74 add_action('woocommerce_subscription_payment_failed', function ($subscription, $last_order) {75 $user_id = $subscription->get_user_id();76 $retry_count = $subscription->get_failed_payment_count();7778 // Notify user and optionally pause access79 if ($retry_count >= 2) {80 $user = new WP_User($user_id);81 $user->remove_role('subscriber_member');82 // Send dunning email83 $subscription->update_status('on-hold');84 }85 }, 10, 2);8687 // When subscription is cancelled88 add_action('woocommerce_subscription_status_cancelled', function ($subscription) {89 $user_id = $subscription->get_user_id();90 $user = new WP_User($user_id);91 $user->remove_role('subscriber_member');92 });93 ```94953. **Query subscriptions programmatically**9697 ```php98 <?php99100 // Get all active subscriptions for a user101 function get_user_active_subscriptions(int $user_id): array {102 return wcs_get_users_subscriptions($user_id, ['active']);103 }104105 // Check if a user has an active subscription to a specific product106 function user_has_active_subscription_to_product(int $user_id, int $product_id): bool {107 $subscriptions = wcs_get_subscriptions_for_product($product_id, 'any', ['customer_id' => $user_id]);108 foreach ($subscriptions as $subscription) {109 if ($subscription->has_status('active')) {110 return true;111 }112 }113 return false;114 }115116 // Get subscription by ID117 function get_subscription_details(int $subscription_id): ?WC_Subscription {118 $subscription = wcs_get_subscription($subscription_id);119 if (!$subscription) return null;120121 return $subscription;122 }123124 // Usage125 $subscription = get_subscription_details(1234);126 if ($subscription) {127 echo $subscription->get_status(); // 'active', 'on-hold', 'cancelled', etc.128 echo $subscription->get_next_payment_date(); // ISO 8601 date129 echo $subscription->get_total(); // Current billing amount130 }131 ```1321334. **Handle plan upgrades and downgrades**134135 ```php136 <?php137138 // Add proration logic for plan switches139 add_filter('woocommerce_subscriptions_switch_proration', function ($proration_amount, $subscription, $new_order, $product, $switch_cart_item) {140 // Custom proration: charge/credit based on days remaining in billing period141 $next_payment = strtotime($subscription->get_next_payment_date());142 $last_payment = $subscription->get_date('last_payment');143 $billing_period_days = ($next_payment - strtotime($last_payment)) / DAY_IN_SECONDS;144 $days_remaining = ($next_payment - time()) / DAY_IN_SECONDS;145146 $old_daily_rate = (float)$subscription->get_total() / $billing_period_days;147 $new_product_price = (float)$switch_cart_item['data']->get_price();148 $new_daily_rate = $new_product_price / $billing_period_days;149150 // Credit remaining days at old rate, charge at new rate151 $proration_amount = ($new_daily_rate - $old_daily_rate) * $days_remaining;152153 return round($proration_amount, 2);154 }, 10, 5);155156 // Trigger a plan switch programmatically157 function switch_subscription_plan(int $subscription_id, int $new_product_id): bool {158 $subscription = wcs_get_subscription($subscription_id);159 if (!$subscription) return false;160161 // Remove existing item and add new product162 foreach ($subscription->get_items() as $item_id => $item) {163 $subscription->remove_item($item_id);164 }165166 $item = new WC_Order_Item_Product();167 $item->set_product(wc_get_product($new_product_id));168 $item->set_quantity(1);169 $subscription->add_item($item);170171 // Recalculate totals172 $subscription->calculate_totals();173 $subscription->save();174175 return true;176 }177 ```1781795. **Retry failed payments and synchronize billing dates**180181 ```php182 <?php183184 // Trigger an immediate payment retry (e.g., from an admin action)185 function retry_failed_subscription_payment(int $subscription_id): bool {186 $subscription = wcs_get_subscription($subscription_id);187 if (!$subscription || !$subscription->has_status('on-hold')) {188 return false;189 }190191 // Create a renewal order and attempt payment192 $renewal_order = wcs_create_renewal_order($subscription);193 if (is_wp_error($renewal_order)) {194 return false;195 }196197 // Process payment using the subscription's payment method198 $payment_gateway = wc_get_payment_gateway_by_order($subscription);199 if ($payment_gateway && method_exists($payment_gateway, 'scheduled_subscription_payment')) {200 $payment_gateway->scheduled_subscription_payment(201 $renewal_order->get_total(),202 $renewal_order203 );204 }205206 return true;207 }208209 // Synchronize all active subscriptions to renew on the 1st of the month210 add_filter('woocommerce_subscriptions_synced_next_payment_date', function ($next_payment_date, $product, $from_timestamp, $trial_end_timestamp) {211 if ($product->get_meta('_subscription_period') === 'month') {212 $next_month = date('Y-m-01', strtotime('+1 month'));213 return strtotime($next_month);214 }215 return $next_payment_date;216 }, 10, 4);217 ```218219## Examples220221### Suspension and reinstatement flow222223```php224<?php225226// Admin-triggered suspension with reason logging227function suspend_subscription_with_reason(int $subscription_id, string $reason): void {228 $subscription = wcs_get_subscription($subscription_id);229 if (!$subscription) return;230231 // Store suspension reason as meta before status change232 $subscription->update_meta_data('_suspension_reason', $reason);233 $subscription->update_meta_data('_suspension_date', current_time('mysql'));234 $subscription->save();235236 // Change status to on-hold (fires woocommerce_subscription_status_on-hold action)237 $subscription->update_status('on-hold', sprintf('Suspended: %s', $reason));238}239240// Reinstate and reset payment date241function reinstate_subscription(int $subscription_id): void {242 $subscription = wcs_get_subscription($subscription_id);243 if (!$subscription || !$subscription->has_status('on-hold')) return;244245 // Reset next payment date to avoid immediately triggering a renewal246 $subscription->set_date('next_payment', strtotime('+1 month'));247248 // Activate subscription249 $subscription->update_status('active', 'Reinstated by admin');250 $subscription->save();251}252```253254### Custom subscription email255256```php257<?php258259// Add a custom "Renewal Reminder" email 7 days before renewal260add_filter('woocommerce_email_classes', function ($email_classes) {261 require_once plugin_dir_path(__FILE__) . 'class-renewal-reminder-email.php';262 $email_classes['My_Renewal_Reminder_Email'] = new My_Renewal_Reminder_Email();263 return $email_classes;264});265266// Schedule the emails via WooCommerce action scheduler267add_action('woocommerce_scheduled_subscription_payment', function ($subscription_id) {268 $subscription = wcs_get_subscription($subscription_id);269 if (!$subscription) return;270271 // Schedule reminder 7 days before next payment272 $next_payment = strtotime($subscription->get_next_payment_date()) - (7 * DAY_IN_SECONDS);273 if ($next_payment > time()) {274 as_schedule_single_action(275 $next_payment,276 'my_plugin_send_renewal_reminder',277 [['subscription_id' => $subscription_id]]278 );279 }280}, 10);281```282283## Best Practices284285- **Use Action Scheduler** (bundled with WooCommerce) for all async subscription tasks — never rely on WP cron for payment-critical operations286- **Always use `wcs_get_subscription()` with null-checks** — subscriptions may be deleted or missing in edge cases (manual deletions, import failures)287- **Test payment gateway token handling** — subscription renewals require a stored payment token; test that the gateway correctly charges the original card on file during renewal288- **Handle failed payment dunning explicitly** — the default retry schedule is 1, 4, and 7 days after failure; customize via `wcs_retry_rules` filter to match your business policy289- **Log all subscription status changes** — store status change history in order notes or a custom table for auditing and customer support290- **Respect proration on plan switches** — don't force customers to pay double for the same period; use built-in proration or implement custom logic291- **Test upgrade/downgrade edge cases** — especially when a trial is active or when the billing period changes (monthly to annual)292- **Use `wcs_user_has_subscription()` for access control** — it's more reliable than checking user roles alone293294## Common Pitfalls295296| Problem | Solution |297|---------|----------|298| Renewal payments not processing | Check that the payment gateway has `supports[] = subscription` in its capabilities; gateways must explicitly declare subscription support |299| Subscription status stays "pending" after successful payment | The `woocommerce_payment_complete` action must fire — verify the payment gateway calls `$order->payment_complete()` on success |300| Proration results in negative charge | Implement minimum floor of `0.00` in the proration filter return value; negative amounts cause gateway errors on most processors |301| `wcs_get_subscriptions_for_product` returns empty | Pass the product's parent ID for variations — WCS stores the parent product ID, not the variation ID, on subscription items |302| Trial ends but renewal isn't charged | Ensure `_subscription_trial_length` meta is set to a number > 0 AND the payment gateway token is stored correctly from the initial order |303| Cancellation emails sent on admin status changes | Add `remove_action` for `woocommerce_subscription_status_cancelled` before programmatic cancellations and re-add after if you need to suppress emails |304305## Related Skills306307- @woocommerce-plugin-development308- @woocommerce-rest-api309- @subscription-billing310- @stripe-integration311- @woocommerce-blocks