Consent: any CMP, the __consent contract, and what we store

Embed
Preview of the card
<a href="https://forumulate.com/guides/consent-js-contract"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/15511885-f57f-450d-8954-326762fee8a5/social-card.svg?v=3e7ae33eec8b" alt="Consent: any CMP, the __consent contract, and what we store" width="600" height="315"></a>

Forumulate works with any consent management platform (CMP). Cookie Munch, Cookiebot, OneTrust, Osano, Termly, Usercentrics and Didomi each have a native preset. Any other CMP works through the IAB signals (TCF v2.2, GPP) or through the vendor-neutral JavaScript contract on this page. Global Privacy Control (GPC) is honoured on top of all of them.

The object is Cookie Munch's shared consent contract. Its neutral name is window.__consent; window.__fdConsent is the same object under its first name and stays a permanent alias, so code written against either keeps working. Forumulate reads __consent first — only when it is the contract (version: 1, a has function), since a short global like that may belong to another script — and falls back to __fdConsent. When Forumulate provides the object itself, it publishes it under both names, never over a __consent that is not the contract.

On every reader page whose space has an active CMP, the contract answers for that CMP:

Member

Returns

Meaning

ready()

Promise<void>

Resolves once the CMP has loaded and answered. Never rejects.

has(category)

true / false / null

true granted, false refused, null not decided yet.

on('change', cb)

unsubscribe function

Fires after every decision or withdrawal, with all categories.

request()

—

Opens the CMP's preference centre.

identify(token)

boolean

Hands the CMP a signed-in subject where it supports one (Cookie Munch, Didomi). Returns whether it was accepted.

layout.bottomInset() / layout.on('change', cb)

pixels

How much of the viewport bottom the banner covers, so a floating launcher can sit above it. The same value is in the CSS variable --forumulate-safe-bottom, and under its first name --fd-safe-bottom.

has() accepts the suite's six categories: necessary, preferences, statistics, marketing, support_chat and referrals. It also accepts the usual aliases (analytics, functional, advertising, …).

If a CMP has no category of its own for support_chat, the answer follows preferences. Likewise referrals follows marketing.

Identity: the same person on your product and on your docs

When a reader is signed in on your docs or community, Forumulate tells the page's consent manager who they are by calling identify(token). Their consent choices then follow them between your product's own pages and ours, and across devices.

Cookie Munch checks every identity token the same way: aud: cookiemunch:<cbid>, token_use: "template", and exp - iat of at most 120 seconds. It knows the person as (iss, sub). A token can come from one of two places, and Forumulate picks one per reader:

  1. The IdP's own server mint (Atlas). If the reader signed in through Atlas and the Atlas provider holds the instance secret key, our server asks Atlas for the token: POST /v1/sessions/{sid}/integration_tokens/cookiemunch?cbid=<your cbid>. The sid is the Atlas session the reader signed in with. This is the same token your product's pages get from getToken({ for: 'cookiemunch' }), so it needs no Cookie Munch configuration beyond the issuer your site already trusts.

  2. Forumulate-signed, acting for the reader's IdP. This works for any IdP: Atlas without a saved key, any OIDC provider, or external_jwt with an issuer and a JWKS. Forumulate signs { iss: "https://forumulate.com", sub: <the reader's subject at their IdP>, aud: "cookiemunch:<cbid>", token_use: "template", iat, exp: iat + 120 } with an Ed25519 key (alg: EdDSA, with a kid). The public keys are published at https://forumulate.com/.well-known/jwks.json. Cookie Munch accepts the token only when your site lists Forumulate in its config, with actsFor set to your IdP's issuer:

    { "consent": { "identifyIssuers": [
      { "issuer": "https://forumulate.com",
        "jwksUrl": "https://forumulate.com/.well-known/jwks.json",
        "actsFor": "<your IdP's issuer>" }
    ] } }

    With actsFor, Cookie Munch treats the token's sub as the same person your IdP's own tokens name. You do not need to work out these values: GET /orgs/{orgId}/consent/identity returns the exact identifyIssuers entries for your organization. It also shows which mint each of your reader IdPs gets.

When Atlas is configured but declines (for example, the integration is not enabled), Forumulate signs instead, provided your site lists us.

