# DPDPA Consent Management API — Quick Reference Card

**Print and keep at your workstation. All 23 languages, 5 endpoints, 6 audit events.**

---

## Section 5(3) Language Table — All 22 Eighth Schedule Languages + English

| Language | ISO 639-1 / 3 | BCP-47 Code | Script | Notes |
|----------|--------------|------------|--------|-------|
| English | en | `en` | Latin | Baseline; always required |
| Assamese | as | `as` | Bengali | Northeast India |
| Bengali | bn | `bn` | Bengali | West Bengal, Bangladesh border states |
| Bodo | — (ISO 639-3: brx) | `brx` | Devanagari | Assam |
| Dogri | — (ISO 639-3: doi) | `doi` | Devanagari | Jammu |
| Gujarati | gu | `gu` | Gujarati | Gujarat |
| Hindi | hi | `hi` | Devanagari | Official language of the Union; highest traffic |
| Kannada | kn | `kn` | Kannada | Karnataka |
| Kashmiri | ks | `ks` | Perso-Arabic (primary) / Devanagari | Kashmir |
| Konkani | kok | `kok` | Devanagari | Goa |
| Maithili | mai | `mai` | Devanagari | Bihar, Jharkhand |
| Malayalam | ml | `ml` | Malayalam | Kerala |
| Manipuri (Meitei) | mni | `mni` | Meitei Mayek | Manipur |
| Marathi | mr | `mr` | Devanagari | Maharashtra |
| Nepali | ne | `ne` | Devanagari | Sikkim, Darjeeling |
| Odia | or | `or` | Odia | Odisha |
| Punjabi | pa | `pa` | Gurmukhi | Punjab |
| Sanskrit | sa | `sa` | Devanagari | Low empirical traffic; still required |
| Santali | sat | `sat` | Ol Chiki | Jharkhand, Odisha, West Bengal |
| Sindhi | sd | `sd` | Perso-Arabic | Gujarat, Rajasthan |
| Tamil | ta | `ta` | Tamil | Tamil Nadu |
| Telugu | te | `te` | Telugu | Andhra Pradesh, Telangana |
| Urdu | ur | `ur` | Perso-Arabic | Widespread across India |

**Section 5(3):** The Data Fiduciary must serve any of these languages on request. Missing a language when asked is a direct Section 5(3) breach — even for Sanskrit or Santali.

**BCP-47 usage in API:** pass as `?lang=ta` query parameter on `GET /v1/notices/{purpose_id}`.

---

## API Endpoint Quick-Lookup

| Method | Path | Legal Anchor | What It Must Prove | Error Codes |
|--------|------|-------------|---------------------|-------------|
| `GET` | `/v1/notices/{purpose_id}?lang={code}` | S.5(1), S.5(3) | Notice exists in the requested language; `content_hash` matches stored bytes | `notice_not_found`, `unsupported_language` |
| `POST` | `/v1/consents` | S.6(1), S.6(10) | Content hash shown = stored hash; version active; receipt signed by KMS key | `notice_version_superseded`, `content_hash_mismatch`, `unsupported_language`, `duplicate_active_consent` |
| `GET` | `/v1/consents/{consent_id}/receipt` | S.6(10) | Signed receipt is verifiable offline with published public key | `consent_not_found`, `access_denied` |
| `DELETE` | `/v1/consents/{consent_id}` | S.6(4), S.8(7) | Withdrawal recorded; processor fanout dispatched; `202 Accepted` immediately | `consent_not_found`, `consent_already_withdrawn` |
| `GET` | `/v1/audit/{consent_id}` | Rule 6(1)(c), 6(1)(e) | Hash chain is intact; no raw principal_id in payloads | `consent_not_found`, `access_denied` |

---

## Schema Entity Table

