<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Seya</title>
    <description>The latest articles on DEV Community by Seya (@sxwxbxr).</description>
    <link>https://dev.to/sxwxbxr</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1450751%2F53b5a864-1084-4ebc-a35e-ef52421ae70a.png</url>
      <title>DEV Community: Seya</title>
      <link>https://dev.to/sxwxbxr</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sxwxbxr"/>
    <language>en</language>
    <item>
      <title>Validating XRechnung and ZUGFeRD without Java: how the KoSIT validator works, and how to do it in TypeScript</title>
      <dc:creator>Seya</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:41:33 +0000</pubDate>
      <link>https://dev.to/sxwxbxr/validating-xrechnung-and-zugferd-without-java-how-the-kosit-validator-works-and-how-to-do-it-in-56kl</link>
      <guid>https://dev.to/sxwxbxr/validating-xrechnung-and-zugferd-without-java-how-the-kosit-validator-works-and-how-to-do-it-in-56kl</guid>
      <description>&lt;p&gt;In Germany every business has to be able to &lt;strong&gt;receive&lt;/strong&gt; e-invoices since 1 January 2025. The obligation to &lt;strong&gt;issue&lt;/strong&gt; them follows in steps: until the end of 2026 paper and PDF are still allowed for everyone, in 2027 only businesses with a prior-year turnover of up to EUR 800,000 may still use them, and from 1 January 2028 the e-invoice is the rule. (The &lt;a href="https://www.ihk.de/rhein-neckar/recht/steuerrecht/umsatzsteuer/e-rechnungspflicht-5931752" rel="noopener noreferrer"&gt;IHK overview&lt;/a&gt; has the details and exceptions.) A plain PDF is no longer an invoice in the sense of the law. A structured file in the European standard EN 16931 is, either as pure XML (XRechnung) or as a PDF with the XML embedded (ZUGFeRD, called Factur-X in France).&lt;/p&gt;

&lt;p&gt;For developers this means a new kind of bug: an invoice that your system produces or receives looks fine on screen and is still rejected, by a customer's accounting software, by a public buyer's portal or by your own audit later. The way to find out before they do is to &lt;strong&gt;validate&lt;/strong&gt;. This article explains what validation really involves, because most of the confusion comes from not knowing which checks exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Valid in which sense?
&lt;/h2&gt;

&lt;p&gt;An e-invoice can be wrong on four levels, and each needs a different check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Not well-formed XML.&lt;/strong&gt; A parser finds this immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not valid against the XML Schema (XSD).&lt;/strong&gt; Elements in the wrong order or in the wrong place, a missing required element, a misspelt name, a date written as &lt;code&gt;04.04.2016&lt;/code&gt;, an amount with a comma. No business logic is needed to see this, but a normal XML parser does not check it unless you give it the schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not valid against the business rules.&lt;/strong&gt; EN 16931 defines hundreds of rules in Schematron, for example that the invoice total with VAT must equal the total without VAT plus the VAT amount. XRechnung adds German rules on top, such as the mandatory buyer reference (Leitweg-ID for public buyers) or contact details of the seller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not consistent as a ZUGFeRD PDF.&lt;/strong&gt; The PDF has to contain the XML as an attachment, and the profile declared in the PDF metadata has to match the profile of the XML.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reference implementation for the first three levels in Germany is the &lt;strong&gt;KoSIT validator&lt;/strong&gt;, published by the Koordinierungsstelle für IT-Standards. It works in two steps: first the XSD, then the Schematron rules. Public buyers and many accounting systems effectively use the same rule sets, so "passes KoSIT" is the practical definition of "valid XRechnung".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Schematron is the hard part
&lt;/h2&gt;

&lt;p&gt;Schematron is a rule language that expresses a rule as an XPath expression in a context. A rule from EN 16931 looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;assert&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"BR-CO-15"&lt;/span&gt; &lt;span class="na"&gt;flag=&lt;/span&gt;&lt;span class="s"&gt;"fatal"&lt;/span&gt;
  &lt;span class="na"&gt;test=&lt;/span&gt;&lt;span class="s"&gt;"every $Currency in cbc:DocumentCurrencyCode satisfies
    (count(cac:TaxTotal/xs:decimal(cbc:TaxAmount[@currencyID=$Currency])) eq 1)
    and (cac:LegalMonetaryTotal/xs:decimal(cbc:TaxInclusiveAmount) =
      round((cac:LegalMonetaryTotal/xs:decimal(cbc:TaxExclusiveAmount)
        + cac:TaxTotal/xs:decimal(cbc:TaxAmount[@currencyID=$Currency])) * 10 * 10) div 100)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109)
  + Invoice total VAT amount (BT-110).
