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_id at the time of each event.
  • The ordering of events must be provable. An append-only table with a monotonic event_ts and a database-level DEFAULT 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.

Browser permission layer versus server endpoint record The browser permission grant and the server-side endpoint record are separate states. Either can change without informing the other, so neither transition writes a compliant audit row on its own. Layer 1 — browser grant (Notification.permission) permission: granted blocked in site settings site data cleared Layer 2 — your endpoint record (subscriptions table) status: active 410 Gone tombstone purged by retention job browser revokes silently — no consent_withdrawn row exists and you keep sending until the next 410 410 arrives after the fact — an implicit withdrawal you must log Neither layer notifies the other; the append-only consent log is the only place the two are reconciled.
Two independent states, four ways to diverge. Every divergence still owes you an audit row.

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.

The same ordering applies when the exit point is an action button on the notification rather than a page control — the handler just runs inside the service worker instead of the main thread, as shown in one-click push unsubscribe from a notification. Whichever surface triggers it, the audit row is written before the subscription is torn down, and the interface state that renders the result must follow the accessibility rules in designing accessible push notification opt-out flows.


Consent and withdrawal audit trail lifecycle A five-stage timeline: consent captured, subscription created, preference change logged, withdrawal event logged, and endpoint purged. The endpoint exists only between stages two and five, while audit rows are retained for three years from withdrawal. Consent lifecycle — what persists after the endpoint is gone 1 consent_granted Consent captured 2 endpoint_hash stored Subscription created 3 preference_change Topic toggled 4 consent_withdrawn Withdrawal logged expiry_ts set here 5 queue drained Endpoint purged audit row kept the endpoint is deliverable only inside this span audit rows retained 3 years from withdrawal (expiry_ts), then archived deleting the endpoint is required; deleting the audit row is not
Audit rows persist beyond endpoint purge. The delivery queue drains at stage 5; the compliance record spans the entire window.

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

  1. Client (immediate): logWithdrawalEvent() writes the audit row and calls subscription.unsubscribe(). The browser notifies the push service; the push service will start returning 404 or 410 for that endpoint within seconds.

  2. API server (< 1 minute): The /api/push/consent-log handler marks the endpoint as withdrawn in 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.

  3. 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.

  4. 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.

  5. VAPID key rotation (ongoing): If you rotate VAPID keys, old endpoint records may no longer be valid anyway. Audit rows must reference the endpoint_hash computed at consent time, not the current key. See VAPID key generation and rotation for how endpoint URLs relate to key pairs.

  6. Data subject access requests: Your SAR response must include the full audit trail for the requesting user — every push_consent_audit row, decoded to human-readable form (event type, timestamp, metadata diff). The endpoint_hash should be noted as a pseudonymous identifier, not disclosed as the raw URL.

Each stage owns a deadline, and each missed deadline has a distinct user-visible symptom. Treat the four delivery-side rows below as SLAs you alert on, not as best-effort cleanup.

Withdrawal propagation deadlines Four propagation stages — client, API server, queue worker, and scheduled campaigns — each paired with its deadline of seconds, under one minute, under one hour, and under twenty-four hours, and with the symptom produced when the deadline is missed. propagation stage deadline symptom if you miss it client — audit POST, then subscription.unsubscribe() seconds no paper trail at all API — mark withdrawn and enqueue the purge task under 1 min the next send still targets it queue worker — filter before dispatch, not after under 1 hour already-queued messages land scheduled campaigns — join exclusions at dispatch time under 24 h a batch breaches the withdrawal a later 410 Gone is not a substitute alert on each stage separately
Four deadlines, four symptoms. The pipeline is only compliant when the slowest stage still clears inside 24 hours.

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
Audit retention windows by jurisdiction Horizontal bars comparing recommended audit-row retention: three years for the EU and EEA, three years for the UK, two years for California, and five years for Brazil. Recommended retention of the audit row, measured from withdrawal EU / EEA (GDPR) 3 years UK (UK GDPR) 3 years California (CPRA) 2 years Brazil (LGPD) 5 years 1y 2y 3y 4y 5y 6y
Set expiry_ts from the longest window that applies to the data subject, not the shortest one in your table.

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() returns null on next call but no consent_withdrawn event was logged. Poll navigator.permissions.query({ name: 'notifications' }) on page focus change and write a consent_withdrawn row server-side when you detect a transition from granted to denied. 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_withdrawn row with metadata: {"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 two consent_withdrawn rows 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_change row (not consent_withdrawn) referencing the old endpoint_hash and a new consent_granted row 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 a consent_withdrawn. Only call subscription.unsubscribe() and write consent_withdrawn when the user opts out of all push. Topic changes belong in metadata, not as separate consent_withdrawn rows 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.


Back to Opt-Out & Preference Centers