← Back to blog

How to test a WooCommerce subscription renewal when nobody is at the keyboard

The SandyWP team 7 min read

Clone the store, let the scheduler fire the renewal on its own, and check three separate things: that woocommerce_scheduled_subscription_payment ran, that the saved card cleared without the customer present, and that the renewal order, status and emails came out right. Clicking Process Renewal in wp-admin tests none of the first one.

That button is the method every guide leads with, and it is the one method that removes the failure most renewals actually hit.

The three layers, and why they fail separately

A checkout has a customer in front of it. A renewal has nobody. Everything that would normally be caught by a human looking at a screen has to work unattended, weeks after the code that set it up ran.

WooCommerce documents the mechanism: when a renewal is due, the system runs the woocommerce_scheduled_subscription_payment event in Action Scheduler, which creates a renewal order and charges "the payment gateway used for the initial purchase" (renewal process).

Three things have to happen in order.

  1. The scheduled action runs. Action Scheduler has to be processing its queue at all.
  2. The off-session charge succeeds. The stored payment token is charged with no browser, no customer, no 3DS challenge available.
  3. The side effects land. Renewal order created, subscription status moved, emails sent, access extended.

Process Renewal starts at step 2. It is a fine test of the gateway integration and useless as a test of the schedule.

Before anything: a clone of a live store has live gateway keys

This is the part to get right before you touch a subscription.

When you copy a production store, you copy wp_options, and the payment gateway's live API keys are in wp_options. So is every customer's saved payment token. Click Process Renewal on that copy and you charge a real person's real card, from a site they have never heard of.

The same applies to mail. WP-Cron on a copied store will happily run the renewal reminder, the failed-payment notice and the credit-card-expiry email against the real subscriber list.

So the first three commands on a fresh copy, over SSH, are damage control, not testing:

wp option update woocommerce_stripe_settings --format=json '{"enabled":"yes","testmode":"yes"}'
wp user list --field=user_email | head
wp cron event list

Check the gateway is in test mode, look at whose email addresses you are holding, and see what is queued before anything runs. On SandyWP, outgoing mail is intercepted by default: Email Log captures wp_mail() inside the sandbox and, with no SMTP configured, nothing reaches a real inbox. That covers WordPress mail. It does not cover the gateway, which emails receipts from its own servers using the API key you just copied.

Layer 1: did the scheduled action actually run?

Set the next payment date to the near past, then leave it alone and see whether the site renews without you.

wp eval 'echo wcs_get_subscription( 123 )->get_date( "next_payment" );'
wp action-scheduler list --hook=woocommerce_scheduled_subscription_payment --status=pending

If the action is pending and stays pending, the subscription is not the problem. The queue is. Force one batch and watch what happens:

wp action-scheduler run --hooks=woocommerce_scheduled_subscription_payment --batch-size=1

Two details from the Action Scheduler CLI documentation matter here. The default batch size is 100 actions, and --force overrides a concurrency limit that "ensures the server does not get overwhelmed". Both exist because this queue is shared with everything else on the site.

That is also the trap in a cloned store. Every scheduled renewal in the copy is now overdue, so the first time cron runs you get the whole backlog at once rather than the single action you meant to test. Run one hook with --batch-size=1 and keep DISABLE_WP_CRON on until you are ready.

The other failure mode is the timeout. WooCommerce marks an action failed when it has "been 'running' for more than five minutes, at which point Subscriptions considers it failed, and marks it accordingly", and their guidance points at a short PHP time limit and fatal errors as the usual causes (scheduled action errors).

Fatal errors are the tractable one. Turn on debug mode so WP_DEBUG_LOG is writing, run the single action, then read the log:

tail -f wp-content/debug.log

A renewal that dies in a hook callback leaves a stack trace there and leaves nothing at all in the order list.

Layer 2: an off-session charge is not a checkout

This is the failure class that only exists on renewals, and it is why a passing checkout test tells you nothing about month two.

