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.
{
"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:
.idp-button{position:fixed}
.my-banner{color:red}
{
"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
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 }"}'