Create Webchat Settings
POST /webchat/settings
Create the theme for one bot and surface. ONE ROW PER PAIR.
409 when one already exists - use PATCH /webchat/settings for that.
Colours must be hex and dimensions must be CSS lengths; two model-level guards additionally refuse an illegible text/background pair and an absurd design-token magnitude. All of them run at THIS boundary, so a violation is a 422 naming the field.
Authorizations
Parameters
Header Parameters
Request Body required
The widget’s whole appearance and behaviour, for one bot and surface.
Every colour is validated as a hex value, every dimension as a CSS length,
and two MODEL-level guards reject an illegible text/background pair and an
absurd design-token magnitude. All of those live in the app’s model, which
is what this mirrors - see the module docstring on why designTokens is
imported rather than re-declared.
ONE SETTINGS ROW PER (bot, surface). Creating a second is a 409, not a
second row; use PATCH /webchat/settings to change one that exists.
object
Owning chat bot id (UUID).
Extended widget theme tokens stored in chat_widget_settings.designTokens.
Shape is the shared FE/BE contract — the TypeScript twin lives in frontend/features/webchat/widget-preview/chat-design-settings.ts.
object
Color slots consumed by the widget’s —wcw-* CSS var vocabulary.
LIGHT-mode and mode-invariant leaves ONLY. Per-mode dark variants live on the
sibling DesignTokenColorsDark group (designTokens.colorsDark), NOT as
*Dark twins here (Phase G). The @field_validator("*") hex guard below
is exactly WHY the dark palette must be a sibling group rather than a nested
colors.dark object — a nested dict would be fed to _require_hex_color
and 422 the save (the same force that made DesignTokenHeader separate).
A legacy stored colors.<name>Dark leaf is lifted into colorsDark at
parse time by DesignTokens._lift_legacy_dark_leaves — no data migration.
object
Per-mode DARK colour overrides (designTokens.colorsDark, Phase G).
A sibling of DesignTokenColors holding the dark variant of every colour
leaf that renders differently in dark mode. ABSENT ⇒ the widget renders
exactly as it did before this group existed (the dark literal, or — for a
leaf an org set in colors and that historically painted both modes — that
colors value). Only leaves with a genuine dark render path live here; a
mode-invariant leaf (launcher, header gradient, send button, hero) has no
entry, so no dead tokens are introduced.
Field names carry NO Dark suffix — the group is the mode. The 13 leaves
that previously lived as colors.<name>Dark twins were migrated here; the
upconverter on DesignTokens lifts any legacy value so no row regresses.
object
RFC Phase E4 — launcher / panel / badge / chip geometry, CLAMPED.
The most locked-down group in the model, because chat-script injects these
into the CUSTOMER’s page rather than into the widget iframe (RFC §17.1).
Unclamped geometry on a host page is a defacement vector, so every leaf is
whole-pixel-only and range-bounded — see _require_px_in_range.
Ranges for launcherSize / launcherOffset* are INHERITED from the ratified
product decision (RFC §19 decision 4: “launcher geometry in scope with
clamped ranges — size ~36-64px, offsets ~8-48px”), not invented here.
Widget COLLAPSED WIDTH is deliberately absent: it is already plumbed as the
flat chatWidth column and read at runtime by the deployed bundle, so a
token would be a second writer for one property.
── TWO CLAMPS ARE ONE-DIRECTIONAL (max pinned at today’s value) ────────────
widgetWidthExpanded (max 640) and launcherOffsetX (max 20) may only ever
SHRINK. This is not conservatism, it is a live constraint:
The expanded panel needs a widgetWidthExpanded + launcherOffsetX viewport
to fit, but the mobile veto that hides the expand control is the hardcoded
MOBILE_BREAKPOINT_PX = 560 (chat-script/src/components/container.ts,
mirrored as a literal @media (max-width: 560px) in styles.ts). Today’s
640 + 20 = 660 already exceeds it, so viewports 561-659px ALREADY render an
overflowing panel — a pre-existing defect (GH #12173). Letting either value
GROW would widen that band onto more real devices.
A media-query CONDITION cannot read a custom property, so the guard cannot be
derived the way the panel’s bottom is; correcting it means making
MOBILE_BREAKPOINT_PX config-derived across container.ts + expand.ts +
the emitted media query. That is GH #12173, and it is what lifts these two
ceilings. Until it lands, 640 and 20 are load-bearing — do not raise them.
THE RULE, so it is applied correctly next time: an axis needs a
one-directional clamp exactly when it has NO viewport clamp. The horizontal
axis has none. The vertical axis has calc(100dvh - 140px), and the worst
case under these ranges is launcherOffsetY(48) + launcherSize(64) + gap(11) = 123px, which stays inside that 140px reserve with 17px to spare at every
viewport height — which is why launcherOffsetY, launcherSize, badgeSize
and chipGap are free to grow. Any future range change must preserve
launcherOffsetY + launcherSize + 11 <= 140.
object
How the header surface is painted — mode + gradient blend point.
A SEPARATE group, not new DesignTokenColors slots, and that is forced:
DesignTokenColors hex-validates EVERY field (@field_validator("*")), so a
"solid" mode or a numeric stop there would 422 the whole save.
Both leaves are ABSENT by default and absence is meaningful — it means “render exactly as this org rendered before this group existed”:
modeabsent -> gradient (today’s only behaviour).fromStopabsent -> no interpolation hint is emitted at all, so the CSS is byte-identical to the legacy string (seebuildWidgetCssVars). The 50/50 default is therefore expressed as ABSENCE, never as a written 50 — existing rows keep rendering without relying on hint-equivalence.
Border-radius slots (CSS dimensions; launcher is typically 50%).
Box-shadow slots — charset-restricted, see _require_css_shadow.
RFC Phase E3 — interaction transform SCALES (unitless multipliers).
Motion timing (duration + easing) already shipped as DesignTokenMotion;
this is the other half of E3. Every leaf is optional and absence means “the
literal chat-script/src/styles.ts ships today”, so transform absent =
byte-identical rendering.
Each leaf carries its OWN range because the ranges are semantic, not cosmetic: a “grow” interaction above ~1.2 reads as a glitch rather than feedback, and a “press” interaction must stay at or below 1.0 or the element grows when pushed.
Rotation is deliberately NOT here. RFC §16 scopes E3 to scales, and the launcher’s icon swap uses a ±90deg PAIR that must stay symmetric — one leaf would invite an asymmetric swap, two would invite a broken one.
object
Font family / weights. fontUrl is an optional CSS URL (Google Fonts css2).
object
Responses
201
Successful Response
object
An open envelope for one webchat object.
Open because the widget’s config grows a field per product decision and a pinned schema would DROP it. Same choice the desk surface made.
object
object
Whether there are more pages
Items per page
Current page number (1-indexed)
Total number of items
422
Validation Error