Skip to content
// legal · payment-infrastructure · solo-founder · eu-compliance

Building the EU CRD Article 16(m) consent flow in Next.js

A practical guide to the EU Consumer Rights Directive Article 16(m) waiver for digital goods. The exact dual-checkbox UI, the consent_logs schema, and the Withdrawal Button you have to add by June 19, 2026.

by İsmail Günaydın7 min readupdated

If you sell digital goods to a single customer in the EU or UK, your checkout has to do four things that most checkouts do not. This post walks through each one with the exact Next.js implementation, the database schema, and the gotchas.

The four things:

  1. Two separate checkboxes at checkout for EU CRD Article 16(m) consent
  2. A consent_logs table that records what the buyer agreed to
  3. A Withdrawal Button on every eligible order (mandatory from 19 June 2026)
  4. A refund pathway that honours the waiver while staying buyer-friendly

This is what we shipped on toolgenx.com. It is not legal advice — I am an engineer, not a lawyer — but it satisfies the directive as I read it, with citations to the underlying text.

What the directive actually says

EU Directive 2011/83/EU Article 16(m), unchanged through 2026:

Member States shall not provide for the right of withdrawal set out in Articles 9 to 15 in respect of distance contracts as regards [...] the supply of digital content which is not supplied on a tangible medium if the performance has begun with the consumer's prior express consent and his acknowledgment that he thereby loses his right of withdrawal.

Two consents are required, both before performance begins. "Performance" for a digital download means the moment your server signs the URL or unlocks the file.

The UK has a near-identical provision in the Consumer Contracts Regulations 2013 Schedule 4(m). For practical purposes the implementation is the same.

The checkout UI

Two checkboxes, both unticked by default, both required to enable the buy button.

// components/checkout/ConsentCheckboxes.tsx
'use client';

import { useState } from 'react';

export const CONSENT_TEXTS = {
  immediate_access: 'I expressly request that download or access to the file begins immediately.',
  withdrawal_waiver: 'I acknowledge that by starting the download, I lose my right of withdrawal.',
};
export const CONSENT_TEXT_VERSION = '2026-06-03';

interface Props {
  onChange: (ready: boolean) => void;
}

export function ConsentCheckboxes({ onChange }: Props) {
  const [access, setAccess] = useState(false);
  const [waiver, setWaiver] = useState(false);
  const ready = access && waiver;
  // emit upward whenever state changes
  if (typeof window !== 'undefined') onChange(ready);
  return (
    <fieldset className="space-y-3">
      <legend className="text-sm font-semibold">
        Required to proceed (EU Consumer Rights Directive)
      </legend>
      <label className="flex items-start gap-3">
        <input
          type="checkbox"
          name="consent_immediate_access"
          checked={access}
          onChange={(e) => setAccess(e.target.checked)}
          required
        />
        <span className="text-sm">{CONSENT_TEXTS.immediate_access}</span>
      </label>
      <label className="flex items-start gap-3">
        <input
          type="checkbox"
          name="consent_withdrawal_waiver"
          checked={waiver}
          onChange={(e) => setWaiver(e.target.checked)}
          required
        />
        <span className="text-sm">{CONSENT_TEXTS.withdrawal_waiver}</span>
      </label>
    </fieldset>
  );
}

Three things to note:

  1. The default state is unticked, which is mandatory under recital 36 of the directive.
  2. Each checkbox emits a distinct name so the server can verify both were checked, not just one.
  3. The text is hashed and versioned (see schema below) so that if the wording changes, you can prove which version each buyer agreed to.

The consent_logs schema

Single table. Row per consent moment. Retained for six years.

create table public.consent_logs (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references public.profiles(id) on delete set null,
  email text not null,
  consent_type text not null check (
    consent_type in ('immediate_access', 'withdrawal_waiver')
  ),
  consent_text_hash text not null,
  consent_text_version text not null,
  ip inet not null,
  user_agent text not null,
  locale text not null,
  created_at timestamptz not null default now()
);

Why each column matters:

  • email is required because consent can be given by an unauthenticated guest checkout.
  • consent_text_hash lets you prove exactly what text the buyer saw. A SHA-256 of the checkbox label is enough.
  • consent_text_version is a human-readable tag (e.g., 2026-06-03) so support staff can look up the legal wording without computing a hash.
  • ip and user_agent are forensic data. They are not what proves consent, but they corroborate the timestamp.
  • locale matters because the same text in different languages may have different legal weight in different jurisdictions.

The webhook persistence

When the Stripe checkout.session.completed event fires, the webhook writes two rows to consent_logs (one per consent type), one row to orders, and one row per item to order_items. All inside a single Postgres transaction.

// app/api/webhooks/stripe/route.ts
import { createHash } from 'node:crypto';
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { supabaseAdmin } from '@/lib/supabase/admin';
import { CONSENT_TEXTS, CONSENT_TEXT_VERSION } from '@/components/checkout/ConsentCheckboxes';

export async function POST(req: Request) {
  const sig = (await headers()).get('stripe-signature');
  const body = await req.text();
  const event = Stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);

  if (event.type !== 'checkout.session.completed') return new Response('ok');

  const session = event.data.object;
  const meta = session.metadata ?? {};

  // Both consents are required — assume the checkout endpoint already rejected
  // requests where they were not provided.
  await supabaseAdmin.from('consent_logs').insert([
    {
      email: session.customer_email!,
      consent_type: 'immediate_access',
      consent_text_hash: sha256(CONSENT_TEXTS.immediate_access),
      consent_text_version: CONSENT_TEXT_VERSION,
      ip: meta.ip,
      user_agent: meta.ua,
      locale: meta.locale,
    },
    {
      email: session.customer_email!,
      consent_type: 'withdrawal_waiver',
      consent_text_hash: sha256(CONSENT_TEXTS.withdrawal_waiver),
      consent_text_version: CONSENT_TEXT_VERSION,
      ip: meta.ip,
      user_agent: meta.ua,
      locale: meta.locale,
    },
  ]);

  // ... order + license persistence below ...
  return new Response('ok');
}

