Consenti

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.

ℹ️This is copy-paste application code, not a Consenti feature — it lives in your own server file alongside 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:

webhook.ts
typescript
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:

webhook-scoped.ts
typescript
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:

webhook-signed.ts
typescript
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)
⚠️If 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.

Frequently asked questions