Syncing consent across your CRM, CDP and marketing stack
The banner is solved. The hard part is the twelve systems downstream that each cached a stale answer — and the delivery guarantees that keep them honest.
Consent propagation is a distributed-systems problem wearing a compliance hat. The moment a withdrawal is recorded, you have an unbounded set of consumers holding a value that is now wrong, and a legal obligation with a clock on it. Treating this as an integration checklist rather than a consistency problem is why so many programmes leak.
Push, pull, or both
| Model | Latency | Failure mode |
|---|---|---|
| Push (webhook / event) | Seconds | Silent drop — the consumer never knows it missed an update |
| Pull (check at use time) | Zero staleness | Availability coupling — your consent API becomes a hard dependency on send |
| Push + periodic reconciliation | Seconds, self-healing | More moving parts, but drift is bounded and detectable |
Push plus reconciliation is the only model that survives contact with real vendor APIs. Push gives you the latency you need for withdrawals; reconciliation catches everything the push layer lost to a 503, a rate limit or a silently deprecated webhook endpoint.
Design the event so it cannot be applied out of order
Consent events arrive out of order. Always. A retry from three minutes ago will land after the update that superseded it, and if your consumers apply last-write-wins on arrival time, you will re-enable marketing for someone who opted out.
// Every consumer applies the same rule: version wins, not arrival time.
function applyConsentEvent(current, incoming) {
if (current && incoming.version <= current.version) {
return current; // stale replay — drop it
}
return {
subjectId: incoming.subjectId,
purpose: incoming.purpose,
granted: incoming.decision === 'granted',
version: incoming.version,
appliedAt: incoming.collectedAt,
};
}Idempotency is not optional
At-least-once delivery means every consumer will see duplicates. A monotonic version per (subject, purpose) plus an idempotency key on the event makes duplicate delivery a no-op instead of a race.
Identity is the actual hard part
Your CRM keys on email. Your CDP keys on an internal ID. Your ad platform keys on a hashed email that was normalised differently. A consent event that cannot be resolved to the right record in each system is a consent event that silently does nothing.
- Normalise before hashing — lowercase, trim, strip gmail dots — and document the exact recipe. A mismatched normalisation is invisible and total.
- Carry every known identifier on the event rather than making each consumer do a lookup.
- Treat an unresolvable subject as a hard failure with an alert, never a silent skip. A skipped withdrawal is the expensive kind of bug.
- Reconcile identity graphs on a schedule; merges and splits happen constantly and they orphan consent.
Fail closed on the way out
If the consent lookup fails at send time, the safe default is to not send. This is uncomfortable for growth teams and non-negotiable in practice: an unsent email is a metric, a non-consented one is a breach. Cache aggressively so the fail-closed path is rare, but make it the path.
async function canSend(subjectId, purpose) {
try {
const consent = await consentCache.get(subjectId, purpose, { maxAgeMs: 60_000 });
return consent?.granted === true;
} catch (error) {
logger.error({ err: error, subjectId, purpose }, 'consent lookup failed');
return false; // fail closed — never "assume yes" on error
}
}What to monitor
- 1Propagation lag, p99, per downstream system — the number that matters for withdrawal obligations.
- 2Reconciliation drift count. Should trend to zero; a rising floor means a push path is quietly broken.
- 3Unresolved-identity rate. A step change here almost always precedes a data incident.
- 4Fail-closed rate at send time. Should be near zero; if it is not, your cache strategy is wrong.
A consent decision that has not reached the system acting on the data has not been honoured — regardless of what your dashboard says.
Last updated .