Hiding a button is not compliance
Most WooCommerce stores implement the EU right of withdrawal as a policy page. A few build a returns workflow. Very few build something that would survive being tested — and the difference comes down to where the deadline is checked.
Most WooCommerce stores handle the EU right of withdrawal with a policy page and an email address. A smaller number build a returns workflow. Very few build something that would hold up if it were ever actually examined.
The gap between those three is worth understanding, because it's the difference between documenting an obligation and meeting it.
What the obligation actually is
Under EU consumer law, a buyer can withdraw from an online purchase within 14 days. Three things follow from that, and most implementations miss all three.
The window is fixed by law, not by policy. You can be more generous than 14 days. You cannot be less, and you cannot make it conditional on your own approval.
A withdrawal is a declaration, not a request. The customer isn't asking permission. Your system's job is to receive the declaration and record it, not to adjudicate it.
You need to be able to prove what happened. Potentially years later, potentially after the order has been archived and the person who handled it has left.
A policy page and an inbox satisfies none of these. It puts the burden on the customer to compose something valid, on your staff to interpret it, and on you to reconstruct events from memory when it matters.
Where client-side checks fail
The most common half-solution is a returns workflow where the withdrawal button disappears after 14 days.
Here's the test I'd apply to any implementation, including my own: what happens if someone sends a request directly to the endpoint on day 20?
If the answer is "it goes through," the deadline isn't enforced. It's decorative. You've built a user interface that suggests a boundary rather than a system that has one. Nobody needs to be malicious for this to matter — a stale browser tab, a cached page, a bookmarked URL, or a bot replaying an old request will all find the same gap.
The reverse case is worse and less obvious. A client-side-only check can also wrongly deny someone inside the window if their clock is off or the page was cached, which turns a statutory right into a support ticket.
The rule is simple: if a boundary has legal meaning, it has to be checked somewhere the client can't reach.
What the customer sees
Order #TDS-4471 · Terracotta planter
Presentation only. A suggestion, not a boundary.
What the server does
Re-evaluated on every request.
Compute eligibility, don't store it
The second common mistake is storing eligibility as a flag on the order — withdrawal_open: true — and flipping it with a scheduled job.
This creates a category of bug that's very hard to see. If the job doesn't run, the flag is wrong. If it runs twice, the flag might be wrong. If an order's date is corrected, the flag doesn't follow. Now the state of a legal right depends on the reliability of a cron task.
Compute it instead. Eligibility is a function of the order record and the current time, evaluated at the moment of the request. There is no stored state to drift, nothing to reconcile, and no scheduled job in the critical path of a compliance obligation.
The button in the interface can be driven by the same function — but the interface is the convenience, and the server-side evaluation is the enforcement.
Retention outlives the order
The third piece is the one that gets deferred and then forgotten.
Storing the withdrawal record as order metadata feels natural, and it's insufficient. Orders get archived. Plugins get replaced. Bulk operations run. Store migrations drop metadata nobody recognised as important. The record you need is the one that has to survive all of that, because the moment you need it is precisely the moment the original context is gone.
Persist withdrawal records in their own table, keyed to the order's unique identifier rather than embedded in the order object. Record what was declared, when it was received, and what was decided. Never overwrite — append. If a request is later rejected, that's a second record, not an edit to the first.
The question you're building for isn't "what's the status of this withdrawal." It's "show me what happened, in order, three years ago."
What I'd check in an existing store
If you have a withdrawal or returns flow live right now, four questions:
- Send a request to the endpoint outside the window. Does it succeed?
- Is eligibility computed at request time, or read from a stored flag?
- Does the customer receive a copy of their own declaration?
- If the order were archived tomorrow, would the withdrawal record still exist?
If any of those come back badly, the fix is usually smaller than the exposure. Most of this is an afternoon of work — it's just an afternoon that's easy to keep postponing, because nothing is visibly broken until it is.
I built this system for an Italian retailer selling across the eurozone, to their legal counsel's specification. Read the case study.
While you're here: if you run a store, the email deliverability checker and store health check are free and take about ten seconds each.
This is an engineering note, not legal advice. Where the requirement is genuinely load-bearing for your business, have a lawyer specify it and build to that.
Tagged
- Compliance
- WooCommerce
- EU Law
- PHP
- Architecture