function sha256(s: string) {
  return createHash('sha256').update(s).digest('hex');
}

The IP and user-agent come from the checkout request and are passed through Stripe metadata. This sidesteps the limitation that the webhook itself does not know the original client.

The Withdrawal Button (mandatory from 19 June 2026)

EU Directive 2023/2673 introduces a Withdrawal Button that must be visible in every eligible buyer's account from 19 June 2026 onward. The directive is specific about the requirements:

  • The button must be clearly labeled ("Withdraw from contract" or jurisdiction-equivalent).
  • It must use an unambiguous electronic function specifically for exercising withdrawal.
  • It must be available during all 14-day withdrawal periods.
  • A submission triggers a standardised confirmation page with the contract details.

The implementation in Next.js:

// app/[locale]/(account)/account/orders/page.tsx
import { isWithdrawalEligible } from '@/lib/orders';
import { WithdrawButton } from './WithdrawButton';

// ... within the order row render ...
{
  isWithdrawalEligible(order) ? (
    <WithdrawButton orderId={order.id} />
  ) : (
    <p className="text-fg-subtle text-xs">
      Withdrawal window closed
      {order.downloaded_at ? ' (file downloaded)' : ' (14 days elapsed)'}
    </p>
  );
}

isWithdrawalEligible checks the order is less than 14 days old AND no download has been recorded against any of its licenses. The button submits to /api/withdraw/[orderId], which validates the same conditions server-side, calls the provider refund API (Stripe or Iyzico), marks the order withdrawn, and deactivates the licenses.

The standardised confirmation page is just a Next.js route at /account/orders/[id]/withdraw that pre-fills the order details and asks for a single click confirm. Optional reason field. Email confirmation after submission.

The provider-specific gotchas

Three things differ between Stripe and Iyzico that affect this implementation:

  • Stripe offers a Customer Portal where buyers can self-serve refunds. The Withdrawal Button can call the portal directly or call the refund API. Either is compliant.
  • Iyzico does not have a self-serve portal at this writing. The Withdrawal Button submits to your own endpoint which calls Iyzico's refund API server-side. Same compliance outcome, different plumbing.
  • Stripe charges a small fixed fee per refund (the original processing fee is not returned). Iyzico's refund cost depends on the plan. Both are passed to the seller, not the buyer.

For toolgenx.com the provider picker presents both options at checkout and the Withdrawal Button routes the refund through whichever provider handled the original payment.

Three things I learned implementing this

  1. Build the legal evidence schema before the checkout. I built Stripe Checkout first, then realised I needed consent_logs, then refactored both. Doing it in the right order (schema → consent UI → checkout → webhook → withdrawal endpoint) is faster end-to-end.

  2. Hash the consent text and version it. I almost shipped without versioning. The first time I tweaked a comma in the consent label, every prior row had a stale hash with no way to know which actual wording the buyer had agreed to. The version tag solves it.

  3. Treat the Withdrawal Button as the easiest part. It is genuinely the cheapest piece to add. Most of the engineering went into the consent capture and the audit trail. The button is a thin UI over an endpoint that already exists for refund handling.

The defensive read

If you are running a small EU-facing digital shop and you do not have a consent_logs table, you are exposed. The exposure compounds: every order without proper consent capture extends the buyer's withdrawal window from 14 days to 14 days plus one year. After a hundred orders that is a meaningful tail risk.

The fix is two days of engineering work. The cost of not fixing it is unbounded.


The full refund and withdrawal policy is at /legal/refund. The decision matrix for which payment provider to use is in Stripe vs Iyzico vs Gumroad. The wider context of shipping as a solo founder is in the solo founder hub post.

// faq

Frequently asked

Does this apply if I am not in the EU but my buyers are?
Yes. The directive protects EU consumers regardless of the seller's location. If a buyer in Germany buys from a Turkish shop, German consumer law including the CRD applies to that contract. The same is true for any EU member state buyer.
What happens if I do not collect the dual consent?
The right of withdrawal stretches from 14 days to 14 days plus one year. If the buyer requests a refund inside that extended window, you owe it, even if the file was downloaded immediately. The burden of proof that consent was given is on the seller.
Is one checkbox covering both points enough?
No. The directive requires the two consents to be separate and given individually. One combined checkbox is interpreted as bundling, which makes the waiver invalid. Two checkboxes, both unticked by default, both required.
Does the Withdrawal Button apply to digital goods that were downloaded?
The button itself must be visible on every eligible order. Eligibility means inside the 14-day window AND the right of withdrawal still applies. For downloaded digital goods where consent was properly captured, the button shows a disabled state with the reason. For undownloaded orders the button is active.
What is the simplest schema for consent_logs?
A single table with columns id, user_id, email, consent_type (immediate_access or withdrawal_waiver), consent_text_hash, consent_text_version, ip, user_agent, locale, created_at. One row per consent moment. Retention 6 years matches both the EU statute of limitations and the typical legal claim window.

// related products

// related writing

Written by

İsmail Günaydın

Software Engineer · SEO/GEO/AEO Strategist · Digital Entrepreneur

Software engineer and digital entrepreneur with 15+ years building SEO-driven products. Founder of ModernWebSEO and ToolGenX. Focused on developer experience, web performance, and making technical content accessible. Builds customer-generating digital infrastructure through SEO, AEO, and GEO strategies.