← Back to all posts
September 12, 2026
•

Building a Custom Subscription Workflow in WooCommerce (Beyond the Default Plugin)

Wooden blocks spelling subscribe on a table, illustrating a custom subscription workflow in WooCommerce

The custom subscription workflow WooCommerce store owners describe to us usually starts with the sentence “WooCommerce Subscriptions almost does it”. The product is a meal plan that skips weeks, a SaaS seat count that changes mid-cycle, a membership with a paused state that must not exceed 60 days a year, or usage-based billing where the renewal amount is only known on the day it is charged. The default plugin handles fixed schedules well and everything else with difficulty.

This guide is for the store owner or CTO deciding whether to extend WooCommerce Subscriptions, switch to another plugin, or build the billing logic on WooCommerce’s own primitives. It covers the hooks, tables and scheduling approach we use in a custom subscription workflow build, with enough code to judge the effort honestly before you commit to it.

where the default plugin stops

WooCommerce Subscriptions (the official extension) stores each subscription as its own order-like object: the shop_subscription post type, or a row in wp_wc_orders with type = 'shop_subscription' under HPOS. It schedules renewals through Action Scheduler on the woocommerce_scheduled_subscription_payment hook, creates a renewal order, and asks the payment gateway to charge the saved token. That model is solid, and the hooks around it are stable enough to build on. Where it stops:

  • One product, one schedule. Billing interval and period live on the product. Letting a customer choose weekly or monthly for the same item means variations, and letting them change it mid-term means switching, which the plugin treats as a partial cancel plus a new subscription.
  • Proration is limited. Upgrades and downgrades prorate the sign-up fee or the recurring price, but not arbitrary usage.
  • Pauses are open-ended. Customers can put a subscription on hold if you allow it, but there is no built-in limit on duration or frequency.
  • Usage-based amounts are not native. The renewal total is the product price. Metered billing requires modifying the renewal order before it is paid.

Alternatives such as SUMO Subscriptions, YITH Subscription and Subscriptions for WooCommerce cover slightly different edges, but none of them handle a genuinely custom workflow without the same kind of code, so the plugin choice matters less than the design.

custom subscription workflow WooCommerce routes: extend, switch or build

Before choosing a route, write down the states, the events that move between them, and the actions each transition triggers. It fits on one page and it removes most of the ambiguity from the build. A typical version for a “pause with limits” workflow:

State Allowed transitions What happens
trial active (trial ends), cancelled No charge; next payment date set to trial end
active paused, past_due, cancelled Renewal charged on schedule; usage recorded
paused active (customer resumes or 30-day cap is hit) Next payment date pushed by pause length; pause days counted against the annual cap
past_due active (retry succeeds), cancelled (retries exhausted) Retry on days 1, 3 and 7; access restricted after day 3
cancelled none Access ends at period end; win-back email at day 14

Then answer the questions that determine whether you extend or build. Is the renewal amount known in advance? Does the schedule ever change per customer? Do you need seat or quantity changes mid-cycle with proration? Do you bill in more than one currency? If the answers are yes, no, no and no, extend the plugin. If two or more go the other way, the from-scratch section below is probably the cheaper route over two years, even though it costs more up front.

extending WooCommerce Subscriptions: the hooks that matter

Most custom subscription workflow WooCommerce projects we take on end up here, because the plugin’s renewal engine, gateway token handling and My Account screens are worth keeping. The customisation lives in a small must-use plugin that listens to a handful of hooks.

controlled pauses

// Allow a pause only if the customer has pause days left this year.
add_filter( 'woocommerce_can_subscription_be_updated_to_on-hold', function ( $can, $subscription ) {
    if ( is_admin() ) {
        return $can;
    }
    $used = (int) $subscription->get_meta( '_swd_pause_days_used' );
    return $can && $used < 60;
}, 10, 2 );

// When it resumes, push the next payment out by the pause length and record the days.
add_action( 'woocommerce_subscription_status_updated', function ( $subscription, $new_status, $old_status ) {
    if ( 'on-hold' !== $old_status || 'active' !== $new_status ) {
        return;
    }
    $paused_at = (int) $subscription->get_meta( '_swd_paused_at' );
    $days      = max( 1, (int) ceil( ( time() - $paused_at ) / DAY_IN_SECONDS ) );
    $next      = $subscription->get_time( 'next_payment' ) + $days * DAY_IN_SECONDS;

    $subscription->update_dates( [ 'next_payment' => gmdate( 'Y-m-d H:i:s', $next ) ] );
    $subscription->update_meta_data( '_swd_pause_days_used', (int) $subscription->get_meta( '_swd_pause_days_used' ) + $days );
    $subscription->save();
}, 10, 3 );

