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.


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 and withdrawal audit trail lifecycle Horizontal timeline showing five stages: consent captured, subscription created, preference change logged, withdrawal event logged, and endpoint purged. The audit row persists beyond endpoint purge with a 3-year retention window. 1 Consent captured consent_granted row written 2 Subscription created endpoint_hash stored 3 Preference change preference_change row written 4 Withdrawal event logged consent_withdrawn row + expiry_ts 5 Endpoint purged queue drained audit row kept Audit rows retained for 3 years from withdrawal (expiry_ts)
Audit rows persist beyond endpoint purge. The delivery queue drains at step 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.


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() 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.


Parent: Opt-Out Preference Centers