QA clicks Accept, copies the Floodlight hop from DevTools, clicks Reject, copies again, and compares screenshots. The banner flipped. The two URLs are identical.
That is not a flaky CMP skin. The vendor that received the second hop received the same grant string it got on accept. GDPR says consent must be as easy to withdraw as to give. The wire half of that sentence is not the modal copy. It is whether the next pixel, collect ping, or CAPI post carries a different payload, or no payload at all.
Accept is already instrumented. Withdraw usually is not
The grant path is wired. A CMP callback expands gdpr_consent, gtag('consent', 'update', …) runs, Meta gets an fbp write, Floodlight fires with gdpr=1 and a TC String. Teams paste that URL into tickets and call consent done.
Withdraw is supposed to be the same class of artifact with different bits, or silence. If the only thing that changes is banner text, every vendor that heard yes keeps processing on the old string. Cookies written on grant still exist. Pixels that read _fbp or a CMP cookie do not poll the banner. They read the next HTTP request.
Researchers auditing Tranco top sites treated revocation as its own object, not the inverse screenshot of accept. On TCF sites they could read after revoke, 22 of 136 still stored a TC String that encoded positive consent. That is not a missing parameter. That is a yes that outlived the user's no. On another subsample, third parties that had been told about accept were not informed of withdraw on 101 of 136 sites. Vendors do not call __tcfapi on a schedule. They see the hop you fire, or they do not.
What gdpr_consent actually carries
gdpr_consent is not a boolean. It is a URL-safe base64 TC String whose core segment fixes version 2 in the first six bits, then encodes purpose consent, legitimate interest, vendor flags, and timestamps in the bit field IAB Europe documents for TCF v2.
gdpr_consent=1 passes a character-set check and fails the contract: those bits decode to version 1, not a TC String. A real string that still encodes grant after reject is a different failure. The alphabet looks like TCF. The purposes still read yes.
The macro name matters too. ${GDPR_CONSENT_123} must expand with vendor 123's GVL id in the macro suffix. A template that still holds [GDPR_CONSENT_123] at serve time is empty consent. A populated grant macro after revoke is worse: the ad server did expand something, and what it expanded is still yes.
Floodlight, Meta /tr, LinkedIn pixels, and server-side CAPI each need their own signal or their own hold. Consent Mode on gtag does not write gdpr_consent. dpo=LDU does not write a TC String. A Purchase queued at accept and retried after revoke is new processing unless the webhook gates on the current CMP state.
How you catch it
Do not stop QA at refuse-on-landing in a fresh profile. Run the returning-user sequence: accept, capture, revoke, capture again.
After revoke, facebook.com/tr, a Floodlight activity, and /g/collect should be gone, redacted, or carrying a string that no longer encodes grant. If they match the accept capture byte for byte, withdraw did not travel. Redirect chains matter: if hop two drops the updated string, hop three is still the old grant.
I maintain Pixellint, an open source linter for pixels and conversion API payloads. The core privacy pack decodes gdpr_consent and gpp header bits on live URLs. It will not delete cookies or rule on lawful basis. It will tell you whether the URL you captured after revoke is still a granted IAB hop.
On a placeholder after accept or revoke:
$ pixellint validate url 'https://ad.doubleclick.net/ddm/activity/src=1234567;type=convr0;cat=purch0;gdpr=1;gdpr_consent=1'
rulepack: core
error core.privacy.tc_string_version
`gdpr_consent` is `1`, whose first six bits decode to version 1.
The TC String spec fixes the version to 2, so this is not a TC String.
fix: Pass the TC String from the CMP. A placeholder such as `1` is
base64-shaped but carries no consent.
On a syntactically valid string, core checks pass. That is where you compare captures. Paste the post-revoke Floodlight URL into the playground beside the accept capture. Same core segment, same purpose bits: the banner lost to the macro. Different LastUpdated deciseconds with grant still set: the CMP updated the timestamp without flipping consent.
Server-side GTM and CAPI get the second paste too. A Graph 200 after revoke means Meta accepted JSON. It does not mean you had a basis to send it.
{
"data": [{
"event_name": "Purchase",
"event_time": 1770000000,
"action_source": "website",
"user_data": {
"em": ["7b52009b64fd0a2a49e6d8a939753079792b5a1"],
"fbp": "fb.1.1770000000.123",
"client_ip_address": "203.0.113.42"
}
}]
}
pixellint validate json on that body after revoke flags shape errors (seconds on event_time, hashed IP rules) and missing website fields. It does not know your CMP state. You gate the webhook: when the basis is gone, skip the postback or send a redacted event. Meta documents opt_out for attribution-only use; that is not a substitute for stopping match keys you no longer have permission to send.
cargo install pixellint
# or: npm install pixellint
pixellint validate url 'https://...'
pixellint validate json @capi-after-revoke.json
Where to go next
The consent revocation guide walks the full accept-then-revoke pass: cookies that survive reject, third parties that never hear no, and why a CMP callback update alone is incomplete.
TCF v2 on pixels covers gdpr=0 vs gdpr=1, macro expansion, truncation, and sunset v1 strings. That is the accept-side contract this article assumes you already fire correctly.
What counts as a pixel separates impression beacons from click trackers and from CAPI posts so you know which artifact must change on withdraw for each vendor.
The Meta Conversions API pack lists event_source_url, hashing rules, and opt_out with citations. Use it on the server body in the same session where you lint the browser hop.
Process docs describe storing the CMP choice with the session so retries and batch jobs do not replay grant-era payloads after reject.
The short version
Withdraw is a request class, not a banner state. Capture the pixel URL after accept and after revoke; they must differ, or the revoke hop must be absent. Decode gdpr_consent on both. A grant string after reject is a fail even if the UI flipped. Repeat for CAPI JSON and for every third party that heard yes. Fix the CMP callback, the ad-server macro, or the webhook gate, not the screenshot.
Pixellint is independent of IAB Europe and of the audit literature cited on our docs site. The rule ids above cite the TC String spec and vendor parameter docs because that is where the wire contract lives.
Top comments (0)