
Compliance
CCPA Compliance: Deletion, Opt-Out, and the 45-Day Clock
CCPA compliance as an engineering problem: honouring the GPC opt-out signal at the edge, deletion that survives a backup restore, and a clock you can evidence.
Search for CCPA compliance and you get law-firm explainers and consent-banner vendor pages, none containing a configuration file. Read the statute and its regulations as a specification and they describe a system: intake, identity resolution, fan-out, suppression, evidence — on a statutory deadline.
Three requirements have no direct EU analogue: a machine-readable opt-out signal a server must recognise, a written backup carve-out, and a state-run deletion platform. Built for Article 32? Same discipline, different shape.
What the CCPA Actually Obliges You to Build
Scope is a revenue-and-volume test. Civil Code section 1798.140(d) catches a for-profit business doing business in California meeting any of three thresholds: gross revenue "in excess of twenty-five million dollars ($25,000,000)", CPI-adjusted to $26,625,000 effective 1 January 2025; "[a]lone or in combination, annually buys, sells, or shares the personal information of 100,000 or more consumers or households"; or deriving "50 percent or more of its annual revenues from selling or sharing" it. Read the second exactly — buy-sell-share, not volume-of-collection.
Five rights follow, each a system surface: know, delete, correct, opt out of sale and sharing, limit the use of sensitive personal information. Every one lands on storage, so the first artefact is an inventory, reviewed like code. Getting them wrong is priced per event: section 1798.155(a) sets a fine of up to "$2,500 for each violation or ... $7,500 for each intentional violation" — $2,663 and $7,988 after the section 1798.199.95(d) adjustment, on the same 1 January 2025 CPI date as the revenue figure.
# personal-data-inventory.yaml — one entry per store that can hold a
# consumer record. Reviewed in pull requests.
stores:
- name: postgres/customer
identifier: account_id
deletion: sql-delete
archived_or_backup: false
- name: minio/pgbackrest
identifier: not-addressable
deletion: deferred-under-7022d # suppression ledger re-applies on restore
archived_or_backup: trueThe forgotten stores are the derived ones — the warehouse, the log store, the vector index behind a support assistant. An inventory stopping at the primary database answers a deletion request with a lie.
Opt-Out of Sale and Sharing Starts at the Edge
The regulations are unusually concrete about mechanism. Section 7025(b)(1) requires an opt-out preference signal "in a format commonly used and recognized by businesses", then names one: "An example would be an HTTP header field or JavaScript object." That moves the control into the ingress, enforced once for everything behind it.
The signal in practice is Global Privacy Control. The W3C editor's draft defines the header field Sec-GPC with the single valid value 1, the DOM property navigator.globalPrivacyControl, and a support resource at /.well-known/gpc.json. The Sec- prefix makes it a forbidden header name: page JavaScript cannot forge it, and intermediaries are told not to strip it.
{
"gpc": true,
"lastUpdate": "2026-08-11"
}Termination is a routing decision. The Gateway API matches on host, header and path, so an exact header match selects the opted-out population and a RequestHeaderModifier filter stamps an internal header downstream services trust.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: gpc-aware-storefront
namespace: storefront
spec:
parentRefs:
- name: public-gateway
hostnames:
- "www.example.com"
rules:
# Exact header match; Sec-GPC has one valid value.
- matches:
- headers:
- type: Exact
name: Sec-GPC
value: "1"
filters:
# `set` overrides: a client cannot pre-seed it.
- type: RequestHeaderModifier
requestHeaderModifier:
set:
- name: X-Privacy-Optout-Sale-Sharing
value: "1"
backendRefs:
- name: storefront
port: 8080
# Fallback: strip the internal header.
- filters:
- type: RequestHeaderModifier
requestHeaderModifier:
remove:
- X-Privacy-Optout-Sale-Sharing
backendRefs:
- name: storefront
port: 8080Two caveats. RequestHeaderModifier behaviour varies between Gateway API controllers, so check your controller's conformance report — as with any bare-metal ingress choice. And section 7025(c)(1) closes with a sentence worth reading before budgeting the sprint: "This is not required for a business that does not sell or share personal information."
Device Scope Is Not Account Scope
The hard part is not receiving the signal but deciding what it covers. Section 7025(c)(1) is explicit: a valid opt-out "for that browser or device and any consumer profile associated with that browser or device, including pseudonymous profiles", and "[i]f known", for the consumer. Three widening scopes, one request.
The February 2026 Disney settlement describes that widening failing to happen. On the $2.75 million resolution — allegations settled, not guilt adjudicated — the Attorney General's office said a toggle opt-out reached only "the specific streaming service the user was watching, and often only the specific device the consumer was using", while the webform stopped sharing "through the company's own advertising platform" as embedded "third-party ad-tech companies" kept receiving data. Two fan-out defects. A $12.75 million General Motors settlement on 8 May 2026 is the "Largest CCPA penalty in California history to date" — quote either with its date.
Signal handling is priced differently again. In the CPPA's PlayOn Sports Order of Decision the agency concluded the business "failed to configure its Digital Properties to recognize and honor" opt-out preference signals, violating statute and regulations "each time" — a $1,100,000 fine. A missed-signal rate is unit-priced: a number to hand a finance team.
Deletion Fan-Out and the Backup Safe Harbour
Deletion has the same shape and a wider blast radius. Section 1798.105(c)(1) obliges a business to "delete the consumer's personal information from its records, notify any service providers or contractors to delete", and "notify all third parties to whom the business has sold or shared" it — subject to a disproportionate-effort escape.
Section 7022(b) makes the structure precise, and this is where most summaries go wrong. Compliance means "doing all of the following": (b)(1) "[p]ermanently and completely erasing the personal information from its existing systems except archived or backup systems, deidentifying ... or aggregating" it; (b)(2) notifying service providers and contractors; (b)(3) notifying third parties. The dispositions are alternatives inside the first limb only — deidentifying does not discharge the notifications.
Section 7022(d) then grants what the GDPR never wrote down. Where personal information sits on archived or backup systems, a business "may delay compliance with the consumer's request to delete ... until the archived or backup system ... is restored to an active system or is next accessed or used for a sale, disclosure, or commercial purpose". Read the verb: delay. The obligation queues rather than lapses, and "accessed or used" is nowhere defined — so read it narrowly.
The tombstone is authorised twice over. Section 1798.105(c)(2) permits "a confidential record of deletion requests solely for the purpose of preventing the personal information of a consumer ... from being sold", and section 7022(e) fits a restore exactly: any of them "may retain a record of the request for the purpose of ensuring that the consumer's personal information remains deleted". The suppression ledger, authorised by name.
-- One subject, many identifiers.
CREATE TABLE privacy_identifier (
subject_id uuid NOT NULL,
kind text NOT NULL, -- account|device|profile|email_sha256
value text NOT NULL,
PRIMARY KEY (kind, value)
);
-- The confidential record Civ. Code 1798.105(c)(2) permits.
CREATE TABLE privacy_suppression (
subject_id uuid PRIMARY KEY,
do_not_sell boolean NOT NULL DEFAULT false,
deleted boolean NOT NULL DEFAULT false,
received_at timestamptz NOT NULL,
source text NOT NULL -- webform|signal|drop|agent
);
-- Reg. 7101(a): requests and how you responded, kept 24 months.
CREATE TABLE privacy_request_receipt (
request_id uuid PRIMARY KEY,
subject_id uuid NOT NULL,
-- delete|correct|know: 45 calendar days. opt_out|limit: 15 BUSINESS days
-- (reg. 7026(f)(1), 7027(g)(1)). Two clocks, two budgets.
request_type text NOT NULL,
received_at timestamptz NOT NULL,
responded_at timestamptz,
extended_at timestamptz, -- single 45-day extension; VCR types only
disposition text
CHECK (disposition IN ('erased', 'deidentified', 'aggregated',
'opted_out', 'limited', 'denied'))
);
-- Run by the restore runbook against a database recovered from a backup
-- predating the deletion, before it serves traffic.
DELETE FROM customer_profile cp
USING privacy_identifier pi
JOIN privacy_suppression ps ON ps.subject_id = pi.subject_id
WHERE pi.kind = 'account' AND pi.value = cp.account_id AND ps.deleted;The ledger belongs in a store you control, on its own backup schedule, restored first — an entry in the disaster-recovery runbook. On CloudNativePG, a point-in-time recovery into a scratch namespace rehearses the re-apply.
DROP: A State-Run Deletion Channel on a 45-Day Access Duty
This section applies to one population: a "data broker" as Civil Code section 1798.99.80 defines it — broadly, a business knowingly selling personal information about consumers with whom it has no direct relationship. That determination gates everything below: build the poll without being one and you have wasted a sprint; be one and skip it and you have been non-compliant since 1 August 2026.
The duty is an access cadence. Section 1798.99.86(c)(1) provides that "Beginning August 1, 2026, a data broker shall access the accessible deletion mechanism ... at least once every 45 days" and, "[w]ithin 45 days after receiving a request", process it. Section 7612(b) says a broker "may access the DROP manually or through automated means", and requires a manual download whenever automation cannot deliver in time — the specifications likewise describe integrations "whether API-based or manual". A poll is an engineering choice, never the legal form of the duty.
The automated surface, per the CalPrivacy DROP technical specifications (Version 1.2.0, July 2026 — build against your account's copy, not a cached PDF), is small: base URL https://api.drop.privacy.ca.gov, an X-API-KEY header, GET /data/download, and POST /data/upload taking multipart/form-data under the field name files with an Id,Status CSV — statuses 2 exempted, 3 deleted, 4 opted out, 5 not found, where 4 is required when several consumers share one identifier. Section 7612(c) makes every download after the first incremental, so the local copy is the system of record.
The download is where integrations go quietly wrong: three success-shaped answers, only one of them a list. 200 with application/zip is the archive. 200 can also carry JSON meaning "No new consumer request data is available" — a real access with nothing to fetch. 202 means "[t]he request was received and the download is being prepared", with the documented action "[c]all GET /data/download again later". curl --fail cannot tell the three apart — it reacts only to 4xx and 5xx — so a 202 body lands in the file you named .zip, survives a non-empty check, stamps a success and exits zero. Branch on the status code, and on what arrived.
Hashing fails more quietly still. Identifiers are SHA-256 hashed — UTF-8 in, Base64 out — but the standardization applied first is per-list: one generic strip-and-lowercase matches nothing. Email has all whitespace removed and is lowercased, dots and plus signs left in place; phone keeps the last ten digits; the NDZ and NameVIN types hash each field separately, then hash the concatenated Base64 hashes. Write the match code against the live standardization and hashing tables, not this summary.
# REGISTERED DATA BROKERS ONLY (Civ. Code 1798.99.80). Keys and list
# selection come from the DROP portal.
apiVersion: batch/v1
kind: CronJob
metadata:
name: drop-consumer-deletion-list
spec:
# Daily against a 45-day floor: a missed access becomes an alert, not
# a violation.
schedule: "0 4 * * *"
timeZone: America/Los_Angeles
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: download
image: curlimages/curl:8.11.1
env:
- name: DROP_API_KEY
valueFrom:
secretKeyRef:
name: drop-api-key
key: apiKey
- name: STALENESS_BUDGET_DAYS # alert inside the ceiling
value: "30"
command: ["/bin/sh", "-c"]
args:
- |
set -eu
dir=/var/lib/drop; stamp="$dir/last-access"; body="$dir/.body"
budget=$(( STALENESS_BUDGET_DAYS * 86400 ))
now=$(date -u +%s)
age=$(( now - $(cat "$stamp" 2>/dev/null || echo 0) ))
ok=0
# No --fail: it drops the body, and 202 is no error.
meta=$(curl --silent --show-error \
--header "X-API-KEY: ${DROP_API_KEY}" \
--header "accept: application/zip, application/json" \
--output "$body" \
--write-out '%{http_code} %{content_type}' \
https://api.drop.privacy.ca.gov/data/download) \
|| meta="000 -"
code=${meta%% *}; ctype=${meta#* }
if [ "$code" = 200 ] && [ "${ctype%%;*}" = application/zip ] \
&& [ "$(head -c 2 "$body")" = PK ]; then
mv "$body" "$dir/${now}.zip" # a real archive
echo "$now" > "$stamp"; age=0; ok=1
elif [ "$code" = 200 ]; then # JSON: nothing new to fetch
echo "$now" > "$stamp"; age=0; ok=1 # 1798.99.86(c)(1)
elif [ "$code" = 202 ]; then # preparing: stamp nothing
echo "DROP: preparing (202)" >&2
else
# Reg. 7612(b) makes the fallback manual: alert first miss.
echo "DROP: download failed (HTTP ${code})" >&2
fi
rm -f "$body"
[ "$age" -lt "$budget" ] || echo "DROP access stale" >&2
[ "$ok" -eq 1 ] && [ "$age" -lt "$budget" ]
volumeMounts:
- name: lists
mountPath: /var/lib/drop
volumes:
- name: lists
persistentVolumeClaim:
claimName: drop-listsTwo Clocks, and an SLO You Have to Publish
Section 1798.130(a)(2)(A) gives 45 days from receipt of a verifiable consumer request, extendable "once by an additional 45 days when reasonably necessary, provided the consumer is provided notice of the extension within the first 45-day period". That notice is a state transition, not an email someone remembers — hence extended_at.
That clock is not the only one, and collapsing them is the common bug: it covers disclose, correct and delete. Opt-out is not a verifiable consumer request at all — section 7026(d) bars requiring one — and section 7026(f)(1) binds tighter: cease selling or sharing "as soon as feasibly possible, but no later than 15 business days". Section 7027(g)(1) puts a request to limit on the same bound. Read the whole phrase — a ceiling, not a target, counted in business days. opt_out and limit need their own error budget.
Above a threshold the clock becomes public. Section 7102(a) reaches a business that "buys, receives ... sells, shares, or otherwise makes available for commercial purposes" the personal information of "10,000,000 or more consumers in a calendar year": compile, per request type, the number "received, complied with in whole or in part, and denied" — three counts, not two — plus "[t]he median or mean number of days within which the business substantively responded", disclosed "by July 1 of every calendar year". Median and mean diverge on a long tail; measure both.
#!/usr/bin/env bash
# Reg. 7102(a)(1) per type: received, complied in whole or part, denied,
# plus median OR mean days — calendar days, not business days.
set -euo pipefail
YEAR="${1:?usage: ccpa-metrics.sh <calendar-year>}"
psql --no-psqlrc --quiet --csv "${PRIVACY_DSN:?}" <<SQL
SELECT
request_type,
count(*) AS received,
-- "complied with in whole or in part": responded, not denied.
count(*) FILTER (WHERE responded_at IS NOT NULL
AND disposition <> 'denied') AS complied,
count(*) FILTER (WHERE disposition = 'denied') AS denied,
round(percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM responded_at - received_at) / 86400
)::numeric, 1) AS median_days,
round(avg(extract(epoch FROM responded_at - received_at) / 86400)::numeric, 1)
AS mean_days
FROM privacy_request_receipt
WHERE received_at >= make_date(${YEAR}, 1, 1)
AND received_at < make_date(${YEAR} + 1, 1, 1)
GROUP BY request_type
ORDER BY request_type;
SQLThe ledger outlives the year anyway. Section 7101(a) requires records of consumer requests "and how it responded ... for at least 24 months", with "reasonable security procedures and practices" applied to them — a retention floor and a security obligation on the evidence.
Failure Modes We See in the Field
These recur in our engagements. Not an industry survey; no percentages implied.
- The signal never reaches the origin. A CDN or WAF normalises headers and
Sec-GPCdies before your Gateway sees it. Send it; assert on what the backend received. - Deletion stops at the primary database. The warehouse, the log store and the embedding index still hold it — what the inventory prevents, and an observability stack worsens.
- A restore resurrects deleted rows. Backup predates the deletion, restore is clean, consumer is back. Section 7022(d) permits the delay, not the resurrection.
- A DROP poll fails silently. A
202body written to a file named.zipand counted as a download looks exactly like a list — until day 46.
Each is caught the same way: submit through every channel, then query every store in the inventory.
Exit Ramps: Own the Rights Engine, Not the Vendor
Privacy SaaS is useful at the intake edge — hosted webforms, agent verification, a consumer portal. What it must not own is the state. Three artefacts decide whether replacing it is a config change or a migration, and all three stay readable without the vendor: the request ledger in your own Postgres, exportable as CSV or JSONL; the data inventory as YAML in the repository; the suppression list as a documented table, not an opaque segment in a marketing platform. If a vendor holds the only copy of who asked to be forgotten, section 7101(a) is discharged by somebody else's uptime — the usual exit-cost arithmetic.
The Long Game: One Engine, Many Statutes
The signal handler is not California-specific. Colorado's Attorney General maintains the official list of Universal Opt-Out Mechanisms and states that "[c]urrently, the only UOOM considered valid by The Department is GPC". That list changes — treat the mechanism as pluggable.
The evidence has a longer horizon. The regulations took effect on 1 January 2026, and two filings follow: a business whose 2026 gross revenue exceeded $100,000,000 owes its first cybersecurity audit report by 1 April 2028, and risk assessments conducted in 2026 and 2027 are due the same day. Records written this year already sit inside a future filing's scope — the argument for owning them, as with SOC 2 evidence you generate rather than screenshot and data-principal rights as a workflow.
Build the engine once, keep the ledgers in open formats on infrastructure you control, and each new statute is another channel into a machine that works. Build per jurisdiction and you rebuild every time a legislature moves.
§FAQ/Common questions
Frequently asked
What does CCPA compliance require engineers to build?
Five request types, each a system surface rather than a policy paragraph: know, delete, correct, opt out of sale and sharing, and limit the use of sensitive personal information. Practically, four artefacts: an intake path accepting the required methods plus an opt-out preference signal, which regulation section 7025(b)(1) says may be an HTTP header field; an identity graph resolving that signal to every profile, device and account identifier it covers; a fan-out executing all three limbs of section 7022(b) — a disposition in your own systems, notification of service providers and contractors, notification of third parties; and two ledgers, suppression to survive a backup restore and a request ledger stamping the clock on every transition.
How long do you have to respond to a CCPA consumer request?
It depends on the request type: there are two clocks, not one. Civil Code section 1798.130(a)(2)(A) gives 45 days from receipt of a verifiable consumer request to disclose and deliver the information, correct inaccurate personal information, or delete — extendable once by a further 45 days where reasonably necessary, provided notice of the extension reaches the consumer inside the first 45-day period, which makes the extension a state transition your system has to own. Opt-out of sale or sharing runs on a shorter clock and is not a verifiable consumer request at all: regulation section 7026(d) bars requiring one, and section 7026(f)(1) requires ceasing to sell or share as soon as feasibly possible and no later than 15 business days from receipt. Section 7027(g)(1) applies the same 15-business-day outer bound to a request to limit. One 45-day budget across all five request types therefore mis-times the two the regulations time most tightly, and counts the wrong kind of day doing it.
Do you have to delete personal information from backups under the CCPA?
Eventually, yes — but the regulations grant a delay the GDPR does not. Regulation section 7022(d) lets a business, service provider or contractor storing personal information on archived or backup systems delay compliance with a deletion request, as to data on that system, until it is restored to an active system or is next accessed or used for a sale, disclosure or commercial purpose. That is a deferral, not an exemption, and the regulation never defines accessed or used, so read it narrowly. The engineering consequence is a suppression record that outlives the backup and re-applies the deletion before a restored system serves traffic — which section 7022(e) expressly contemplates, permitting a record of the request retained to ensure the personal information remains deleted.
How do you implement Global Privacy Control server-side?
Terminate it at the ingress rather than in a tag. The W3C editor's draft defines the signal as the HTTP header field Sec-GPC with the single valid value 1, a navigator.globalPrivacyControl DOM property, and a support resource at /.well-known/gpc.json carrying gpc and lastUpdate members. The Sec- prefix makes it a forbidden header name, so page JavaScript cannot spoof it and intermediaries are directed not to strip it. On Kubernetes, a Gateway API HTTPRoute matches the header exactly and a RequestHeaderModifier filter sets an internal header downstream services trust, with the fallback rule removing that header so it can only originate at the Gateway — verify that against your controller's conformance report. Regulation section 7025(c)(1) then decides scope: the browser or device, any associated consumer profile including pseudonymous ones, and the consumer where known.
Does the California DELETE Act require a DROP API integration?
No. The duty is an access cadence, not an integration. Civil Code section 1798.99.86(c)(1) requires a data broker, beginning 1 August 2026, to access the accessible deletion mechanism at least once every 45 days and to process requests within 45 days of receipt. Regulation section 7612(b) states that a broker may access DROP manually or through automated means, and requires a manual download through the DROP account whenever an automated integration cannot download the list in time — with written notification to the Agency where the failure was not the broker's own error. The CalPrivacy technical specifications match, describing an integration that may be API-based or manual. A scheduled job is worth building because it detects its own failure early, but that is a reliability choice, not the legal form of the obligation. Whether the DELETE Act applies at all turns on the data-broker definition in Civil Code section 1798.99.80.
Further reading
- GDPR Article 32: technical measures an auditor can verify
- DPDP compliance: consent, data-principal rights and breach workflow
- Audit-grade Kubernetes disaster recovery: proving RPO and RTO
- PostgreSQL on Kubernetes with CloudNativePG: sovereign stateful data
- Self-hosted vector databases: Qdrant and pgvector for on-prem RAG
- Self-hosted observability with OpenTelemetry, Prometheus, Grafana and Loki
- SOC 2 on self-hosted Kubernetes: own the evidence
- Modelling vendor lock-in as an exit cost you can price
- Bare-metal ingress with MetalLB, kube-vip and the Gateway API
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.