Build WooCommerce Subscriptions
KINDAreplaces $23.25/mosaves $279/yrback to the verdict
A small set of plans sold through hosted checkout, webhooks as the only writer of entitlement, renewals that handle an authentication requirement by asking the customer rather than failing silently, a dunning sequence with a grace period, and self-service cancellation that does not need you.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree if you already have one
Why This is billing logic bolted to something that already exists. Building a store first is a different project.
Get it Use the store you already run, or a small application with user accounts. What matters is that there is something to gate.
Verify
A gated page exists that you can protect - API keyfree in test mode; live billing costs per transaction plus a further fee on subscriptions in most regions
Why Hosted checkout, saved cards, subscription events and the authentication flow all come from here.
Get it Sign up, create prices for your plans, take the secret key and the webhook signing secret.
Verify
A triggered subscription event reaches your endpoint and is verified - have readyfree
Why Phase 3 is the whole point of this build and it cannot be tested with a card that always succeeds.
Get it Your provider publishes test card numbers including ones that always require authentication and ones that fail on renewal. Find them before Phase 3.
Verify
A renewal produced a requires-action state in test mode - API keyfree tiers cover this volume
Why The authentication link and the dunning sequence both depend on delivery, and one in spam is a lost customer.
Get it Create an account, add the DNS records they specify, take the credentials.
Verify
A test email arrives at three providers and is not in spam - decidefree
Why How long a customer keeps access after a failed payment is a commercial decision rather than a technical one, and it should be decided before it is implemented.
Get it Decide the grace period, the number of retries and when access ends. Write it down and put it in your terms.
Verify
The policy is written and matches the configuration
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- plans(id, name, price_minor, currency, interval, provider_price_id, active) - customers(id, email, provider_customer_id, created_at) - subscriptions(id, customer_id, plan_id, provider_subscription_id, state, current_period_end, cancel_at_period_end, grace_until) - invoices(id, subscription_id, provider_invoice_id, amount_minor, state, attempted_at, authentication_url, paid_at) - entitlements(customer_id, plan_id, granted_at, revoked_at, reason) · the only thing any access check reads - webhook_events(provider_event_id, type, processed_at) · idempotency, because events are redelivered - State is derived from invoices and entitlements rather than set by hand, so the database cannot disagree with itself.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
STRIPE_SECRET_KEYsecret | required | sk_test_... | Stay in test mode until reconciliation and the authentication path are both proved. |
STRIPE_WEBHOOK_SECRETsecret | required | whsec_... | From the registered endpoint or the listen command. |
GRACE_PERIOD_DAYS | required | 7 | How long access persists after a failed or pending renewal. A commercial decision, stated in your terms. |
DUNNING_ATTEMPTS | required | 4 | Retry attempts before access ends. Bounded, with one email each. |
EMAIL_FROM | required | billing@example.com | On the verified sending domain, since billing email that lands in spam costs customers. |
ACCOUNT_PORTAL_URL | required | https://example.com/account | Where customers manage and cancel without contacting you. |
The build, in order
Entitlement as the single gate
Every access check reads one table, before any money is involved.
Customer and plan to a boolean with a reason. Nothing else decides access anywhere.
Files
src/access.phpRoutes, not templates. A hidden link is not a gate.
Files
src/gates.phpWalk the routes, request each with and without entitlement, assert both outcomes.
Files
tests/GatesTest.php
done when · tick each as it passeswatch out- Checking a subscription's state at each gate spreads billing logic through the application, and the first time a state is added, half the gates are wrong. One entitlement table, one function, and subscriptions write to it rather than being consulted.
First payment and the webhook
Someone can subscribe, and only the webhook grants access.
Prices from your own table. Never an amount supplied by the client.
Files
src/checkout.phpSignature verified, event identifiers recorded, entitlements written there and nowhere else.
Files
src/webhook.phpThe thank-you page may load first. Say so gracefully rather than showing no access.
Files
src/return.php
done when · tick each as it passeswatch out- Granting on the success redirect hands out access to anyone who can replay a URL, and the customer may close the tab before it loads anyway. The webhook is the truth and the redirect is a thank-you page that must handle arriving before the entitlement exists.
The renewal that needs the customer
A renewal requiring authentication asks the customer instead of failing.
It is a normal outcome, not an error. Store the authentication link from the invoice.
Files
src/renewals.phpThe customer cannot act on something they were not told about.
Files
src/dunning.phpProviders offer test cards that always require it. Use one rather than hoping.
done when · tick each as it passeswatch out- Treating a payment that requires action as a failure and revoking access immediately punishes a customer for their bank's prompt, which they have not seen because they were not present. Expect this state as routine, keep access, ask them, and give them time.
Dunning without nagging
A failed payment is retried and communicated a fixed number of times, then resolved.
A fixed number of attempts over a fixed window. Unbounded retries are just noise.
Files
src/dunning.phpAt send time, not at queue time. This is what prevents chasing a paid customer.
Files
src/dunning.phpSo access ending is not the first the customer hears of it.
Files
src/dunning.php
done when · tick each as it passeswatch out- Continuing to email after a customer has paid or cancelled is the failure people remember and complain about publicly. Check the current state before every send rather than trusting the schedule that queued it, since the state may have changed between queuing and sending.
Cancellation, upgrades and the money in between
A customer can change or cancel without you, and the amounts are right.
Through the provider's portal or your own page. Requiring an email is a dark pattern.
Files
src/account.phpAnd compare against the provider's figure. A mismatch is a bug rather than rounding.
Files
src/proration.phpAmount and date. It is the question every subscriber has.
Files
src/account.php
done when · tick each as it passeswatch out- Proration arithmetic in floating point produces amounts that disagree with the provider's by a penny, and reconciling that at scale is miserable. Compute in integer minor units and reconcile against the provider's own figure rather than trusting yours.
Reporting, refunds and going liveproduct builder
You can answer who is subscribed and what happened to the money.
Counts and revenue for the same window. A gap is a bug, not a rounding difference.
Files
src/reports.phpVoluntary and involuntary. They have different fixes and mixing them hides the cheaper one.
Files
src/reports.phpRevoke entitlement. Trigger both in test mode before going live.
Files
src/webhook.phpterminalstripe trigger charge.refunded
done when · tick each as it passeswatch out- Involuntary churn from failed payments is usually a larger share of cancellations than people expect, and it is invisible unless you separate it from customers who chose to leave. Report it separately, because the fix is a billing fix rather than a product one.
That is the whole plan for WooCommerce Subscriptions. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- The plugin's breadth of subscription types and store configurations.
- Tax determination and remittance.
- Migrating an existing subscriber base between processors.
- Vendor support when something breaks mid-renewal-cycle.
- Integration with the WooCommerce order, coupon, tax and shipping machinery, so subscriptions are no longer just orders your other plugins understand
- Gateway breadth: you are married to one processor instead of the dozen Woo supports
- Customer-facing plan switching, pausing, resubscribing and prorated upgrades in your own UI, unless you accept the hosted portal as-is
- Admin reporting, renewal calendars and bulk actions over thousands of subscribers
- Years of edge case handling for partial renewals, mixed carts, manual renewals and payment method migrations
- Annual plans with a discount, which is a second price and a different proration case
- A pause rather than cancel option, which retains more customers than any dunning change
- A revenue report by cohort, once you have enough months for it to say anything
Need the files? The project pack on the verdict page hands your agent the whole brief · more commerce.