&lt;span class="nt"&gt;&amp;lt;/assert&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch: the official rule sets use &lt;strong&gt;XPath 2.0&lt;/strong&gt;, not 1.0. The rule above already shows it: an &lt;code&gt;every ... satisfies&lt;/code&gt; quantifier, &lt;code&gt;xs:decimal&lt;/code&gt; casts and the &lt;code&gt;eq&lt;/code&gt; operator. Other rules use sequences, regular expressions with &lt;code&gt;matches()&lt;/code&gt; and functions such as &lt;code&gt;distinct-values()&lt;/code&gt;. Most XPath engines available in JavaScript implement 1.0 only, so the standard route is to run the validator on the JVM (the KoSIT validator is a Java application, often started as a daemon or a Docker container).&lt;/p&gt;

&lt;p&gt;That is a reasonable choice for a back-office batch. It is a poor fit if your product is a Node.js service, a serverless function, an edge worker or a browser app where a user drops an invoice file and wants an answer immediately. Then you either run a second runtime next to your application or you skip validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same two steps in TypeScript
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://packages.sweber.dev/summand" rel="noopener noreferrer"&gt;Summand&lt;/a&gt; is an MIT-licensed library that implements both steps without Java: it compiles the official XML Schemas (UBL 2.1 and UN/CEFACT CII D16B) and the official Schematron rule sets (CEN EN 16931 1.3.16 and KoSIT XRechnung 2.6.0 for XRechnung 3.0) and ships its own XPath 2.0 and Schematron engine written in TypeScript. It has no dependencies and is about 135 KB gzipped with all rule sets, schemas and both message languages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @sweberdev/summand
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateInvoice&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sweberdev/summand&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// XML string, XML bytes or a ZUGFeRD PDF&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// "XRechnung 3.0"&lt;/span&gt;
&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;        &lt;span class="c1"&gt;// { id: "BR-CO-15", severity: "error", message: "…",&lt;/span&gt;
                         &lt;span class="c1"&gt;//   location: "/Invoice/cac:LegalMonetaryTotal", line: 214 }&lt;/span&gt;
&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;schemas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// "ubl-2.1" (the XML Schema was checked first)&lt;/span&gt;
&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// "RE-2026-0042"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every finding carries the rule id, the severity, the XPath and the line, which is what you need to show a user or to hand to the people who fix the invoice template. Messages are available in English and German (&lt;code&gt;{ lang: "de" }&lt;/code&gt;), for every rule: accountants do not read English Schematron texts.&lt;/p&gt;

&lt;p&gt;For ZUGFeRD and Factur-X, the PDF is opened, the embedded XML is found and validated, and the profile in the PDF metadata is compared with the profile of the XML.&lt;/p&gt;

&lt;h3&gt;
  
  
  In CI
&lt;/h3&gt;

&lt;p&gt;The command line tool returns a non-zero exit code when an invoice is invalid, so a template change that breaks the output fails the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sweberdev/summand validate invoices/&lt;span class="k"&gt;*&lt;/span&gt;.xml invoices/&lt;span class="k"&gt;*&lt;/span&gt;.pdf
npx @sweberdev/summand validate invoice.xml &lt;span class="nt"&gt;--lang&lt;/span&gt; de &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your system produces invoices, keep a handful of representative ones in the repository (a normal invoice, a credit note, one with a discount, one with several VAT rates, one reverse charge) and validate them on every commit. This one test catches most regressions in invoice generation.&lt;/p&gt;

&lt;h3&gt;
  
  
  In the browser
&lt;/h3&gt;

&lt;p&gt;Because there is no JVM and no server, the same call runs in the browser. A drop zone that tells an accountant "valid" or "invalid because of rule X on line 214" without uploading the file anywhere is useful in itself, and it is also a privacy argument: invoices contain personal and commercial data. There is a &lt;a href="https://packages.sweber.dev/summand/demo" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; that works that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we know it gives the same answer as the official validator
&lt;/h2&gt;

