Theme API¶
Applies to: All subscriptions
Purpose¶
Describe the endpoints behind Theme Framework — reading and writing an organization's branding and color theme, the change-history and restore endpoints added in Phase V3C (Theme Governance), and the two endpoints that work without a session, because the login screen renders before one exists.
Audience¶
Integration developers automating provisioning (setting a new tenant's branding as part of onboarding), and administrators scripting bulk updates.
Prerequisites¶
- API Authentication —
GET /andPUT /use a user token with theadmin.manage_userspermission (the same permission the Administration API's feature-toggle endpoint uses);GET /publicandGET /assetsrequire no token at all - Branding and Theming — what each field means and how the color rules work, without the raw API shape
Reference¶
Base path /api/theme.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/ |
User token | The caller's own organization's effective theme (their configuration, or the platform default if they have none) |
PUT |
/ |
User token, admin.manage_users |
Update the caller's organization's theme |
GET |
/public |
None | The platform DEFAULT theme — see Scope |
GET |
/assets |
None | Streams one uploaded asset's bytes — ?kind=logo\|favicon\|hero&org=<orgId> |
POST |
/assets |
User token, admin.manage_users |
Upload one asset — ?kind=logo\|favicon\|hero, multipart/form-data field file |
GET |
/history |
User token, admin.manage_users |
The caller's organization's theme change history, newest first (Phase V3C) |
POST |
/history/:id/restore |
User token, admin.manage_users |
Reapply an earlier history entry as a new write (Phase V3C) |
Response shape¶
GET /, GET /public, and a successful PUT / all return:
{
"success": true,
"data": {
"org_id": "1001",
"theme_name": "Acme Corporate",
"product_name": "Acme Compliance",
"product_short_name": "Acme",
"logo_url": "/api/theme/assets?kind=logo&org=1001",
"favicon_url": null,
"login_hero_image_url": null,
"primary_color": "#0B5FA5",
"secondary_color": "#EEF1F4",
"accent_color": "#E3F1FF",
"dark_mode_default": "system",
"support_email": "support@acme.example",
"support_phone": null,
"is_active": true
}
}
Every field can be null except org_id, dark_mode_default, and
is_active — a null field means "not configured; the platform default
applies," never a broken state.
Scope: organization, not GSTIN¶
Every field here is scoped to the caller's organization, not to one GSTIN — an organization holding several GSTIN registrations has exactly one theme, shared by all of them. This is deliberately different from most of the rest of the platform's configuration (feature flags, for example, are per-GSTIN) — see Feature Flags for the general pattern this is an exception to.
PUT / validation¶
A request that fails validation returns 422 with a machine-readable code
and one message per problem:
{
"success": false,
"code": "THEME_VALIDATION_FAILED",
"errors": [
"primary_color \"#FFFFAA\" fails WCAG AA against the white text it renders with (1.04:1, needs 4.5:1). Choose a darker shade."
]
}
Colors are validated against the text color they actually render with —
primary_color against white (it is always paired with white text:
buttons, the shell bar), secondary_color and accent_color against a
fixed dark color (they are always paired with dark text — light fills, not
solid brand surfaces). A color a customer's design team proposes can be
checked against this rule before it is submitted: see
Branding and Theming
for the exact contrast formula.
Asset upload¶
POST /assets?kind=logo (or favicon, or hero) accepts one
multipart/form-data file (PNG, JPEG, SVG, WebP, or ICO; 5 MB default
limit, THEME_ASSET_MAX_MB to change it) and returns the URL now stored
against that field:
{ "success": true, "data": { /* full theme, as above */ }, "url": "/api/theme/assets?kind=logo&org=1001" }
That URL is what logo_url (or favicon_url / login_hero_image_url)
contains afterward, and is what <img src> should point at directly — it
is a public, unauthenticated GET (an <img> tag cannot carry a bearer
token), so no additional handling is needed to display it.
Theme history and restore (Phase V3C — Theme Governance)¶
Every successful PUT / — a normal save, a "Reset to Complifly defaults"
payload, or a restore — appends one row to the organization's change
history. Nothing is inferred after the fact; each row is written at the
moment of the change.
GET /history (optionally ?limit=N, default 50, max 200):
{
"success": true,
"data": [
{
"history_id": 42,
"org_id": "1001",
"action": "UPDATE",
"changed_by": "admin@acme.example",
"changed_at": "2026-09-15T10:22:41.000Z",
"previous_values": { "primary_color": "#0066CC", "...": "..." },
"new_values": { "primary_color": "#1E40AF", "...": "..." },
"changed_fields": ["primary_color"]
}
]
}
action is one of:
| Value | Written when |
|---|---|
UPDATE |
A normal save |
RESET |
The exact "Reset to Complifly defaults" payload (every field null except dark_mode_default: "system" and is_active: false) |
RESTORE |
A POST /history/:id/restore call |
previous_values and new_values are full snapshots of every writable
field, not a delta — restoring new_values from any row always reproduces
a complete, self-consistent state, never a partial merge against whatever
happens to be current.
POST /history/:id/restore reapplies that history entry's new_values as
a brand-new PUT / — same validation, same history recording (tagged
RESTORE), same cache invalidation. It never deletes or modifies the entry
being restored from:
// 200 — success, same shape as PUT /
{ "success": true, "data": { /* full theme, as above */ } }
// 404 — historyId does not belong to this organization
{ "success": false, "code": "THEME_HISTORY_NOT_FOUND", "errors": ["That history entry does not exist for this organization."] }
A restore is re-validated against today's WCAG AA rule, exactly like a new
save — a 422 THEME_VALIDATION_FAILED here would mean the platform's
contrast rule changed since that version was originally saved, not that
the value was ever invalid when it was written.
Validation¶
| Check | Method | Pass condition |
|---|---|---|
| Theme resolves for a new organization with no configuration | GET / immediately after provisioning |
Returns the platform default (org_id: "DEFAULT"), not an error or empty body |
| Color validation is enforced | PUT / with a color that fails contrast |
422 with THEME_VALIDATION_FAILED and a specific message naming the field and ratio |
| Asset upload round-trips | POST /assets, then GET the returned url |
The same bytes come back, with a correct Content-Type |
| Public endpoint needs no token | GET /public with no Authorization header |
200, not 401 |
| Every save is recorded | PUT /, then GET /history |
The newest entry's new_values matches what was just written |
| Restore never deletes history | POST /history/:id/restore |
GET /history afterward is one row longer; the entry restored from is unchanged |
| Restoring an unknown id fails clearly | POST /history/999999999/restore |
404 THEME_HISTORY_NOT_FOUND, not a silent no-op |
Troubleshooting¶
| Symptom | Cause | Action |
|---|---|---|
PUT / returns 400 Organization context is required |
The calling token has no orgId claim |
Re-authenticate; a token issued before organization assignment cannot write a theme |
PUT / returns 422 THEME_VALIDATION_FAILED for a color that "looks fine" |
The color fails the contrast check against the text color it is actually paired with — not against an arbitrary background | Check which text color the error names (white vs. dark) and adjust lightness accordingly |
| Uploaded logo does not appear on the login screen | logo_url was set to something other than this API's own returned url |
Only a URL this endpoint returned is guaranteed servable — do not point logo_url at an external image URL in V1 |
| Changing the theme does not appear to take effect | Client-side cache (60s in-process server cache aside, the browser also caches the last-applied theme for the FOUC guard) | A hard reload re-fetches; the server-side change itself is immediate |
POST /history/:id/restore returns 404 THEME_HISTORY_NOT_FOUND for an id you can see in GET /history |
The id belongs to a different organization, or was mistyped | Re-check the history_id came from this same caller's own GET /history response — RLS scopes every read and write to the caller's organization |
Related Articles¶
- Branding and Theming — the configuration reference for administrators, without the raw API shape
- Endpoint Index
- Administration API — the feature-flag toggle endpoint this one's authorization pattern is modeled on