Banner design
Read and change a banner's themes, layout, colours, typography, buttons, logo, categories and custom CSS, and match the banner to your website, with the CookieBeam REST API.
The banner design endpoints expose the same settings as the banner designer in the dashboard. Every path sits under one banner:
https://cookiebeam.com/api/v1/banners/{bannerId}/design/…
bannerId is the banner's internal ID, as returned by GET /api/v1/banners. Every operation is also available as an MCP tool with the name listed below; both run the same checks.
Writes change the draft only. Nothing you change here reaches visitors until the banner is published from the dashboard. There is no publish endpoint yet: the banners:publish scope is reserved.
How these endpoints differ
- The key acts for its creator. Each call re-checks that the member who created the key is still active and may read (and, for writes, edit) this part of this banner. See Who a key acts for.
- Scopes. Reads need
banners.design:read, writesbanners.design:write;banners:readandbanners:writealso satisfy them. Deleting, duplicating and setting the default theme needbanners:admin. - Test keys may read and change drafts. They may not delete or duplicate themes, set the default theme, store or remove logo files, or start site captures and style matches (HTTP 403
test_key_production_forbidden). - Versioned writes.
PATCH,PUTandDELETEon a theme or theme section needIf-Match. See Versioned writes. - Extra rate limits. Writes are limited to 120 per minute per key; logo import, site capture, style match and refine to 20 per hour per banner. See Errors and rate limits.
- Plan features. Some settings need a plan feature (advanced design, custom CSS, branding removal, site capture). A change that needs a feature your plan lacks returns HTTP 403
plan_requiredwithdetails.feature. Applying a preset is the exception: it applies only the parts your plan includes.
Themes
themeId is a theme UUID, or default for the banner's default theme.
| Method | Path | Scope | MCP tool | What it does |
|---|---|---|---|---|
GET | /design/themes | banners.design:read | list_themes | The banner's themes, newest first, paginated. Each item is a whole theme |
POST | /design/themes | banners.design:write | create_theme | Create a theme (HTTP 201, Location header) |
GET | /design/themes/{themeId} | banners.design:read | get_theme | One whole theme, with its ETag |
PATCH | /design/themes/{themeId} | banners.design:write | update_theme | Change a whole theme. themeConfig is merged onto the stored one (objects merge, null clears a key); every other field replaces the stored value |
DELETE | /design/themes/{themeId} | banners:admin | delete_theme | Delete a theme that is not the default and not used by an active A/B test. Needs If-Match |
POST | /design/themes/{themeId}/duplicate | banners:admin | duplicate_theme | Copy a theme under newName (the copy is never the default) |
POST | /design/themes/{themeId}/set-default | banners:admin | set_default_theme | Make this theme the one visitors see after the next publish |
GET | /design/themes/{themeId}/usage | banners.design:read | get_theme_usage | A/B test variants that use the theme, and whether it can be deleted |
A theme document:
{
"id": "0c6c1c3e-2f0a-4b8e-9d55-7e1f2a3b4c5d",
"bannerId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Default Theme",
"isDefault": true,
"themeConfig": {
"mode": "opt-in",
"guiOptions": {
"consentModal": { "layout": "box", "position": "bottom right" },
"preferencesModal": { "layout": "box", "position": "middle center", "controlStyle": "toggle" },
"darkMode": "auto"
},
"categories": {
"necessary": { "enabled": true, "readOnly": true },
"analytics": { "enabled": false, "readOnly": false }
},
"branding": { "position": "left", "showBranding": true }
},
"cssVariables": { "--cc-btn-primary-bg": "#1d4ed8" },
"customCss": null,
"customFeatures": {},
"createdAt": "2026-09-01T10:00:00.000Z",
"updatedAt": "2026-10-20T08:30:00.000Z"
}
POST /design/themes takes the dashboard's theme input. All fields are optional:
| Field | Type | Notes |
|---|---|---|
name | string | 1–255 characters. Default Default Theme |
isDefault | boolean | Default true |
themeConfig | object | mode (opt-in or opt-out), cookie, guiOptions, categories, language, branding |
cssVariables | object | CSS variable names mapped to values |
customCss | string | Up to 50,000 characters. Sanitised on save; a non-empty value needs the custom CSS plan feature |
customFeatures | object | |
templateId | string | Up to 50 characters |
curl -X POST https://cookiebeam.com/api/v1/banners/3fa85f64-5717-4562-b3fc-2c963f66afa6/design/themes \
-H "Authorization: Bearer $COOKIEBEAM_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "name": "Autumn campaign", "isDefault": false }'
Theme sections
Each section is a slice of one theme with its own read, its own write and its own ETag. Writes to these paths are JSON merge patches (RFC 7396): send only the keys to change, and send null to remove a key so its default applies. Custom CSS is the exception: it is replaced as a whole with PUT.
| Section | Path (GET and write) | MCP tools | Fields |
|---|---|---|---|
| Layout | /design/themes/{themeId}/layout (PATCH) | get_design_layout, update_design_layout | themeConfig.disablePageInteraction; themeConfig.guiOptions.consentModal.layout and .position; themeConfig.guiOptions.preferencesModal.layout, .position and .controlStyle |
| Colours | /design/themes/{themeId}/colors (PATCH) | get_design_colors, update_design_colors | themeConfig.guiOptions.darkMode (auto, light, dark); colour CSS variables in cssVariables, such as --cc-bg, --cc-primary-color, --cc-btn-primary-bg, --cc-btn-primary-color, --cc-link-color, --cc-toggle-on-bg |
| Typography | /design/themes/{themeId}/typography (PATCH) | get_design_typography, update_design_typography | cssVariables: --cc-font-family, --cc-font-size, --cc-font-weight, --cc-line-height, --cc-text-align |
| Buttons | /design/themes/{themeId}/buttons (PATCH) | get_design_buttons, update_design_buttons | consentModal: equalWeightButtons, flipButtons, showCloseButton, showAcceptNecessary, equalWidthButtons; preferencesModal: equalWeightButtons, flipButtons, showAcceptNecessary; showFloatingSettings; floatingSettingsButton (position, size 32–96, backgroundColor, color, hoverBackgroundColor, offset 0–100) — all under themeConfig.guiOptions |
| Logo settings | /design/themes/{themeId}/logo (PATCH) | get_design_logo, update_design_logo | themeConfig.guiOptions.logo: showLogo, logoUrl (must be a logo stored for this banner, see Logo files), logoPosition (start, center, title-end), logoSize (sm, md, lg), logoAlt (up to 120 characters), watermarkOpacity, watermarkCorner |
| Branding footer | /design/themes/{themeId}/branding (PATCH) | get_design_branding, update_design_branding | themeConfig.branding: privacyPolicyUrl, cookiePolicyUrl, position (left, center, right), showBranding. Hiding the CookieBeam branding needs the branding-removal plan feature |
| Categories | /design/themes/{themeId}/categories (PATCH) | get_design_categories, update_design_categories | themeConfig.categories: a map of category name to { enabled, readOnly, autoClear? }. A category you send replaces the stored record for that category |
| Custom CSS | /design/themes/{themeId}/custom-css (PUT) | get_design_custom_css, replace_design_custom_css | customCss, up to 50,000 characters, sanitised on save |
Allowed values for the layout section:
| Field | Values |
|---|---|
consentModal.layout | box, box inline, box wide, cloud, cloud inline, bar, bar inline |
consentModal.position | top, bottom, middle, top left, top center, top right, middle left, middle center, middle right, bottom left, bottom center, bottom right |
preferencesModal.layout | box, side, side wide, bar |
preferencesModal.position | left, right, center, top, bottom, and the nine top/middle/bottom × left/center/right combinations |
preferencesModal.controlStyle | toggle, checkbox |
The OpenAPI document at /api/v1/openapi.json lists the full schema of every section, including every colour variable.
Versioned writes
Read a section first. The response carries an ETag:
curl -i https://cookiebeam.com/api/v1/banners/3fa85f64-5717-4562-b3fc-2c963f66afa6/design/themes/default/layout \
-H "Authorization: Bearer $COOKIEBEAM_API_KEY"
HTTP/1.1 200 OK
ETag: "design.layout:q3VZ1m0rXgk8Jw2nT4bYfA"
{
"data": {
"themeConfig": {
"disablePageInteraction": false,
"guiOptions": {
"consentModal": { "layout": "box", "position": "bottom right" },
"preferencesModal": { "layout": "box", "position": "middle center", "controlStyle": "toggle" }
}
}
},
"version": "v1"
}
Send the change with that value as If-Match:
curl -X PATCH https://cookiebeam.com/api/v1/banners/3fa85f64-5717-4562-b3fc-2c963f66afa6/design/themes/default/layout \
-H "Authorization: Bearer $COOKIEBEAM_API_KEY" \
-H "Content-Type: application/json" \
-H 'If-Match: "design.layout:q3VZ1m0rXgk8Jw2nT4bYfA"' \
-d '{ "themeConfig": { "guiOptions": { "consentModal": { "layout": "bar", "position": "bottom" } } } }'
The response is the section after the change, with the new ETag. If someone changed the section after your read, the write is refused:
{
"data": {
"error": "The resource was changed by another writer. Read it again and retry.",
"code": "conflict",
"details": { "currentEtag": "\"design.layout:Hk2pW9sQe1VtYc0uRz7mLb\"" },
"requestId": "9b2e5f0c-8d7a-4e0b-9c1d-2f3a4b5c6d7e"
},
"version": "v1"
}
Read again, re-apply your change and retry with the new ETag. If-Match: * skips the check and overwrites whatever is stored. A write without If-Match returns HTTP 428 precondition_required. A theme used by an active A/B test may be locked: HTTP 409 resource_locked, with the test named in details.
Presets
| Method | Path | Scope | MCP tool | What it does |
|---|---|---|---|---|
GET | /design/presets | banners.design:read | list_design_presets | The built-in presets: id, name, type, colorScheme, description, cssVariables, optional customCss |
POST | /design/themes/{themeId}/apply-preset | banners.design:write | apply_design_preset | Apply a preset by presetId. Replaces the theme's CSS variables and custom CSS; plans without advanced design get only the basic palette and font, and keep their custom CSS. If-Match is optional here |
Logo files
Logo files are stored per banner and served from the CDN. Storing one returns its URL; set that URL on a theme with the logo settings section, then publish.
| Method | Path | Scope | MCP tool | What it does |
|---|---|---|---|---|
POST | /design/logo/upload | banners.design:write | upload_logo | Upload dataBase64: PNG, JPEG, WebP or SVG, at most 200 KB. The type is detected from the content and SVG is sanitised |
POST | /design/logo/import | banners.design:write | import_logo_from_url | Fetch a logo from a public https url (no redirects, size-capped) |
POST | /design/logo/use-detected | banners.design:write | use_detected_logo | Copy the logo found by the latest site capture |
DELETE | /design/logo?url=<cdn url> | banners.design:write | remove_logo | Delete one logo file. A file still used by a theme or a published version is kept and the response is { "deleted": false, "reason": "in_use" } |
The three POST operations return HTTP 201:
{
"data": {
"url": "https://cdn.cookiebeam.com/…/logo.svg",
"contentType": "image/svg+xml",
"bytes": 4210
},
"version": "v1"
}
Test keys cannot call these four operations, because the files are written to the live CDN.
Match your website
These operations capture your homepage, propose banner styles that match it, and save a proposal as a new theme. They need the site capture plan feature, and test keys cannot start captures or matches.
| Method | Path | Scope | MCP tool | What it does |
|---|---|---|---|---|
POST | /design/site-capture | banners.design:write | start_site_capture | Start a homepage capture, or join the one running, and return at once (HTTP 202). url defaults to the banner domain and must be one of its domains |
GET | /design/site-capture | banners.design:read | get_site_capture | The latest capture: id, status (pending, ready, failed), requestedUrl, and what it found. null when there is none. Poll this after starting a capture |
POST | /design/site-capture/manual | banners.design:write | save_manual_site_capture | Enter brand colours by hand instead: primary, background, text (lowercase #rrggbb) and font (system-ui, Arial, sans-serif, Georgia, serif, Verdana, sans-serif, Times New Roman, serif) |
POST | /design/site-match | banners.design:write | match_website | Run the AI style match on the latest capture. Optional baseThemeId and locale. Returns proposals (id, name, …). Counts against the plan's match allowance |
GET | /design/site-match/proposals | banners.design:read | list_site_match_proposals | The kept proposals of the latest match, with savedThemeIds for proposals already saved, or null |
POST | /design/site-match/proposals/{proposalId}/refine | banners.design:write | refine_site_match_proposal | Adjust a proposal with an instruction (1–300 characters) and optional logo and locale. Counts as a match |
POST | /design/site-match/proposals/{proposalId}/apply | banners.design:write | apply_site_match_proposal | Save a proposal as a new theme on a copy of baseThemeId, optionally named themeName (1–40 characters). HTTP 201 with themeId. Saving the same proposal again returns that theme with alreadySaved: true |
A typical flow: start a capture, poll GET /design/site-capture until status is ready, run a match, apply the proposal you like, then make the new theme the default (or edit it further) and publish from the dashboard.
Errors
Besides the common errors, these endpoints return:
| HTTP status | Code | Cause |
|---|---|---|
| 400 | invalid_request | Validation failed. details.issues[].path starts with params. or body. |
| 403 | key_owner_inactive | The member who created the key is no longer active in the team |
| 403 | insufficient_permission | The key's creator cannot make this change on this banner |
| 403 | test_key_production_forbidden | A test key was used for an operation test keys may not run |
| 403 | plan_required | The plan lacks the feature named in details.feature |
| 404 | not_found | The banner, theme, proposal or path does not exist, or the key or its creator cannot see it. details.resource names the resource where available |
| 405 | method_not_allowed | The path exists, but not for this method |
| 409 | conflict | Stale If-Match (details.currentEtag), or the current state does not allow the operation (for example no capture yet) |
| 409 | resource_locked | The theme is used by an active A/B test |
| 413 | payload_too_large | The body is larger than 64 KB (512 KB for logo uploads) |
| 428 | precondition_required | A versioned write was sent without If-Match |
| 429 | rate_limited | A per-key or per-operation limit was reached |