Your server container is forwarding events to vendors the visitor declined, because the payload the browser sends carries the event and nothing about the decision — and the container, having no other source, forwards everything.
Triage: Inspect What Actually Crosses
- Trigger an event with the banner declined, and open the request to your collection endpoint in DevTools → Network → Payload.
- Look for a consent field. Its absence is the whole problem; a boolean is nearly as bad.
- Check the container’s own logs for the same event and confirm which vendors it forwarded to.
- Compare against the browser: the browser made no vendor requests, and the container made several. That gap is the finding.
Root Cause: The Envelope Carries an Event, Not a Decision
A collection endpoint typically evolves from an analytics beacon: it was designed to carry what happened, and consent was handled elsewhere — in the browser, by not sending the beacon at all. Moving the fan-out server-side changes that assumption without changing the payload, because the beacon still sends and the suppression that used to happen client-side now has to happen somewhere that has no information.
Sending a boolean is the natural first fix and is still wrong in two ways. It records a verdict rather than evidence, so the container cannot make a different decision for a vendor in a different category, and an audit later cannot reconstruct what the visitor actually chose. And a single field cannot express the three-valued state — granted, denied, pending — so a visitor who has not yet answered is indistinguishable from one who declined, which is the same ambiguity that causes leaks on the client.
The envelope should therefore carry the resolved category state, the region that produced the policy, and the policy version under which it was captured. Those three let the container decide per vendor, reject payloads built under a schema it no longer honours, and log a decision someone can later verify.
Resolution: A Versioned Envelope, Rejected on Absence
// client — build the envelope per event, not once per page.
function envelope(event) {
const c = consentSource.snapshot(); // per-category values
return {
event,
consent: c,
region: window.__POLICY__?.region ?? null,
policyVersion: CONSENT_POLICY_VERSION,
path: location.pathname, // path only — never the query string
ts: Date.now(),
};
}
navigator.sendBeacon('/collect', JSON.stringify(envelope(evt)));
// container — absence is a rejection, never a default.
function decide(env) {
if (!env || typeof env.consent !== 'object') return reject('missing consent');
if (env.policyVersion !== CURRENT_POLICY_VERSION) return reject('stale policy');
const permitted = [], withheld = [];
for (const [vendor, category] of Object.entries(VENDOR_CATEGORY)) {
(env.consent[category] === 'granted' ? permitted : withheld).push(vendor);
}
// Both lists are recorded. The withheld list is what proves a negative later.
audit.record({ ts: env.ts, region: env.region, permitted, withheld });
return { permitted };
}
Rebuilding the envelope per event rather than once per page is the detail most often got wrong, and it produces a subtle failure: a visitor who declines after the page has loaded continues to have events forwarded under the snapshot taken at load. Because the events themselves are correct and the volume is unchanged, nothing looks wrong from either side.
Verification: Decline, Trigger, Read Both Sides
Decline, trigger an event, and read the container’s log line for it. Every vendor should appear in the withheld list and none in the permitted list. Then accept, trigger again, and confirm the lists invert. A log line with an empty withheld list in the declined case means the container is not recording what it refused, which is the half of the evidence that matters.
Keeping the Client and Container in Step
The envelope is an interface between two codebases that are usually deployed independently, which makes versioning it a practical necessity rather than a nicety. A client deployed on Monday and a container deployed on Thursday will overlap, and during the overlap both schemas are in flight simultaneously.
Treat the policy version as the contract and make the container tolerant in exactly one direction: it should accept the current version and the immediately previous one, and reject anything older. That gives a deployment window without opening an indefinite one, and it makes the rejection rate a metric worth watching — a sudden rise means a client is stuck on an old build, which is useful to know before someone notices missing data.
Queued beacons make this concrete. A payload assembled before a policy change can be flushed minutes or hours later, from a background tab or a resumed session, and it arrives carrying consent for a schema that no longer exists. Rejecting it is correct: the visitor consented to something, but not to the thing your current configuration would do with the event.
Who Owns the Envelope Schema
The envelope is an interface, and interfaces without an owner drift. In practice the client half is maintained by whoever owns the front end and the container half by whoever owns the tagging infrastructure, and those are frequently different teams with different release cadences and no shared test.
Name one owner for the schema itself and keep its definition in a place both halves import rather than in two copies. A shared package, a generated type, or at minimum a single documented file with a version history is enough — what matters is that adding a field is a change to one artefact rather than a coordination exercise conducted through a chat message.
The failure this prevents is quiet and expensive. A field renamed on the client and not on the container does not error; the container simply reads undefined, applies whatever its fallback is, and continues. If that fallback is a rejection you lose data visibly; if it is a permit you lose compliance invisibly, which is why the rejection default matters as much as the schema ownership does.
Common Pitfalls
- Defaulting to permitted when the field is missing. This converts every malformed payload into a forwarded one, and malformed payloads are exactly the ones you cannot explain later.
- Sending a verdict rather than the state. A boolean cannot support per-vendor decisions and cannot be audited.
- Snapshotting consent once per page. A decision made after load never reaches the container.
- Forwarding the full URL. Query strings carry search terms, tokens and identifiers that were never intended for a vendor. Send the path.
Frequently Asked Questions
Should the container trust the client's consent field at all?
It has no alternative, and that is worth stating plainly rather than pretending otherwise. The consent decision is made in the browser and only the browser can observe it, so the container is necessarily accepting an assertion from a client it does not control — someone could forge a payload claiming consent that was never given.
What the container can do is refuse the obviously invalid: a missing field, an unknown policy version, a category that does not exist in its map. That converts the trust relationship from unbounded to bounded, and it means an anomaly in the rejection rate is a signal worth investigating rather than noise. For the residual risk, a server-side consent record — written when the decision is made and looked up per request — is the stronger design, at the cost of a lookup on the hot path.
How long should the audit record be kept?
As long as the consent records it corresponds to, because it is the same evidence viewed from the other side. If your policy retains proof of consent for two years, a record showing what was forwarded under that consent is only useful for the same period.
Keep it aggregate wherever the question you need to answer is aggregate. Daily counts per vendor per category prove that the mechanism works and that a declined category produced no forwards, without storing a per-visitor row about someone whose main interaction with you was declining to be tracked.
Does this apply if the container only forwards to one vendor?
Yes, and arguably more urgently, because a single-vendor container is the configuration most likely to skip the consent field entirely on the reasoning that “the gate already handled it”. The gate handled whether the beacon was sent; it did not handle what happens to a beacon that was sent under a decision which has since changed.
The other reason is growth. A container built for one vendor acquires a second within a year, and at that point the envelope either already carries the state or has to be retrofitted across a client and a container that are now both in production. Adding three fields at the start costs nothing; adding them later is a coordinated deployment.