Skip to content
Getting StartedSelf-hosted mode

Self-hosted mode

Markdown
Loading…

Send each consent decision to your own server as a record, and keep it as proof.

In mode: "self-hosted", every decision a visitor makes is also sent to your server as a consent record, so you can keep proof of it. The choice is still kept in the browser cookie too.

Turn it on

initCookieYes({
  mode: "self-hosted",
  apiUrl: "https://example.com/api/consent", // your server's endpoint
});

Each decision is sent as one POST with a JSON body. If you set apiKey, it goes in an Authorization: Bearer header; anyone can see it in the browser, so never treat it as a secret.

Receive it

Any server works. Store the record, then reply with a 2xx status.

For example, a route handler at /api/consent, the path in apiUrl above:

app/api/consent/route.ts
// A stand-in for your database.
const records = new Map<string, unknown>();

export async function POST(request: Request) {
  const record = await request.json();
  // A record can arrive twice, with the same recordId. Keep the first.
  if (!records.has(record.recordId)) records.set(record.recordId, record);
  return new Response(null, { status: 204 });
}

The request body

An example of what your server receives:

Example request body
{
  "recordId": "9lBcA17N3AVZ.mul2v4rq.9wicmt",
  "consentId": "9lBcA17N3AVZ",
  "categories": {
    "necessary": true,
    "functional": false,
    "analytics": false,
    "performance": false,
    "advertisement": false
  },
  "regulation": "GDPR",
  "domain": "example.com",
  "decidedAt": "2026-09-28T10:01:21.638Z",
  "taxonomyHash": "2fbx48",
  "action": "reject_all",
  "source": "banner"
}
FieldWhat it is
recordIdThis decision's id. The same decision always has the same id, so store each one once
consentIdThe visitor. It stays the same until their consent is reset
categoriesEach category, and whether it is granted
regulationGDPR or CCPA
domainThe site the decision was made on
regionThe visitor's detected region, like US-CA. Sent only with region detection
decidedAtWhen the visitor decided, in UTC. Not when the record was sent
taxonomyHashA fingerprint of your category list. It changes when you add, remove or rename a category, so you can tell which list a decision was made against
actionWhat the visitor did: accept_all, reject_all, accept_selected or save
sourceWhere: banner, preferences, optout, or api for a call from your own code

When your server is down

A record your server has not confirmed is kept in the visitor's browser and sent again: on the next page load, when the browser is back online, and on a timer while the page stays open. Up to 10 records are kept, for at most 7 days.

Your server is where the proof lives. How long to keep records is for you and your legal team to decide.

Your own transport

Instead of apiUrl, pass a backend with a persist(record) function. Throw when your server did not store the record, so it is sent again:

initCookieYes({
  mode: "self-hosted",
  backend: {
    async persist(record) {
      const res = await fetch("/api/consent", { method: "POST", body: JSON.stringify(record) });
      if (!res.ok) throw new Error(`Consent record not stored: HTTP ${res.status}`);
    },
  },
});

Common mistakes

Your server has the same decision twice. It stored the record, but its reply never reached the browser, so the record was sent again. Store records by recordId.

A record that failed in your backend is never sent again. Your persist function resolved although the record was not stored. Throw when your server fails.

Records never arrive when your server is on another domain. The browser blocks the request unless your server allows it. Answer the OPTIONS request and allow POST and the Content-Type header, plus Authorization if you set apiKey. The console shows a CORS error.

Your Content Security Policy blocks the request. Add your apiUrl origin to connect-src.

Next steps

On this page