Skip to content

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 / and PUT / use a user token with the admin.manage_users permission (the same permission the Administration API's feature-toggle endpoint uses); GET /public and GET /assets require 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