Configuration
All settings live in a single withdraw_settings option, saved through the WordPress Settings API from WooCommerce > Withdrawal. Both admin screens and the option page itself require the manage_woocommerce capability.
Settings reference
Section titled “Settings reference”| Setting | Key | Default | What it does |
|---|---|---|---|
| Withdrawal period (days) | period_days |
14 |
Length of the withdrawal window in days. Clamped to 1 to 365 on save. |
| Withdrawal form page | form_page_id |
0 (none) |
The page holding the [withdraw_form] shortcode. The order button links here and does not render while this is unset. |
| Eligible order statuses | eligible_statuses |
completed, processing |
Which order statuses may start a withdrawal. Checkbox list built from wc_get_order_statuses() with the wc- prefix stripped, so custom statuses are included. Saving with nothing checked stores Completed and Processing instead. |
| Link text | link_text |
empty | Label on every control that opens the form: the order-view button, the [withdraw_link] shortcode and the footer link. Empty uses the statutory “withdraw from contract here”, translated with the rest of the plugin. Art. 11a(1) allows an unambiguous equivalent, so this is not locked down. Before 1.6.2 the order-view button ignored it. |
| Notification email | notify_email |
empty | Address for the shop notification. When empty, the site admin_email is used. |
| Form intro text | intro_text |
empty | Paragraph printed above the order lookup step. Empty uses the plugin’s own translated wording. |
| Model withdrawal text | model_form_text |
empty | Text printed on the items step. Empty generates the statutory model withdrawal form (Annex I.B) from the seller details below. |
| Seller name | seller_name |
empty | Trading name used in the generated texts. Empty uses the site title. |
| Seller email | seller_email |
empty | Empty uses the site admin email. |
| Seller phone | seller_phone |
empty | Optional. Annex I asks for a phone number where available. |
Above the form, the settings page prints a live count of pending, accepted, processed and rejected requests, taken from one grouped query, plus a View requests link.
How the withdrawal period is calculated
Section titled “How the withdrawal period is calculated”The deadline is computed per order, at the moment the customer submits the lookup form:
start = order completion date, or order creation date when the order was never completeddeadline = start + (period_days x 86400 seconds)Two consequences worth planning around:
- The clock starts at completion, not at delivery. If you mark orders completed on dispatch, the window you actually give the customer is shorter than the statutory one, which counts from the moment the consumer takes physical possession. If you complete orders late, it is longer. Set
period_dayswith your own fulfilment habit in mind, or complete orders on delivery. - An order that is never completed falls back to its creation date, so a Processing order that sits for weeks can pass its deadline without ever having been delivered.
Past the deadline the form refuses with “The withdrawal period for this order has ended.” Before it, the items step prints the deadline date, formatted with your site date format through date_i18n().
Eligibility rules
Section titled “Eligibility rules”A declaration is only recorded when all of these hold:
- The order exists and the entered email matches the order’s billing email, compared case-insensitively.
- The order status is one of the eligible statuses.
- The deadline above has not passed.
- No request for that order is currently
pendingoraccepted. Arejectedorprocessedrequest does not block a new one. - At least one item was selected with a quantity above zero.
Submitted quantities are capped server-side at the ordered quantity per line, so editing the HTML does not help. Rules 1 to 4 are evaluated twice, once when the items step is built and again on submit, so a stale or tampered form cannot bypass them.
One limit to be aware of: while one declaration is open, the customer cannot file a second one for other lines of the same order. The declaration checkbox is enforced on the server as well as in the browser, so a submission without it is refused.
The form
Section titled “The form”[withdraw_form] renders the whole flow from one shortcode: the lookup step, the items step, a review step and the post-submit confirmation. It takes no attributes and can only usefully appear once per page.
The review step exists because Art. 11a(3) wants the confirmation to be a control of its own, labelled only “confirm withdrawal”, rather than the same click that picks the items.
Each step is nonce protected: withdraw_lookup opens the items step, withdraw_review opens the review step, withdraw_confirm submits. A failed nonce drops the visitor back to the lookup step with an error.
The lookup step reads a wd_order query argument through absint() and pre-fills the order number from it, which is how the order button hands over. The email field is never pre-filled.
The front-end stylesheet and script load only on pages where the shortcode actually renders. The script does one thing: it blocks submitting the items step when every quantity is zero. The form works without JavaScript, the server performs the same check.
Request log
Section titled “Request log”WooCommerce > Withdrawal Requests lists requests newest first with ID, order, customer email, items with quantities, date and status, and a subsubsub filter for each of the four statuses.
- The four statuses are
pending,accepted,rejectedandprocessed. New rows start aspendingandupdateStatus()accepts nothing outside that list. - Status changes post to
admin-post.php, are checked against thewithdraw_set_statusnonce and themanage_woocommercecapability, then redirect back with a success notice. - The list pages 25 rows at a time, with a search box matching customer email or order number that combines with the status filter. A bookmarked page number that no longer exists lands on the last page rather than an empty table. Before 1.5.0 the list stopped at the newest 100 rows with no way past them.
- The order column links to the order, choosing the HPOS order URL or the classic post editor depending on which one the shop is using. Before 1.0.8 it always used the classic URL, which did not open an order once HPOS was on.
- The ID column opens a detail screen for that request: the reason the customer gave, the declaration they confirmed, both timestamps, the item list and the refund deadline. The access token is deliberately not shown; it is a credential, not a record.
The note, and why a rejection needs one
Section titled “The note, and why a rejection needs one”Next to the status control is a note field. It is required before a request can be rejected, and it is printed in the email the customer receives, for every status. A rejection that says only “your withdrawal request could not be accepted” gives the customer nothing to act on and leaves you no record of why you refused, which is the half that matters if the decision is ever questioned.
Re-saving the same status without retyping the note keeps the stored one, and editing the note alone does not send the customer a second message.
Emails
Section titled “Emails”Since 1.4.0 every message is a WooCommerce email. They use your store template, logo and footer, and each has its own section under WooCommerce > Settings > Emails, so any of them can be reworded, restyled or switched off on its own. Templates can be overridden from a theme at yourtheme/woocommerce/emails/.
| Section | Sent when | |
|---|---|---|
| Withdrawal declaration received | withdraw_acknowledgement |
A declaration is recorded. To the customer. |
| New withdrawal request | withdraw_new_request |
A declaration is recorded. To the shop. |
| Withdrawal accepted | withdraw_accepted |
You set the request to accepted. |
| Withdrawal rejected | withdraw_rejected |
You set it to rejected. |
| Withdrawal processed | withdraw_processed |
You set it to processed. |
| Withdrawal under review | withdraw_under_review |
You move it back to pending. |
| Withdrawal access link | withdraw_access_link |
A guest asks for a one-time link, and only while that setting is on. |
Three details worth knowing:
- The shop notification has its own Recipient field. Leave it empty and it uses the Notification email setting on the Withdrawal screen, or the site admin address. Nothing moves for a shop that never opens the emails screen.
- The acknowledgement quotes the stored declaration. The wording the customer confirmed is written to the request row and read back from there, so a later translation, a renamed product or an edited template cannot change what a past customer is told they declared. It also states the date and time of submission, which is what Art. 11a(4) asks for.
- The access link email cannot outlive its feature. It reports itself disabled whenever Guest access is off, so the plugin setting stays the single switch. With guest access on and this email off, a guest cannot withdraw at all.
The three plain status messages share one template pair, withdraw-status.php and plain/withdraw-status.php, so a theme override of that file changes all three. The acceptance has its own, because it is the only one carrying the return information.
Return information and your own refund deadline
Section titled “Return information and your own refund deadline”The acceptance message carries the Art. 14(1) information, configured under Returns on the Withdrawal screen:
| Setting | Key | Default | What it does |
|---|---|---|---|
| Return address | return_address |
empty | Where the goods go back. Empty falls back to your WooCommerce store address; if that is empty too, the message says nothing rather than printing a blank block. |
| Who pays to send the goods back | return_cost |
not_stated |
not_stated, customer or shop. Anything else is rejected on save. |
| Return cost, extra wording | return_cost_note |
empty | Appended to the cost sentence. |
return_cost ships as Say nothing on purpose. Art. 14(1), read with Art. 6(1)(i), only lets you put the direct cost of the return on the consumer if you told them so before the contract. If you did not, the cost is yours, so a default of “the customer pays” would have the plugin assert a claim on your behalf that might not hold.
The return deadline printed to the customer is 14 days from the day they declared, not from the day you accepted. A shop that takes a week to accept was previously giving the customer a week more than the law does.
Your own clock runs the same way. Art. 13(1) gives you 14 days from being informed to refund, including the standard delivery cost the customer paid, and the request log prints that date and marks it red once it has passed. It clears when the request is set to processed or rejected. For goods, Art. 13(3) lets you hold the refund until they are back or the customer proves they sent them.
The statutory texts
Section titled “The statutory texts”Up to 1.5.0 the form intro and the model withdrawal text were English sentences sitting in a config file and printed to the customer word for word, placeholder and all. A string in a config file cannot be translated, so a non-English shop showed English until an admin rewrote it by hand. Both are now generated, and translated with the rest of the plugin.
- Annex I(B), the model withdrawal form is printed on the items step. It is built from the seller name, the WooCommerce store address, the seller email and phone, falling back to the site title and admin email so it always names somebody. Type your own into Model withdrawal text and yours is used instead.
- Annex I(A), the model instructions on withdrawal are rendered by the
[withdraw_instructions]shortcode. Art. 6(1)(h) makes them pre-contractual information, so they belong on a terms or returns page rather than on the withdrawal form. They state your configuredperiod_daysrather than a hardcoded 14, so they cannot drift out of step with what the form actually enforces.
[withdraw_instructions][withdraw_instructions heading="no" form="no"]heading="no" drops the heading, for a page that already has one. form="no" drops the model form printed underneath.
The return-cost sentence is added to those instructions only once you have set the rule under Returns. That sentence is the Art. 6(1)(i) notice a trader later relies on to charge the consumer for the return, so generating it by default would have the plugin manufacture the very notice being relied on.
Every paragraph passes through the withdraw/model_instructions filter, which is where a shop adds the service-contract or digital-content wording its own catalogue needs:
add_filter( 'withdraw/model_instructions', function ( array $parts ): array { $parts[] = 'If you asked us to begin performing services during the withdrawal period, you shall pay us an amount proportionate to what has been provided.'; return $parts;} );On update, an intro or model text left byte for byte as it shipped is cleared so the generated wording takes over. Anything you edited, including a hand translation of the placeholder, is matched exactly and kept.
Privacy tools
Section titled “Privacy tools”Withdraw registers a Withdrawal Declarations exporter and an eraser with the WordPress personal-data tools:
- The exporter returns order ID, status, reason and date for every request matching the requested email, 100 rows per page.
- The eraser anonymises rather than deletes: it rewrites
customer_emailto[email protected]and blanks the reason, keeping the row and its order reference so your withdrawal bookkeeping stays intact.
Storage and uninstall
Section titled “Storage and uninstall”Withdraw stores:
- the
withdraw_settingsandwithdraw_schema_versionoptions; - one row per declaration in
{prefix}withdraw_requests, holding order ID, customer email, a token, the item list as JSON, the reason, the status and created and updated timestamps.
The schema is version gated against withdraw_schema_version and re-checked on boot for each site, so a Multisite install gets its table even where activation never ran.
Uninstalling deletes both options and drops the requests table for the current site’s prefix only. On Multisite, drop the per-site tables of the other sites by hand if you want them gone.
Extending
Section titled “Extending”After every service has registered its hooks, the plugin fires:
do_action( 'withdraw/booted', $plugin );That action is the documented entry point for add-on code. Hook it early, from a plugin that loads before Withdraw finishes booting, or your listener never runs.