
# Set the custom CSS

`PUT /v1/branding/css` · permission `branding:write`

Writes the custom CSS of the environment draft (Startup plan or higher, feature `branding_custom_css`). The CSS goes through an allow-list sanitizer: only `ui_catalog` classes (`idp-*`), allowed properties and values; the protected layer (legal text, consent and accessibility elements) cannot be hidden or covered. All or nothing: if there is a single violation, nothing is written. The draft stores the sanitizer's canonical output, not the text you sent; `css` in the response is that output. An empty string clears it and is not plan-gated.

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `If-Match` | header | yes | The `ETag` of the draft as last read (`"0"` if never written). Missing: `428`; stale: `412`. |
| `css` | string | yes | At most 51200 bytes, valid UTF-8; `""` clears. |

## Response

`200 OK` with the environment draft and its `ETag`. `theme` holds only the tokens that are set; unset tokens inherit from the platform default.

```json
{
  "theme": {"mode": "light", "light": {"color": {"primary": "#0a3d91"}}, "logo_height": 40},
  "css": ".idp-c .idp-title{color:#0a3d91}\n",
  "page_template": null,
  "updated_at": "2026-10-05T09:30:00.123456Z"
}
```

## Errors

| HTTP | code | When |
|---|---|---|
| 400 | `unknown_field` | The body has an unknown field or is not a JSON object. |
| 403 | `plan_feature_unavailable` | The plan does not include the feature (`plan_feature_unavailable`, extensions `feature` and `required_plan`). Development environments are not gated. |
| 412 | `precondition_failed` | `If-Match` does not match the current version of the draft. |
| 422 | `validation_failed` | `css` is missing or `null` (`required`), too long (`too_long`) or not valid UTF-8 (`invalid_utf8`). |
| 422 | `branding_validation_failed` | The CSS failed the sanitizer. The `violations` extension lists every violation: `line`, `column` (1-based), `rule` (for example `class_not_in_catalog`, `protected_property`, `property_not_allowed`, `syntax_error`) and, when there is one, `name` (the property, class or function name). CSS values are never echoed. After 100 violations the sanitizer stops; the last item has rule `too_many_issues`. The canonical output is longer than the input (every rule gets a scope prefix); if it exceeds 51200 bytes, the only violation is rule `too_large` at line 1, column 1. |
| 428 | `precondition_required` | The `If-Match` header is missing. |
| 401 | `unauthenticated` | The credential is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The credential lacks the required permission. |
| 429 | `rate_limited` | The rate limit was exceeded; retry after the `Retry-After` period. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable; retry after the `Retry-After` period. |

Example sanitizer rejection:

```css
.idp-button{position:fixed}
.my-banner{color:red}
```

```json
{
  "type": "https://docs.keenauth.com/errors/branding_validation_failed",
  "title": "Branding validation failed",
  "status": 422,
  "code": "branding_validation_failed",
  "violations": [
    {"line": 1, "column": 13, "rule": "property_not_allowed", "name": "position"},
    {"line": 2, "column": 1, "rule": "class_not_in_catalog", "name": "my-banner"}
  ],
  "request_id": "req_01J9Z3N5W8Q2F6V4K7T1M0XB3C"
}
```

## Example

```bash
curl -X PUT https://api.eu.keenauth.com/v1/branding/css \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "3"' \
  -d '{"css":".idp-title { color: #0a3d91 }"}'
```
