Stripe Checkout consent_collection: encoding a German withdrawal waiver into the pay button

Why our Stripe Checkout sessions require a consent checkbox for the §356(5) BGB withdrawal waiver, and the Dashboard field that breaks sessions without it.

June 10, 2026

For a few days, our terms page was lying. The withdrawal section told EU buyers, in lawyer-reviewed prose, that "checkout asks for this consent; delivery happens immediately afterwards." Checkout asked for nothing. The legal copy described a payment flow we had not built yet, which is a worse state than having no legal copy at all: it looks compliant while the one thing the statute actually requires, an affirmative act by the buyer, never happens.

This post is about the commit that made the sentence true, why the fix is a Stripe Checkout parameter and not a paragraph of terms, and the account-level dependency that turns a missing Dashboard field into a checkout that cannot create sessions at all.

The statute wants an act, not a paragraph

BoiledPlate is delivered by inviting the buyer's GitHub account to a private releases repo. The webhook fires the invite seconds after payment. Instant delivery is the product working as intended, and it is exactly the scenario EU consumer law was written to scrutinize: distance contracts come with a 14-day right of withdrawal, and a buyer who can clone a repo on day one and withdraw on day thirteen has received the goods either way.

German law closes this for digital content in § 356 (5) BGB, but only on two conditions, both of which must happen before delivery: the consumer expressly consents to immediate performance, and acknowledges that this consent costs them the withdrawal right. "Expressly" is doing real work in that sentence. A clause sitting in your terms that the buyer never interacts with is not an express request for anything. The buyer has to do something.

So the requirement decomposes into a UI problem: somewhere between "Buy Pro" and the card form, the buyer must take an affirmative action whose text states both halves of the waiver. We do not control the card form, Stripe's hosted Checkout page does. Which means the waiver either lives inside the Checkout session configuration, or it does not exist.

What the session looks like now

Stripe has a purpose-built parameter pair for this. Our server/api/stripe/checkout.post.ts creates sessions like this (comment included, because the comment is half the point):

// EU digital-content withdrawal waiver (§ 356 (5) BGB): the buyer must
// actively accept the terms incl. immediate delivery and the loss of
// the withdrawal right BEFORE paying; /terms relies on this consent.
// Requires the Terms of Service URL to be set in the Stripe Dashboard
// (Settings -> Business -> Public details), else session creation fails.
consent_collection: { terms_of_service: 'required' },
custom_text: {
 terms_of_service_acceptance: {
 message: serverT('checkout.withdrawalConsent'),
 },
},

consent_collection.terms_of_service: 'required' puts a checkbox on the hosted page that gates the pay button. Left alone, that checkbox carries Stripe's generic terms-acceptance wording, which would get us agreement to our terms but not a waiver. The custom_text.terms_of_service_acceptance.message replaces the label, and the label is where the statute lives:

I expressly request immediate delivery of the digital content (GitHub repository access) and acknowledge that I thereby lose my 14-day right of withdrawal.

One sentence, two statutory conditions: the express request for immediate performance and the acknowledgment of the consequence. The buyer cannot reach a paid state without ticking it, and Stripe records the acceptance on the session object, so the consent has a timestamped, third-party-held record attached to the exact transaction it covers. Then, and only then, the webhook runs its fixed sequence: record the purchase, record any affiliate commission, send the GitHub invite, send the email. Consent strictly precedes performance because the thing that performs (the webhook) cannot fire before the thing that consents (the paid session) exists.

The dependency that has no type error

Now the second half of the title. That code comment warns about a constraint you will not find in your editor: terms_of_service: 'required' only works if your Stripe account has a Terms of Service URL configured in the Dashboard, under Settings, then Business, then Public details. The checkbox needs a document to link to, and Stripe resolves that link from account settings, not from the API call.

If the field is empty, nothing degrades. stripe.checkout.sessions.create throws, the API route 500s, and every buyer hits a dead checkout. The parameter is valid TypeScript, the SDK accepts it, test sessions on a configured account work fine. The failure lives entirely in account state, which is why we wrote it into a comment directly above the parameter rather than trusting anyone (including a future coding agent running our own Stripe provisioning script against a fresh account) to remember it. Our setup-stripe.mjs provisions products, prices, and the webhook endpoint via the API; the ToS URL is the one piece of the billing setup that remains a human Dashboard step, so it is the one most likely to be skipped.

That is the trade encoded in the title. The consent is required, by us and by the BGB. The price of making it required is a new way for checkout to fail that no typecheck will catch.

One more constraint shaped the commit: this repo's i18n rule is absolute, no user-facing string is ever hardcoded, and the consent message is about as user-facing as text gets. But it is assembled server-side, inside a Nitro route where the @nuxtjs/i18n module does not exist. It goes through serverT(), the same minimal resolver our transactional emails use, which imports the locale JSONs directly. The key, checkout.withdrawalConsent, ships in all four locale files, so the buyer the waiver was written for can read it in German:

Ich verlange ausdrücklich die sofortige Bereitstellung des digitalen Inhalts (GitHub-Repository-Zugang) und nehme zur Kenntnis, dass ich dadurch mein 14-tägiges Widerrufsrecht verliere.

The honest caveat: serverT resolves the configured default locale, not the individual session's language, because the server route has no reliable view of the buyer's locale cookie at session-creation time. A German buyer browsing in English sees the English waiver. We logged that as a known gap rather than papering over it.

What the waiver does not buy

The waiver extinguishes a statutory right. It does nothing about chargebacks, and it does not prevent us from refunding someone as a goodwill exception. So the defensive machinery downstream stays fully armed: the webhook handles charge.refunded by stamping refunded_at on the purchase row and revoking the GitHub access, both the collaborator role and any still-pending invite. Entitlement everywhere is "an unrefunded purchase row exists." The consent checkbox decides what a buyer can lawfully demand; the refund handler decides what happens when money moves backward regardless of demands. They are different layers and we keep them that way, for the same reasons we treat Stripe webhooks as the source of truth for every other billing state.

The takeaway

If you deliver digital goods instantly to EU consumers, audit one thing this week: does your payment flow contain an affirmative act that states the § 356 (5) waiver, or does the waiver only exist as prose on a terms page? If it is the latter, your "all sales are final" policy is decorative. Stripe Checkout's consent_collection plus a terms_of_service_acceptance message is the smallest honest implementation we found, with consent recorded on the session itself. Set the Terms of Service URL in the Dashboard before you ship it, and leave a comment saying so, because that dependency is invisible right up until no session gets created at all.

#stripe #checkout #eu-law #digital-goods #i18n

Read more