Privacy Tools Guide

Consent receipts provide cryptographically signed records of user privacy choices, including exactly which purposes and data categories were consented to, when consent was given, and by whom. This creates irrefutable audit trails for regulators while demonstrating good faith compliance to users. Developers should implement receipts as hashed records stored in customer accounts with asymmetric signature verification, ensuring neither customers nor companies can later alter consent history.

Under regulations like GDPR and CCPA, you must be able to demonstrate that users consented to data processing. A consent receipt serves as that proof. It contains the consent choice, timestamp, version of the privacy notice presented, and a unique identifier that allows verification later.

For developers, implementing consent receipts means building a data model that captures granular consent choices, storing them securely, and providing ways for users to retrieve their consent history.

The foundation of any consent receipt system is a well-structured data model. Here’s a practical example:

from dataclasses import dataclass
from datetime import datetime
from enum import Enum
import uuid
import hashlib
import json

class ConsentType(Enum):
    MARKETING_EMAILS = "marketing_emails"
    PERSONALIZED_ADS = "personalized_ads"
    ANALYTICS = "analytics"
    THIRD_PARTY_SHARING = "third_party_sharing"
    DATA_RETENTION = "data_retention"

class ConsentAction(Enum):
    GRANTED = "granted"
    REVOKED = "revoked"
    UPDATED = "updated"

@dataclass
class ConsentReceipt:
    receipt_id: str
    user_id: str
    consent_type: ConsentType
    action: ConsentAction
    timestamp: datetime
    privacy_notice_version: str
    ip_address: str
    user_agent: str
    purpose: str
    data_controller: str

    def to_json(self):
        return json.dumps({
            "receipt_id": self.receipt_id,
            "user_id": hash_user_id(self.user_id),  # Hash for privacy
            "consent_type": self.consent_type.value,
            "action": self.action.value,
            "timestamp": self.timestamp.isoformat(),
            "privacy_notice_version": self.privacy_notice_version,
            "purpose": self.purpose,
            "data_controller": self.data_controller,
            "verification_hash": self.generate_hash()
        }, indent=2)

    def generate_hash(self):
        data = f"{self.receipt_id}{self.user_id}{self.timestamp.isoformat()}"
        return hashlib.sha256(data.encode()).hexdigest()[:16]

def hash_user_id(user_id: str) -> str:
    return hashlib.sha256(user_id.encode()).hexdigest()[:12]

This model captures the essential elements: what was consented, when, and under what version of your privacy notice. The verification hash allows you to prove the receipt hasn’t been tampered with.

For production systems, you need durable storage that supports both quick retrieval and audit requirements. Consider this PostgreSQL schema:

CREATE TABLE consent_receipts (
    receipt_id UUID PRIMARY KEY,
    user_hash VARCHAR(12) NOT NULL,
    consent_type VARCHAR(50) NOT NULL,
    action VARCHAR(20) NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    privacy_notice_version VARCHAR(20) NOT NULL,
    purpose TEXT NOT NULL,
    data_controller VARCHAR(255) NOT NULL,
    verification_hash VARCHAR(16) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_user_hash ON consent_receipts(user_hash);
CREATE INDEX idx_timestamp ON consent_receipts(timestamp);

-- For GDPR right to access, retrieve all receipts for a user
CREATE TABLE user_consent_map (
    user_id_encrypted VARCHAR(255) PRIMARY KEY,
    user_hash VARCHAR(12) NOT NULL
);

This schema separates the encrypted user identifier from the pseudonymous hash, adding an extra layer of privacy while maintaining the ability to retrieve consent records.

When a user updates their privacy preferences, you need to capture consent at the point of choice. Here’s a Flask example:

from flask import Flask, request, jsonify
from datetime import datetime
import uuid

app = Flask(__name__)

@app.route('/api/consent', methods=['POST'])
def update_consent():
    data = request.get_json()

    # Validate required fields
    required = ['user_id', 'consents', 'privacy_version']
    if not all(k in data for k in required):
        return jsonify({"error": "Missing required fields"}), 400

    receipts = []
    for consent_type, granted in data['consents'].items():
        receipt = ConsentReceipt(
            receipt_id=str(uuid.uuid4()),
            user_id=data['user_id'],
            consent_type=ConsentType(consent_type),
            action=ConsentAction.GRANTED if granted else ConsentAction.REVOKED,
            timestamp=datetime.utcnow(),
            privacy_notice_version=data['privacy_version'],
            ip_address=request.remote_addr,
            user_agent=request.headers.get('User-Agent', ''),
            purpose=get_purpose_for_consent(consent_type),
            data_controller="Your Company Name"
        )

        save_receipt(receipt)
        receipts.append(receipt.to_json())

    return jsonify({
        "status": "success",
        "receipts": receipts
    }), 200

def get_purpose_for_consent(consent_type: str) -> str:
    purposes = {
        "marketing_emails": "Send promotional emails about products and services",
        "personalized_ads": "Deliver targeted advertisements based on interests",
        "analytics": "Analyze usage patterns to improve our services",
        "third_party_sharing": "Share data with trusted partners",
        "data_retention": "Retain data for specified periods"
    }
    return purposes.get(consent_type, "Unknown purpose")

This endpoint accepts a user’s consent preferences and generates a receipt for each choice. The response includes the full receipt data so users can immediately verify what was recorded.

Users need a way to view their consent history. Build a simple retrieval endpoint:

@app.route('/api/consent/history', methods=['GET'])
def get_consent_history():
    user_id = request.headers.get('X-User-Id')
    if not user_id:
        return jsonify({"error": "Authentication required"}), 401

    user_hash = hash_user_id(user_id)
    receipts = fetch_receipts_by_hash(user_hash)

    return jsonify({
        "user_consents": receipts,
        "last_updated": max(r['timestamp'] for r in receipts) if receipts else None
    }), 200

Pair this with a frontend that displays each consent type, its current status, and the timestamp of the last change. Allow users to export their full consent history as JSON or PDF for their records.

For audit purposes or regulatory requests, you need to verify a receipt’s authenticity:

def verify_receipt(receipt_json: dict) -> bool:
    stored_hash = receipt_json.get('verification_hash')

    # Recalculate hash
    expected_hash = hashlib.sha256(
        f"{receipt_json['receipt_id']}{receipt_json['user_id']}{receipt_json['timestamp']}".encode()
    ).hexdigest()[:16]

    return stored_hash == expected_hash

This verification ensures the receipt hasn’t been modified after generation. Store the original receipt data immutably—append-only logs work well for this.

Practical Considerations

When implementing consent receipts, keep these points in mind:

First, version your privacy notices. Every receipt should reference the specific version the user saw when making their choice. When you update your privacy policy, increment the version number so you can prove which version applied to each consent.

Second, implement consent refresh. For ongoing processing activities, periodically re-confirm consent to maintain valid legal basis. GDPR recommends refreshing consent every two years for legitimate interests.

Third, handle cross-border transfers. If your data processing involves international transfers, capture the transfer mechanism (SCCs, BCRs, etc.) in the receipt so you can demonstrate compliance.

Fourth, prepare for data subject requests. Your consent receipt system should integrate with your response to GDPR access requests or CCPA consumer rights requests. The ability to export a user’s full consent history in a standard format is essential.

Built by theluckystrike — More at zovo.one