Consent manager

The Consent manager app that holds a shop's purposes, vendors, cookies and banner, freezes each published banner into a hashed policy version, and keeps a record of every visitor decision.

The Consent manager holds a shop's consent. It keeps the purposes the shop runs with the legal basis of each, the vendors and the cookies behind them, and the banner the visitor reads. A banner is never changed live: a draft is saved, previewed and then published as a numbered policy version. A version is frozen, carries a SHA-256 hash over its content, and is never edited or deleted. Every visitor decision is stored as one record that names the version the visitor was shown, and a record is never edited.

The app stores no IP address, no full user agent and no query string. A record keeps the browser family and major version when the storefront sends them, the path the decision was made on, and the consent id from the visitor's own cookie.

This page is for two readers:

  • A storefront that shows the banner, reads the published policy and records decisions. Its calls are the delivery reads and POST /v1/consent-manager/records.
  • The merchant-side integration that manages the registry and the banner, publishes versions and keeps the evidence for a data protection officer. Its calls are the rest.

To set up a shop, go in this order:

  1. Set up the registry: POST /v1/consent-manager/defaults, then adopt vendors from the catalogue or declare them.
  2. Edit the banner draft with PUT /v1/consent-manager/banner.
  3. Preview the draft with POST /v1/consent-manager/policy/preview.
  4. Publish it with POST /v1/consent-manager/policy/publish.
  5. The storefront then reads the policy from delivery and records each decision.

Base path: https://api.revenexx.com/v1/consent-manager.

Each route needs its own scope, named after the route's capability, for example consent-manager.records.create, or consent-manager.* for every route of the app. See Scopes. A key without the scope is refused with 403.

The list routes take limit, offset and order (column, column.asc or column.desc). On the vendors list, limit defaults to 50 and is capped at 200.

Calls from the storefront

The storefront's calls go from its own server, the BFF, with an API key. They never come from the browser. See The BFF pattern for why, and Authentication for how the key is used.

The key needs consent-manager.delivery.policy to read the policy and consent-manager.records.create to store decisions. It also needs consent-manager.delivery.preview, but only if the storefront shows previews. Give the storefront key exactly these scopes, not consent-manager.*: the wildcard would also let it read and export every record, unlink contacts, prune and publish.

A visitor's decisions chain by consent_id, the random UUID v4 in the visitor's own rvx_consent cookie.

A record carries a contact only when the platform attaches a signed-in buyer's identity to the call. A call made with the storefront's API key alone leaves contact_id null, and unlinking a contact only matters for records that carry one.

Send the same X-Revenexx-Market header on POST /v1/consent-manager/records as on the delivery read. A record is stored under the market in the header, not the market of the version, so a record sent without it counts for the shop.

The market header

X-Revenexx-Market names the market a call is about. The banner draft, the policy versions and the records are kept per market. The shop is the tenant as a whole. It is what a call means when no market is named, which is an empty header value.

  • Banner reads answer the market's own draft. A market without one gets the shop's draft with inherited: true.
  • Banner writes save the draft of the market in the header, and an empty value saves the shop's draft. A market's first save starts from a copy of the shop's draft for the fields it does not send. A texts object replaces the stored one whole: languages and labels left out are dropped, so send the full set.
  • Delivery answers the market's latest published policy, and the shop's when the market has none.
  • Publish freezes the draft of the market named in the header. The market is never read from the body. The version number rises across all markets of the tenant. A version's material_number is the number of the latest material version of the same market, so it skips the numbers other markets used.

Registry

The registry holds the purposes, the vendors, the cookies and the catalogue of shipped vendors. It also answers the value sets the app owns.

A purpose has one legal basis: consent waits for the visitor's decision, legitimate_interest runs until the visitor objects, and necessary always runs. A purpose's code is fixed once it is created, because vendors, records and the visitor's cookie name it. A purpose that a vendor still serves cannot be deleted; switch it inactive instead, and it leaves the next published version.

A vendor names the purposes it serves. An active vendor with no active purpose blocks every publish and preview, not just itself, until it is linked to a purpose or switched inactive. A vendor's cookies are its storage entries (cookie, local storage, session storage, IndexedDB or pixel), each with a host of first-party or the third-party host.

Installing the app normally seeds the five purposes (necessary, statistics, marketing, comfort, external_media), a default banner and one vendor, revenexx, which is the shop's own necessary cookies. The seeding at install is not guaranteed to run, so call POST /v1/consent-manager/defaults to be sure (step 1 above); it only adds what is missing. Every other vendor appears only once the merchant adopts it from the catalogue or declares it.

