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:
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:
{ "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:
// 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:
{
"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:
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
})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:
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:
DELETE /consenti/api/v1/consent/visitor-uuidThis deletes the visitor record, all consent records, and all consent history for that visitor. The operation fires a consent.erased event on the eventBus:
eventBus.on('consent.erased', ({ visitorId }) => {
// Remove from your own DMP / analytics / CRM
myDmpClient.deleteUser(visitorId)
})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.