The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.
Status: mechanism implemented (2026-07-18); a backend client and legal sufficiency still the consumer's. The package now ships the receipt model (
ConsentReceipt/ConsentReceiptInput+isConsentReceiptInput), a pluggableReceiptStorewith an in-memory reference (createInMemoryReceiptStore) and a durable adapter over an injected backend (createDurableReceiptStore), the server builder (buildConsentReceipt+coarsenIp) with an optional integritysignseam, and the TanStack Start endpoint helper (recordConsentReceiptFromRequest); the reference app wires an HMAC signer over that seam. What remains for a production deployment: a backend client to inject intocreateDurableReceiptStore(the in-memory one is dev-only), a configured retention period, an optional notice-snapshot store, an HMAC/signature key for the signer, and counsel review of legal sufficiency. This document remains the contract those pieces satisfy. Access dates for all citations: 2026-07-18. Items flagged UNCERTAIN need legal counsel review before reliance.
1. Why a client record is not proof
The controller carries the burden of proving consent (GDPR Art. 7(1); Recital 42 — gdpr-info.eu/art-7-gdpr). The operative standard is EDPB Guidelines 05/2020 on consent, §5.1 (paras 104–110). Para 108 is decisive:
a controller should retain, in an online context, information on the session in which consent was expressed, documentation of the consent workflow at the time, and a copy of the information presented to the data subject — and "it would not be sufficient to merely refer to a correct configuration of the respective website."
A tamperable client ConsentRecord in local storage is exactly such a "website configuration": it can be cleared,
forged, or diverge across devices. It therefore MUST NOT be the record of proof. Analogous burden-of-proof duties:
Brazil LGPD Art. 8 §2; Vietnam PDPL (Law 91/2025/QH15, in force 2026-01-01) Art. 9 (consent) / Art. 10 (withdrawal)
— verified 2026-07-18, the enacted-law numbering, superseding the predecessor Decree 13/2023's Art. 11–12; both require
a printable/verifiable format; Quebec Law 25 s.14 (UNCERTAIN — precise record-keeping article).
The five dimensions a receipt MUST make demonstrable: who consented, when, to what (purposes), how (the workflow/method), and under which notice text/version presented at that moment.
2. The receipt data model
"Required" is derivable from EDPB §5.1 para 108 + Art. 7 burden. "Recommended" strengthens the proof per ISO/IEC TS 27560:2023 (Consent record information structure — iso.org/standard/80392.html) and market practice.
| Field | Req? | Basis | PII sensitivity |
|---|---|---|---|
receiptId (UUID/ULID) |
MUST | ISO 27560 record id; correlates client ↔ server | Low (pseudonymous key) |
schemaVersion |
MUST | ISO 27560 conformsTo |
None |
subjectId |
MUST* | EDPB "who"; Art. 7(1) | High — the linking id; a pseudonymous cookie id for anonymous visitors |
subjectIdType (cookie/userId/email-hash) |
MUST | ISO 27560; OneTrust identifierType |
Medium |
timestamp (epoch ms, UTC) |
MUST | EDPB "when" | Low |
decision ({ ads, analytics }) |
MUST | EDPB "to what"; reuses spec-consent §1 shape | Low |
purposes ({ id, version }[]) |
MUST | ISO 27560 purpose + version resolved by consent date | Low |
policyVersion |
MUST | EDPB "under which policy text" | None |
noticeVersion + noticeSnapshotRef |
MUST | EDPB para 108 "copy of the information presented" | None (but the snapshot store must exist) |
noticeLanguage (BCP-47) |
MUST | EDPB informed-consent | None |
method (banner-accept/banner-reject/granular/gpc-signal/withdrawal) |
MUST | ISO 27560 method of expression; EDPB "how" | None |
eventType (give/update/withdraw) |
MUST | ISO 27560 consent events; PDPL Art. 10 | None |
sourceUrl |
SHOULD | Cookiebot; session context | Low (strip query string) |
userAgent |
SHOULD | market practice | Medium — store browser/OS family only |
ipCoarse (truncated/pseudonymized) |
SHOULD | Cookiebot bit-truncation; TrustArc pseudonymization | High if full — never store full IP |
jurisdiction (ISO 3166) |
SHOULD | ISO 27560 hasJurisdiction; drives retention |
Low |
integrityKey (HMAC/signature) |
SHOULD | Cookiebot tamper-verification | None |
retentionUntil |
SHOULD | EDPB para 107 proportionality | None |
* subjectId is conceptually required (something must link the receipt to a person/session); it MAY be a pseudonymous
cookie id for anonymous visitors.
Market corroboration (vendor docs, primary only for "what this vendor stores"): Cookiebot stores a Consent ID + state + timestamp + UA + submission URL + anonymized IP (last 16 bits IPv4 / 96 bits IPv6 removed) + an integrity key; Didomi retains the full notice configuration as presented, UA + parsed device, 5-year retention; OneTrust's Create Consent Receipt API links purpose-version and notice-version by consent date; TrustArc pseudonymizes full IP immediately, 13-month default. IP is consistently minimized, never stored raw.
3. Server endpoint contract
POST /api/consent/receipts # write a receipt at consent time
Body: receipt input (model above minus server-derived fields)
Server derives: receiptId, authoritative timestamp, ipCoarse (from the
socket, truncated server-side), integrityKey
→ 201 Created { receiptId, integrityKey, retentionUntil }
GET /api/consent/receipts/:id # auth-gated read for the controller's DSAR handlerBecause the response carries PII, the endpoint MUST:
- Set
Cache-Control: no-storeand carry no CDN caching (s-maxage,CDN-Cache-Controlmust be absent) — it is a function route that must bypass any CDN cache. This is the same per-visitor no-store discipline as spec-server-lane §2, and applies with more force because a receipt is PII, not just a region default. - Derive
ipCoarseserver-side from the connection, never accept an IP from the request body (client-supplied IP is untrustworthy, and full IP must not transit). Reject bodies carrying a full IP. - Be append-only: an update or withdrawal is a new receipt with a new
eventTypereferencing the priorreceiptId; existing receipts are never mutated in place. - Never place
subjectIdor receipt data in a URL query string (path param, opaque ids only) — consistent with the privacy rules in the root spec.
4. Retention and minimization
- R1 (MUST) — retain the receipt while the associated processing is active. (EDPB §5.1 para 107.)
- R2 (MUST) — after processing ends, delete once no longer strictly necessary for a legal obligation or to establish/exercise/defend legal claims. (EDPB para 107; GDPR Art. 17(3)(b),(e).)
- R3 (MUST) — the retention period MUST be configurable per jurisdiction (default SHOULD track the local limitation period for claims, commonly ~3–6 years), never indefinite and never hard-coded. (EDPB para 110: no fixed statutory period; market: TrustArc 13 mo, Didomi 5 yr.) — the exact number is UNCERTAIN per jurisdiction, a legal-review call.
- R4 (MUST) — the receipt MUST NOT collect more personal data than necessary to evidence the consent link. (EDPB para 106 — a minimization ceiling on the receipt itself.)
- R5 (MUST) — a full IP address MUST NOT be persisted; only a truncated/pseudonymized form.
- R6 (SHOULD) —
userAgentSHOULD be reduced to browser/OS family unless the full string is justified as evidence. - R7 (MUST) — withdrawal events MUST be retained in the same provable, exportable format as grants. (PDPL Art. 10; GDPR Art. 7(3).)
5. Authoritative-source rule
The client ConsentRecord (local storage) is authoritative only for runtime UX gating on the current device — a
cache of the decision used to gate ad/analytics firing. The server receipt is the sole authoritative record for legal
proof. On any divergence, the server receipt governs for compliance and audit; the client governs only "what to do
right now on this device." A missing, cleared, or tampered client record triggers re-prompting (per
spec-consent §6) and never invalidates a stored server receipt. The client SHOULD carry the
receiptId (+ integrityKey) so its state can be verified against the stored original.
Conformance vectors
V1 — grant, both categories. Input
{ decision:{ads:true,analytics:true}, policyVersion:"2026-05", noticeVersion:"banner-v3", lang:"en" } → stored receipt
has eventType:"give", method:"banner-accept", purposes resolved to their versions as of the timestamp, server-set
receiptId/timestamp/integrityKey, ipCoarse truncated; response Cache-Control: no-store.
V2 — granular. decision:{ads:false,analytics:true}, method:"granular" → the exact per-category split is
recorded, never collapsed to one boolean.
V3 — GPC. method:"gpc-signal", decision.ads:false, analytics unaffected — matches the GPC-denies-ads-only rule
(spec-consent §3).
V4 — withdrawal. A new receipt eventType:"withdraw" referencing the prior receiptId; the original grant receipt
remains intact (append-only) and the withdrawal is itself printable/verifiable.
V5 — full IP rejected. A body carrying a full IP is rejected/ignored; the server persists only its own truncated
ipCoarse.
V6 — anonymous visitor. subjectId = pseudonymous cookie id, subjectIdType:"cookie"; the receipt is still valid.
V7 — retention expiry. A receipt past retentionUntil with no active processing or claim hold is purged (R2/R3);
one under an active claim hold is retained past the default.
V8 — notice snapshot integrity. Given noticeVersion:"banner-v3", noticeSnapshotRef MUST resolve to the exact
text presented (EDPB para 108); a dangling ref is a conformance failure.
Uncertainties requiring legal review
Quebec Law 25's precise record-keeping article vs. its s.14 validity provision; and the concrete retention number per target jurisdiction (make it configurable, do not hard-code). (Vietnam's PDPL numbering — Art. 9 consent / Art. 10 withdrawal — was resolved in the 2026-07-18 verification pass; see §1.)
License
Released under the MIT License.