The catalogue is a library of vendors met in B2B shops. Adopting an entry copies it into the tenant, and the copy is the tenant's: nothing re-reads the catalogue. When the shipped catalogue is newer, the adopted vendor is flagged with catalog_update and stays unchanged until the merchant refreshes it.

OperationWhat it does
POST /v1/consent-manager/defaultsCreates the five standard purposes, the shop's default banner and the revenexx vendor, whatever of them is missing. Idempotent: anything the merchant changed is left as it is. The body is {}.
GET /v1/consent-manager/purposesLists the purposes. Filters include code, legal_basis, is_active and is_system.
POST /v1/consent-manager/purposesAdds a purpose. code, name and legal_basis are required. 409 with code_taken when the code exists.
GET /v1/consent-manager/purposes/{id}Reads one purpose.
PUT /v1/consent-manager/purposes/{id}Partial update. Sending a different code is refused with 422 (code_immutable). A changed legal basis applies to versions published afterwards.
DELETE /v1/consent-manager/purposes/{id}Removes a purpose that no vendor serves. 409 with purpose_in_use names how many vendors still do.
GET /v1/consent-manager/vendorsLists the vendors, with filters on company, country, transfer, catalogue key and version, and more. Flags catalog_update on an adopted vendor whose catalogue entry is newer.
POST /v1/consent-manager/vendorsDeclares a vendor the catalogue does not carry. code and name are required, and purposes names the purpose codes it serves. To be publishable, an active vendor also needs a company and at least one active purpose. 409 with code_taken; 422 for an unknown purpose or an invalid legal basis override.
GET /v1/consent-manager/vendors/{id}Reads one vendor with the codes of its purposes, its cookies and any newer catalogue entry.
PUT /v1/consent-manager/vendors/{id}Partial update. A purposes list replaces the vendor's purposes. 422 for a changed code (code_immutable) or an unknown purpose.
DELETE /v1/consent-manager/vendors/{id}Removes the vendor with its cookies and purpose links. Published versions keep naming it, because they are frozen.
GET /v1/consent-manager/cookiesLists cookies and storage entries. Usually filtered with vendor_id.
POST /v1/consent-manager/cookiesDeclares a cookie under a vendor. vendor_id and name are required; kind defaults to cookie. 422 for an unknown vendor (unknown_vendor) or a kind outside the five storage kinds (invalid_kind).
GET /v1/consent-manager/cookies/{id}Reads one cookie.
PUT /v1/consent-manager/cookies/{id}Partial update of one cookie.
DELETE /v1/consent-manager/cookies/{id}Removes one cookie.
GET /v1/consent-manager/catalogLists the shipped catalogue, with adopted, adopted_version and newer_available per entry. ?category= narrows it to one group.
POST /v1/consent-manager/catalog/adoptCopies one catalogue vendor, its cookies and its purpose links into the tenant. Body: key (required), purposes (optional, replaces the catalogue's purpose codes; an empty list keeps them) and refresh (optional, see below). Answers 201 when adopted and 200 when refreshed. 404 for an unknown key; 409 with already_adopted when the vendor is adopted and refresh was not sent; 422 with unknown_purpose.
GET /v1/consent-manager/vocabulariesLists the six value sets the app owns, without their values: legal-bases, cookie-kinds, record-actions, record-surfaces, banner-layouts, google-signals.
GET /v1/consent-manager/vocabularies/{name}Reads one value set in full, with each value's German and English label. The sets are closed; a value outside one is refused. 404 for an unknown name.

Refreshing an adopted vendor

Sending refresh: true on adopt takes the catalogue's current fields and replaces the vendor's cookies. It also overwrites the merchant's own edits to the vendor's catalogue fields and purpose links. code, is_active and position are kept. A vendor the merchant declared with the same code as the catalogue key counts as adopted: adopt refuses it with 409, and a refresh overwrites it. Without refresh, an adopted key is refused with 409.

Settings

The app has seven settings. Each one is set per market or per tenant. Five of them (consent_lifetime_days, banner_layout, show_cookie_details, banner_exempt_paths, google_consent_mode) are frozen into each version, so a change reaches visitors with the next publish. reconsent_on_publish applies at the moment you publish. proof_retention_days is read by every prune, so a change applies at the next prune run, the daily one included.

SettingScopeDefaultRange or valuesEffect
banner_layoutmarketboxbox, bar, modalHow the first layer is shown: a box in a corner, a bar along the bottom, or a modal over the page. Accept all and reject all are equal in every layout. A draft layout of null follows this setting.
consent_lifetime_daysmarket36530 to 730 daysHow long a decision holds. After that the banner asks again. A value outside the range falls back to 365.
reconsent_on_publishmarketmaterial_onlymaterial_only, alwaysWhether a publish asks every visitor again. material_only asks when the publish is material; always asks after every publish.
show_cookie_detailsmarkettruetrue, falseWhether the second layer lists each cookie and storage entry under its vendor.
banner_exempt_pathsmarket/datenschutz,/impressumComma-separated paths, up to 1000 charactersPages where the banner covers nothing and shows as a bar, so the privacy policy and imprint stay readable before a decision.
google_consent_modemarketbasicbasic, offbasic sends Consent Mode v2 with every signal denied until a decision, then the update. off sends nothing. Advanced mode is not offered.
proof_retention_daystenant1095365 to 3650 daysHow long a record is kept after it is superseded or expired. Records past this are removed by the prune. A change applies at the next prune, the daily one included, not at a publish. Values below 365 are never applied.

The banner draft holds the layout and the labels per language, and the links to the shop's privacy policy and imprint. A visitor sees nothing from a draft until it is published.

  • Layout is box, bar or modal, or null to follow the banner_layout setting.
  • Labels are given per language. The labels title, body, accept_all, reject_all, save and settings are required before publishing.
  • Reject all must be present in every language the draft speaks. Reject all always stands beside accept all, so the refusal applies wherever accept all is offered.
A publish stores nothing when it is refused. A language that lacks its reject-all label gets 422 with reject_label_missing, and no version is created. The same goes for every refusal in the table below.

A publish or a preview is refused with 422 when:

CodeRefused whenDetails
no_localeThe draft carries no text in any language.
reject_label_missingA language has no reject-all label.locale
banner_incompleteA language lacks one of the required labels.locale, missing
vendor_without_purposeAn active vendor serves no active purpose.vendors
vendor_without_companyAn active vendor has no company. Only the shop's own platform vendor may leave it empty.vendors

Preview is refused by the same rules as a publish.

A publish renders the draft with the active purposes, the active vendors and the settings in every language the draft speaks. It stores the result as the next version, with a SHA-256 hash over the version's content field, serialized as compact JSON (no whitespace) with object keys sorted recursively and arrays in their stored order, as UTF-8. Recomputing the hash that way over a stored version's content and comparing it with the stored sha256 shows whether the content still matches what was published.

OperationWhat it does
GET /v1/consent-manager/bannerReads the banner draft for the market in the header, or the shop's draft with inherited: true. A tenant with no draft gets the default one created.
PUT /v1/consent-manager/bannerSaves the draft for the market. A market's first save starts from a copy of the shop's draft for the fields it does not send. A texts object replaces the stored one whole: languages and labels left out are dropped, so send the full set. Nothing a visitor sees changes until the next publish. 400 when the body has no field to update; 422 for an invalid layout (invalid_layout), an unknown label (unknown_label) or invalid texts.
POST /v1/consent-manager/policy/previewRenders the draft exactly as a publish would, without publishing it, and answers a preview token. Send {} when you set nothing; ttl_hours sets how long the token answers, 72 by default and at most 168. 422 for any refusal in the table above.
POST /v1/consent-manager/policy/publishFreezes the draft of the market in the header as the next policy version. Body fields: material (default true, see below) and note (at most 500 characters); send {} for the defaults. 409 with publish_raced when another publish took the same number at the same moment; publish again. 422 for any refusal in the table above.
GET /v1/consent-manager/policy-versionsLists the published versions. Filters include number, market, material, material_number, sha256, note, published_at and published_by.
GET /v1/consent-manager/policy-versions/{id}Reads one version with its frozen content and hash. 404 for a version that is not this tenant's.

Versions are the evidence of what visitors read, so none can be edited or deleted: the version routes have no write method, and an edit or delete is answered with 405.

Material and renewal

material defaults to true, so a plain publish asks every visitor again. Send material: false for a change that should not: it keeps the visitors' earlier decisions valid. Two cases ask every visitor again anyway: the reconsent_on_publish setting is always, or there is no earlier version. The material_number of a version is the number of the latest material version of the same market, up to and including it. The storefront asks again when the number in a visitor's cookie is lower.

Delivery

Delivery is what a storefront reads.

OperationWhat it does
GET /v1/consent-manager/delivery/policyReads the published policy for the market in the header, or the shop's. The answer carries a version object with id, number, sha256, material, material_number and published_at, followed by the frozen content the storefront renders and gates by. Cached at the gateway for the whole tenant, per market, for 300 seconds, and dropped on every publish.
GET /v1/consent-manager/delivery/preview/{token}Reads a draft preview by token. The answer has the same shape as the delivered policy, with version.preview: true and no version id. Not cached. 404 for an unknown token; 410 with preview_expired once it has run out.
With nothing published, delivery answers 404 with no_policy_published and details.reason: nothing_published. The storefront treats that as everything denied: no banner is shown and no optional tool loads.

Records

A record is one visitor decision. It is stored under the version that was shown, and it is never edited. A visitor's decisions chain by consent_id. A full withdrawal mints a new one. The consent_id is never handed to a tag.

POST /v1/consent-manager/records takes one decision, at most 4 KB. The required fields are consent_id, action, surface, policy_version_id and locale.

JSON
{
  "consent_id": "6f1c2a9e-4b7d-4e8a-9c3f-2d5b8e1a7f40",
  "action": "custom",
  "surface": "preferences",
  "policy_version_id": "0b9e4f7a-3c21-4d6e-8a5b-1f2e3d4c5b6a",
  "locale": "de",
  "decisions": { "purposes": { "statistics": "granted" }, "vendors": { "youtube": "granted" } },
  "user_agent_class": "Firefox 131",
  "client_ts": "2026-10-16T09:30:00Z",
  "page_path": "/produkte"
}

The ids in this example are placeholders: policy_version_id is the version.id the storefront got from delivery, and every purpose and vendor code must be in that version (here, youtube must have been adopted from the catalogue before the publish).

  • action is one of accept_all, reject_all, custom, vendor_grant, withdraw or renew. vendor_grant is the visitor allowing one vendor. renew is a decision given again when the banner asks again. The labels are in GET /v1/consent-manager/vocabularies/record-actions.
  • surface is one of first_layer, preferences, privacy_link or content_gate. The labels are in GET /v1/consent-manager/vocabularies/record-surfaces.
  • decisions holds purposes and vendors by code, each granted, denied or objected. Every purpose of the shown version is stored explicitly. A necessary purpose is always stored granted. A consent purpose left out is stored denied; a legitimate_interest purpose left out is stored granted, and on reject_all or withdraw it is stored objected. An accept_all that denies or objects to a purpose, or a reject_all or withdraw that grants one, is refused with 422 and contradicts_action.
  • user_agent_class is optional. It is the browser as family and major version. A longer value is reduced to that; a full user agent is never stored. Without it, the record keeps no browser class.
  • client_ts is the visitor's clock when they decided, kept as a hint. A record retried on a later page view carries it.
  • The contact is never read from the body. A record carries one only when the call is made for a signed-in buyer (see Calls from the storefront above).
  • expires_at is the time received plus the version's consent lifetime. It is set by the app, not sent by the caller.
OperationWhat it does
POST /v1/consent-manager/recordsRecords one decision. 201 with the stored record. 200 with deduplicated: true when the browser's latest record has the same action and decisions under the same version, so nothing new is stored. 413 with too_large above 4 KB. 422 for an unknown version, locale, purpose or vendor; an invalid action, surface or consent id; a decision value other than granted, denied or objected (invalid_decision); or a decision that contradicts its action.
GET /v1/consent-manager/recordsLists the records. Filters include consent_id, action, surface, policy_version_id, policy_number, policy_sha256, locale, market, recorded_at, expires_at, page_path and contact_id.
GET /v1/consent-manager/records/{id}Reads one record. 404 for a record that is not this tenant's.
GET /v1/consent-manager/records/by-consent/{consent_id}Reads one browser's decisions, oldest first, each naming the version it was shown under. 400 when the id is not a UUID.
GET /v1/consent-manager/records/exportExports the records received from from (inclusive) to to (exclusive), oldest first, as rows and as one CSV rendering. At most 1000 rows per call; continue with next_offset. Optional market and limit/offset. 400 when a date is invalid.
GET /v1/consent-manager/records/summaryCounts per purpose (granted, denied, objected, and a rate) and per action, for one market and an optional period. It names no consent id, record id or contact. It stops after about 10,000 records (it counts in pages of 500) and then says truncated: true, meaning more records may exist.
POST /v1/consent-manager/records/unlink-contactFor an erasure request. Body: contact_id (required). Sets contact_id to null on every record of that contact and changes nothing else; the proof stays. 422 with invalid_contact when the id is not a UUID.
POST /v1/consent-manager/records/pruneRemoves records that were superseded or expired longer ago than proof_retention_days. Previews by default: send dry_run: false to delete. Bounded per call; has_more says whether to call again. The daily prune-records schedule runs it for real.

Records are never edited. An update answers 405. The only change a record ever sees is unlinking its contact, and the only removal is a prune past proof_retention_days.

Events

The app emits consent_record.created when a record is stored and policy_version.published when a version is frozen. See Events.

Was this page helpful?