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:

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:

  1. A clearly visible entry point, for example Vertrag widerrufen or Widerruf erklären.
  2. 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.

Common placements:

  • A public /withdraw page 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.

Kirby Kart is not affiliated with the developers of Kirby CMS. We are merely standing on the shoulder of giants.
© 2026 Bruno Meilick All rights reserved.