WooCommerce Subscriptions & Bookings Customization: Two Client Stories
Two client stories on one page: a coffee and meal-kit subscription store with about 12,000 subscribers, and a wellness clinic taking about 8,500 bookings a month. Both needed behaviour that stock WooCommerce plugins do not provide.
- Client
- Coffee / meal-kit subscription store and a wellness clinic
- My role
- Lead WooCommerce engineer
- WooCommerce Subscriptions
- WooCommerce Bookings
- Stripe
- PayPal
- Action Scheduler
- Twilio SMS
- Google Calendar API
- PHP
- active subscribers (Client A)
- ≈ 12,000
- ARR on subscriptions (Client A)
- ≈ $1.8M
- churn after skip/pause + free switch (Client A)
- -18%
- bookings a month (Client B)
- ≈ 8,500
- no-shows (Client B)
- -31%
active subscribers (Client A)
ARR on subscriptions (Client A)
churn after skip/pause + free switch (Client A)
bookings a month (Client B)
no-shows (Client B)
The challenge
Client A, a coffee and meal-kit subscription store, had customers who wanted to change box size mid-cycle, skip a delivery or pause indefinitely. The default WooCommerce Subscriptions plan-switch proration charged a surprise amount. Stripe and PayPal webhooks also sometimes arrived out of order and created duplicate renewal orders.
Client B, a wellness clinic, needed deposits, prepaid packages of 10 sessions, two-way Google Calendar sync and SMS reminders. Daylight saving changes were causing double-bookings when the clocks changed.
What I built
- 01
Client A: custom proration on plan switch
Customers are charged only the difference for the remaining cycles, with a free switch once every 90 days as an incentive.
- 02
Client A: skip and pause flow
A custom endpoint on the My Account subscription page, with resume-date logic and a banner that tells the customer what their next box is.
- 03
Client A: prepaid gift subscriptions
A custom product type that converts to a normal subscription at the end of its term.
- 04
Client A: idempotent Stripe and PayPal webhook handler
Every event is keyed by the gateway event ID and processed once. Events that arrive before the local order exists are retried after a delay.
- 05
Client A: SMS reminders with Twilio
A text goes out 24 hours before renewal, to help recover failed payments.
- 06
Client B: prepaid packages and deposits
A prepaid package product type decrements a session counter and books against it. Deposits are a custom booking flow: part paid upfront, the balance on the day.
- 07
Client B: Google Calendar sync
Two-way sync with conflict detection and a nightly full resync that heals events deleted on the Google side (410 responses).
- 08
Client B: DST-safe scheduling
All timestamps are stored in UTC and converted at the display and booking layer with DateTimeZone.
Gotchas and how I fixed them
Client A: the Stripe webhook race
checkout.session.completed could arrive before WooCommerce had committed the order. Sleeping and retrying is not a fix. The event is persisted to a custom table, the handler returns 200 straight away, and Action Scheduler reprocesses it with a 30-second delay and a 3-attempt backoff. This one change removed about 200 duplicate renewal orders a year.
Client A: plan-switch edge cases
Proration breaks in three places: switches mid-cycle, switches with a sign-up fee, and switches where the old plan was on a free trial. Test with wcs_get_subscriptions_for_order() and a matrix of start dates.
Client B: timezone mixing
WooCommerce Bookings stores dates in site time, while Google Calendar returns UTC. Normalize at the boundary and never mix the two.
Client B: Google Calendar quota
The free quota is easy to blow out during bulk imports. Requests are batched with exponential backoff, and there is a "resync" admin action.
Code from the build
function my_process_gateway_event( array $event ): void {
// Atomic insert — survives concurrent requests and cache misses.
global $wpdb;
$inserted = $wpdb->query( $wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->prefix}gateway_events (event_id, created_at)
VALUES (%s, %s)",
$event['id'], current_time( 'mysql', true )
) );
if ( ! $inserted ) {
return; // already seen — safe to return 200 to the gateway
}
// ... process the event; if it fails, delete the row so retry can work.
}wp_cache_add is not atomic under the default object cache. For webhooks, use a DB unique index — it's the only reliable mutex across PHP workers.
add_filter( 'wcs_subscription_plan_switch_payment_required', function ( $payment_required, $subscription, $new_plan ) {
$last = (int) $subscription->get_meta( '_free_switch_at' );
if ( ! $last || ( time() - $last ) > 90 * DAY_IN_SECONDS ) {
$subscription->update_meta_data( '_free_switch_at', time() );
$subscription->save();
return false; // waive prorated charge
}
return $payment_required;
}, 10, 3 );Test against trials, sign-up fees, and mid-cycle switches.
function my_local_to_utc( string $local, string $tz ): DateTimeImmutable {
return ( new DateTimeImmutable( $local, new DateTimeZone( $tz ) ) )
->setTimezone( new DateTimeZone( 'UTC' ) );
}Store UTC, display local, never compare a "local" string to a UTC timestamp — this was the root cause of the DST double-booking bug.
Results
- Client A: about 12,000 active subscribers and about $1.8M ARR on subscriptions.
- Client A: churn down about 18% after the skip/pause flow and free switch launched.
- Client A: failed-payment recovery up about 23% with SMS reminders.
- Client A: renewal order creation time cut from about 2.1s to about 450ms by removing a per-renewal remote API call and batching it.
- Client A: about 200 duplicate renewal orders a year removed by the webhook fix.
- Client B: about 8,500 bookings a month, no-shows down about 31%.
- Client B: double-bookings eliminated, down from 3–5 a month around DST changes.
Questions people ask
How do I stop Stripe webhooks from creating duplicate WooCommerce renewal orders?
Key every event by the gateway event ID and insert it into a table with a unique index, so each event is processed once. If an event arrives before the order exists, save it, return 200 and reprocess it with Action Scheduler (30-second delay, 3 attempts). On the subscription client this removed about 200 duplicate renewals a year.
Can WooCommerce Subscriptions allow a free plan switch once in a while?
Yes. The wcs_subscription_plan_switch_payment_required filter lets you waive the prorated charge. For Client A, the first switch in any 90 days is free, and later switches pay only the difference for the remaining cycles. Test trials, sign-up fees and mid-cycle switches.
Why does WooCommerce Bookings double-book when the clocks change?
Local time strings were being compared with UTC timestamps. Storing everything in UTC and converting with DateTimeZone only at the display and booking layer fixed it. Double-bookings dropped from 3–5 a month to zero.
Can WooCommerce Bookings sync with Google Calendar in both directions?
Yes. For the wellness clinic I built two-way sync with conflict detection, a nightly full resync for events deleted on Google's side, and batched requests with exponential backoff to stay inside the API quota. Bookings dates are normalized to UTC at the boundary.