Consenti

How Consent Flow Works

The backend's job is to receive consent decisions from the widget, store them durably, and provide endpoints for verification and GDPR erasure. This guide traces the full lifecycle of a consent record — from the first HTTP POST to deletion.

The lifecycle at a glance

1. Widget submits → POST /consenti/api/v1/consent
2. Route validates input → calls ConsentService
3. Service creates/updates visitor record
4. Service writes consent record to storage
5. EventBus fires consent.created
6. Response 201 → widget writes browser cookie
7. Subsequent loads → GET /consent/:visitorId/verify (optional)
8. GDPR erasure → DELETE /consent/:visitorId

Stage 1 — Widget submits consent

After the visitor interacts with the banner, the widget sends a POST request if api.enabled: true:

bash
POST /consenti/api/v1/consent
Content-Type: application/json

{
  "profileId": "my-profile-id",
  "consentJson": {
    "necessary": "granted",
    "analytics": "granted",
    "marketing": "denied"
  },
  "visitorId": "a1b2c3d4-...",
  "locale": "en",
  "gpcDetected": false,
  "source": "banner"
}

The visitorId is a UUID generated by crypto.randomUUID()on the widget's first load and stored in localStorage. It persists across sessions in the same browser. If you pass core.userId (for authenticated users), the widget uses that as the visitor identifier instead — enabling cross-device sync.

Stage 2 — Validation

The route layer validates the request body. Missing required fields or invalid consentJson values return 422 Unprocessable Entity with a structured error:

json
{ "error": "validation_error", "details": ["consentJson.analytics must be 'granted' | 'denied' | 'objected'"] }

The route then calls ConsentService.submit(). Services contain all business logic — routes never touch storage directly.

Stage 3 — Visitor upsert

If this is the first consent from a visitorId, the service creates a new visitor record. If the visitor already exists, it updates the record. IP addresses are never stored raw — only a SHA-256 hash is persisted:

typescript
// Inside the service — you never write this yourself
createHash('sha256').update(req.ip).digest('hex')

Stage 4 — Consent record written

The service writes a consent record to the storage adapter. Every update creates a new history entry — the full consent change log is always preserved.

Successful response:

json
{
  "id": "record-uuid",
  "visitorId": "visitor-uuid",
  "profileId": "my-profile-id",
  "locale": "en",
  "consentJson": { "necessary": "granted", "analytics": "granted", "marketing": "denied" },
  "gpcDetected": false,
  "source": "banner",
  "createdAt": "2026-07-07T10:00:00.000Z",
  "updatedAt": "2026-07-07T10:00:00.000Z"
}

Stage 5 — EventBus fires

After the record is saved, createConsenti() emits events on its eventBus. Subscribe to these to trigger webhooks, forward data to analytics platforms, or invalidate CDN caches:

typescript
const { eventBus } = createConsenti({ /* ... */ })

eventBus.on('consent.created', (record) => {
  // record: full ConsentDbRecord
  myAnalyticsClient.track('consent_submitted', {
    visitorId: record.visitorId,
    profileId: record.profileId,
    consent: record.consentJson,
  })
})

eventBus.on('consent.updated', ({ previous, current }) => {
  // compare previous and current consent states
})
💡You can also use the afterConsentSave plugin hook to intercept consent saves without coupling to the eventBus directly. See the Plugins reference.

Stage 6 — Verify consent (optional)

On subsequent page loads, the widget can verify the stored cookie against the backend to detect a profile change (edits mint a new profile id, so this is how staleness is detected — there is no separate version counter) or expired consent:

bash
GET /consenti/api/v1/consent/visitor-uuid/verify

# Valid
{ "valid": true, "reasons": [] }

# Stale — profile was edited since the visitor last consented (a new id is now active)
{ "valid": false, "reasons": ["profile_changed"], "currentProfileId": "...", "consentProfileId": "..." }

# Other reasons: "consent_expired" | "hmac_invalid"

When verification fails, the widget re-shows the banner automatically (if configured to do so).

Stage 7 — GDPR right to erasure

GDPR Article 17 gives visitors the right to have their data deleted. Consenti exposes a public endpoint for this:

bash
DELETE /consenti/api/v1/consent/visitor-uuid

This deletes the visitor record, all consent records, and all consent history for that visitor. The operation fires a consent.erased event on the eventBus:

typescript
eventBus.on('consent.erased', ({ visitorId }) => {
  // Remove from your own DMP / analytics / CRM
  myDmpClient.deleteUser(visitorId)
})
🚨The audit_logs table is append-only by design — erasure requests are logged there and those log entries are retained for compliance evidence. Only the consent data itself is deleted.

Frequently asked questions