Publish a versioned Data Agreement. Collect a signed receipt. Let individuals see and revoke it.
Data-protection authorities increasingly ask for machine-readable consent receipts keyed to a versioned Data Agreement — not just a "you clicked accept" line in the audit log. ISO/IEC TS 27560:2023 defines the shape. CodeB ships the store and the endpoints; this cookbook wires them into your own app in about an afternoon. It complements the DPIA + Records of Processing that the wallet operator already publishes, and gives every individual a first-class view of their own consent trail via the privacy dashboard.
- A versioned Data Agreement object per (tenant, purpose). CRUD + revision history + delete.
- Signed consent receipts keyed to the individual, with opt-in / opt-out and a revoke path.
- Per-subject access log — every event that touched the record (issue, present, sign, revoke).
- Individual-facing
privacy-dashboard.htmlthat reads all three and lets the subject download or revoke. - ARF 3.0 §5.11 consent lifecycle row on the conformity page, badged Met.
0 Why this exists
The Records of Processing Activity (RoPA) and the DPIA describe what a controller does with personal data. What they do not do is create a per-individual, per-purpose, revision-locked receipt that a data-subject can retrieve, download and revoke. The ISO/IEC TS 27560:2023 Data Agreement object was written to fill that gap. The European Digital Identity Wallet Architecture Reference Framework version 3.0 folds the same idea into §5.11 consent lifecycle & individual rights: providers should offer a first-class store, revocation, and machine-readable export.
CodeB implements all of it inside /oidc.ashx, backed by flat JSON under App_Data/<tenant>/. No database. Atomic writes with .backup. Per-tenant isolation. Rate-limited entry points. Every endpoint emits a [CONSENT-*-DIAG] trace line.
1 Endpoint surface
Replace <HOST> with your CodeB tenant hostname (for example www.aloaha.com).
| Purpose | Method | URL | Auth |
|---|---|---|---|
| Create DA | POST | /oidc.ashx?action=da-create | admin bearer OR HMAC |
| Read DA | GET | /oidc.ashx?action=da-read&da_id=… | admin bearer OR HMAC |
| Update DA (new revision) | POST | /oidc.ashx?action=da-update | admin bearer OR HMAC |
| Delete DA | POST | /oidc.ashx?action=da-delete | admin bearer OR HMAC |
| List DAs | GET | /oidc.ashx?action=da-list | admin bearer OR HMAC |
| List revisions | GET | /oidc.ashx?action=da-list-revisions&da_id=… | admin bearer OR HMAC |
| Sign receipt | POST | /oidc.ashx?action=receipt-sign | subject bearer (or admin) |
| Revoke receipt | POST | /oidc.ashx?action=receipt-revoke | subject bearer (or admin) |
| List my receipts | GET | /oidc.ashx?action=receipt-list | subject bearer |
| Read my access log | GET | /oidc.ashx?action=access-log | subject bearer |
Each endpoint is advertised in the OIDC discovery document under the vendor-specific keys data_agreement_endpoint, consent_receipt_endpoint, consent_receipt_list_endpoint, subject_access_log_endpoint. A wallet or downstream RP can discover them without hardcoding the query-string form.
2 Get an admin bearer token
Every admin endpoint accepts either a Bearer JWT with role in {admin, superuser}, or an X-CodeB-Admin-Signature HMAC over the fixed bucket string (matches the TS7 / TS8 admin paths). The simplest path in a script is client-credentials against your OIDC provider:
curl -s -u '$CLIENT_ID:$CLIENT_SECRET' \
-H 'Accept: application/json' \
-d 'grant_type=client_credentials&scope=admin' \
https://<HOST>/oidc.ashx?action=token
# => { "access_token": "eyJ...", "token_type":"Bearer", "expires_in":900 }
export ADMIN_TOKEN=eyJ...
admin scope in /oidc-clients.html. If you would rather use HMAC, set X-CodeB-Admin-Signature: <base64(hmac-sha256(admin_secret, "ts7-ts8-admin|<host>"))> on the request instead of the Authorization header.3 Create a Data Agreement
The purpose and lawful_basis fields are mandatory. Everything else is optional but strongly recommended — auditors love a complete record.
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
https://<HOST>/oidc.ashx?action=da-create \
-d '{
"purpose": "Marketing analytics on aggregate signup funnel",
"lawful_basis": "consent",
"controller": "Aloaha Limited, Malta",
"controller_contact": "dpo@aloaha.com",
"retention_days": 90,
"data_categories": ["email", "signup_ts", "utm_source"],
"third_parties": [],
"cross_border_transfers": false,
"policy_url": "https://<HOST>/privacy.html",
"withdrawal_url": "https://<HOST>/privacy-dashboard.html",
"language": "en"
}'
# => { "ok": true, "da_id": "3f9a2b7e10c4d55e", "rev": 1 }
The response da_id is a 16-hex string generated server-side. You can pass an explicit "da_id" in the request if you want a stable identifier chosen by your system (must be lowercase alphanumeric + - / _).
4 Update — append a new revision
Updates never mutate an existing revision. They append a new revision with a monotonically increasing rev number, inheriting from the previous revision for any field you do not include in the patch. Receipts already signed against previous revisions stay tied to those older revisions.
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
https://<HOST>/oidc.ashx?action=da-update \
-d '{
"da_id": "3f9a2b7e10c4d55e",
"patch": {
"retention_days": 60,
"third_parties": ["Aggregated Analytics BV"]
}
}'
# => { "ok": true, "da_id": "3f9a2b7e10c4d55e", "rev": 2 }
5 List DAs + revisions
# All DAs for this tenant (bounded at 5000 rows per call). curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ https://<HOST>/oidc.ashx?action=da-list # Every revision of one DA (up to 200). curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ "https://<HOST>/oidc.ashx?action=da-list-revisions&da_id=3f9a2b7e10c4d55e"
6 Sign a consent receipt
This is the individual-facing call. The subject signs in against your OIDC provider first, receiving their own access token, then POSTs the receipt-sign request. The server records a receipt keyed by SHA-256 of the raw sub (never storing the raw sub in the directory name) and appends an access-log entry.
# From the browser after sign-in:
await fetch('/oidc.ashx?action=receipt-sign', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + userAccessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
da_id: '3f9a2b7e10c4d55e',
opt_in: true
})
});
// => 201 { "ok": true, "receipt_id": "9c11...", "da_id": "3f9a2b7e10c4d55e" }
"sub": "<subject-identifier>" to the request body. Non-admin callers cannot: the server always uses the bearer's own sub, no matter what the request body says.7 Revoke a receipt
Revoke does not delete. The signature stays as an audit trail with revoked_at populated and opt_in flipped to false.
await fetch('/oidc.ashx?action=receipt-revoke', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + userAccessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
da_id: '3f9a2b7e10c4d55e',
receipt_id: '9c11...'
})
});
// => 200 { "ok": true, "receipt_id": "9c11...", "da_id": "3f9a2b7e10c4d55e" }
8 Privacy dashboard
You do not need to build a UI. Link to /privacy-dashboard.html from your account page. It:
- Reads the subject's receipts and access log via the same bearer token they already hold.
- Renders every receipt with its DA id, revision, opt-in state, signed / revoked timestamps and a Download button.
- Offers a per-row Revoke button that calls
?action=receipt-revoke. - Offers a big Request full deletion button that opens an ARF-TS7 Article-17 request via
?action=data-deletion-request.
An equivalent German page ships at /de/privacy-dashboard.html. The dashboard is fully self-contained — one HTML file, no build step, no framework, no external CDN.
9 The Article-17 deletion path
Consent revocation is not the same as erasure. When an individual wants every trace of their account removed, the dashboard opens a queued deletion request against the existing ?action=data-deletion-request endpoint (ARF TS7). A human operator picks it up from the admin queue at ?action=data-deletion-list, actions it, and records the outcome. The ARF-3.0 target completion window is 30 days.
The endpoint is rate-limited to 3 requests per hour per IP, contact-validated (email or https URL only), and body-capped at 4 KB — safe to expose to unauthenticated callers.
10 Metrics + billing
Every consent-lifecycle event emits a [METRIC] line that flows into the standard usage-metering pipeline. Reserved metric names:
consent.da.created/consent.da.updated/consent.da.deletedconsent.receipt.signed/consent.receipt.revokedconsent.access.read
These integrate with the platform's meter-everything + license-gates rules and can be billed downstream to individual client organisations via the standard wholesale clientRef slot.
11 Audit + review
The ARF-3.0 conformity page carries a §5.11 consent lifecycle & individual rights category. Rows: Data Agreement store + revisions (Met), signed consent receipts (Met), per-subject access log (Met), privacy dashboard (Met). Verification pointer: this cookbook + /privacy-dashboard.html.
Related documents:
- Data Protection Impact Assessment — the platform-wide risk analysis.
- Records of Processing Activities — the platform-wide processing register.
- Privacy notice — the layperson-facing summary.
- ARF 3.0 conformity self-declaration — where these features are badged.