At checkout the customer is present, so the gateway can throw up a 3D Secure challenge and they can complete it. At renewal there is no one to challenge. If the bank asks for authentication, the charge fails.

Stripe publishes test cards for exactly this, and the descriptions are worth reading carefully:

  • 4000002500003155 "requires authentication for off-session payments unless you set it up for future payments. After you set it up, off-session payments no longer require authentication."
  • 4000008260003178 requires authentication for one-time payments, and "All payments are declined with an insufficient_funds failure code even after being successfully authenticated or previously set up."
  • 4000000000009995 declines with insufficient_funds.

(All from Stripe's testing reference.)

The first one is the useful test. It passes at checkout and passes at renewal only if your gateway integration saved the card correctly for future use. If the integration took a one-off payment and kept the token anyway, this card is how you find out, on a copy, instead of finding out from a customer whose access lapsed.

The second and third exist so you can test the failure path, which on a subscription store is a whole feature: the retry rule, the on-hold transition, and the email that asks the customer to update their card. Test that with a real declined charge, not by editing the subscription status by hand.

Run each of these three cards through its own subscription on the same copy, then compare what the store did.

Layer 3: check the side effects, not the status badge

A renewal that says active in the subscriptions list has not necessarily done its job. Look at what came out of it.

wp post list --post_type=shop_order --field=ID --posts_per_page=3
wp eval 'print_r( wcs_get_subscription( 123 )->get_related_orders( "ids", "renewal" ) );'

Four things to confirm, in this order:

  • A renewal order exists and is linked to the subscription, not an orphaned order with the right total.
  • The next payment date moved forward by exactly one billing period. A date that did not move means the action ran and the subscription did not.
  • The right emails fired. Open the Email Log and read the rendered body. A renewal receipt with a broken order table or a missing customer name is a real bug that only ever appears on renewals.
  • Access actually extended. If a membership, LMS or download plugin hangs off the subscription, the renewal is only successful if that plugin saw it. Check the thing the customer would check.

That last one is where third-party integrations break, because they listen for a subscription status transition that a hand-edited status change does not always emit.

Do it on a copy, and throw it away

Renewal testing writes permanent data. Renewal orders, order numbering, retry counts, subscription notes and Analytics rows all persist, and there is no clean way to unpick a test run from a real store's reporting.

Two habits make this cheap. Clone the production store so you are testing the real plugin stack and the real subscription data rather than a fresh install with one test product. Then save the configured copy as a template once the gateway is in test mode and the cards are set up, so the next renewal test starts from a known state instead of a fresh clone and another round of key-scrubbing.

If you drive this from a terminal, the CLI gives you the same sandbox with sandywp ssh <name> --cmd "wp action-scheduler list", which is enough to script the whole loop.

What a sandbox will not tell you

Being honest about the edges is what makes the rest of this worth trusting.

  • Live-mode behaviour. Test-mode Stripe is not your bank. Real declines, real 3DS challenge rates and real card-expiry churn only exist in production.
  • The passage of time. A renewal you force is not a renewal that sat for 30 days. Card tokens expire, gateways rotate credentials, and account updater services change card numbers underneath you. Stripe's Test Clocks cover some of this on the gateway side and nothing on the WordPress side.
  • Your host's cron. If production runs a real system cron and the sandbox runs WP-Cron, you are testing the callback, not the trigger. Our WordPress cron testing post covers that split.
  • Load. Ten subscriptions renewing in a copy is not a thousand renewing at midnight on the first of the month, which is the scenario that produces the five-minute timeout in the first place.

For those, watch the first real renewal cycle in production with logging on. For everything else, a disposable copy answers the question in about ten minutes, and nobody gets charged.

The short version

Set the next payment date into the past, do not touch Process Renewal, and let Action Scheduler run one action. Then check the log, the charge, the renewal order and the email.

If all four are right, the renewal works. If you clicked the button, you tested a third of it.