usage-based renewal amounts

The renewal order is created before it is charged, which gives you one clean place to add metered charges. Record usage during the period in a custom table (we use wp_swd_subscription_usage with subscription id, period start, units and a source), then sum it when the renewal is created:

add_filter( 'wcs_renewal_order_created', function ( $renewal_order, $subscription ) {
    global $wpdb;
    $units = (float) $wpdb->get_var( $wpdb->prepare(
        "SELECT COALESCE(SUM(units),0) FROM {$wpdb->prefix}swd_subscription_usage
         WHERE subscription_id = %d AND billed = 0",
        $subscription->get_id()
    ) );

    if ( $units > 0 ) {
        $fee = new WC_Order_Item_Fee();
        $fee->set_name( sprintf( 'Usage: %s units', $units ) );
        $fee->set_amount( $units * 0.12 );
        $fee->set_total( $units * 0.12 );
        $renewal_order->add_item( $fee );
        $renewal_order->calculate_totals();
        $renewal_order->save();

        $wpdb->update(
            "{$wpdb->prefix}swd_subscription_usage",
            [ 'billed' => 1 ],
            [ 'subscription_id' => $subscription->get_id(), 'billed' => 0 ]
        );
    }
    return $renewal_order;
}, 10, 2 );

Two more hooks cover most of the rest: woocommerce_subscription_payment_failed for dunning and woocommerce_subscription_renewal_payment_complete for provisioning, whether that is a licence key, a seat count in another system or a shipment. If the provisioning target is a CRM or ERP, queue it rather than calling the API inline; the pattern in our CRM integration guide works unchanged for renewals.

building from scratch on Action Scheduler and gateway tokens

When the schedule varies per customer, the amount is computed on the day, or the plugin’s data model keeps fighting you, a from-scratch billing engine is less code than it sounds. WooCommerce already provides the hard parts: saved payment methods (WC_Payment_Tokens), order creation, gateway charge methods and a reliable job queue. You add a subscription table and a scheduler.

The table is deliberately small: wp_swd_subscriptions with id, customer id, plan id, status, currency, next_charge_at, token id and a JSON column for plan overrides. Every state transition writes a row to wp_swd_subscription_events so support can see the history. Scheduling uses Action Scheduler, which ships inside WooCommerce and keeps working when WP-Cron is broken as long as a real system cron calls it:

// On creation, and again after each successful charge:
as_schedule_single_action( $next_charge_at, 'swd_charge_subscription', [ 'subscription_id' => $id ], 'swd-billing' );

add_action( 'swd_charge_subscription', function ( $subscription_id ) {
    $sub = swd_get_subscription( $subscription_id );
    if ( 'active' !== $sub->status ) {
        return;
    }
    $order = wc_create_order( [ 'customer_id' => $sub->customer_id, 'created_via' => 'swd_renewal' ] );
    $order->add_product( wc_get_product( $sub->plan_product_id ), $sub->quantity );
    $order->set_currency( $sub->currency );
    $order->update_meta_data( '_swd_subscription_id', $subscription_id );
    $order->calculate_totals();
    $order->save();

    $token  = WC_Payment_Tokens::get( $sub->token_id );
    $result = swd_charge_saved_token( $order, $token ); // thin wrapper around the gateway's off-session charge

    if ( $result['success'] ) {
        $order->payment_complete( $result['transaction_id'] );
        swd_advance_subscription( $sub );
    } else {
        swd_mark_past_due( $sub, $result['error'] );
    }
} );

The one gateway-specific piece is the off-session charge. With Stripe, that is a PaymentIntent created with off_session: true and confirm: true against the customer’s saved payment method, with the resulting id stored on the order. Handle the authentication_required error by emailing the customer a link to confirm, which is what Strong Customer Authentication requires in Europe. PayPal and Braintree have equivalent vaulted-payment flows.

Plan changes and proration are simpler in this model than in the plugin, because you own the arithmetic. When a customer moves from 5 seats to 8 on day 12 of a 30-day period, the engine records a pending adjustment of 3 seats for the remaining 18 days, charges it immediately or folds it into the next renewal depending on the plan’s rules, and updates the quantity on the subscription row. Every adjustment writes an event with the before and after values, which is what finance needs when reconciling revenue and what support needs when a customer disputes the amount.

