WooCommerce Subscriptions: renewal scheduler
Use this when code touches renewal timing, scheduled payments, renewal order creation, or retry behavior. The key rule: change subscription dates/status through WC_Subscription methods, not by writing schedule meta or Action Scheduler rows directly.
Misconception this skill corrects
"To reschedule a subscription, update
_schedule_next_paymentor callas_schedule_single_action()."
That bypasses WCS validation and the scheduler's cleanup/reschedule behavior. WCS_Scheduler listens to woocommerce_subscription_date_updated, woocommerce_subscription_date_deleted, and woocommerce_subscription_status_updated. Those fire when you use WC_Subscription::update_dates(), delete_date(), or update_status().
When to use this skill
Trigger when ANY of the following is true:
- The user wants to change
next_payment,trial_end,end,payment_retry, or renewal timing. - Code contains
_schedule_next_payment,_schedule_payment_retry,as_schedule_single_action,woocommerce_scheduled_subscription_payment,WCS_Action_Scheduler,wcs_create_renewal_order, orWCS_Retry_Manager. - A gateway or integration needs to charge recurring payments, create renewal orders, react after a renewal succeeded, or handle failed renewal retries.
Renewal flow
For a normal WCS-managed automatic renewal:
- A future Action Scheduler action exists in group
wc_subscription_scheduled_event. - The action hook is
woocommerce_scheduled_subscription_paymentwith argsarray( 'subscription_id' => $id ). WC_Subscriptions_Manager::prepare_renewal()runs at priority1. It puts the subscription on hold and creates a renewal order withwcs_create_renewal_order().wcs_renewal_order_createdfires after the renewal relation is stored.WC_Subscriptions_Payment_Gateways::gateway_scheduled_subscription_payment()runs at priority10.- For non-manual gateways that do not manage their own schedule, WCS triggers
woocommerce_scheduled_subscription_payment_{gateway_id}with$amount, $renewal_order. - After payment result, use
woocommerce_subscription_renewal_payment_completeorwoocommerce_subscription_renewal_payment_failedfor business side effects.
Do not fulfill, ship, grant service, or call an external "success" API on woocommerce_scheduled_subscription_payment; at that point the renewal may not be paid yet.
Safe date changes
$subscription = wcs_get_subscription( $subscription_id );
if ( ! $subscription instanceof WC_Subscription ) {
return;
}
// UTC MySQL datetime. This updates WCS props, validates ordering, saves,
// and lets WCS_Action_Scheduler reschedule the matching action.
$subscription->update_dates(
array(
'next_payment' => '2026-05-15 10:00:00',
),
'gmt'
);
Use these date keys:
| Date type | Meaning | Scheduled hook |
|---|---|---|
next_payment |
Next renewal payment due date | woocommerce_scheduled_subscription_payment |
trial_end |
Trial expiry | woocommerce_scheduled_subscription_trial_end |
end on active subscription |
Subscription expiration | woocommerce_scheduled_subscription_expiration |
end on pending-cancel/cancelled |
End of prepaid term | woocommerce_scheduled_subscription_end_of_prepaid_term |
payment_retry |
Retry for last renewal order | woocommerce_scheduled_subscription_payment_retry |
update_dates() is strict. It throws if a date is invalid or out of order. If you are importing messy external data and want to keep valid values while ignoring invalid ones, use update_valid_dates() and still catch exceptions for impossible ordering.
try {
$subscription->update_valid_dates( $dates, 'gmt' );
} catch ( InvalidArgumentException $e ) {
wc_get_logger()->warning( $e->getMessage(), array( 'source' => 'myplugin-subscriptions-import' ) );
}
Force a renewal safely
Prefer triggering WCS's normal scheduled-payment action when you want "run the renewal flow now":
$subscription = wcs_get_subscription( $subscription_id );
if ( $subscription instanceof WC_Subscription && $subscription->has_status( 'active' ) ) {
do_action( 'woocommerce_scheduled_subscription_payment', $subscription->get_id() );
}
This uses the same flow as the scheduled action: status handling, renewal order creation, and gateway hook dispatch. If you only need to create a pending renewal order for later payment, call wcs_create_renewal_order() directly:
If the payment method supports gateway_scheduled_payments, the gateway owns its own recurring schedule and WCS will not create/charge the normal renewal order from this hook.
$renewal_order = wcs_create_renewal_order( $subscription );
if ( is_wp_error( $renewal_order ) ) {
wc_get_logger()->error( $renewal_order->get_error_message(), array( 'source' => 'myplugin-renewals' ) );
return;
}
$renewal_order->update_meta_data( '_myplugin_external_id', $external_id );
$renewal_order->save();
When decorating renewal orders, wcs_renewal_order_created is usually better than wrapping wcs_create_renewal_order() everywhere:
add_filter( 'wcs_renewal_order_created', function ( WC_Order $renewal_order, WC_Subscription $subscription ): WC_Order {
$renewal_order->update_meta_data( '_myplugin_subscription_source', $subscription->get_id() );
$renewal_order->save();
return $renewal_order;
}, 10, 2 );
Gateway recurring charge hook
Payment gateways that charge WCS-managed renewals should hook the gateway-specific dynamic action:
add_action( 'woocommerce_scheduled_subscription_payment_my_gateway', function ( $amount, WC_Order $renewal_order ): void {
$subscription_ids = wcs_get_subscription_ids_for_order( $renewal_order, 'renewal' );
// Charge the saved token. On success call $renewal_order->payment_complete().
// On failure call $renewal_order->update_status( 'failed', ... ).
}, 10, 2 );
The suffix is the payment method ID stored on the renewal order. Do not use the old scheduled_subscription_payment_{gateway} hook; WCS keeps compatibility shims, but new code should use the woocommerce_ hook.
Correct success/failure hooks
| Need | Hook | Args |
|---|---|---|
| Provision after any subscription payment | woocommerce_subscription_payment_complete |
WC_Subscription $subscription |
| Provision after renewal payment only | woocommerce_subscription_renewal_payment_complete |
WC_Subscription $subscription, WC_Order $last_order |
| React to any payment failure | woocommerce_subscription_payment_failed |
WC_Subscription $subscription, string $new_status |
| React to renewal payment failure | woocommerce_subscription_renewal_payment_failed |
WC_Subscription $subscription, WC_Order $related_order |
| Customer paid a failed renewal | woocommerce_subscriptions_paid_for_failed_renewal_order |
WC_Order $renewal_order, WC_Subscription $subscription |
Retry flow
WCS retries are their own objects/rules. Do not reschedule retries by hand unless replacing the retry system.
| Need | Hook/filter | Use |
|---|---|---|
| Toggle retry feature | wcs_is_retry_enabled |
Disable/enable retry handling. |
| Change retry cadence | wcs_default_retry_rules |
Replace default rules array. |
| Modify a specific retry | wcs_get_retry_rule_raw or wcs_get_retry_rule |
Customize by retry number/order. |
| Before/after retry rule applied | woocommerce_subscriptions_before_apply_retry_rule, woocommerce_subscriptions_after_apply_retry_rule |
Observe creation of retry schedule. |
| Before/after retry payment | woocommerce_subscriptions_before_payment_retry, woocommerce_subscriptions_after_payment_retry |
Wrap the actual retry attempt. |
wcs_is_scheduled_payment_attempt is a filter around WCS's internal doing_action() check, not a public getter. If your own code needs to know whether it is inside a scheduled payment attempt, check the actions directly:
add_filter( 'woocommerce_email_enabled_customer_processing_renewal_order', function ( bool $enabled ): bool {
if ( doing_action( 'woocommerce_scheduled_subscription_payment' ) || doing_action( 'woocommerce_scheduled_subscription_payment_retry' ) ) {
return false;
}
return $enabled;
} );
Only hook wcs_is_scheduled_payment_attempt when you intentionally need to override WCS retry detection.
Scheduler customization
Only use these when you intentionally extend WCS scheduling. For ordinary next-payment changes, use update_dates().
add_filter( 'woocommerce_subscriptions_date_types_to_schedule', function ( array $date_types ): array {
$date_types[] = 'myplugin_followup';
return array_unique( $date_types );
} );
add_filter( 'woocommerce_subscriptions_scheduled_action_hook', function ( string $hook, string $date_type ): string {
return 'myplugin_followup' === $date_type ? 'myplugin_subscription_followup' : $hook;
}, 10, 2 );
add_action( 'myplugin_subscription_followup', function ( int $subscription_id ): void {
$subscription = wcs_get_subscription( $subscription_id );
if ( $subscription instanceof WC_Subscription ) {
myplugin_send_followup( $subscription );
}
} );
If you change scheduled args with woocommerce_subscriptions_scheduled_action_args, keep them deterministic. WCS uses the args to find and unschedule existing actions.
Common mistakes
// WRONG: bypasses WCS date validation and Action Scheduler cleanup.
update_post_meta( $subscription_id, '_schedule_next_payment', '2026-05-15 10:00:00' );
// RIGHT:
$subscription = wcs_get_subscription( $subscription_id );
$subscription->update_dates( array( 'next_payment' => '2026-05-15 10:00:00' ), 'gmt' );
// WRONG: create a duplicate AS action with different args/group.
as_schedule_single_action( time() + DAY_IN_SECONDS, 'woocommerce_scheduled_subscription_payment', array( $subscription_id ) );
// RIGHT: set the subscription date and let WCS schedule the canonical action.
$subscription->update_dates( array( 'next_payment' => gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ) ), 'gmt' );
// WRONG: fulfillment at scheduled-payment time, before payment succeeds.
add_action( 'woocommerce_scheduled_subscription_payment', 'provision_customer' );
// RIGHT:
add_action( 'woocommerce_subscription_renewal_payment_complete', 'provision_customer_after_renewal', 10, 2 );
What this skill does NOT cover
- Building the full gateway tokenization/payment-method-change UI.
- General HPOS compatibility for raw order queries. Use
wc-hpos-compatibility. - A complete hook catalog. Use
wcs-subscription-hooksfor broader hook selection.
Cross-references
- Run
wcs-subscription-hookswhen you need a broader action/filter map beyond renewal timing. - Run
wc-hpos-compatibilitybefore writing SQL orWP_Queryover subscriptions/orders.