| Table | Primary Key | Purpose | DPDPA Anchor |
|-------|-------------|---------|-------------|
| `purpose` | `purpose_id` (UUID) | Defines each processing purpose; one consent per purpose | S.6(1): purpose limitation |
| `notice_version` | `notice_version_id` (UUID) | Immutable versioned notice bundle; one per policy change | S.5(1): notice content; S.6(10): version traceability |
| `notice_translation` | `translation_id` (UUID) | Per-language rendering; 23 rows per version | S.5(3): 22 Eighth Schedule + English |
| `consent_record` | `consent_id` (UUID) | Point-in-time grant evidence; append-only | S.6(10): burden of proof artefact |
| `consent_audit_event` | `(event_id, occurred_at)` — partitioned | Hash-chained lifecycle log; tamper-evident | Rule 6(1)(c): access logging; Rule 6(1)(e), Rule 8(3): 1-year floor |
| `processor_fanout` | `fanout_id` (UUID) | Per-processor erasure propagation tracker | S.8(7): processor erasure on withdrawal |

---

## Audit Event Type Table

| Event Type | When Emitted | Payload Must Contain | Legal Trigger |
|-----------|-------------|---------------------|--------------|
| `NOTICE_SHOWN` | Client renders the notice | `language_shown`, `content_hash`, `notice_version_id` | S.5(1): notice was given |
| `GRANT` | Principal submits consent | `receipt_hash`, `notice_version_id`, `language_shown`, `content_hash` | S.6(10): consent was given |
| `VIEW` | System accesses personal data under this consent | `accessor_id` (pseudonymised), `purpose_id`, `data_categories_accessed` | Rule 6(1)(c): log all access events |
| `WITHDRAW` | Principal or system initiates withdrawal | `initiated_by`, `reason` (optional) | S.6(4): right to withdraw |
| `ERASURE_FANOUT` | Erasure request dispatched to a processor | `processor_id`, `fanout_id`, `dispatched_at` | S.8(7): processor erasure required |
| `ERASURE_CONFIRMED` | Processor acknowledges successful erasure | `processor_id`, `fanout_id`, `ack_received_at` | S.8(7): erasure confirmed |

**Rule for payloads:** Never include raw `principal_id` or any direct identifier. Use `principal_pseudonym` (HMAC-SHA256 under KMS key) throughout all event payloads.

---

## Consent Receipt Fields Table

| Field | What It Proves | DPDPA Anchor |
|-------|---------------|-------------|
| `version` | Receipt format version for future-proof parsing | Protocol versioning |
| `principal_pseudonym` | The Data Principal gave this consent (pseudonymised to prevent audit log becoming shadow PII store) | S.6(10), S.8(5) |
| `purpose_id` | Consent is specific to one purpose — not bundled | S.6(1): specific consent |
| `notice_version_id` | Which exact version of the notice was active at grant time | S.6(10): notice was given |
| `content_hash` | SHA-256 of the exact bytes the client rendered — proves the principal saw this specific text | S.6(10): informed consent |
| `language_shown` | The Data Principal chose this language — proves Section 5(3) was honoured | S.5(3): language option |
| `granted_at` | Timezone-aware ISO 8601 timestamp — proves consent was given at a point in time | S.6(10): timestamped evidence |
| `receipt_hash` | SHA-256 of the canonical string — integrity check before signature verification | S.6(10): tamper detection |
| `signature` | Ed25519 over receipt_hash by KMS key — provides non-repudiation; verifiable offline | S.6(10): burden of proof |

**Offline verification:** `ed25519.verify(public_key, bytes.fromhex(receipt_hash), bytes.fromhex(signature))`

Public key published at: `/.well-known/consent-signing-key.pub`

---

## Hash Chain Verification Algorithm

Step-by-step procedure for an auditor or the `HashChainAuditLog.verify_chain()` method:

**Step 1:** Fetch all audit events for the consent, ordered by `event_id ASC` (ascending).

**Step 2:** For each event, set:
```
prev_hash  = \x00  if this is the first event (or prev_event_hash is "00")
           = bytes.fromhex(event.prev_event_hash)  otherwise

payload_canonical = json.dumps(event.event_payload, sort_keys=True, separators=(',',':'))
occurred_at_iso   = event.occurred_at  (ISO 8601 with timezone offset)
```

