Skip to content

API · v4

The hosted API

Eleven routes. The split between the contract and the palette is the interesting part: the legal-step table doesn’t depend on the hue you ask about, so it’s fetched once per build, while a palette is twelve hexes on the wire.

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.

We’ll use this only to ask about your use. Ask, and we’ll delete it.

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.

RouteSinceKeyWhat it answers
healthv1noneIs it up, and which build is answering.
contractv1yesWhich inks are legal on which surface, for every step. Fetch once per build.
palettev1yesTwelve colors for one hue, with the fills read against your page.
variantv1yesWhich level on the saturation axis reproduces a color.
saturationv1yesThe saturation axis read forward, at a fixed lightness, in batches.
huesv3yesConvert hues between HSLuv and OKLCH, and turn them on the OKLCH wheel.
measurev3yesExact contrast for colors the contract doesn’t describe.
anchorv3yesFit typed brand colors onto the lightness ladder.
sweepv3yesFit the hue sweep that carries one family through several typed colors.
printv3yesMake a whole theme print-safe, with the contrast guard across every family.
capturev3yesWhich 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.

ParameterRequiredNotes
hueyesThe HSLuv hue angle, in degrees. Any finite number; it is read round the wheel.
variantyesOne of vibrant, pastel, neutral.
saturationnoOne level for the whole scale, from 0 to 100. Can’t be sent with saturationLadder.
saturationLaddernoA 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.
saturationLiftnoHow 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.
hueShiftnoSigned degrees reached at the ends of the scale, from −180 to 180. Zero at the origin step.
hueShiftModenolinear or each-way. Linear turns the light and dark ends in opposite directions; each-way turns both the same way.
hueShiftCurvednoSwaps the straight profile for a curve that is flat near the origin and steep at the ends. true or false, or 1 or 0.
hueShiftOriginnoThe step the sweep turns around. One of the step names.
lightnessnoA 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.
pagenoThe background the fills are read against, as #rrggbb. White when left out.
vibrancyShiftnotrue 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.
saturationPinsnostep: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.
saturationShiftnoThe 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.
saturationShiftModenolinear or each-way, as for the hue sweep. Echoed, but changes nothing without saturationShift.
saturationShiftCurvednoAs for the hue sweep. Echoed, but changes nothing without saturationShift.
saturationShiftOriginnoThe 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.
notationnoHow 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.

ParameterRequiredNotes
hexyes#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.

ParameterRequiredNotes
atyesSemicolon-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.
notationnoHow 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.

ParameterRequiredNotes
hsluvnoUp to 64 HSLuv hues. Send this or oklch.
oklchnoUp to 64 OKLCH hues. Send this or hsluv.
offsetsnoUp 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.

ParameterRequiredNotes
scalenoScale mode. The brand family as twelve hexes, lightest first.
neutralnoScale mode. A second family of twelve hexes, measured beside the first.
surfacesnoScale 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.
inksnoPair mode. Up to 32 ink hexes.
onnoPair mode. Up to 32 surface hexes.
barsnoLimits the answer to these published bar keys. Every bar the contract publishes when left out; an unknown one is refused.
notationnoHow 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.

ParameterRequiredNotes
hexyesComma-separated #rrggbb hexes, up to 8.
notationnoHow the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation.
GET /api/v4/anchor?hex=%23c2410c&notation=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.

ParameterRequiredNotes
pinsyesSeveral pins, up to eight, each #rrggbb or #rrggbb:step. A pin with no step claims one the way anchor does.
basenoA group’s base hue. Without it the first pin anchors the sweep at its own step; with it, every origin is searched.
notationnoHow 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.

ParameterRequiredNotes
familiesyesThe theme’s family ids, lowercase and comma-separated, up to eight.
printnoThe families to make print-safe. All of them when left out.
profilenoThe measured press: fogra51, fogra52, Fogra’s data for offset on coated and on uncoated paper. fogra51 when left out.
tolerancenoHow 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.
darkEndnoHow much of its level the last printable step keeps, as a fraction. 0.9 when left out.
belowFloornoThe curve past the darkest step the press can hold: straight or ease. ease when left out.
curvenostep gives each step its own level; smooth limits the change between neighbours to slew. smooth when left out.
slewnoThe largest level change between neighbouring steps under smooth. 40 when left out.
notationnoHow the answer writes its colors: hex, rgb, hsl, oklch. hex when left out. See notation.
<id>.hueyesAs on palette.
<id>.variantyesAs on palette.
<id>.saturationnoAs on palette.
<id>.saturationLaddernoAs on palette.
<id>.saturationLiftnoAs on palette.
<id>.hueShiftnoAs on palette.
<id>.hueShiftModenoAs on palette.
<id>.hueShiftCurvednoAs on palette.
<id>.hueShiftOriginnoAs on palette.
<id>.lightnessnoAs on palette.
<id>.pinsnoThe exact brand colors pinned on the family, as step:#rrggbb.
<id>.printSafenoThe 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.

ParameterRequiredNotes
hexesyesone to four #rrggbb hexes in role order: primary, secondary, tertiary, quaternary.
neutralnoOne typed neutral, pinned exactly instead of a neutral being derived.
tolerancenoHow 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.
schemenoSwitches to the second question. One of monochromatic, analogous, complementary, triadic, tetradic, square, split-complementary, custom.
schemeFillnoWith a scheme. What fills an empty slot: expand generates a family at the slot’s hue, neutral stands the neutral there.
neutralTintnoWith 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.
groupsnoWith 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>noWith a scheme. Picks another of that family’s candidate sweeps, by index. The typed colors stay exact whichever is chosen.
measurenoWith 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.
notationnoHow 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.

StatuserrorWhen
400invalid_urlThe URL doesn’t parse, or a query value isn’t valid percent-encoding.
400missing_parameterA required parameter is absent. The detail names it.
400invalid_parameterA parameter is out of range, malformed, or contradicts another. The detail says which and why.
400unknown_parameterA parameter the route doesn’t know. The detail lists every one it does.
401unauthorizedNo key, or a wrong one.
404not_foundNot an API path, a version this build doesn’t serve, or a route that doesn’t exist at that version.
422generation_failedpalette, before v4: the engine couldn’t produce a scale from a well-formed request.
422detection_failedvariant, before v4: the same, for the reading.
422measurement_failedsaturation, before v4: the same, for the points.
422measure_failedmeasure, before v4: the same.
422anchor_failedanchor: two colors claiming one step. Before v4, any other failure too.
422sweep_failedsweep, before v4: the same.
422hues_failedhues, before v4: the same.
422print_failedprint, before v4: the same.
422capture_failedcapture: 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.
500engine_faultA 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.
503not_configuredThe 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.