API · v3
The hosted API
Access is in private beta.
The engine is a hosted service rather than a package, and rate limiting is not finished — around a thousand authenticated requests would be enough to reconstruct the generated surface, so keys are not being issued yet. The reference below is complete and current; pricing will land with the limiter.
Why the contract is its own call
The obvious design is one response per palette carrying the scale plus, for every family and grade, which steps are legal against which. Building it surfaced something better: the legal-step table does not depend on the requested hue.
Legality is a property of the engine, not of a palette — the same table for blue, for yellow, and for a brand colour nobody has picked yet. That is the perceptual-uniformity claim restated as an API contract instead of as a comment, and it is what makes an interactive playground affordable on top of a hosted engine: changing hue is a small response against a table the client already holds.
Authentication
A bearer token on every request except health, which is unauthenticated on purpose: a liveness probe must not need a secret, and it discloses nothing but a build string. Tokens are compared in constant time.
curl https://bezold.dev/api/v1/palette?hue=263&variant=vibrant \
-H "Authorization: Bearer $BEZOLD_API_KEY"Server-side only. The key must never reach a browser — and neither, in any deployment, should the engine: putting generation in a client bundle hands the calibration to anyone with devtools, which is the whole reason this is a service.
With BEZOLD_API_KEY unset the API answers 503 not_configured to everything but health. It refuses rather than serving openly, because an engine that quietly becomes public because an environment variable was forgotten is the exact failure the split exists to prevent.
Endpoints
| Method | Path | Auth | Cache |
|---|---|---|---|
| GET | /api/v3/health | none | no-store |
| GET | /api/v3/contract | bearer | immutable per engine build |
| GET | /api/v3/palette | bearer | immutable per input |
| GET | /api/v3/variant | bearer | immutable per input |
| GET | /api/v3/saturation | bearer | immutable per input |
Cached responses carry Vary: Authorization. Without it a CDN keys on the URL alone, and the first authorised request populates an entry that a later unauthorised request to the same URL is then served from — the auth bypassed by anyone who knows the path.
Unknown parameters are refused
Every endpoint rejects a query key it does not recognise with 400 unknown_parameter, and the detail names every key that build does understand.
This is the single most important thing on the page. A parser that ignored unknown keys would answer a request for a hue-shifted palette with an unshifted one: a 200, a well-formed body, twelve real hexes, and the wrong colour. Nothing downstream can detect that — the shape validates and the values are plausible. Refusing what we do not understand turns it into an error at the boundary, and the echoed input covers the same ground from the other side.
GET /api/v3/contract
Every surface the engine can be handed, every ink it will consider, and the worst-case figures each achieves — plus, per published rule, the set of inks that clear it. Static per engine build. Fetch it once. Takes no parameters.
{
"build": "<engine build id>",
"format": 1,
"rules": {
"heading": { "lc": 60, "wcag": 3 },
"nonBody": { "lc": 75, "wcag": 4.5 },
"uiBackground": { "lc": 45, "wcag": 3 },
"bodyLc75": { "lc": 75, "wcag": 4.5 },
"bodyLc90": { "lc": 90, "wcag": 4.5 }
},
"inks": ["20", "50", ..., "950", "white", "black"],
"surfaces": ["white|neutral", "20|neutral", ..., "950|brand"],
"bySurface": {
"white|neutral": {
"legal": {
"bodyLc90": ["700", "800", "900", "950", "black"],
"bodyLc75": ["600", "700", "800", "900", "950", "black"],
"nonBody": ["600", "700", "800", "900", "950", "black"],
"heading": ["500", "600", "700", "800", "900", "950", "black"],
"uiBackground": ["400", "500", "600", "700", "800", "900", "950", "black"]
},
"figures": {
"400": { "lc": 46.94, "wcag": 3.09 },
"700": { "lc": 92.31, "wcag": 9.85 },
"black": { "lc": 106.04, "wcag": 21 }
}
}
}
}rules is echoed back so a consumer never restates a bar. If a threshold moves in a future engine build, the consumer’s copy moves with it instead of silently disagreeing.
The values in legal are a floor. A step listed there clears its bar at every calibration hue; your specific palette may do better. Do not narrow the floor by re-measuring one hue to gain a step — that is how a system-wide rule becomes a per-palette accident.
GET /api/v3/palette
Twelve hexes, the input as the engine read it, and the fill legality that genuinely does depend on the palette.
| Parameter | Type | Required | Notes |
|---|---|---|---|
| hue | number | yes | HSLuv hue angle. Normalised into 0–360. |
| variant | string | yes | One of vibrant, pastel, neutral. |
| page | hex | no | The background fills are tested against. #rrggbb; defaults to #ffffff. |
| saturation | number | no | One level for the whole scale, 0–100. Mutually exclusive with saturationLadder. |
| saturationLadder | number list | no | A level per step: 12 comma-separated values in the order 20, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950. Positional rather than step:level pairs because the ladder is dense by construction, and the length check is what keeps the two sides agreeing about the order. |
| saturationLift | number | no | 0–1. How far past the axis to push, as a fraction of the remaining chroma headroom — for a brand colour more saturated than the pipeline reaches on its own. /variant reports the value that makes the output pass through a given colour. |
| lightness | number list | no | A lightness per step, same positional encoding, each 0–100 and strictly decreasing. The anchored-brand path: a brand colour off a standard step moves the whole ladder, and every step then generates at that lightness, with compensation, the envelope and the knee all evaluated there. |
| hueShift | number | no | Signed degrees reached at the scale extremes, −180 to 180. Zero at the origin step. |
| hueShiftMode | string | no | linear or each-way. Linear rotates tints and shades in opposite directions; each-way rotates both the same way onto one hue. |
| hueShiftCurved | boolean | no | Swaps the straight profile for x³/x² — flat near the origin, steep at the ends. true/false, or 1/0. |
| hueShiftOrigin | step | no | The step the sweep rotates around. One of 20, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950. |
| vibrancyShift | boolean | no | Asks the engine to choose the sweep for this hue — max-chroma origin, sign toward the gamut ridge. Mutually exclusive with the four manual shift fields, because supplying one and asking for the other is a contradiction and silently preferring either would be a decision made on your behalf. |
GET /api/v1/palette?hue=263&variant=vibrant&page=%23ffffff
{
"build": "<engine build id>",
"format": 1,
"input": { "hue": 263, "variant": "vibrant" },
"scale": {
"20": "#f8f9ff", "50": "#eff0ff", "100": "#e2e4ff",
"200": "#c1c5ff", "300": "#a3abfa", "400": "#808bf1",
"500": "#5c6add", "600": "#4453bf", "700": "#313d96",
"800": "#202a6b", "900": "#0f1745", "950": "#070d31"
},
"fills": {
"page": "#ffffff",
"usable": ["500", "600", "700", "800", "900", "950"],
"mostVivid": "500"
}
}
# With vibrancyShift=true the engine picks the sweep and reports it
# separately, so "input" stays comparable to what you sent:
{
"input": { "hue": 263, "variant": "vibrant", "vibrancyShift": true },
"derived": { "hueShift": -18, "hueShiftOrigin": 500 },
...
}input is the request as the engine resolved it, echoed field for field. It is there to be compared: send a parameter this build does not understand and you get a 400, but send one it drops for any other reason and the echo is what catches it. A response with no echo should be treated as a failure rather than read as all-defaults.
derived appears only when you passed vibrancyShift, and carries the sweep the engine chose. It is deliberately not merged into input: the echo answers “what did you send me”, and a derived value sitting there would break the field-for-field comparison every time.
fills.usable is the full set of steps that work as a filled control on that page — label bar and separation bar both cleared. fills.mostVivid is the most chromatic of them, which is a maximum over the set rather than a choice among equals; the set is returned alongside it so the caller can pick differently.
GET /api/v3/variant
Measurement, not generation: which level on the saturation axis reproduces this colour. It inverts the axis through the compensation curves, so it is engine work and cannot be done consumer-side without the calibration.
| Parameter | Type | Required | Notes |
|---|---|---|---|
| hex | hex | yes | #rrggbb. The only parameter this endpoint takes. |
GET /api/v1/variant?hex=%235c6add
{
"build": "<engine build id>",
"format": 1,
"input": { "hex": "#5c6add" },
"level": 71.4,
"variant": "vibrant",
"inputSaturation": 62.8,
"matchedSaturation": 62.8,
"confidence": 1,
"lift": null
}level is continuous, not one of the three names, because a real brand has no reason to sit on an anchor. Reporting “level 63, nearest pastel” tells you something true; reporting “pastel” alone quietly moves your colour before you have seen it.
lift is the saturationLift that makes the pipeline’s output pass through your colour, or null when the axis already reaches it. It is a field here rather than a fifth endpoint because every caller wants it at the level this response already reports — and because the rule for computing it belongs in one place, which is this side of the wire.
GET /api/v3/saturation
The same axis read forward: what saturation does the pipeline produce at this hue and lightness. Batched, because the caller always wants several at once — a chart’s series hues in one request rather than eight round trips.
| Parameter | Type | Required | Notes |
|---|---|---|---|
| at | tuple list | yes | Semicolon-separated hue,lightness,level-or-variant tuples. Lightness is 0–100; the third value is a level 0–100 or a variant name. At most 64 per request. |
GET /api/v1/saturation?at=217,49.4,70;120,54,vibrant
{
"build": "<engine build id>",
"format": 1,
"samples": [
{ "hue": 217, "lightness": 49.4, "level": 70, "saturation": 78.31 },
{ "hue": 120, "lightness": 54, "variant": "vibrant", "saturation": 89.32 }
]
}The tuples go in one parameter rather than a repeated at=, because a repeated key would silently keep only the last — a request for eight series hues answered for one, which is exactly the failure the strict parsing above exists to prevent.
The 64-point cap is a bound, not a courtesy. This is the calibration read directly, with no palette wrapped round it, so an unbounded batch is the cheapest possible way to enumerate the surface.
What the API will not do
Every field it returns is a range, a bound or a verdict. Nothing names a component, and nothing returns one colour where several would have passed. If an endpoint here ever answers “the button is this colour”, it has made a placement decision and it is on the wrong side of the line.
That is not modesty about taste — the engine is full of taste; the lightness ladder has five deliberate bends in it. The axis is generation aesthetics against application aesthetics. How a colour is made is bezold’s; where it is used is yours.
Errors
Every failure returns { error, detail? }, uncached. The error string is stable and machine-readable; detail is prose for a human and may change.
| Status | error | Meaning |
|---|---|---|
| 400 | missing_parameter | A required parameter was not supplied — hue or variant on palette, hex on variant, at on saturation. |
| 400 | invalid_parameter | A parameter is present but unusable. The detail names which and why. Also covers the two mutually-exclusive pairs: saturation with saturationLadder, and vibrancyShift with a manual sweep. |
| 400 | unknown_parameter | A query key this build does not understand. The detail lists every key it does. See above — this is refused rather than ignored on purpose. |
| 400 | invalid_url | The request URL could not be parsed, including a query string with bad percent-encoding. |
| 401 | unauthorized | Missing or wrong bearer token. Compared in constant time. |
| 404 | not_found | No such endpoint at this version. |
| 422 | generation_failed | The input is well-formed and the engine could not produce a palette from it. Deterministic, so it reproduces — send it in. |
| 422 | detection_failed | Same, for /variant. |
| 422 | measurement_failed | Same, for /saturation. |
| 503 | not_configured | The deployment has no BEZOLD_API_KEY set, so it refuses everything but health rather than serving openly. |
Versioning
The path carries the API version (v3). Every response body also carries its own format, which moves only when that response’s shape changes — so a consumer pinned to a format can reject a later payload rather than misread it. Engine value changes move the contract digest instead, which is what a cached contract is keyed on.
The formats are per response rather than per engine, because the shapes move independently: /contract is at format 3, while /palette, /variant and /saturation are still at 2, 1 and 2. One number across all four would have had three of them announce a shape change to describe a change in the fourth.
Older versions stay routed rather than being redirected. A consumer pinned to a format this build no longer serves at the current version keeps its own path until it migrates, and a version this build does not serve at all is a 404 — answering it with a different shape would be the silent wrong answer the parameter handling exists to prevent.