API · v4
The hosted API
API keys aren’t issued at this point in time.
The API is built for decisionsby.design. If you’d like to use it for your own work, register your interest.
Why the contract is its own call
The obvious design is one response per palette, carrying the scale plus which steps are legal against which. Building it showed something better: the legal-step table doesn’t 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 color nobody has picked yet. That is perceptual evenness stated as an API, and it’s what makes an interactive tool affordable on top of a hosted engine. Changing hue is a small request against a table the client already holds.
The key
Send the key in the X-Bezold-Key header on every request except health. Keys are compared in constant time.
curl "https://bezold.dev/api/v4/palette?hue=217&variant=vibrant&saturationShift=-40&saturationShiftOrigin=300" \
-H "X-Bezold-Key: $BEZOLD_API_KEY"Authorization: Bearer is still accepted as a transitional fallback, and will be dropped. A request that carries Authorization is never cached at the edge, so every one of them runs the engine again. Only the Bearer form counts, and when both headers arrive, X-Bezold-Key wins.
Server-side only. The key must never reach a browser, and neither should the engine: putting generation in a client bundle would hand the calibration to anyone with devtools, which is the whole reason this is a service.
A deployment with no key configured answers 503 not_configured to everything but health. It refuses rather than serving openly, because an engine that goes public when a setting is forgotten is exactly the failure the split exists to prevent.
Routes
Every route is GET /api/{version}/{route}. The first five have answered since v1; the other six answer from the version shown.
| Route | Since | Key | What it answers |
|---|---|---|---|
| health | v1 | none | Is it up, and which build is answering. |
| contract | v1 | yes | Which inks are legal on which surface, for every step. Fetch once per build. |
| palette | v1 | yes | Twelve colors for one hue, with the fills read against your page. |
| variant | v1 | yes | Which level on the saturation axis reproduces a color. |
| saturation | v1 | yes | The saturation axis read forward, at a fixed lightness, in batches. |
| hues | v3 | yes | Convert hues between HSLuv and OKLCH, and turn them on the OKLCH wheel. |
| measure | v3 | yes | Exact contrast for colors the contract doesn’t describe. |
| anchor | v3 | yes | Fit typed brand colors onto the lightness ladder. |
| sweep | v3 | yes | Fit the hue sweep that carries one family through several typed colors. |
| v3 | yes | Make a whole theme print-safe, with the contrast guard across every family. | |
| capture | v3 | yes | Which color schemes typed brand colors allow, and the families one scheme makes. |
Caching
Every successful keyed answer is sent with Cache-Control: public, max-age=0, s-maxage=31536000 and Vary: X-Bezold-Key. The edge keeps an answer for as long as the deployment is live, and resets when a new one ships. Browsers and any cache on your side keep nothing without asking again. Vary keeps one cached copy per key, and the key is checked before the cache is consulted, so a cached answer only ever goes to a caller who has shown it.
Health and every error are sent no-store.
Unknown parameters are refused
Every route but health refuses a query key it doesn’t recognise, with 400 unknown_parameter, and the detail names every key that build does understand.
This is the most important rule 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, real hexes, and the wrong color. Nothing downstream can detect that. Refusing what isn’t understood turns it into an error at the boundary, and the echoed input covers the same ground from the other side.
A key repeated in the query keeps only its last value, so every list goes in one value, separated by commas, or by semicolons for saturation’s points.
Colors and their notation
Every route whose answer carries a color takes notation: hex, rgb, hsl or oklch, with hex when it’s left out. Every color bezold works out is written in it, to the fewest decimals that turn back into the same hex for every sRGB color, so the color you paint is the color bezold checked. The engine computes and checks in hex, so the notation never moves a figure or the contract’s contractDigest. When you send it, notation comes back in input.
A field that holds a color is named color, and fields named for their role, such as a palette’s steps and fills.page, keep their names. What you sent stays as you sent it: the echoed input, measure’s surface keys and inks, and a print finding’s message. The routes whose answers carry no color, contract, variant and hues, refuse the key like any other they don’t take.
Contrast figures
The APCA Lc and WCAG contrast ratio given for an ink on a surface, in the contract, measure, capture’s measured families and a palette’s fills, are written to two decimal places by cutting them, never by rounding them up: an Lc toward zero, since a pair read the other way round has a negative Lc, and a ratio toward one. A figure that misses a bar reads as missing it, however close it comes, and a figure that clears a bar still reads as clearing it, so you can hold the figures to your own bars as they come. The contract’s legal inks and a palette’s most vivid fills agree with the figures written beside them. The ratios in anchor’s compression are given unrounded.
GET /api/v4/health
The one route that needs no key. A liveness probe shouldn’t need a secret, and the answer gives nothing away: that it’s up, the path version, and the build. It answers even on a deployment with no key configured, it’s never cached, and it ignores any query, because uptime monitors often add a cache-busting parameter and refusing it would report a working service as down.
GET /api/v4/health
{ "ok": true, "version": "v4", "build": "<build>" }If you keep answers in a store of your own, key it on build and drop it when build changes. A recalibration changes the numbers without changing any URL, and this is the cheapest way to hear about it.
GET /api/v4/contract
Every surface bezold can be handed, every ink it will consider, the worst-case figures each ink reaches on each surface, and, for every published bar, the inks that clear it. It doesn’t depend on the hue, so it takes no parameters and refuses any it’s sent.
GET /api/v4/contract
{
"build": "<build>",
"runtime": "<runtime>",
"contractDigest": "931baae89311a6fe",
"format": 4,
"rules": {
"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", … ],
"surfaces": [ "white", "20", … ],
"bySurface": {
"20": { "legal": { … }, "figures": { … } },
"50": { "legal": { … }, "figures": { … } },
…
},
"standard": "bridge-pca",
"opacityBasis": "worst-case-across-hues",
"bars": {
"aa": {
"body": { … },
"nonBody": [ … ],
"heading": { … },
"boundary": { … }
},
"aaa": {
"body": { … },
"nonBody": [ … ],
"heading": { … },
"boundary": { … }
}
},
"sizeBands": {
"xs": { "minPx": 12, "maxPx": 13 },
"sm": { "minPx": 14, "maxPx": 15 },
"md": { "minPx": 16, "maxPx": 17 },
"lg": { "minPx": 18, "maxPx": 23 },
"xl": { "minPx": 24, "maxPx": null }
},
"lightness": { "20": 98, "50": 95.6, … },
"conformance": {
"20": { "polarity": "light", "solid": { … }, "byBar": { … }, "unmet": [] },
"50": { "polarity": "light", "solid": { … }, "byBar": { … }, "unmet": [] },
…
},
"constants": {
"variantSaturationLevels": { "neutral": 12, "pastel": 50, "vibrant": 88 },
"sweepCeiling": 30,
"maxHueShift": 180,
"chart": { "minAdjacentHueSeparation": 25, "minOppositionSeparation": 90 },
"captureTolerance": 11.25,
"schemeAngles": {
"monochromatic": [],
"analogous": [ … ],
"complementary": [ … ],
"triadic": [ … ],
"tetradic": [ … ],
"square": [ … ],
"split-complementary": [ … ],
"custom": []
},
"curatedHues": [ 27.5, 55.5, … ]
},
"strongest": {
"20": { "20": { … }, "50": { … }, … },
"50": { "20": { … }, "50": { … }, … },
…
}
}Surfaces and inks are keyed by step, with white and black at either end, and one entry answers for a grey and a saturated color alike, because each figure is the worst case over every hue and variant. bars holds the bars at each conformance level: body, nonBody and heading, each by size and weight (the heading with its advisory ceiling, may), and boundary. In every bar, must is the WCAG ratio, the requirement; shall is the APCA figure, which is advisory. conformance gives, for each surface and bar, the legal inks with the lowest opacity each can be used at, and unmet names the bars no ink clears on that surface, so an empty list is stated rather than left to be read. strongest holds the highest figures rather than the lowest, for the rules that cap contrast. constants carries the engine numbers a consumer would otherwise copy by hand, among them schemeAngles, each scheme’s slot angles as OKLCH offsets from the primary, and curatedHues, the curated faces’ OKLCH hue targets.
contractDigest fingerprints the body. It moves when the values move, so it’s what a cached copy should be checked against. rules is the original block of bars, and each surface’s legal the inks that clear them, kept so an older reader’s fields never moved. Neither has the heading, whose bars are in bars.
GET /api/v4/palette
A scale of twelve colors, the input as bezold read it, and the fill readings, which do depend on the palette.
| Parameter | Required | Notes |
|---|---|---|
| hue | yes | The HSLuv hue angle, in degrees. Any finite number; it is read round the wheel. |
| variant | yes | One of vibrant, pastel, neutral. |
| saturation | no | One level for the whole scale, from 0 to 100. Can’t be sent with saturationLadder. |
| saturationLadder | no | A level for each step: twelve 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 a ladder always fills every step, and the length check is what keeps both sides agreeing on the order. |
| saturationLift | no | How far past the axis to push, as a fraction of the chroma headroom left, from none of it to all of it. It’s for a brand color more saturated than the pipeline reaches on its own; variant reports the lift that makes the scale pass through a given color. |
| hueShift | no | Signed degrees reached at the ends of the scale, from −180 to 180. Zero at the origin step. |
| hueShiftMode | no | linear or each-way. Linear turns the light and dark ends in opposite directions; each-way turns both the same way. |
| hueShiftCurved | no | Swaps the straight profile for a curve that is flat near the origin and steep at the ends. true or false, or 1 or 0. |
| hueShiftOrigin | no | The step the sweep turns around. One of the step names. |
| lightness | no | A lightness for each step, positional like saturationLadder, each on HSLuv’s scale and strictly darker from step to step. This is how an anchored brand ladder is generated: anchor returns one ready to send. |
| page | no | The background the fills are read against, as #rrggbb. White when left out. |
| vibrancyShift | no | true asks bezold to choose the hue sweep for this hue; false, the default, doesn’t. Spelled like hueShiftCurved. When true it can’t be sent with any hueShift key: asking for both is a contradiction, and quietly preferring one would be a decision made on your behalf. |
| saturationPins | no | step:level pairs, one per pinned brand color, up to one per step. bezold solves a saturation ladder through them, which replaces saturation when the pins spread far enough to make one. Can’t be sent with saturationLadder. |
| saturationShift | no | The saturation version of the hue sweep, from −100 to 100. The level stays as sent at the origin step and moves toward the ends of the scale. It pivots on saturation, or on the variant’s own level. Can’t be sent with saturationLadder or saturationPins, which each give every step its level already. |
| saturationShiftMode | no | linear or each-way, as for the hue sweep. Echoed, but changes nothing without saturationShift. |
| saturationShiftCurved | no | As for the hue sweep. Echoed, but changes nothing without saturationShift. |
| saturationShiftOrigin | no | The step whose level stays as sent. Left out, it follows the hue sweep: your hueShiftOrigin, the derived origin under vibrancyShift, or step 500. Echoed, but changes nothing without saturationShift. |
| notation | no | How the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation. |
GET /api/v4/palette?hue=217&variant=vibrant&saturationShift=-40&saturationShiftOrigin=300
{
"build": "<build>",
"format": 2,
"input": {
"hue": 217,
"variant": "vibrant",
"saturationShift": -40,
"saturationShiftOrigin": 300
},
"derived": { "saturationLadder": { "20": 100, "50": 98, … } },
"scale": { "20": "#f1fbff", "50": "#e0f6ff", … },
"fills": {
"page": "#ffffff",
"usable": [ "500", "600", … ],
"mostVivid": "500",
"byStep": { "20": { … }, "50": { … }, … },
"mostVividByBar": { "4.5/60": "300", "4.5/75": "200", … }
}
}input echoes what you sent, field for field, so you can compare it. A parameter this build doesn’t understand is a 400, but the echo catches one dropped for any other reason. Treat a response with no echo as a failure, not as all defaults. What bezold chose for itself goes under derived instead, so the comparison never breaks: the ladder from saturationPins or saturationShift, the origin a shift pivoted on when you left it out, and the sweep from vibrancyShift.
fills.byStep reads every step as a filled control: the label ink that contrasts harder, that label’s figures, the fill’s separation from the page, and its chroma. fills.usable is every step whose label and separation both clear their bars. fills.mostVivid is the most chromatic of them, and fills.mostVividByBar is, for each non-body bar, the most chromatic step whose label clears it. Each is a maximum over a set that comes back in full, not a pick among equals, so you can choose differently.
GET /api/v4/variant
Measurement, not generation. It inverts the saturation axis through the compensation curves, so it’s engine work and can’t be done on your side without the calibration.
| Parameter | Required | Notes |
|---|---|---|
| hex | yes | #rrggbb. The only parameter this route takes. |
GET /api/v4/variant?hex=%233b82f6
{
"build": "<build>",
"format": 1,
"input": { "hex": "#3b82f6" },
"level": 99.99999999995453,
"variant": "vibrant",
"inputSaturation": 92.86631057420422,
"matchedSaturation": 80.2616824759786,
"confidence": 0.8432591433409798,
"lift": 0.6385867530444715
}level is continuous, not one of the three names, because a real brand has no reason to sit on an anchor. lift is the saturationLift that makes the scale pass through your color, or null when the axis already reaches it.
GET /api/v4/saturation
What saturation the pipeline produces at a hue and lightness, and the color it renders. It’s for colors that sit at one lightness and aren’t asking for a palette, such as a chart’s series, so it takes several points in one request.
| Parameter | Required | Notes |
|---|---|---|
| at | yes | Semicolon-separated hue,lightness,level-or-variant points. The third value is a level from 0 to 100 or a variant name. At most 64 points. |
| notation | no | How the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation. |
GET /api/v4/saturation?at=217,49.4,70;52,80,pastel
{
"build": "<build>",
"format": 3,
"samples": [
{
"hue": 217,
"lightness": 49.4,
"level": 70,
"saturation": 76.77877467577872,
"color": "#3c7e90"
},
{
"hue": 52,
"lightness": 80,
"variant": "pastel",
"saturation": 55.162709695992845,
"color": "#eabf8d"
}
]
}The points go in one parameter rather than a repeated at=, because a repeated key keeps only its last value: a request for several series answered for one. The cap is a bound, not a courtesy. This route reads the calibration directly, so an unbounded batch would be the cheapest way to map it.
GET /api/v4/hues
bezold generates on HSLuv, which keeps lightness exact, but equal steps on that wheel don’t look equal. Anything about how hues relate, such as a scheme or a chart’s series, is reasoned on OKLCH, where equal angles look equal, and converted back to generate.
| Parameter | Required | Notes |
|---|---|---|
| hsluv | no | Up to 64 HSLuv hues. Send this or oklch. |
| oklch | no | Up to 64 OKLCH hues. Send this or hsluv. |
| offsets | no | Up to 64 offsets in degrees, added on the OKLCH wheel to a single base hue and converted back. An offset of zero returns the base hue itself, read round the wheel. |
GET /api/v4/hues?hsluv=217&offsets=0,120,240
{
"build": "<build>",
"format": 1,
"input": { "hsluv": [ 217 ], "offsets": [ 0, 120, … ] },
"hues": [
{ "offset": 0, "hsluv": 217, "oklch": 215.308467 },
{ "offset": 120, "hsluv": 318.594219, "oklch": 335.308467 },
…
]
}GET /api/v4/measure
The contract is a floor over every hue. Some colors sit outside it: an anchored brand ladder, where every step’s lightness moved, or an ink that isn’t a step at all. measure answers for those exact colors, in the same shape as a contract surface, so one reader works on both. Send a scale, with surfaces if you like, or inks and on, not both.
| Parameter | Required | Notes |
|---|---|---|
| scale | no | Scale mode. The brand family as twelve hexes, lightest first. |
| neutral | no | Scale mode. A second family of twelve hexes, measured beside the first. |
| surfaces | no | Scale mode. Which surfaces to answer for: white, black, or step|brand, or step|neutral when you sent neutral. Every one of them when left out. |
| inks | no | Pair mode. Up to 32 ink hexes. |
| on | no | Pair mode. Up to 32 surface hexes. |
| bars | no | Limits the answer to these published bar keys. Every bar the contract publishes when left out; an unknown one is refused. |
| notation | no | How the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation. |
GET /api/v4/measure?inks=%23000000,%23767676&on=%23ffffff&bars=4.5/75
{
"build": "<build>",
"format": 2,
"input": {
"inks": [ "#000000", "#767676" ],
"on": [ "#ffffff" ],
"bars": [ "4.5/75" ]
},
"basis": "exact",
"bySurface": {
"#ffffff": {
"color": "#ffffff",
"polarity": "light",
"solid": { … },
"byBar": { … }
}
}
}Under byBar, each legal ink carries two opacity floors. must is the lowest whole percent that clears the WCAG bar, which is required; shall is the lowest that also clears the APCA bar, which is advisory, or null if full opacity doesn’t. basis says these are figures for these exact colors, not the contract’s worst case. Each surface’s color is written in the notation, but the surface keys, the keys of solid and each ink stay as you sent them, so you can match them to your request.
GET /api/v4/anchor
A typed brand color rarely sits exactly on a step. bezold moves the ladder to hold it: the color claims a step, the ladder stretches so that step lands on the color’s exact lightness, and the steps bent to clear a bar are solved again on the stretched ladder. Several colors give one shared ladder holding all of them.
| Parameter | Required | Notes |
|---|---|---|
| hex | yes | Comma-separated #rrggbb hexes, up to 8. |
| notation | no | How the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation. |
GET /api/v4/anchor?hex=%23c2410c¬ation=oklch
{
"build": "<build>",
"format": 2,
"input": { "hex": [ "#c2410c" ], "notation": "oklch" },
"claims": [
{
"color": "oklch(0.5534 0.17386 38.4)",
"step": 500,
"nearest": 500,
"lightness": 46.01318263686085,
"tie": false
}
],
"ladder": { "20": 98, "50": 95.4692015888127, … },
"compression": {
"warnings": [],
"tightest": { "lighter": 20, "darker": 50, "ratio": 1.0633137383214673 },
"maxShift": 2.6868173631391485
},
"bends": { "moved": [], "lost": [] }
}step is where a color pins and nearest is where it sits. They differ when pinning at the nearest step would break that step’s guarantee, and returning both lets you explain the move. ladder goes straight to the palette’s lightness. compression reports how close neighbouring steps came, and bends names every bend solved again and every one that couldn’t be held, never dropping one silently. Two colors claiming the same step can’t both be held, and that’s a 422.
GET /api/v4/sweep
A sweep turns a family’s hue gradually along the scale. When one family has to hold several typed colors, bezold fits the sweep that passes through all of them, and lists every shape it weighed.
| Parameter | Required | Notes |
|---|---|---|
| pins | yes | Several pins, up to eight, each #rrggbb or #rrggbb:step. A pin with no step claims one the way anchor does. |
| base | no | A group’s base hue. Without it the first pin anchors the sweep at its own step; with it, every origin is searched. |
| notation | no | How the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation. |
GET /api/v4/sweep?pins=%23c2410c,%23fdba74,%237c2d12
{
"build": "<build>",
"format": 2,
"input": { "pins": [ { … }, … ] },
"best": {
"mode": "linear",
"curved": false,
"origin": 500,
"hueShift": -77.22535597556538,
"baseHue": 19.379318693246546,
"clamped": false,
"gradual": true,
"maxResidual": { "hsluv": 13.821639961944555, "oklch": 22.561139051491125 }
},
"pins": [
{
"color": "#c2410c",
"step": 500,
"claimed": true,
"hue": 19.379318693246574,
"achieved": 19.379318693246546,
"residual": 0
},
…
],
"candidates": [
{
"mode": "linear",
"curved": false,
"origin": 500,
"hueShift": -77.22535597556538,
"baseHue": 19.379318693246546,
"clamped": false,
"gradual": true,
"maxResidual": { … }
},
…
]
}best is the fit, with ties broken toward the tamest reading. pins[].residual is how far the sweep lands from each color, in signed OKLCH degrees. candidates lists every shape at every candidate origin, each marked gradual or not. Whether a near fit counts, and whether to offer a shape that doubles back, are yours to decide.
GET /api/v4/print
Every bar a screen color clears, its print version must clear too, against white, black and every other stop in the theme. No single palette can see the others, so the request is the whole theme: each family’s palette keys go under its own id. Any family can stay on screen colors, and it still counts in the guard. How print-safe works is explained on the docs page.
| Parameter | Required | Notes |
|---|---|---|
| families | yes | The theme’s family ids, lowercase and comma-separated, up to eight. |
| no | The families to make print-safe. All of them when left out. | |
| profile | no | The measured press: fogra51, fogra52, Fogra’s data for offset on coated and on uncoated paper. fogra51 when left out. |
| tolerance | no | How far a printed color may differ from the color asked for, as ΔE2000: 1, 2, 3. Left out, it’s the lowest that every pinned color of a printed family fits, so a brand color the press can’t print at the lowest raises the whole theme; the highest when one fits none. Sent, it wins. |
| darkEnd | no | How much of its level the last printable step keeps, as a fraction. 0.9 when left out. |
| belowFloor | no | The curve past the darkest step the press can hold: straight or ease. ease when left out. |
| curve | no | step gives each step its own level; smooth limits the change between neighbours to slew. smooth when left out. |
| slew | no | The largest level change between neighbouring steps under smooth. 40 when left out. |
| notation | no | How the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation. |
| <id>.hue | yes | As on palette. |
| <id>.variant | yes | As on palette. |
| <id>.saturation | no | As on palette. |
| <id>.saturationLadder | no | As on palette. |
| <id>.saturationLift | no | As on palette. |
| <id>.hueShift | no | As on palette. |
| <id>.hueShiftMode | no | As on palette. |
| <id>.hueShiftCurved | no | As on palette. |
| <id>.hueShiftOrigin | no | As on palette. |
| <id>.lightness | no | As on palette. |
| <id>.pins | no | The exact brand colors pinned on the family, as step:#rrggbb. |
| <id>.printSafe | no | The pins to make print-safe, as hexes from <id>.pins. Each becomes the printable color nearest its typed hex, and the rest keep their typed hex, with a warning if the press can’t print them. |
GET /api/v4/print?families=primary,neutral&primary.hue=24.6&primary.variant=vibrant&primary.pins=600:%23c2410c&neutral.hue=24.6&neutral.variant=neutral&print=primary
{
"build": "<build>",
"format": 2,
"input": { "families": [ { … }, … ], "profile": "fogra51" },
"profile": {
"id": "fogra51",
"label": "Coated (FOGRA51)",
"tolerance": 3,
"synthetic": false
},
"toleranceRaised": true,
"families": [
{
"id": "primary",
"print": true,
"screen": { … },
"scale": { … },
"flags": { … }
},
…
],
"stops": [
{
"family": "primary",
"step": 700,
"color": "#75320f",
"level": 86,
"status": "moved",
"flags": [],
"trim": null,
"liftOff": false,
"partnered": [],
"blocked": []
},
…
],
"findings": [
{
"family": "primary",
"step": 900,
"color": "#261815",
"kind": "dark-end",
"message": "primary 900 is past the range any press holds"
},
…
],
"warnings": []
}stops lists only the stops print-safe changed or couldn’t change, to keep the body small. findings explains each step that couldn’t be printed as asked. warnings lists the pinned brand colors the press can’t print as typed, and whether each was made print-safe. A finding’s message quotes a pinned color as you typed it. profile.tolerance is the tolerance the theme was printed at, and toleranceRaised is true where a pinned color raised it from the lowest.
GET /api/v4/capture
One route, two questions. Without scheme, it says which schemes can hold the colors you typed, each with its fit. With one, it returns every family that scheme makes: its anchor, its candidate sweeps, and the exact palette input it generates at. Every reading comes back, not only the best, so the choice stays with you.
| Parameter | Required | Notes |
|---|---|---|
| hexes | yes | one to four #rrggbb hexes in role order: primary, secondary, tertiary, quaternary. |
| neutral | no | One typed neutral, pinned exactly instead of a neutral being derived. |
| tolerance | no | How close a color must sit to its slot to count as a fit, in OKLCH degrees, from 0 to 22.5. 11.25 when left out, as constants.captureTolerance in the contract. |
| scheme | no | Switches to the second question. One of monochromatic, analogous, complementary, triadic, tetradic, square, split-complementary, custom. |
| schemeFill | no | With a scheme. What fills an empty slot: expand generates a family at the slot’s hue, neutral stands the neutral there. |
| neutralTint | no | With a scheme. Where a derived neutral takes its hue: near (the default) on the primary’s, far on the scheme’s last angle from it in constants.schemeAngles. A scheme with no angles keeps the neutral near. Under schemeFill=neutral the neutral stands at the open slot, and far gives way to the primary. A typed neutral keeps its own hue. |
| groups | no | With a scheme. Which typed colors share a palette, by their place in hexes counting from zero: + joins colors on one palette and , separates palettes, as in 0+1,2. Name every color once, and leave out the greys that ride along as the neutral. A grey typed on a step an earlier grey or the neutral key already holds is a palette of its own, so name it. Under monochromatic the key holds no step. Without groups, the scheme builds its default grouping. A grouping the scheme can’t seat exactly is refused. |
| sweep.<role> | no | With a scheme. Picks another of that family’s candidate sweeps, by index. The typed colors stay exact whichever is chosen. |
| measure | no | With a scheme. Adds the generated scales and their contrast figures, in measure’s shape. The heaviest request the API answers, so leave it off unless you need the figures. |
| notation | no | How the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation. |
GET /api/v4/capture?hexes=%23c2410c,%23155e75
{
"build": "<build>",
"format": 2,
"input": { "hexes": [ "#c2410c", "#155e75" ] },
"options": [
{
"scheme": "monochromatic",
"grouping": "0+1",
"quality": "out",
"maxMiss": 150.67468909050586,
"families": 1,
"assignment": [],
"offered": false,
"close": false,
"mono": { … }
},
{
"scheme": "analogous",
"grouping": "0,1",
"quality": "out",
"maxMiss": 180,
"families": 3,
"assignment": [],
"offered": false,
"close": false
},
…
],
"best": "complementary"
}quality is exact within the tolerance, close within the looser band, and out past it. maxMiss is the worst miss in OKLCH degrees. best is a suggestion. offered is true for exact fits, or for every scheme when only one color was typed. grouping says which typed colors share a palette, written the way groups takes it. A scheme can appear more than once: the options open with each scheme’s default reading, one per scheme in scheme order, and every other exact grouping follows. A scheme’s default is its coarsest grouping whose shared palettes each sweep 30° or less, or its finest when none does, and its other groupings follow coarsest first. best takes only a default within that sweep, and custom’s when no other is. Custom never puts every color on one palette: that reading is monochromatic. A derived neutral takes the primary’s sweep when it sits on the primary’s hue, and always when several typed colors share the primary’s palette.
Each answer above is the engine’s own, computed when this page was built. Long lists and deep objects are cut for reading, and every cut is marked with an ellipsis. build and runtime stand in for the deployment’s own values.
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 color where several would have passed. If a route ever answers “the button is this color”, it has made a placement decision and is on the wrong side of the line.
That isn’t modesty about taste. The engine is full of taste: the lightness ladder bends on purpose. The line is between how a color is made and where it’s used. How a color is made is bezold’s; where it’s 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. Most codes are the request’s to fix. These are ours: not_configured, a deployment with no key, and engine_fault, which means the engine broke, never that the request was wrong. We log every engine_fault.
| Status | error | When |
|---|---|---|
| 400 | invalid_url | The URL doesn’t parse, or a query value isn’t valid percent-encoding. |
| 400 | missing_parameter | A required parameter is absent. The detail names it. |
| 400 | invalid_parameter | A parameter is out of range, malformed, or contradicts another. The detail says which and why. |
| 400 | unknown_parameter | A parameter the route doesn’t know. The detail lists every one it does. |
| 401 | unauthorized | No key, or a wrong one. |
| 404 | not_found | Not an API path, a version this build doesn’t serve, or a route that doesn’t exist at that version. |
| 422 | generation_failed | palette, before v4: the engine couldn’t produce a scale from a well-formed request. |
| 422 | detection_failed | variant, before v4: the same, for the reading. |
| 422 | measurement_failed | saturation, before v4: the same, for the points. |
| 422 | measure_failed | measure, before v4: the same. |
| 422 | anchor_failed | anchor: two colors claiming one step. Before v4, any other failure too. |
| 422 | sweep_failed | sweep, before v4: the same. |
| 422 | hues_failed | hues, before v4: the same. |
| 422 | print_failed | print, before v4: the same. |
| 422 | capture_failed | capture: a scheme that can’t hold the colors or seat the grouping, or a sweep pick it doesn’t offer. Before v4, any other failure too. |
| 500 | engine_fault | A fault in the engine, not in your request, and logged on our side. Every route answers one from v4, and every version answers one for a failure outside a route. |
| 503 | not_configured | The deployment has no key set, so it refuses everything but health. |
Versions
The path carries the API version, and the newest is v4. A new response shape gets a new path, and the old path keeps answering the old shape, so a consumer pinned to one isn’t broken by the next. This build still answers v1, v2 and v3, which keep answering their routes in the shapes they published. v3 is v4 without notation, capture’s neutralTint or print’s tolerance and toleranceRaised, printing on a stand-in table, synthetic-coated, rather than a measured press, with each color field named hex, with its contrast figures rounded to nearest rather than cut, with a derived neutral keeping a sweep of its own where several typed colors share the primary’s palette, and with its contract as it was published: one heading bar, a heading in rules and legal, no unmet, and no schemeAngles or curatedHues in its constants. A version it doesn’t serve is a 404, never a redirect: answering with a different shape would be the silent wrong answer the parameter rules exist to prevent.
Every body but health’s also carries its own format, which moves only when that response’s shape changes. The formats are per route because the shapes move apart: at v4, contract is at 4, palette is at 2, variant is at 1, saturation is at 3, hues is at 1, measure is at 2, anchor is at 2, sweep is at 2, print is at 2 and capture is at 2. A change in the engine’s values, rather than its shapes, moves the contract’s contractDigest instead.