Which IdPs Forumulate vouches for. One Forumulate issuer serves every organization on the platform. So we only sign for an issuer whose keys came from the issuer itself: its own discovery document, a JWKS on the issuer's host, or Atlas's per-instance discovery. A provider that names some issuer alongside a key set hosted elsewhere, or an external_jwt that uses a shared secret, still signs readers in, but we do not vouch for it. Otherwise any organization could make Forumulate assert identities at somebody else's IdP. Those readers get issuer_not_anchored.

How the page does it:

  • The reader signs in through your IdP, either with the token hand-off or the redirect button. We keep their IdP issuer, subject and session id on the server. None of it reaches the browser.

  • A reader page with <html data-viewer="in"> calls POST /public/orgs/{orgId}/consent/identity-token with { "spaceId": "…" }, same-origin and with the reader's own cookie. It does this on each page load and whenever the reader signs in or out. It then passes the token to window.__consent.identify(token).

  • When the reader signs out, the page calls identify(null), but only if Forumulate made the earlier identify call. Otherwise it would wipe an identity your own pages gave the consent manager.

The endpoint answers 200 { token, expiresAt } with Cache-Control: no-store. When nothing can be minted it answers 404, and the page calls nothing. The error.reason says why:

error.reason

Meaning

not_an_idp_sign_in

The reader did not sign in through an IdP.

no_consent_provider

The space's provider takes no identity token. Only cookiemunch and managed do.

unknown_issuer

The reader's issuer is no longer configured.

issuer_not_anchored

We do not vouch for this issuer (see above).

signing_not_configured

Forumulate signing is off on this deployment.

integration_not_enabled, session_inactive, …

The IdP declined.

A request with no reader signed in gets 401, and a cross-site request gets 403. Neither the token nor any key is ever logged.

What to set up, once:

  • Atlas server mint: save the instance's secret key (config.secretKey, scope sessions:write) on the Atlas reader sign-in provider. It is write-only: a read answers secretKeySet: true. Then enable the cookiemunch integration on the instance with your site id.

  • Any other IdP, or as a fallback: paste the identifyIssuers entries from GET /orgs/{orgId}/consent/identity into your Cookie Munch site config.

  • Both: set the space or org consent provider to cookiemunch (or managed) with its cbid.

A reader who used "Sign in with Atlas" from another Atlas account has no session on your instance to mint for. If your site lists Forumulate, they are identified by a Forumulate-signed token instead.

Forumulate uses yours and installs nothing. That is how a CMP implements the contract natively, and how the custom provider works: your own platform defines window.__fdConsent, and everything below uses it.

Per-vendor adapters

When the CMP does not provide the contract, our adapter for it does, using only the vendor's public browser API:

Provider

Read from

Undecided while

Preference centre

Cookie Munch

CookieMunch.consent, hasResponse, isConsentRequired(), CookieMunchOn* events

hasResponse is false and consent is required

CookieMunch.showSettings()

Cookiebot

Cookiebot.consent, hasResponse, CookiebotOn* events

hasResponse is false

Cookiebot.renew()

OneTrust

OnetrustActiveGroups (C0002 statistics, C0003 preferences, C0004/C0005 marketing), OneTrust.OnConsentChanged

a group is inactive and the banner is still open

OneTrust.ToggleInfoDisplay()

Osano

Osano.cm.getConsent(), osano-cm-consent-* events

no osano_consentmanager cookie yet

Osano.cm.showDrawer()

Termly

Termly.getConsentState(), Termly.on('consent')

no stored Termly consent

Termly.displayPreferenceModal()

Usercentrics

__ucCmp.getConsentDetails() (v3) or UC_UI (v2), by category slug

consent still required

showSecondLayer()

Didomi

Didomi.getUserStatus() purposes, Didomi.on('consent.changed')

shouldConsentBeCollected()

Didomi.preferences.show()

Any other (custom, Iubenda, Complianz, Sourcepoint, …)

__tcfapi (TCF v2.2), else __gpp (GPP)

the TCF event is not tcloaded or useractioncomplete

the CMP's own