&lt;p&gt;A validator that is almost right is worse than none, because people trust it. So correctness is the actual product here, and it was tested against the references rather than against our own expectations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All &lt;strong&gt;1,142&lt;/strong&gt; expectations of the CEN EN 16931 unit test suite pass.&lt;/li&gt;
&lt;li&gt;All reference invoices of the KoSIT XRechnung test suite are valid against schema and rules.&lt;/li&gt;
&lt;li&gt;The XML Schema check was compared with &lt;strong&gt;Xerces&lt;/strong&gt;, the schema validator that the KoSIT validator uses, on all test invoices and on about 2,800 deliberately modified ones (elements removed, reordered, duplicated, values changed). The verdict (valid or invalid) was the same for every document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Messages differ on purpose: Xerces stops looking at an element's content at its first problem and reports it as &lt;code&gt;cvc-complex-type.2.4.a&lt;/code&gt;. Summand reports each problem it finds, in plain language, so you see all problems at once instead of fixing one layer after the other.&lt;/p&gt;

&lt;p&gt;What the library does &lt;strong&gt;not&lt;/strong&gt; do is equally important. It validates; it does not decide whether an invoice is correct in a tax sense. It also does not replace the legal advice or your accountant's view on archiving requirements. The rule sets keep their licences (EUPL-1.2 for the EN 16931 artefacts, Apache-2.0 for the XRechnung Schematron), which are documented in the package's NOTICE file.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical approach for your system
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Receive.&lt;/strong&gt; Every business can already receive e-invoices. Validate incoming files at the point where they enter your system, and keep the report next to the file. If an invoice is invalid, you want to know before the payment run, not during an audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue.&lt;/strong&gt; Validate every invoice you generate before it leaves the system, as a hard gate in the code path, not as an optional step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test.&lt;/strong&gt; Put sample invoices into CI so that a template change cannot silently break the format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Archive.&lt;/strong&gt; An e-invoice has to be stored unchanged, complete and verifiably. A validation report stored together with the file is cheap evidence that it was valid when you received or sent it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://packages.sweber.dev/summand/docs" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; has guides on the schema check, Schematron, profiles, the Leitweg-ID check digit and ZUGFeRD PDFs. For teams that process invoices in volume, Summand Pro adds a typed invoice object, a readable HTML or PDF view with the validation report, watching a folder or a mailbox, duplicate detection and an export for DATEV.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article describes software and public standards, not tax or legal advice. Check your own obligations with your tax adviser.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I maintain the library mentioned in this article. The requirements and code samples stand on their own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>xml</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The withdrawal button (§ 356a BGB): what the law asks for, and how to build it</title>
      <dc:creator>Seya</dc:creator>
      <pubDate>Wed, 07 Oct 2026 15:32:18 +0000</pubDate>
      <link>https://dev.to/sxwxbxr/the-withdrawal-button-ss-356a-bgb-what-the-law-asks-for-and-how-to-build-it-4kjn</link>
      <guid>https://dev.to/sxwxbxr/the-withdrawal-button-ss-356a-bgb-what-the-law-asks-for-and-how-to-build-it-4kjn</guid>
      <description>&lt;p&gt;If you run an online shop, a SaaS product or a booking app for consumers in Germany, you have had a new item on your list since 19 June 2026: a &lt;strong&gt;withdrawal button&lt;/strong&gt;. It comes from EU directive 2023/2673 (a new Article 11a in the Consumer Rights Directive) and Germany implemented it as § 356a BGB.&lt;/p&gt;

&lt;p&gt;Most articles about it are written for shop owners. This one is written for the person who has to build it. It lists what the law asks for, where implementations usually go wrong, and ends with working code. It is not legal advice: for your own case, ask a lawyer or your trade association.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the law asks for
&lt;/h2&gt;

