Withdrawal Forms and Buttons
Many online shops need a way for consumers to withdraw from a contract, cancel an eligible order, or request a return. Kirby Kart should not try to decide the legal setup for every shop, product type, country, language, and payment provider. But a Kirby project using Kart should make this workflow explicit.
This guide gives you a practical starting point for a Kirby implementation. It is not legal advice. Treat the examples as developer scaffolding and have the final wording, retention period, privacy notice, product exceptions, and refund process checked for the jurisdictions where the shop sells.
Research snapshot
Sources checked on 2026-05-21:
EU consumer contracts are mainly governed by the Consumer Rights Directive, which covers pre-contract information, the right of withdrawal, and the standard withdrawal form for distance and off-premises contracts.
The official EU consumer portal explains the usual 14-day cooling-off period, common exceptions, return-cost rules, and country-specific follow-up.
The legal text is in Directive 2011/83/EU and the newer Directive (EU) 2023/2673, which adds an online withdrawal function for distance contracts concluded through an online interface.
For Germany, check BGB § 312g, BGB § 355, BGB § 356, the official model withdrawal instruction in Anlage 1 EGBGB, the official model withdrawal form in Anlage 2 EGBGB, and the Federal Ministry of Justice page for withdrawal-right forms.
For the German withdrawal-button change, see the Federal Government note on more consumer protection for online contracts and practical summaries from Verbraucherzentrale and the IHK.
In the United States, there is no single EU-style general withdrawal form for ordinary online purchases. Relevant federal rules include the FTC Mail, Internet, or Telephone Order Merchandise Rule, especially shipping-delay cancellation/refund rights, and the narrower FTC Cooling-Off Rule for certain off-premises sales. State, sector, subscription, marketplace, and payment-provider rules can add more requirements.
Kirby references used for the implementation pattern: Kirby contact form cookbook, Kirby email guide, Kirby csrf() helper, creating pages from frontend forms, and the Kart guide on sending emails.
Form vs. withdrawal button
A model withdrawal form is the legal text or form a consumer can use to tell the merchant that they withdraw from an eligible contract. In the EU/Germany context, consumers can also use any other clear statement; the model form is not the only valid way to withdraw.
The upcoming EU/German withdrawal button requirement is different. As of this guide's source check on 2026-05-21, it is scheduled to apply from 2026-06-19. Many B2C distance contracts concluded through a website, app, or similar online interface must then offer an easily accessible electronic withdrawal function. In Germany, current guidance describes a two-step flow:
- A clearly visible entry point, for example
Vertrag widerrufenorWiderruf erklären. - A confirmation step, for example
Widerruf bestätigen, after the consumer has provided the allowed identification details.
For Germany, the online function should generally ask only for what is necessary: the consumer's name, the contract/order identifier or partial contract identifier, and an electronic communication method for the receipt confirmation. Do not make a withdrawal reason mandatory.
What to decide before implementation
Before adding a form, decide these points with the shop owner or legal advisor:
Which countries the shop targets and which languages the withdrawal text must support.
Which products are excluded or special, for example personalized goods, perishables, sealed software, digital downloads, services started during the withdrawal period, subscriptions, or B2B-only sales.
Whether the shop needs only a downloadable model form, an email/contact route, or a full electronic withdrawal function.
Where the entry point appears: footer/legal page, account order detail, order confirmation page, order email, and possibly the public order lookup page.
Where requests are stored: mailbox only, a Kirby content page, a CRM/helpdesk, or an external order system.
How long requests are retained and how the privacy policy describes this processing.
What wording the receipt email uses. For Germany's withdrawal-button flow, avoid saying the withdrawal has already been legally accepted; confirm receipt, date, time, and submitted content.
Suggested fields
For an online withdrawal function, keep the required fields narrow:
consumer_name: consumer name.email: email address for the receipt confirmation.order_number: order, invoice, payment, or contract reference.contract_items: what contract, product, service, or part of the order the consumer wants to withdraw from.statement: a clear withdrawal declaration. You can prefill this.submitted_at: generated server-side.
Optional fields:
received_on: useful for physical goods, but do not rely on the customer to calculate the legal deadline.address: useful for a classic model withdrawal form, but be careful before making it required in an electronic withdrawal-button flow.message: additional information from the customer.reason: only if optional. In the EU/Germany flow, do not require a reason.
Example page template
Create a regular Kirby page, for example /withdraw, and render a simple form. This example is intentionally independent from Kirby Kart internals.
site/templates/withdraw.php
<?php snippet('header') ?>
<main>
<h1><?= $page->title()->html() ?></h1>
<?php if ($success): ?>
<p class="alert success"><?= esc($success) ?></p>
<?php else: ?>
<?php if (isset($alert['error'])): ?>
<p class="alert error"><?= esc($alert['error']) ?></p>
<?php endif ?>
<form method="post" action="<?= $page->url() ?>">
<input type="hidden" name="csrf" value="<?= csrf() ?>">
<div class="honeypot" hidden>
<label for="website">Website</label>
<input type="url" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<p>
<label for="consumer_name">Name</label><br>
<input
type="text"
id="consumer_name"
name="consumer_name"
value="<?= esc($data['consumer_name'] ?? '', 'attr') ?>"
autocomplete="name"
required
>
<?php if (isset($alert['consumer_name'])): ?>
<br><small><?= esc($alert['consumer_name']) ?></small>
<?php endif ?>
</p>
<p>
<label for="email">Email</label><br>
<input
type="email"
id="email"
name="email"
value="<?= esc($data['email'] ?? '', 'attr') ?>"
autocomplete="email"
required
>
<?php if (isset($alert['email'])): ?>
<br><small><?= esc($alert['email']) ?></small>
<?php endif ?>
</p>
<p>
<label for="order_number">Order or invoice number</label><br>
<input
type="text"
id="order_number"
name="order_number"
value="<?= esc($data['order_number'] ?? '', 'attr') ?>"
required
>
<?php if (isset($alert['order_number'])): ?>
<br><small><?= esc($alert['order_number']) ?></small>
<?php endif ?>
</p>
<p>
<label for="contract_items">Product, service, or contract part</label><br>
<textarea id="contract_items" name="contract_items" required><?= esc($data['contract_items'] ?? '') ?></textarea>
<?php if (isset($alert['contract_items'])): ?>
<br><small><?= esc($alert['contract_items']) ?></small>
<?php endif ?>
</p>
<p>
<label for="received_on">Received on, if applicable</label><br>
<input
type="date"
id="received_on"
name="received_on"
value="<?= esc($data['received_on'] ?? '', 'attr') ?>"
>
</p>
<p>
<label for="message">Additional information, optional</label><br>
<textarea id="message" name="message"><?= esc($data['message'] ?? '') ?></textarea>
</p>
<input
type="hidden"
name="statement"
value="I hereby withdraw from the contract identified in this form."
>
<button type="submit" name="withdraw" value="1">Confirm withdrawal</button>
</form>
<?php endif ?>
</main>
<?php snippet('footer') ?>
For a German-language site, the public entry point should use clear wording such as Vertrag widerrufen. The final submit button can use Widerruf bestätigen. Keep the wording configurable in multilingual projects.
Example controller
The controller validates CSRF, blocks a simple honeypot, validates the minimum fields, sends a merchant notification, and sends a receipt confirmation to the customer.
site/controllers/withdraw.php
<?php
return function ($kirby, $pages, $page) {
$alert = [];
$data = [];
$success = null;
if ($kirby->request()->is('POST') && get('withdraw')) {
if (csrf(get('csrf')) !== true) {
$alert['error'] = 'The form could not be verified. Please try again.';
} elseif (empty(get('website')) === false) {
go($page->url());
} else {
$data = [
'consumer_name' => trim((string)get('consumer_name')),
'email' => trim((string)get('email')),
'order_number' => trim((string)get('order_number')),
'contract_items'=> trim((string)get('contract_items')),
'received_on' => trim((string)get('received_on')),
'message' => trim((string)get('message')),
'statement' => trim((string)get('statement')),
];
$rules = [
'consumer_name' => ['required', 'minLength' => 2],
'email' => ['required', 'email'],
'order_number' => ['required', 'minLength' => 2],
'contract_items'=> ['required', 'minLength' => 2],
'statement' => ['required'],
];
$messages = [
'consumer_name' => 'Please enter your name.',
'email' => 'Please enter a valid email address.',
'order_number' => 'Please enter an order or invoice number.',
'contract_items'=> 'Please identify the product, service, or contract part.',
'statement' => 'The withdrawal statement is missing.',
];
if ($invalid = invalid($data, $rules, $messages)) {
$alert = $invalid;
} else {
$submittedAt = date('Y-m-d H:i:s T');
$merchantBody = implode("\n", [
'Withdrawal request received',
'',
'Received at: ' . $submittedAt,
'Name: ' . $data['consumer_name'],
'Email: ' . $data['email'],
'Order/reference: ' . $data['order_number'],
'Items/contract: ' . $data['contract_items'],
'Received on: ' . ($data['received_on'] ?: '-'),
'Statement: ' . $data['statement'],
'Message: ' . ($data['message'] ?: '-'),
]);
$customerBody = implode("\n", [
'We have received your withdrawal request.',
'',
'Received at: ' . $submittedAt,
'Order/reference: ' . $data['order_number'],
'Items/contract: ' . $data['contract_items'],
'Statement: ' . $data['statement'],
'',
'This message confirms receipt of your request. It does not decide whether the withdrawal is legally valid.',
]);
try {
$kirby->email([
'from' => option('shop.withdrawal.from', 'no-reply@example.com'),
'replyTo' => $data['email'],
'to' => option('shop.withdrawal.to', 'shop@example.com'),
'subject' => 'Withdrawal request: ' . $data['order_number'],
'body' => $merchantBody,
]);
$kirby->email([
'from' => option('shop.withdrawal.from', 'no-reply@example.com'),
'to' => $data['email'],
'subject' => 'We received your withdrawal request',
'body' => $customerBody,
]);
$success = 'Your withdrawal request has been received.';
$data = [];
} catch (Throwable $error) {
$alert['error'] = option('debug')
? 'The request could not be sent: ' . $error->getMessage()
: 'The request could not be sent. Please try again later.';
}
}
}
}
return [
'alert' => $alert,
'data' => $data,
'success' => $success,
];
};
Configure real sender and recipient addresses in site/config/config.php:
<?php
return [
'shop.withdrawal.from' => 'no-reply@example.com',
'shop.withdrawal.to' => 'shop@example.com',
];
Email deliverability matters for receipt confirmations. Configure SMTP or a transactional mail provider as described in the Kart guide on sending emails and Kirby's email guide.
Optional: store requests in Kirby
Email may be enough for a small shop, but many merchants need an audit trail in the Panel or a helpdesk. If you store requests in Kirby content, create a parent page such as withdrawals, lock it down in your blueprints, and keep the retention period short enough for your privacy policy.
Kirby's frontend page-creation cookbook uses $kirby->impersonate('kirby') before $page->createChild(). Only do this in a tightly validated controller.
<?php
use Kirby\Toolkit\Str;
$parent = page('withdrawals');
if ($parent !== null) {
$kirby->impersonate('kirby');
$parent->createChild([
'slug' => Str::slug($data['order_number'] . '-' . time()),
'template' => 'withdrawal',
'content' => [
'title' => 'Withdrawal ' . $data['order_number'],
'submitted_at' => $submittedAt,
'consumer_name' => $data['consumer_name'],
'email' => $data['email'],
'order_number' => $data['order_number'],
'contract_items' => $data['contract_items'],
'received_on' => $data['received_on'],
'statement' => $data['statement'],
'message' => $data['message'],
],
]);
}
Do not store more data than needed. A withdrawal reason should stay optional, and uploaded files are usually unnecessary for a simple withdrawal declaration.
Where to link it
Common placements:
A public
/withdrawpage linked from the footer, legal pages, and return policy.The order confirmation page.
The customer account order detail page, if the shop has accounts.
Order emails, using the same email setup from the Kart sending-emails guide.
A downloadable PDF or text version of the model withdrawal form for customers who prefer email or post.
For the upcoming EU/German electronic withdrawal function, the entry point must be easy to find throughout the withdrawal period. Do not hide it behind a login unless the contract was also concluded only after login.
Implementation checklist
Add a public withdrawal page with clear wording.
Keep the form fields minimal and make the reason optional.
Add CSRF protection, server-side validation, escaping, and a honeypot or stronger spam protection.
Send a merchant notification and a customer receipt confirmation with date, time, and submitted content.
Decide whether the request is also stored in Kirby, an external helpdesk, or an order system.
Update the privacy policy for the personal data, purpose, recipients, and retention period.
Update withdrawal instructions/model form text for each language and jurisdiction.
Link the flow from order pages and order emails.
Test with real SMTP in staging before launch.
Have the final legal copy reviewed.