TCF purposes to suite categories. Every category needs P1 (store on a device).

  • preferences: P1.

  • statistics: P1 plus P8, P9 or P10.

  • marketing: P1 plus P3 and P4, or P1 plus P7.

  • referrals: P1 plus P7.

GPP. The EU section is read like TCF. A US opt-out of sale, sharing or targeted advertising refuses marketing and referrals.

GPC. navigator.globalPrivacyControl === true refuses marketing and referrals, whatever the CMP says.

"Cookie settings" in the reader footer appears for every connected CMP. It calls that vendor's preference centre, or __fdConsent.request() for any other.

What we store, and when

GET /api/public/orgs/{org}/cookie-manifest lists every cookie, localStorage/sessionStorage key and cache your readers can be given, with your real space ids in the names. Each item carries its suite category, lifetime, httpOnly / host-only, when it is set, and a purpose in every locale your sites publish. Analytics vendors your spaces load, and a connected chat widget, are included. The manifest has no credentials and is cacheable, so a CMP's scanner or your cookie policy can read it directly.

While __fdConsent answers, our own storage follows it:

  • Anything in preferences, statistics, marketing, referrals or support_chat is written only when has(category) === true.

  • Such storage is deleted in the same tick that the reader withdraws its category.

  • The reader's language choice (forumulate_reader_lang) still works for the visit as a session cookie, and becomes lasting once preferences is allowed.

  • Necessary storage is never gated: sign-in, the consent record itself, the cart and a draft being typed.

  • On a page with no CMP, storage behaves exactly as it did before.

A reader who has not decided gets none of the gated storage: no lasting language cookie, no reader preference keys, no measurement keys, no chat keys, no referral or attribution storage.

Our status, changelog and roadmap widgets on your site follow the same rule. If your page exposes __fdConsent, their preference keys wait for has('preferences').

We keep no analytics device id. Measurement is counted per tab and deduplicated on our side.

Connect a CMP in one call

PUT /api/orgs/{org}/consent takes connect:{…} beside the config. Each provider has a handler, and the answer lists every step, its status and how it was done.

Step

Cookie Munch

Cookiebot

OneTrust

Osano

Termly

Usercentrics

Didomi

custom

site

api

manual

manual

manual

manual

manual

manual

—

webhook (+ secret)

api

—

manual

—

—

—

—

manual

executor (rights requests)

api

manual

manual

manual

manual

manual

manual

manual

cookie declaration

api

manual

manual

manual

manual

manual

manual

manual

reader banner

api

api

api

api

api

api

api

api

Cookie Munch: connect: {orgProvisioningKey: "cookiemunch_live_org_…", cbid?}. The key needs sites:write, webhooks:read, webhooks:write and dsar:write. Missing scopes are a 422 that names them, and nothing is changed. The connect:

  • creates or reuses the site;

  • creates or rolls the webhook and stores its secret;

  • registers Forumulate as an executor (contract v2), with the key and the completion-webhook secret sent server to server;

  • declares the cookie manifest on the site;

  • points your readers at the banner.

Re-running it is safe.

Every other provider: connect: {executor?: true, completionWebhook?: {url, secret}, rotateWebhookSecret?: true}. We do our half and answer each vendor-side step with the instruction and the exact values (webhookUrl, executorUrl, cookieManifestUrl). An executor key or webhook secret that a human has to paste is returned once, in shownOnce.

?validate=true (or ?dryRun=true) with connect plans the steps and writes nowhere. It does ask Cookie Munch about an orgProvisioningKey (GET /v1/me, read-only): a key Cookie Munch does not know, or one missing sites:write, webhooks:write or dsar:write, is refused 422 by the preview exactly as the connect would refuse it. GET /api/orgs/{org}/consent shows:

  • the last connect;

  • what the current provider can automate (capabilities);

  • the executor block: enabled, version, completionWebhook.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.

Operation

Request

What it does

Shortcuts

ConsentIdentity_token

POST /public/orgs/{orgId}/consent/identity-token

Send a token request in an organization

client.privacy.consentIdentity.token()

Operation

Request

What it does

Shortcuts

ConsentIdentitySetup_setup

GET /orgs/{orgId}/consent/identity

Get the identity in an organization

client.privacy.consentIdentitySetup.setup()

The Forumulate teamUpdated