Quick start
Two lines to collect consent, one more to attribute it to a logged-in user.
<!-- 1. Drop the widget anywhere before </body> -->
<script
src="https://your-domain.com/widget/banner.js"
data-api-key="pk_live_xxxxxxxx"
async
></script>// 2. After your auth flow sets the user session, attribute consent:
const { token } = await fetch("/api/dpdp-identity-token").then(r => r.json());
await window.DPDPConsent.identify({ identityToken: token });Find your API key under Project → Settings → API Keys. Anything embedded in the page is a publishable pk_live_… key — safe to ship.
Concepts & data model
The platform is multi-tenant: every customer is an Organization that owns one or more Projects. Each project carries its own banner, purposes, consent records, and API keys.
- Organization — billing, team, grievance officer, SSO/MFA policies.
- Project — a web property or app. Has its own widget config + API keys.
- Purpose — a processing activity (Analytics, Marketing, Functional, …) with a legal basis and retention.
- ConsentRecord — one per data principal session. Holds per-purpose ConsentItem rows.
- RightsRequest — ACCESS / CORRECTION / ERASURE / NOMINATION / GRIEVANCE per DPDP §12–14.
- NoticeVersion — versioned §5 notice that consents link back to.
- Receipt — ISO/IEC 29184 signed JSON issued per ConsentRecord.
Legal mapping
Section 6 (consent), §9 (children), §11 (withdrawal), §12–14 (rights), §8(6) (retention), §13(3) (30-day SLA + grievance officer) all have direct surface in the product.Widget
The embed script is a single IIFE bundle (~26 kB gzipped) that loads async and exposes window.DPDPConsent.
Public API
interface DPDPConsent {
show(): void;
hide(): void;
withdraw(purposeIds?: string[]): Promise<void>;
getConsent(): { token, givenAt, expiresAt, purposes } | null;
identify(i: { identityToken: string }): Promise<boolean>;
forget(): void;
getIdentity(): { identityToken, email?, externalId? } | null;
}Features out of the box
- Google Consent Mode v2 — emits
consentevents with purpose → signal mapping. Setup guide for GA4, Google Ads, Microsoft Clarity → - Script blocker — mark a script with
type="text/plain" data-dpdp-purpose="analytics"; widget rewrites the type after consent. - GPC (Global Privacy Control) — auto-denies non-essential purposes when
navigator.globalPrivacyControlis true. - Age gate — DPDP §9. Shows an "are you ≥ N?" screen when the project has children's data enabled.
- Guardian OTP flow — email-verified parental consent for minors.
- 23 languages — English + 22 Eighth Schedule. Picks from
navigator.languages, honours?dpdp-lang=xxoverride. - A/B testing — swap banner variants, track conversions per variant.
- Geo-targeting — per-country override rules for banner copy / behaviour.
- Proof of notice — POSTs a display event before consent so §6(3) is auditable.
Script-blocker example
<!-- Google Analytics only loads after consent to the "analytics" purpose -->
<script
type="text/plain"
data-dpdp-purpose="analytics"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"
></script>
<script type="text/plain" data-dpdp-purpose="analytics">
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "G-XXXX");
</script>Identify & attribution
Without identify(), records fall back to hashed IP (shown as "anonymous"). Call identify() after login with a short-lived HMAC token minted on your server.
1. Mint the token server-side
// Node / Next.js API route on your server
import crypto from "crypto";
function identitySecret(projectId: string) {
return crypto.createHmac("sha256", process.env.NEXTAUTH_SECRET!)
.update(`identify:${projectId}`).digest();
}
function b64url(buf: Buffer) {
return buf.toString("base64")
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function createIdentityToken(email: string, externalId: string, projectId: string) {
const now = Math.floor(Date.now() / 1000);
const payload = { email, externalId, projectId, iat: now, exp: now + 300 };
const p = b64url(Buffer.from(JSON.stringify(payload)));
const s = crypto.createHmac("sha256", identitySecret(projectId)).update(p).digest();
return `${p}.${b64url(s)}`;
}2. Forward to the widget
// After login
const { token } = await fetch("/api/dpdp-identity-token").then(r => r.json());
await window.DPDPConsent.identify({ identityToken: token });
// On logout
window.DPDPConsent.forget();Don't trust raw email
If the widget acceptedidentify({ email }) from the page, any script could attribute a consent to someone else's email. The HMAC token proves the host app's server saw a real session. 5-minute TTL by default.Public REST API
Every endpoint under /api/v1/* uses an API key in the Authorization: Bearer pk_live_… header. Same calls the widget makes — perfect for mobile apps, server-to-server sync, and Postman debugging.
| Method | Path | Purpose |
|---|---|---|
| GET | /widget-config | Project banner + purposes + translations. |
| POST | /consent | Record a consent decision. |
| GET | /consent?token=… | Read back a record. |
| DELETE | /consent/:token | Withdraw consent (DPDP §11). |
| PATCH | /consent/:token/identify | Attach an identity to an anonymous record. |
| GET | /consent/:token/receipt | Signed Kantara CR v1.1 receipt. |
| POST | /receipt/verify | Verify a receipt (public, no key needed). |
| POST | /notice/display | Record that the notice was shown (proof of notice). |
| POST | /consent/guardian-otp | Send OTP to the guardian email. |
| POST | /consent/guardian-verify | Verify OTP, return a guardian session token. |
| POST | /age-verification | Persist an age-gate response. |
| GET | /withdraw/:token | Preview a signed one-click withdrawal. |
| POST | /withdraw/:token | Execute the signed withdrawal. |
| POST | /portal/export/request | Data principal initiates a §13 export. |
| POST | /portal/export/verify | Verify OTP and enqueue the export. |
| GET | /portal/export/download/:token | Single-use download of the ZIP. |
POST /consent — record consent
curl -X POST https://your-domain.com/api/v1/consent \
-H "Authorization: Bearer pk_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"purposeIds": ["cm_abc", "cm_def"],
"principalEmail": "user@example.com",
"consentAction": "acceptAll",
"metadata": { "source": "web", "pageUrl": "https://app.example.com/" },
"identityToken": "eyJ…"
}'Response: { consentToken, status, givenAt, expiresAt }
DELETE /consent/:token — withdraw
curl -X DELETE https://your-domain.com/api/v1/consent/CNS-abc123 \
-H "Authorization: Bearer pk_live_xxxxxxxx"Signed consent receipts
Every project gets an Ed25519 keypair at creation. Each consent record can be downloaded as a signed JSON receipt conforming to ISO/IEC 29184 / Kantara CR v1.1. The public key is embedded in the receipt so verification survives key rotation.
Fetch a receipt
curl https://your-domain.com/api/v1/consent/CNS-abc123/receipt \
-H "Authorization: Bearer pk_live_xxxxxxxx"Receipt shape
{
"specVersion": "1.1.0",
"receiptId": "ckx…",
"jurisdiction": "IN",
"consentTimestamp": "2026-04-20T08:14:52.231Z",
"publicKey": "<base64 Ed25519 SPKI>",
"dataFiduciary": {
"name": "Acme Corp",
"website": "https://acme.com",
"grievanceOfficerName": "Priya Sharma",
"grievanceOfficerEmail": "dpo@acme.com"
},
"dataPrincipal": {
"ref": "<sha256 hash>",
"emailMasked": "u***r@example.com"
},
"noticeVersion": "clxy…",
"noticeDisplayEventId": "clxa…",
"withdrawalUrl": "https://…/withdraw/signed/…",
"purposes": [
{ "purposeId": "cm_abc", "name": "Analytics", "legalBasis": "CONSENT",
"status": "GRANTED", "retentionDays": 365, "expiresAt": "2027-04-20T08:14:52.231Z" }
],
"signature": "<base64 Ed25519 signature>"
}Verify a receipt (anywhere)
curl -X POST https://your-domain.com/api/v1/receipt/verify \
-H "Content-Type: application/json" \
-d '{ "receipt": { ... the JSON above ... } }'
# => { "valid": true, "issuerKnown": true, "receiptId": "…", "consentTimestamp": "…", "issuer": "Acme Corp" }Notices & re-consent
Notices are versioned. Each publish is immutable; the active version is what the widget shows and what ConsentRecord.noticeVersionId points at.
- Proof of notice — each banner render writes a NoticeDisplayEvent keyed by widget session. The consent record back-links to it.
- 23 translations — mark a translation reviewed to serve it to principals; machine translations stay server-side until human sign-off.
- Material change → re-consent — publish with
requiresReconsent: trueand the rights worker flips matching active consents toREQUIRES_RECONSENTin batches of 500. - Change flags —
NEW_PURPOSE,DATA_CATEGORY_EXPANDED,RETENTION_EXTENDED, etc. Shown to principals in the re-consent email. - Grievance officer auto-sync — when
dpoSource=ORG_SETTINGS, updating the org-level officer auto-patches every notice that opted in.
// /app/api/notice/publish example shape (dashboard does this for you)
await publishNotice(orgSlug, projectSlug, {
summary: "We collect …",
fullContent: "…",
dataCategories: ["email", "device_id"],
requiresReconsent: true,
changeFlags: "NEW_PURPOSE, DATA_CATEGORY_EXPANDED",
});Data principal rights
Data principals submit requests via the portal. Each falls under one of five DPDP types and inherits a 30-day SLA (§13(3)).
ACCESS(§11) — copy of data held.CORRECTION(§12) — update inaccurate fields.ERASURE(§13) — right to be forgotten.NOMINATION(§14) — nominee in case of death / incapacity.GRIEVANCE(§13(3)) — general complaint to the data fiduciary.
Escalation ladder
Hourly worker fires up to four graduated escalations per request; each tier has a unique-per-request row so the worker is replay-safe.
| Tier | Trigger | Action |
|---|---|---|
| REMINDER | ≤5 days left | Email assigned handler (or DPO). |
| ESCALATED | ≤2 days left | Email all OWNER/ADMIN members + Slack. |
| OVERDUE_FINAL | past deadline | Auto-status → OVERDUE, DPO notified. |
| BREACH_LOGGED | after OVERDUE_FINAL | Audit entry for compliance report. |
Data principal portal
Every project ships a public portal at /{orgSlug}/{projectSlug} with these pages:
/withdraw— manual withdrawal by consent token./withdraw/signed/:token— one-click withdrawal from the confirmation email./rights— submit a rights request./rights/:lookupToken— check status, reply to handler messages./preferences— adjust per-purpose consent./export— §13 self-service data export (email → OTP → ZIP)./privacy/notice— public notice (?lang=hi, etc.)./privacy/policy— published privacy policy document./guardian/:token— guardian dashboard to review/revoke minor consents.
Self-service export ZIP contents
consent/
CNS-abc123.json — signed Kantara CR v1.1 receipt
CNS-def456.json
rights/
RR-xyz789.json — request + handler messages
README.txt — plain-text cover noteChildren's data (DPDP §9)
When a project opts in, the widget shows an age gate before the banner. Users under the threshold enter a guardian flow that issues a single-use session token the consent API validates before writing the record.
- Threshold — default 18, configurable 13–21.
- Minor tracking block (§9(5)) — purposes flagged
isTrackingPurpose/isBehavioralMonitoring/isTargetedAdvertisingare hard-rejected for minors with HTTP 422. - Guardian email OTP — 6-digit code, 5-attempt limit per 24h, 10-minute TTL.
- Guardian session token — single-use, 30-minute TTL, required in the consent body.
- Guardian portal —
/guardian/:tokenlists all consents approved by the guardian with bulk-revoke. - Aged-out worker — daily at 04:00. When a minor turns 18 (if DOB was captured) the record auto-expires and the guardian is notified.
- Integrity sweep — weekly check for minor records containing blocked purposes; alarms OWNERs.
Compliance tooling
Surfaces aimed at DPDP Board auditors and internal review.
RoPA register
One editable overlay per Purpose with principal categories, sensitivity tier (STANDARD / SENSITIVE / CRITICAL), cross-border flag, retention narrative. Immutable snapshots on every review. CSV and JSON export. 90-day review reminder.
curl "https://your-domain.com/api/ropa/export?orgSlug=acme&projectSlug=web&format=csv" \
--cookie "<session>"Vendor / processor register
Per-org register of processors, sub-processors, joint controllers. DataProcessingAgreement lifecycle (DRAFT → ACTIVE → EXPIRED / TERMINATED) with expiry alerts at 30 and 7 days, plus auto-EXPIRE when the deadline passes. VendorAudit rows drive a per-vendor risk score. Each vendor links to any number of purposes via PurposeVendor.
Grievance officer
Org-level contact fields (name / email / phone / address) with an immutable GrievanceOfficerChange log. Notices that opted into ORG_SETTINGS dpoSource auto-patch when the org-level officer changes. Public page at /{orgSlug}/grievance-officer meets §13(3).
Audit hash chain
Every audit log row is hashed into a per-org chain (SHA-256 of previous hash + canonical payload). Weekly anchors snapshot the chain tip for tamper-evidence.
Enterprise auth
Two-factor authentication (TOTP)
Per-user TOTP with 10 bcrypt-hashed backup codes. Secret is AES-256-GCM encrypted with MFA_ENCRYPTION_KEY. Login: password → /api/auth/mfa/precheck → /api/auth/mfa/challenge → NextAuth JWT. OWNERs can enforce org-wide with a configurable grace period; non-enrolled users are redirected to /enroll-mfa.
Custom RBAC
Built-in OWNER/ADMIN/VIEWER plus any number of custom roles with 28 permission codes across 10 domains (CONSENT_READ, RIGHTS_HANDLE, BILLING_MANAGE, …).
import { requirePermission } from "@/lib/permissions";
// In a server action / API route, after requireOrgMember():
await requirePermission(userId, orgId, "BILLING_MANAGE");SSO (SAML / OIDC)
Enterprise-plan only. One SsoConnection per org with SAML metadata URL or OIDC discovery URL + client credentials. Email-domain routing: users whose@company.com matches are bounced straight to the IdP on the login page. JIT provisioning creates the User + Membership on first sign-in with defaultRoleId applied.
SCIM 2.0
Same connection gets a SCIM token via Settings → SCIM. Endpoints:
GET /api/scim/v2/Users ?filter=userName eq "x@y.com"
POST /api/scim/v2/Users provision new
GET /api/scim/v2/Users/{id}
PUT /api/scim/v2/Users/{id}
PATCH /api/scim/v2/Users/{id} flip active / rename
DELETE /api/scim/v2/Users/{id} deprovision membership
GET /api/scim/v2/Groups org roles as SCIM GroupsWebhooks
Configure outbound webhooks at Project → Webhooks. Each delivery is signed and retried with exponential backoff. Subscribe to any of:
consent.givenconsent.withdrawnconsent.expiredrights.submittedrights.resolved
{
"event": "consent.given",
"payload": {
"consentToken": "CNS-abc123",
"projectId": "clx…",
"principalEmail": "user@example.com",
"purposeIds": ["cm_abc", "cm_def"],
"givenAt": "2026-04-20T08:14:52.231Z"
}
}Rate limits & quotas
Every API key belongs to a rate tier. Limits apply per-key, per-minute, with a sliding window. Enterprise custom limits are set via apiKeyRateCustomMax.
| Tier | Requests / minute |
|---|---|
| FREE | 60 |
| BASIC | 600 |
| PRO | 6,000 |
| ENTERPRISE | custom |
IP allowlist
Optional per-key CIDR allowlist. Requests from IPs outside the list return 403 IP_NOT_ALLOWED. Blocked hits are logged as API_KEY_IP_DENIED.
80% proximity alert
When a key crosses 80% of its minute budget, a webhook fires api_key.rate_warning and an optional Slack message is sent.
Environment variables
For self-hosted deployments. Most have sensible defaults — only a handful are required.
| Variable | Required | Purpose |
|---|---|---|
| DATABASE_URL | yes | Postgres 16 connection string. |
| REDIS_URL | yes | Redis 7 for queues + caches. |
| NEXTAUTH_URL | yes | Canonical app URL. |
| NEXTAUTH_SECRET | yes | Session signing + identity/withdrawal tokens. |
| MFA_ENCRYPTION_KEY | if MFA/SSO/receipts | 32 hex bytes — encrypts TOTP secrets + OIDC client secret + Ed25519 receipt keys. |
| RESEND_API_KEY | yes | Transactional email provider. |
| EXPORT_STORAGE_ROOT | no | Directory for principal export ZIPs. Default .exports/. |
| AI_API_KEY / AI_BASE_URL / AI_MODEL | for AI | Used by document generation + machine translation. |
| TWILIO_ACCOUNT_SID / ... | optional | Enables guardian OTP SMS fallback. |
Key rotation
RotatingMFA_ENCRYPTION_KEY invalidates every stored TOTP secret, OIDC client secret, and receipt private key. All users will need to re-enroll MFA, and SSO connections + receipt signing keypairs must be regenerated.Need something not here?
See the integration guide for framework-specific embeds, or talk to sales about custom deployments.