Webhook Integration
There is no built-in webhookUrl config field — you don't need one. createConsenti() already returns an eventBus (a standard EventEmitter) that fires on every consent save. Forwarding decisions to an external webhook is a few lines against that event bus, run in your own process.
createConsenti(), not inside the package.Minimal webhook forwarder
Fires once per consent save, with the full record and whether it was a create or an update:
import { createConsenti } from '@consenti/api'
import type { ConsentDbRecord } from '@consenti/api'
const WEBHOOK_URL = process.env.CONSENT_WEBHOOK_URL!
async function postToWebhook(event: 'created' | 'updated', record: ConsentDbRecord) {
try {
await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, record }),
})
} catch (err) {
// Never let a webhook failure affect the visitor's request — this runs after the
// consent record is already saved and the HTTP response already sent.
console.error('[webhook] delivery failed:', err)
}
}
const { eventBus } = createConsenti({ /* ... */ })
eventBus.on('consent.created', (record: ConsentDbRecord) => {
void postToWebhook('created', record)
})
eventBus.on('consent.updated', ({ current }: { previous: ConsentDbRecord; current: ConsentDbRecord }) => {
void postToWebhook('updated', current)
})Only forward grant/deny transitions for one parameter
Posting on every save is noisy if you only care about one cookie parameter (e.g. only fire when analytics_storage actually changes). Use ConsentAction — it does the previous-vs-current comparison for you and only calls back on a real transition:
import { createConsenti, ConsentAction } from '@consenti/api'
const WEBHOOK_URL = process.env.CONSENT_WEBHOOK_URL!
const { eventBus } = createConsenti({ /* ... */ })
new ConsentAction({
id: 'analytics_storage',
eventBus,
onGrant: ({ visitorId }) =>
fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'analytics_storage.granted', visitorId }),
}).catch(err => console.error('[webhook] delivery failed:', err)),
onDeny: ({ visitorId }) =>
fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'analytics_storage.denied', visitorId }),
}).catch(err => console.error('[webhook] delivery failed:', err)),
})Watching a whole category instead of one parameter works the same way with CategoryAction — see the Events reference for both.
Signing the payload for the receiving end
If your webhook receiver needs to verify the request actually came from your own server (not forged), sign the body yourself before sending — Consenti doesn't do this for you, since the webhook target is entirely your own infrastructure:
import { createHmac } from 'node:crypto'
import { createConsenti } from '@consenti/api'
import type { ConsentDbRecord } from '@consenti/api'
const WEBHOOK_URL = process.env.CONSENT_WEBHOOK_URL!
const WEBHOOK_SECRET = process.env.CONSENT_WEBHOOK_SECRET!
async function postToWebhook(record: ConsentDbRecord) {
const body = JSON.stringify({ event: 'created', record })
const signature = createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')
try {
await fetch(WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Consent-Signature': signature, // receiver: recompute and compare
},
body,
})
} catch (err) {
console.error('[webhook] delivery failed:', err)
}
}
const { eventBus } = createConsenti({ /* ... */ })
eventBus.on('consent.created', postToWebhook)consentSigningKey is configured, record.signature is already present on every ConsentDbRecord— that's a signature over the record's own contents (tamper-evidence for storage), not a signature of your webhook payload. They serve different purposes; use the payload-signing pattern above for the webhook itself.