Withdrawing consent should be the same call as granting it
The GDPR asks that consent be as easy to withdraw as it is to give. That reads as a requirement about buttons, and it is one, but it lands on the API underneath first: if refusing takes a different function, an extra argument or a confirmation step that granting does not need, the symmetry is gone before anything is rendered, and no amount of button styling brings it back.
We build compliance tooling for payment platforms and cross-border commerce, and consent is one of the places where the legal text and the code sit unusually close together. "As easy to withdraw as to give" is a statement about two code paths having the same shape. In most codebases they do not. Granting is a rich call that takes what was chosen and the context it was chosen in. Withdrawing is a thin one: a cleared storage key, a boolean flipped back, sometimes a dialog asking whether the visitor is sure. Those two calls are not inverses of each other, and everything downstream inherits the difference.
Our consent library, consentcore (https://github.com/shipmindlabs/consentcore), is built on the opposite rule: refusing is the same call as granting, the same parameters in the same order, the same return value, and the same kind of entry in the log.
Asymmetry is a property of the signature#
Here is the pair a codebase usually ends up with. The names vary; the shape does not.
// not ours — the shape we keep finding
setConsent(["statistics", "marketing"], { noticeVersion: "2026-08" });
clearConsent();The grant takes what was chosen and the context it was chosen in. The withdrawal takes nothing. Three consequences follow, and not one of them is a UI problem.
The withdrawal cannot be specific. A visitor who wants to keep analytics and drop advertising has no call to make, because the only refusal the API offers is all of it. The interface then grows a second, different path for partial changes, usually "open preferences and submit the whole set again", which is precisely the extra step the symmetry requirement is about.
The withdrawal has nothing to record against. It receives no notice version, no clock, no indication of how the decision was made, so it either writes a poorer record than the grant wrote or writes nothing at all.
And the withdrawal is implemented as deletion. clearConsent() clears the stored value, and the evidence that the visitor withdrew goes with it, the one event you are most likely to be asked to account for later.
The symmetric version puts the two directions on the same signature:
grant(["statistics"], options, state); // adds statistics to what was already granted
refuse(["statistics"], options, state); // removes statistics, leaves the rest aloneSame arguments, same order, same State back, same entry appended to the log. A grant adds to what was already granted; a refusal takes away what it names. Nothing on the refusal path is thinner, later, or more ceremonious than the grant path, because they are the same path with a direction.
One category does not move. necessary covers what the service cannot work without, and it survives being named in a refusal, not as a validation error the caller has to handle, just as a category that does not come off. That is structural rather than a rule someone has to remember:
export type Category = "necessary" | "preferences" | "statistics" | "marketing";
export const OPTIONAL_CATEGORIES: readonly Category[] = [
"preferences",
"statistics",
"marketing",
] as const;
export const CATEGORIES: readonly Category[] = ["necessary", ...OPTIONAL_CATEGORIES] as const;The whole-answer entry points keep the same discipline. acceptAll(options, previous),rejectAll(options, previous),accept(categories, options, previous) and withdraw(options, previous) all take the same options and the same previous state, and all return the same State. "Reject all" is not a special case implemented somewhere else; it is the same constructor with ["necessary"].
The record is the same record#
A decision is a four-field object, and a refusal fills all four exactly as a grant does:
export type Decision = {
readonly granted: readonly Category[];
readonly at: string;
readonly noticeVersion: string;
readonly noticeHash: string;
readonly method: "accept-all" | "reject-all" | "custom" | "withdrawn";
};method is the field an audit actually asks about: not only what was granted but how the answer was arrived at. "withdrawn" sits in that union next to "accept-all", which is the whole reason the union exists. A withdrawal is a decision, not the absence of one.
const state = withdraw(options, previous);
state.decision.method; // "withdrawn"
state.decision.granted; // ["necessary"]
state.pending; // true — the notice goes back on screenThat pending flag is the other half of symmetry. Withdrawing must not leave the visitor with no way to change their mind again, so the notice returns rather than the site quietly entering a state with no controls in it.
The log is append-only. A new decision supersedes the previous answer and never edits or drops it, because the superseded entry is the part that makes the record demonstrable. Storage written by an older version may hand back a single decision rather than an array, so restoring normalises both shapes:
export function asLog(stored: StoredConsent): readonly Decision[] {
if (!stored) return [];
return "method" in stored ? [stored] : [...stored];
}And a withdrawal restored from storage does not read as "never asked": restore returns the withdrawn decision with pending: true, so the banner can come back while the record of why it came back is still sitting there.
Symmetry has to survive the side effects#
A symmetric API is worth nothing if only one direction reaches the page. This is where the asymmetry usually reappears: granting starts things, and refusing merely stops new ones from starting.
One-shot work is gated, and work whose category is withdrawn is dropped rather than parked:
const gate = new Gate(state);
gate.when("necessary", "session", () => startSession());
gate.when("statistics", "analytics", () => loadAnalytics());
gate.when("marketing", "pixel", () => loadPixel());
gate.update(accept(["statistics"], options));Necessary work runs immediately and synchronously so callers can rely on ordering, nothing runs twice however many decisions arrive, and a withdrawn category's pending work is discarded. Holding it in case the visitor changes their mind is how a queue becomes a loophole.
Third-party tags are different in kind. Queued work is a side effect that happens once and is over; a tag keeps running, and consent can move under it after it started. So tags are a registry, and a refusal removes them:
const tags = new Tags(state);
tags.register({ id: "analytics", purpose: "statistics", src: "https://cdn.example/a.js" });
tags.register({
id: "pixel",
purpose: "marketing",
src: "https://cdn.example/p.js",
cleanup: () => { document.cookie = "_pxl=; Max-Age=0"; },
});
tags.update(accept(["statistics"], options)); // analytics goes on the page, the pixel does not
tags.update(withdraw(options, state)); // the analytics element comes off itRemoved, not neutralised, not left in place with type="text/plain" waiting to be revived, which keeps a refused vendor one line of unrelated code away from running. cleanup is where the cookie the tag already set gets dealt with, because taking an element off the page does not undo what it did while it was there. Elements carry data-consent-tag and data-consent-purpose, so you can inspect a page for what is running and under which answer, and tags.history is the record of both directions. A later grant releases a removed tag again: a new answer to the same question, not a replay of the old one.
A refusal can only be as specific as the answer#
Categories are a coarse answer. "statistics" does not say which vendor gets the request or what they set, and the list that does say it is usually prose written three times, in the banner, in the cookie policy, and in a record of processing, which then drift apart. Declaring it once as data fixes both the drift and the granularity question:
const declaration = declare({
purposes: [
{ id: "audience", category: "statistics", name: "Counting visits",
description: "Which pages are read, in aggregate." },
{ id: "retargeting", category: "marketing", name: "Advertising",
description: "Showing you ads for this site on other sites." },
],
vendors: [
{ id: "acme-analytics", name: "Acme Analytics", purposes: ["audience"],
policy: "https://acme.example/privacy", cookies: ["_acme"], transfers: "US" },
{ id: "adnet", name: "AdNet", purposes: ["audience", "retargeting"] },
],
});
allowsPurpose(declaration, state, "audience"); // true
allowsVendor(declaration, state, "adnet"); // false: retargeting was not granted
runnable(declaration, state); // ["acme-analytics"]A purpose is answered through its category, so two purposes under one category are one answer, and therefore one refusal. If you want them refusable separately, that is a decision about the declaration, not about the banner. declare checks the declaration rather than trusting it: a vendor may only name purposes that exist, an id may only be used once, and a purpose with no description is refused, because a notice made of identifiers informs nobody. What comes back is plain data, so you can commit it next to the code, diff it in review, and publish it beside the notice.
What it costs to run#
An append-only log grows, and it is stored per visitor. It is small, four fields per decision, but it is not free, and the restore path has to tolerate both the old single-decision shape and the array.
Binding a decision to a fingerprint of the notice text means editing the wording re-asks everyone, whether or not the version string was bumped. That is the intended behaviour, and it makes notice edits a deliberate act with a cost attached rather than a copy change.
Dropped queued work is not resurrected by a later grant, while removed tags are released again. That difference is a design decision you have to make per side effect: if something must come back when consent returns, it belongs in the registry, not the gate.
And cleanup is real work. Every vendor that sets something needs a function that unsets it, written by someone who knows what it set. There is no generic version of that.
Close#
Symmetry is cheap when it is in the signature from the beginning and expensive to retrofit, because by then the refusal path has grown its own storage behaviour, its own record format, and its own set of side effects that only run in one direction. The test is not what the banner looks like. It is whether you can write the refusal as the same call as the grant, hand it the same arguments, and get back the same kind of state and the same kind of log entry. If you cannot, the property the regulation asks for is probably not in the product, wherever the buttons ended up.