Skip to main content
Ask your AI

Consent management

walkerOS checks consent before events leave a flow. You record the user's choices in the collector, and each source and destination declares which consent it needs. Events that lack consent either wait or are dropped, depending on where you set the requirement and, on a server, on the collector's consentDenied setting.

warning

This page is not legal advice. Consult a legal expert about the laws and regulations that apply to your business.

A Consent Management Platform (CMP) usually collects the user's choice. That happens asynchronously: the CMP has to load, and the user may decide late or not at all. walkerOS provides CMP source packages that listen for CMP events and send the consent command for you.

Without a CMP source, send the walker consent command yourself:

elb('walker consent', { functional: true, marketing: false });

Consent keys are free-form. Common groups are functional, analytics, and marketing, and you can also use one key per vendor. The command stores each value as a boolean and only updates the keys it contains, so elb('walker consent', { marketing: true }) leaves an existing functional state as it is.

This web flow uses a CMP source to record consent, allows the gtag destination to receive events only with analytics or marketing consent, and maps the CMP source's analytics group to Google's analytics_storage:

{
  "version": 4,
  "flows": {
    "default": {
      "config": { "platform": "web" },
      "sources": {
        "cmp": {
          "package": "@walkeros/web-source-cmp-cookiefirst"
        },
        "browser": {
          "package": "@walkeros/web-source-browser",
          "config": {
            "settings": { "pageview": true }
          }
        }
      },
      "destinations": {
        "gtag": {
          "package": "@walkeros/web-destination-gtag",
          "config": {
            "loadScript": true,
            "consent": { "analytics": true, "marketing": true },
            "settings": {
              "como": {
                "marketing": [
                  "ad_storage",
                  "ad_user_data",
                  "ad_personalization"
                ],
                "analytics": "analytics_storage"
              },
              "ga4": {
                "measurementId": "G-XXXXXXXXXX",
                "pageview": false
              }
            }
          }
        }
      }
    }
  }
}

"loadScript": true makes the destination load gtag.js; leave it out when your page already loads it. "pageview": false stops gtag's config from sending a page view of its own next to the browser source's page view.

A destination's config.consent lists the consent keys it needs. The collector checks every event against it before the destination sees the event:

  • One granted key is enough. With { "analytics": true, "marketing": true }, the destination receives events if either analytics or marketing is granted.
  • Events without the required consent wait in the destination's queue (the push answers queued with the reason consent). Once consent is granted, they are delivered in their original order. Server flows built by the CLI skip these events instead (consentDenied: 'skip'), see consent-denied events on a server.
  • The collector initializes the destination only after its required consent is granted: at the first event that passes the check or, for a destination with an on() handler, at the consent command that grants it.
  • The queue holds up to 1000 events per destination by default. When it is full, the oldest events go first. Change the limit with config.queueMax. See buffer bounds.

config.queue does not switch off this consent queue. The collector also keeps the events of the current run, so a destination added later still receives them. "queue": false skips only that replay for the destination.

Once a consent state is recorded, a destination receives it through its on('consent') handler before any later event is sent to it. This holds when consent was set before or after walker run, when the destination waits for consent with require, and when it is added with walker destination while the flow is running. The collector waits for the handler to finish, up to the destination's config.timeout (10 seconds by default), and delivers the state again when it changes. Events pushed meanwhile are held for the destination: their push answers queued with the reason held.

If the handler fails or does not finish in that time, the collector writes an ERROR line (consent not applied) and holds the destination's events for one more config.timeout. The first push after that window retries a failed delivery before it sends, and a handler that finishes late receives the current state again right away. Once a delivery succeeds, the held events are delivered. If none has succeeded by the first push after the window, the held events and every later event with the required consent fail with the reason blocked until one does: the push answers ok: false, the events are counted in status.destinations[id].failed and kept in the destination's dead-letter queue, an ERROR Push failed line is written, and they are not sent when the destination recovers. Later pushes retry the delivery once per config.timeout. A handler that timed out gets no new delivery until it finishes, so one that never finishes keeps its destination blocked. A walker run discards events that are still held, with no counter and no log line. See the failure codes.

The collector drops events from a source when they lack the consent in the source's config.consent. Sources have no consent queue, so granting consent later does not bring these events back:

"browser": {
  "package": "@walkeros/web-source-browser",
  "config": {
    "consent": { "functional": true },
    "settings": { "pageview": true }
  }
}

Use source gating when events must not enter the flow at all without consent. Use destination gating when events should wait for a decision (in a server flow built by the CLI they are skipped, see above).

Wait for the CMP with require​

"require": ["consent"] holds a destination back, and holds a source's on() delivery back, until the collector has a consent state. Any recorded state counts, whether granted or denied, so require does not check values; config.consent decides which events the destination receives. See conditional activation for how require works.

The flow's collector block (next to sources and destinations) sets the state before any CMP reports:

"collector": {
  "consent": { "functional": true }
}

An initial state is a recorded consent state, so it satisfies "require": ["consent"] as soon as the flow starts.

How queued events are processed​

The collector re-checks a destination's queue whenever a new event or a state command (such as walker consent, walker user, or walker globals) reaches the destinations. Before the check, each queued event is updated:

  • consent is replaced with the current collector state. Once the event passes, it carries only the granted keys. A held event keeps the consent it was pushed with (see Revoke consent).
  • user is merged with the collector's user data. Values already on the event take precedence.
  • globals keeps the values the event was captured with.