&lt;p&gt;The statute is short. Reduced to what affects the implementation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A function labelled "Vertrag widerrufen"&lt;/strong&gt; or an equivalent, unambiguous wording, for every contract concluded through an online interface (a website or an app).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Available for the whole withdrawal period&lt;/strong&gt; and, in the wording of the directive, prominent and easy to access. In practice: a link in the footer of every page, not buried in the terms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No login required.&lt;/strong&gt; The consumer may not have an account, or may have lost the password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two steps.&lt;/strong&gt; The first step collects the declaration: who is withdrawing, from which contract, and an electronic address for the acknowledgement. The second step is a button labelled &lt;strong&gt;"Widerruf bestätigen"&lt;/strong&gt; (or equivalent) that actually submits it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An acknowledgement of receipt without delay on a durable medium&lt;/strong&gt; (in practice: e-mail), containing the content of the declaration and the &lt;strong&gt;date and time&lt;/strong&gt; it was received.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In time if used in time.&lt;/strong&gt; A withdrawal submitted through the function before the period ends is in time, whatever happens afterwards.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 14-day withdrawal period itself did not change. Neither did the way it is calculated, which matters more than it seems (see below).&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that make a button non-compliant
&lt;/h2&gt;

&lt;p&gt;We went through the requirements and the first generation of shop pages, and the same problems come up again and again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The label is creative.&lt;/strong&gt; "Widerrufen", "Retoure starten" or "Rückgabe" are not the same as "Vertrag widerrufen". A returns portal is a different function: returning goods and withdrawing from a contract are legally different things. The two button labels are the one place where you should not be clever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The link leads to a login page.&lt;/strong&gt; If the consumer has to sign in to the customer area first, the function is not accessible without login. Guest checkouts are the obvious case. Offer a login-free form and, if you like, prefill the order number from the link in your order e-mail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One click withdraws, or three clicks are needed.&lt;/strong&gt; The directive describes exactly two steps. A single click without a review step risks accidental withdrawals; a wizard with extra pages makes the function harder to use than the statute allows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The time of receipt comes from the browser.&lt;/strong&gt; The date and time in the acknowledgement are the trader's evidence. If you copy them from the client, they can be wrong or manipulated. Set &lt;code&gt;receivedAt&lt;/code&gt; on the server, in one place, and nowhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The acknowledgement is just "thanks".&lt;/strong&gt; It has to contain the content of the declaration (who, which contract, what was declared) and the date and time of receipt. Consumers should be able to keep it as proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rejecting declarations with an unknown order number.&lt;/strong&gt; A withdrawal with a typo in the order number is still a declaration you received. Validate the format of the fields, not whether the number exists, and sort out the assignment yourself afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong deadline arithmetic.&lt;/strong&gt; The period starts the day after the event (§ 187 BGB), and when it ends on a Saturday, Sunday or public holiday it moves to the next working day (§ 193 BGB). If you were not properly informed about the right of withdrawal, the period is extended by up to 12 months. If you show "withdraw by …" in the customer area, calculate it the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A related rule: the cancellation button
&lt;/h2&gt;

&lt;p&gt;German subscription businesses have had to offer a cancellation button ("Verträge hier kündigen", § 312k BGB) since 1 July 2022. In July 2026 the Federal Court of Justice (BGH, I ZR 200/25) made one point very clear: the confirmation page of the cancellation flow must be focused on submitting the cancellation. Offers to keep the customer, such as a discount or a pause, do not belong on that page. Win-back is allowed, but outside the cancellation flow, for example in the follow-up e-mail.&lt;/p&gt;

&lt;p&gt;If you implement one function, it is worth implementing both with the same building blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation: the minimum that works
&lt;/h2&gt;

&lt;p&gt;You need three things: a form with two steps, an endpoint, and an acknowledgement e-mail. This is the core of the endpoint in plain TypeScript with the Fetch API, which runs in Next.js route handlers, Remix, Hono and SvelteKit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// POST /api/withdrawal&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contractRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;contractRef&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contractRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contractRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Contract or order number is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A valid e-mail address is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// The server sets the time of receipt. Never trust the client.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receivedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;withdrawal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;contractRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receivedAt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Eingangsbestätigung Ihres Widerrufs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`Wir haben Ihren Widerruf erhalten.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;``&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Name: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Vertrag / Bestellung: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;contractRef&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Eingegangen am: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details are easy to get wrong in this sketch, so decide on them deliberately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store first, mail second.&lt;/strong&gt; If the e-mail provider is down, you still have the declaration and can send the acknowledgement later. If you send first and the database write fails, the consumer holds a confirmation for a declaration you do not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time zone.&lt;/strong&gt; Show the receipt time in the time zone of your customers, but store it in UTC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting and double submits.&lt;/strong&gt; A public, login-free endpoint will be hit by bots and by people who double-click. Add a rate limit and treat two identical submissions within a few seconds as one, otherwise you send two acknowledgements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the page, build the two steps as one form with a review state: step one collects the fields and has a button "Weiter" (or similar); step two shows the entered data and a single button, &lt;strong&gt;"Widerruf bestätigen"&lt;/strong&gt;. Link to the page from the footer with the text &lt;strong&gt;"Vertrag widerrufen"&lt;/strong&gt;, on every page where a contract can be concluded.&lt;/p&gt;