**Step 3:** Compute:
```
preimage = prev_hash
         + b"|" + consent_id.encode("utf-8")
         + b"|" + event_type.encode("utf-8")
         + b"|" + occurred_at_iso.encode("utf-8")
         + b"|" + payload_canonical.encode("utf-8")

computed_hash = sha256(preimage)
```

**Step 4:** Compare `computed_hash` to `bytes.fromhex(event.this_event_hash)` using a constant-time comparison (`hmac.compare_digest`). Any mismatch = broken link = potential tampering.

**Expected result:** Zero broken links. A broken link on any event indicates retroactive modification of that event or a preceding event's hash.

---

## Common Error Codes and Resolution

| Error Code | HTTP Status | Meaning | Resolution |
|-----------|-------------|---------|-----------|
| `notice_not_found` | 404 | No active notice version for this purpose_id | Verify purpose_id is correct and a notice_version has been seeded with `superseded_at IS NULL` |
| `notice_version_superseded` | 409 | The notice_version_id in the grant request has been superseded | Re-fetch the notice with `GET /notices/{purpose_id}`, show the user the new version, then re-submit the grant |
| `content_hash_mismatch` | 409 | SHA-256 of the rendered notice doesn't match the stored hash | The client may have rendered a stale or tampered version. Flush CDN/Redis cache and re-fetch. S.6(10): the exact bytes must be verifiable |
| `unsupported_language` | 422 | No translation exists for the requested language code | Trigger translation delivery for that language. All 23 languages must be available. S.5(3) |
| `duplicate_active_consent` | 409 | An active granted consent already exists for (principal_id, purpose_id) | Show the user their existing consent. Offer withdrawal if they want to change it |
| `consent_not_found` | 404 | consent_id doesn't exist or is already withdrawn/erased | Verify the consent_id. For DSR responses, use pseudonymise(principal_id) to enumerate all consents |
| `consent_already_withdrawn` | 409 | Consent is already in withdrawn or erased state | Idempotent: treat as success in downstream withdrawal flows |
| `access_denied` | 403 | Caller lacks required scope or is accessing another principal's receipt | Add `consent:read` / `consent:audit` scope to token; verify principal identity |

---

## Severity Guide — What Breaks What

| Issue | Breaks | Severity | Section |
|-------|--------|----------|---------|
| Missing or LLM-generated translation for a scheduled language | Section 5(3) compliance; any consent granted with that translation may be challenged as "uninformed" | Critical | S.5(3), S.6(1) |
| `content_hash_mismatch` not validated before receipt issuance | Section 6(10) — cannot prove the principal saw the specific notice bytes | Critical | S.6(10) |
| Naive timestamp (`datetime.utcnow()`) in receipt or audit event | Section 6(10) — timestamp on consent receipt is not auditable | High | S.6(10) |
| Raw `principal_id` in audit event payload | Section 8(5) — audit log becomes a shadow PII store; unnecessary data retention | High | S.8(5), Rule 6(1)(c) |
| Broken hash chain link in `consent_audit_event` | Rule 6(1)(c) — access logs can no longer be relied on for investigation; Section 6(10) evidence is compromised | Critical | Rule 6(1)(c), S.6(10) |
| Processor fanout not triggered on withdrawal | Section 8(7) — processors retain data they must have erased | Critical | S.8(7) |
| Audit partition dropped before 1-year floor | Rule 6(1)(e) / Rule 8(3) — minimum retention window breached | High | Rule 6(1)(e), Rule 8(3) |
| `consent_record` status mutated outside permitted transitions | Section 6(10) — evidence record is no longer trustworthy | Critical | S.6(10) |
| Notice version mutated without creating a new version row | Section 6(10) — cannot reconstruct which notice the principal saw | Critical | S.6(10), S.5(1) |
| Signing key deleted before old receipts expire | Section 6(10) — old receipts become unverifiable offline | Critical | S.6(10) |

**Rule:** Any Critical issue is a Section 6(10) failure mode. Fix before production; raise as a P1 incident if found post-deployment.

---

**StateCheck Security | DPDPA Compliance Resources**
[https://statechecksecurity.com/contact](https://statechecksecurity.com/contact)