The queues belong to the current run. A walker run command starts a new run and clears the destination queues along with the replay buffer, so events still waiting for consent are lost. The collector already runs by default, so do not send walker run in reaction to a consent change. Only a bundle you build with run: false needs one elb('walker run'): call it once, from your CMP's first consent callback, not from an on consent handler, which is not called before the collector runs.

To stop sending events to a destination, set its required keys to false:

elb('walker consent', { marketing: false });

Once none of a destination's required keys is granted, new events are queued for it instead of delivered (skipped in a server flow built by the CLI). If consent is granted again during the same run, those queued events are delivered. Events that were already sent are not affected. An event that is held for the destination when consent is revoked (while a consent delivery to it runs, or within the window after one failed, see Gate a destination) is still sent when the hold ends, after the revocation, with the consent it was pushed with.

Opt-out​

An opt-out beats consent: when a user opts out, no event of theirs reaches a destination, whatever consent they granted. Mark the user with optout: true and stop the event at the first step of the chain with a gated stop:

{
  "match": { "key": "event.user.optout", "operator": "eq", "value": "true" },
  "stop": true
}
  • Web: put the stop first in your bundle's collector.next. No event of an opted-out browser reaches a destination. Sources still run, for example the session source keeps its ids in storage.
  • Server: put the stop first in the source's next, before deduplication. An opted-out event leaves no dedup entry and no fingerprint hash, and reaches no destination.

eq compares as strings, so a boolean true and the string "true" both match.

The stop applies to events only. A destination with an on() handler still initializes: right away when it has no consent requirement, otherwise once that consent is granted. Its on() handler still receives consent and user changes, including the optout flag and the user's ids. A destination without an on() handler initializes with its first event, so for an opted-out user it does not initialize. A destination that loads a vendor script loads it for an opted-out user too: the gtag destination with "loadScript": true loads gtag.js and runs its config command, and gtag.js then sends what the Google tag collects by itself, such as Google's own page view (unless pageview is false) and the enhanced measurement events of the GA4 data stream. "pageview": false turns off only that page view, not the rest.

Set the flag before the first event. A walker user command queued in elbLayer before your bundle loads applies before anything is sent:

// Before your bundle loads
window.elbLayer = window.elbLayer || [];
window.elbLayer.push(['walker user', { optout: true }]);

data-elbuser is read right before the page view, so it keeps the page view in the browser. Events sent before it is read, such as session start and events queued in elbLayer before your bundle loaded, are sent.

<body data-elbuser="optout:true">

A walker user command after your bundle loaded stops every later event, but the page view of that page has already been sent.

The server flow of flow-complete.json, the tested example flow, stops opted-out events before dedup; its web flow uses the same stop first in collector.next:

/flows/server/sources/express/next/0
{
  "match": {
    "key": "event.user.optout",
    "operator": "eq",
    "value": "true"
  },
  "stop": true
}
An opted-out user (user.optout true) is stopped first: on the server before dedup, so no dedup entry, hash or delivery; on the web first in collector.next, so the browser sends nothing. Guide chapter consent-privacy

An event can carry its own consent. It is merged over the collector state for this event, and the event's values win:

elb({ name: 'order complete', consent: { marketing: true } });

If the event is still waiting for consent, the next check replaces its consent with the collector state, as described above.

To send a single field only with consent, use consent in a mapping value.

Use the walker on command with the consent type to run code when consent changes. rules is an object with one handler per consent key:

elb('walker on', {
  type: 'consent',
  rules: {
    marketing: (consent, context) => {
      if (consent.marketing) {
        // marketing was granted
      } else {
        // marketing was denied
      }
    },
  },
});

A handler receives the consent state and a context with collector and logger:

  • It runs when its key is part of a walker consent command, for a denial as well as a grant, and receives only the values of that command. Check the value inside the handler.
  • When it is registered, and when the collector starts, it receives the full consent state and runs if its key is set. Until the collector runs, no handler is called.
  • rules must be an object keyed by consent name. A bare function has no key and is never called.

To start or stop sending events based on consent, use config.consent and require instead of handlers. See on for all subscription types.

The gtag destination supports Google Consent Mode v2 through its como setting, which is on by default. When the destination has a config.consent requirement (or como_advanced is set), it first sets every mapped parameter to denied with gtag('consent', 'default', ...) before loading any tool; otherwise it sends that default right before its first update. It turns consent commands into gtag('consent', 'update', ...) calls, and holds commands sent before it initializes until initialization. The default mapping is:

walkerOS keyConsent Mode parameters
marketingad_storage, ad_user_data, ad_personalization
functionalanalytics_storage

The CookieFirst and CookiePro sources report measurement consent as analytics and, by default, map strictly necessary cookies to functional, which is granted as soon as the CMP reports a choice. With the default mapping, analytics_storage is then granted even when the user denied analytics. With these sources, map analytics instead, as in the example below.

Pass an object to use your own keys:

"gtag": {
  "package": "@walkeros/web-destination-gtag",
  "config": {
    "loadScript": true,
    "consent": { "analytics": true, "marketing": true },
    "settings": {
      "como": {
        "marketing": ["ad_storage", "ad_user_data", "ad_personalization"],
        "analytics": "analytics_storage"
      },
      "ga4": { "measurementId": "G-XXXXXXXXXX", "pageview": false }
    }
  }
}

Set "como": false to turn it off. Do not call gtag('consent', 'update') yourself while como is on, because Google then receives duplicate or conflicting updates.

💡 Need implementation support?
elbwalker offers hands-on support: setup review, measurement planning, destination mapping, and live troubleshooting. Book a 2-hour session (€399)