&lt;p&gt;And the deadline, as a pure function you can test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isoDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;isoDate&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T12:00:00Z`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUTCDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withdrawalDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isHoliday&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the day of the event does not count (§ 187 BGB)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weekday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T12:00:00Z`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getUTCDay&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 0 = Sunday, 6 = Saturday&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weekday&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;weekday&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isHoliday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// § 193 BGB&lt;/span&gt;
    &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;withdrawalDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-03&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "2026-10-19": 17 October is a Saturday, so it moves to Monday&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass a holiday function that knows the public holidays of the state your business is in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it like a visitor
&lt;/h2&gt;

&lt;p&gt;The most common failure is not in the endpoint but on the page: the link is missing on the checkout page, hidden behind a login, or labelled differently on mobile. Check the rendered HTML of your live pages the way a logged-out visitor sees them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sweberdev/inverse check https://shop.example.com &lt;span class="nt"&gt;--kind&lt;/span&gt; both
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command loads each page without cookies, looks at every link, button and &lt;code&gt;role="button"&lt;/code&gt; element, and reports missing or mislabelled controls. It exits with a non-zero code when something fails, so you can add it to CI and block a deployment that removes the footer link. A button that is only rendered in the browser after hydration is not seen, because the check reads the HTML the server sends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or use a package for the boring parts
&lt;/h2&gt;

&lt;p&gt;If you would rather not maintain validation, receipts in several languages and deadline arithmetic yourself, &lt;a href="https://packages.sweber.dev/inverse" rel="noopener noreferrer"&gt;Inverse&lt;/a&gt; packages exactly what is described above. It is MIT-licensed, has no backend and no tracking, and you keep your own database and e-mail provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/inverse/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createInverseHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@sweberdev/inverse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createInverseHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;company&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme GmbH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hallo@acme.de&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onDeclaration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;declaration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;sendReceipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InverseLink&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"withdrawal"&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/widerruf"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Vertrag widerrufen */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WithdrawalForm&lt;/span&gt; &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/api/inverse"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler validates the fields the law asks for, sets the time of receipt on the server, stores the record through your callback and sends the acknowledgement in German, English, French, Italian, Dutch, Spanish or Polish. For sites without React there is a framework-free form and a single &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag for WordPress, Shopify or static pages. Try the &lt;a href="https://packages.sweber.dev/inverse/demo" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; or read the &lt;a href="https://packages.sweber.dev/inverse/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A paid add-on, Inverse Pro, covers what agencies and larger operators need on top: a tamper-evident log of declarations, a site-wide audit with a report for clients and a weekly digest of open cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist to take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The footer link on every page reads "Vertrag widerrufen" (and "Verträge hier kündigen" if you sell subscriptions).&lt;/li&gt;
&lt;li&gt;The form works without login and has exactly two steps; the second button reads "Widerruf bestätigen".&lt;/li&gt;
&lt;li&gt;The server sets the time of receipt; the declaration is stored before the e-mail is sent.&lt;/li&gt;
&lt;li&gt;The acknowledgement contains the content of the declaration, the date and the time.&lt;/li&gt;
&lt;li&gt;The deadline follows §§ 187, 188 and 193 BGB.&lt;/li&gt;
&lt;li&gt;The cancellation confirmation page contains nothing but the cancellation.&lt;/li&gt;
&lt;li&gt;A CI check fails when the link disappears.&lt;/li&gt;
&lt;li&gt;Your withdrawal information (Widerrufsbelehrung) mentions the new function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This article is software documentation, not legal advice. Check your case with a lawyer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I maintain the library mentioned in this article. The requirements and code samples stand on their own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>legal</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