dunning, notifications and the customer portal

Failed payments are where subscription revenue leaks. A retry schedule of days 1, 3 and 7, each retry scheduled through as_schedule_single_action, with a plain email before the final attempt, typically recovers a large share of soft declines such as expired cards and temporary holds. The update-card link should go straight to the WooCommerce Payment methods endpoint in My Account, which already handles adding a token to an existing customer.

Send those emails through WooCommerce’s own email system by extending WC_Email rather than calling wp_mail directly. That gives you the store’s template, the admin preview under WooCommerce > Settings > Emails, and the ability for the store owner to edit subject lines without a developer. Log every attempt and outcome against the subscription so that when a customer says they were never warned, support can see the send times and the retry results in one place.

Customer-facing controls (pause, resume, skip next delivery, change quantity) belong in My Account as a custom endpoint registered with add_rewrite_endpoint and added to the menu through woocommerce_account_menu_items. Each button submits to a REST route under your own namespace, with the same validation the state table defines. If you later want a mobile app or a headless front end, that REST layer is what it will consume, and our REST API integration guide covers the authentication and caching decisions for it.

testing and migrating without losing customers

Subscriptions are the one WooCommerce feature you cannot test by placing a single order, because the interesting behaviour happens weeks later. Stripe test clocks let you advance time on a test customer and watch renewals, retries and cancellations fire. Locally, Action Scheduler can be driven directly:

wp action-scheduler run --hooks=swd_charge_subscription --batch-size=25
wp action-scheduler action list --status=pending --group=swd-billing --per-page=50
wp db query "SELECT status, COUNT(*) FROM wp_swd_subscriptions GROUP BY status"

Migrating from an existing plugin is mostly a data mapping exercise, with one exception: card tokens. You cannot export raw card data, but you can move Stripe customers and their payment methods between accounts through Stripe’s own migration process, and existing rows in wp_woocommerce_payment_tokens stay valid as long as the gateway account does not change. Run the new engine in shadow mode for one full cycle (compute what it would charge, charge nothing) and compare against the old plugin before switching.

On the day of the switch, freeze new sign-ups for an hour, take a database backup, import the subscriptions with the new engine’s next charge dates set from the old plugin’s next_payment values, and spot-check a sample of twenty by hand. Keep the old plugin installed but inactive for a full billing cycle in case a comparison is needed.

when to do this yourself vs hire someone

Do it yourself if you are extending WooCommerce Subscriptions with one or two hooks, you have a developer who is comfortable with Action Scheduler and gateway tokens, and you can absorb a bug that charges the wrong amount to a handful of customers while you find it.

Hire someone when money is charged without a human in the loop and the workflow is more than a fixed schedule. Billing bugs are expensive in refunds, chargebacks and trust, and the fix is usually cheaper than the first incident. The other case is migration: moving live subscribers between billing systems is a one-shot operation that needs a plan, a rehearsal and a rollback. Whoever does it, the API integration work for provisioning and the billing engine should be scoped together, and the ongoing maintenance plan should include someone watching the queue.

next step

Describe the workflow in a few sentences (what is billed, how often, what can change mid-term) and send it with your store URL. We reply within 24 hours with whether it is an extension or a build, a fixed scope and a fixed price for the custom subscription workflow WooCommerce build, and you work directly with the engineer who writes it. Start the conversation here.

Share this post

keep reading

Portable external hard drive connected to a laptop, illustrating an automated WordPress backup system
September 22, 2026
•

Automated WordPress Backup System Setup: Offsite, Versioned and Actually Tested

Automated WordPress backup system setup done properly: offsite S3 storage, versioned retention, write-only credentials, hourly store backups, tested restores.

Read more
Page speed test results on a monitor, illustrating Core Web Vitals optimization for WordPress
September 20, 2026
•

Core Web Vitals Optimization for WordPress: LCP, INP and CLS Fixes That Actually Work

Core Web Vitals optimization for WordPress, metric by metric: the LCP, INP and CLS fixes in theme, plugins and server that move…

Read more
Glass cloud icon with data layers above a padlock, illustrating AWS hosting migration for WordPress
September 18, 2026
•

AWS Hosting Migration for WordPress: Lightsail vs EC2 vs Managed Hosting

AWS hosting migration for WordPress compared: Lightsail vs EC2 with RDS vs managed hosting, the migration steps we run, and what breaks…

Read more

want this kind of thinking on your project?

Tell us what you are building and we'll come back with a straight answer on scope, timeline and cost within 24 hours.
start a project