GDPR-Compliant Push Unsubscribe Logging
Under GDPR Article 7(3), withdrawal of consent must be as easy to exercise as giving it — and you must be able to prove both events happened, in order, with tamper-evident timestamps.
Quick Answer
Log every consent state transition (grant, withdrawal, preference change) as an immutable append-only row. Hash all personal identifiers (endpoint URL, IP, user agent) with SHA-256 before storage. Retention of the audit row itself must outlast the subscription — typically 3 years for GDPR enforcement windows — even after you purge the endpoint and stop sending. Honor withdrawal within seconds on the client and within 24 hours across your full delivery pipeline.
| Event | When to log | Minimum fields |
|---|---|---|
consent_granted |
User accepts the browser permission prompt | user_id, endpoint_hash, ip_hash, ua_hash, event_ts |
preference_change |
User toggles a topic or frequency setting | same + metadata diff |
consent_withdrawn |
User unsubscribes via preference center or browser | same + expiry_ts |
Why Push Unsubscribe Logging Is Non-Trivial
Push subscriptions span two authorization layers: the browser’s Notification.permission grant and your server’s active endpoint record. A user can revoke browser permission without your server knowing. Your server can silently drop a subscription after a 410 Gone from the push service (see delivery tracking and acknowledgment). Neither event automatically writes a compliant audit row.
GDPR requires that withdrawal be documented with enough fidelity to reconstruct the consent history for a specific data subject. That means:
- The endpoint (or a one-way hash of it) must be linkable to the
user_idat the time of each event. - The ordering of events must be provable. An append-only table with a monotonic
event_tsand a database-levelDEFAULT NOW()column prevents retroactive insertion. - The audit row must survive endpoint purge. Deleting the endpoint from your delivery queue is required; deleting the audit row is not — and in most cases violates your ability to respond to Subject Access Requests.
The opt-out preference center architecture handles the UI and delivery-queue teardown. This reference covers the compliance logging layer underneath it.
Audit Trail Schema
The table below uses PostgreSQL idioms. The ENUM type enforces the closed event vocabulary the DPA will expect during an audit.
-- push_consent_audit: append-only consent ledger
-- Never UPDATE or DELETE rows. Archive after expiry_ts passes.
CREATE TYPE push_consent_event AS ENUM (
'consent_granted',
'consent_withdrawn',
'preference_change'
);
CREATE TABLE push_consent_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- FK to your users table
event_type push_consent_event NOT NULL,
endpoint_hash CHAR(64) NOT NULL, -- SHA-256 hex of raw endpoint URL
ip_hash CHAR(64), -- SHA-256 hex of client IP; NULL if not captured
user_agent_hash CHAR(64), -- SHA-256 hex of User-Agent string
event_ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expiry_ts TIMESTAMPTZ NOT NULL, -- when audit row itself may be purged
metadata JSONB NOT NULL DEFAULT '{}'::jsonb -- diff payload, topic changes, etc.
);
-- Enforce append-only at DB level
CREATE RULE no_update_consent_audit AS
ON UPDATE TO push_consent_audit DO INSTEAD NOTHING;
CREATE RULE no_delete_consent_audit AS
ON DELETE TO push_consent_audit DO INSTEAD NOTHING;
-- Query patterns
CREATE INDEX idx_pca_user_event ON push_consent_audit (user_id, event_ts DESC);
CREATE INDEX idx_pca_endpoint ON push_consent_audit (endpoint_hash, event_ts DESC);
CREATE INDEX idx_pca_expiry ON push_consent_audit (expiry_ts)
WHERE expiry_ts < NOW() + INTERVAL '90 days'; -- supports archival job
expiry_ts calculation: Set expiry_ts = event_ts + INTERVAL '3 years' for consent_withdrawn rows in EU jurisdictions. For consent_granted rows, use event_ts + INTERVAL '3 years' from the corresponding withdrawal event (so the full consent period remains reconstructable). For preference_change, inherit the governing consent window.
metadata schema: Store a JSON diff — {"before": {"topics": ["alerts"]}, "after": {"topics": []}} — to enable granular reconstruction without querying a separate events table.
Client-Side Withdrawal Flow
The JavaScript below runs inside your main thread (not the service worker). It hashes the endpoint with the Web Crypto API before transmitting anything to your backend, so the raw subscription URL never leaves the browser in a log-able form. After logging succeeds, it calls subscription.unsubscribe() and removes the local record from IndexedDB.
Coordinate this with the permission prompt timing patterns so withdrawal can only be triggered after a subscription is confirmed active — preventing null-subscription errors.
/**
* logWithdrawalEvent()
* Logs a consent_withdrawn audit event, then tears down the local subscription.
* Must be called from a user gesture handler (button click, form submit).
*
* @param {string} userId - Server-side user identifier
* @param {string} csrfToken - CSRF token for the POST
* @returns {Promise<void>}
*/
async function logWithdrawalEvent(userId, csrfToken) {
// 1. Retrieve the active push subscription from PushManager
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// No active subscription — log a no-op withdrawal for idempotency
console.warn('logWithdrawalEvent: no active subscription found');
return;
}
// 2. Hash the endpoint with SubtleCrypto SHA-256 (one-way, no raw URL transmitted)
const endpointBytes = new TextEncoder().encode(subscription.endpoint);
const hashBuffer = await crypto.subtle.digest('SHA-256', endpointBytes);
const endpointHash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// 3. POST the withdrawal event to the consent-log endpoint BEFORE unsubscribing
// Logging first ensures an audit row exists even if unsubscribe() throws.
const response = await fetch('/api/push/consent-log', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
user_id: userId,
event_type: 'consent_withdrawn',
endpoint_hash: endpointHash,
timestamp: new Date().toISOString(),
}),
});
if (!response.ok) {
// Do not proceed with unsubscribe if audit logging fails —
// you would lose the paper trail with no way to recover it.
throw new Error(`Consent log POST failed: ${response.status}`);
}
// 4. Revoke the browser-level push subscription
const unsubscribed = await subscription.unsubscribe();
if (!unsubscribed) {
// PushManager rejected the call — subscription may already be expired.
// The audit row is already written; surface the error but don't retry logging.
console.error('subscription.unsubscribe() returned false');
}
// 5. Remove the local subscription record from IndexedDB
// so the preference UI re-renders correctly on next load.
const dbRequest = indexedDB.open('push-prefs', 1);
await new Promise((resolve, reject) => {
dbRequest.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction('subscriptions', 'readwrite');
tx.objectStore('subscriptions').delete(userId);
tx.oncomplete = resolve;
tx.onerror = reject;
};
dbRequest.onerror = reject;
});
}
Note the ordering: audit log POST → unsubscribe() → IndexedDB clear. If you reverse steps 3 and 4, a browser crash after unsubscribe() leaves no audit trail. If you skip step 5, the UI will re-render as subscribed on next page load, causing a second unwanted prompt.
Consent Lifecycle — Audit Trail Diagram
Honoring Withdrawal End-to-End
Logging the withdrawal event is step one. The harder part is propagating it across your stack within a GDPR-acceptable window.
Step-by-Step Propagation Checklist
-
Client (immediate):
logWithdrawalEvent()writes the audit row and callssubscription.unsubscribe(). The browser notifies the push service; the push service will start returning 404 or 410 for that endpoint within seconds. -
API server (< 1 minute): The
/api/push/consent-loghandler marks the endpoint aswithdrawnin your subscriptions table and enqueues a purge task. Do not rely on the push service 410 to trigger this — the 410 may not arrive until you next attempt a send. -
Delivery queue (< 1 hour): Your queue worker must filter withdrawn endpoints before dispatch, not just after. If you use a pre-built queue system, see scaling push queues for patterns on tagging and skipping withdrawn records without dequeuing the entire batch.
-
Scheduled campaigns (< 24 hours): Any segment or scheduled send that was assembled before the withdrawal must exclude the endpoint. Run a withdrawal exclusion join at dispatch time, not at segment-build time.
-
VAPID key rotation (ongoing): If you rotate VAPID keys, old endpoint records may no longer be valid anyway. Audit rows must reference the
endpoint_hashcomputed at consent time, not the current key. See VAPID key generation and rotation for how endpoint URLs relate to key pairs. -
Data subject access requests: Your SAR response must include the full audit trail for the requesting user — every
push_consent_auditrow, decoded to human-readable form (event type, timestamp, metadata diff). Theendpoint_hashshould be noted as a pseudonymous identifier, not disclosed as the raw URL.
What to Store vs. What to Hash
Storing raw push endpoint URLs as PII is unnecessary and increases your GDPR surface area. The endpoint encodes the push service domain, a device-specific path, and sometimes auth parameters — it is linkable to an individual.
| Field | Store as | Rationale |
|---|---|---|
| Endpoint URL | SHA-256 hex hash | Linkable to device; hash is sufficient for audit join |
| IP address | SHA-256 hex hash | PII under GDPR; hash satisfies logging purpose |
| User-Agent string | SHA-256 hex hash | May be fingerprintable; hash is sufficient |
user_id |
Plaintext UUID | Internal pseudonym; needed for SAR joins |
event_type |
Plaintext ENUM | Non-PII; required for audit meaning |
event_ts |
Plaintext TIMESTAMPTZ | Non-PII; required for ordering proof |
| Preference diff | JSONB (no PII keys) | Topic names, frequency values — non-PII |
Salt the IP and User-Agent hashes with a per-deployment secret (not per-user) so they cannot be reversed by an attacker with a rainbow table, but remain consistent for the duration of a session for correlation purposes.
Retention Windows
| Jurisdiction | Recommended audit retention | Basis |
|---|---|---|
| EU / EEA (GDPR) | 3 years from withdrawal | Standard enforcement lookback |
| UK (UK GDPR) | 3 years from withdrawal | ICO guidance mirrors EU practice |
| California (CCPA/CPRA) | 2 years from withdrawal | CPRA 12-month lookback + buffer |
| Brazil (LGPD) | 5 years | LGPD Art. 15 + civil liability window |
Set expiry_ts at insert time using the appropriate interval. An archival cron job can hard-delete rows where expiry_ts < NOW() after exporting them to cold storage. Never delete rows ahead of expiry_ts to satisfy a deletion request — instead, verify that the row contains no directly-identifying PII (it should not, given the hashing policy above) and document in your ROPA that audit rows are exempt from erasure under GDPR Article 17(3)(b).
Gotchas & Edge Cases
-
Browser-revoked permission without app involvement. If the user denies notifications at the OS or browser level (not through your preference center),
pushManager.getSubscription()returnsnullon next call but noconsent_withdrawnevent was logged. Pollnavigator.permissions.query({ name: 'notifications' })on page focus change and write aconsent_withdrawnrow server-side when you detect a transition fromgrantedtodenied. The silent permission pre-qualification pattern covers how to detect this state without triggering a new prompt. -
410 Gone received during a send, no prior withdrawal log. The push service sends 410 when an endpoint is permanently invalid — often because the user uninstalled the browser or cleared site data. You must treat 410 as an implicit withdrawal and write a
consent_withdrawnrow withmetadata: {"source": "push_service_410"}. Do not silently discard the endpoint. -
Duplicate withdrawal events from retry storms. If your
logWithdrawalEvent()call is retried (network error, timeout), you may write twoconsent_withdrawnrows for the same endpoint. This is acceptable — append-only logs tolerate duplicates — but your SAR export logic should de-duplicate by(user_id, event_type, endpoint_hash)when presenting to data subjects. -
Subscription replaced without explicit opt-out. When a VAPID key rotation causes a new endpoint to be issued, the old endpoint becomes invalid. Write a
preference_changerow (notconsent_withdrawn) referencing the oldendpoint_hashand a newconsent_grantedrow for the new one. The gap must be zero — do not send on the new endpoint until the new grant is logged. -
Soft opt-out vs. hard unsubscribe. A user toggling off a topic category in your preference center is a
preference_change, not aconsent_withdrawn. Only callsubscription.unsubscribe()and writeconsent_withdrawnwhen the user opts out of all push. Topic changes belong inmetadata, not as separateconsent_withdrawnrows per topic. Conflating the two makes SAR responses misleading and may incorrectly trigger endpoint purge.
FAQ
Do we need to log a withdrawal event for 410 Gone responses from the push service?
Yes. A 410 Gone response is the push service’s signal that the subscription is permanently invalid — typically because the user cleared browser data, uninstalled the browser app, or the push service itself expired the endpoint. From a GDPR standpoint this is functionally equivalent to a withdrawal: you no longer have a valid consent-to-deliver. Write a consent_withdrawn row with metadata: {"source": "push_service_410", "detected_at": "<ISO timestamp>"} and drain the endpoint from all queues. Your delivery acknowledgment layer is the right place to trigger this — see delivery tracking and acknowledgment for where to hook the 410 handler.
Can we store the raw endpoint URL in the audit log for debugging?
No. The push endpoint URL is considered personal data under GDPR because it is linked to a specific browser installation and, by extension, a natural person. Storing it in plaintext widens your data inventory unnecessarily. The SHA-256 hash is sufficient: you can verify that a given endpoint matches a logged event by hashing it again client-side. If you need the raw URL for debugging a specific delivery failure, retrieve it transiently from your subscriptions table (where it must also be stored encrypted at rest) and never persist it in audit rows.
What if a user re-subscribes after withdrawing? Do old audit rows need to be updated?
No — and you must not update them. Append a new consent_granted row with a fresh endpoint_hash and timestamp. The prior consent_withdrawn row remains intact. This gives you a complete reconstruction of the consent history: initial grant → withdrawal → re-grant. During a SAR, present all rows in chronological order. The re-grant row must have its own expiry_ts independent of the earlier grant’s row.
Related
- Opt-Out Preference Centers — UI teardown, idempotent unsubscribe, and queue drainage patterns
- Designing Accessible Push Notification Opt-Out Flows — WCAG 2.2 AA compliance for the opt-out interface
- Frontend Permission UX & Subscription Flows — full permission lifecycle from pre-qualification to re-engagement
- Permission Prompt Timing Strategies — when to surface the consent UI for maximum legal clarity
- Silent Permission Checks & Pre-qualification — detecting revoked browser permission without prompting
- Delivery Tracking and Acknowledgment — 410 Gone handling and queue drain confirmation
- VAPID Key Generation and Rotation — endpoint lifecycle across key rotations
Parent: Opt-Out Preference Centers