Designing a Color API: REST Endpoints for Color Data
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
A color API sounds niche until you think about how many tools consume color data: design tools that need conversion, accessibility checkers that verify contrast, color picker components that need named color search, and palette generators that produce harmonies. If you are building any of these, or providing color data to other developers, you need a well-designed REST API.
This guide covers the full design surface: schema decisions, endpoint structure, response formats, error handling, and rate limiting â from the perspective of someone who has built the endpoints that power the Color Converter and Contrast Checker at ColorFYI.
API Schema Design for Color Data
The Core Color Object
A color has multiple representations â hex, RGB, HSL, CMYK, OKLCH â and multiple metadata fields. The canonical response object should include all of them, letting clients use whichever format they need without making additional requests:
{
"hex": "#FF5733",
"rgb": {
"r": 255,
"g": 87,
"b": 51,
"css": "rgb(255, 87, 51)"
},
"hsl": {
"h": 11,
"s": 100,
"l": 60,
"css": "hsl(11, 100%, 60%)"
},
"hsv": {
"h": 11,
"s": 80,
"v": 100
},
"cmyk": {
"c": 0,
"m": 66,
"y": 80,
"k": 0
},
"oklch": {
"l": 0.63,
"c": 0.19,
"h": 27.5,
"css": "oklch(0.63 0.19 27.5)"
},
"luminance": 0.215,
"is_dark": false,
"name": "Coral Red",
"name_source": "color_name_database",
"name_distance": 0.04
}
Including is_dark (derived from luminance) saves clients from implementing the WCAG luminance calculation themselves. Including name and name_distance allows clients to show the nearest named color without a separate API call.
Naming Conventions
Use snake_case for JSON keys (consistent with most REST APIs). Nest related fields into objects (rgb, hsl, cmyk) rather than flattening them (rgb_r, rgb_g, rgb_b). Nested objects are more readable, easier to destructure in JavaScript, and map cleanly to typed interfaces:
interface ColorResponse {
hex: string;
rgb: { r: number; g: number; b: number; css: string };
hsl: { h: number; s: number; l: number; css: string };
oklch: { l: number; c: number; h: number; css: string };
luminance: number;
is_dark: boolean;
name: string | null;
name_source: string | null;
name_distance: number | null;
}
Hex Normalization
Accept hex colors in multiple formats â with or without #, 3-digit or 6-digit, uppercase or lowercase â and normalize them in the API layer before any processing:
# Python/Django example
import re
def normalize_hex(raw: str) -> str:
"""Normalize hex input to 6-digit uppercase without #."""
clean = raw.strip().lstrip('#').upper()
if not re.match(r'^[0-9A-F]{3}$|^[0-9A-F]{6}$', clean):
raise ValueError(f"Invalid hex color: {raw!r}")
# Expand 3-digit to 6-digit
if len(clean) == 3:
clean = ''.join(c * 2 for c in clean)
return clean
Return the normalized form in the response â this eliminates ambiguity and makes downstream caching more predictable.
Hex/RGB/HSL Conversion Endpoints
GET /api/color/{hex}
The primary endpoint returns the full color object for a given hex code. This is what the Color Converter uses to populate all format fields simultaneously:
GET /api/color/FF5733
GET /api/color/%23FF5733 (URL-encoded #)
GET /api/color/f57 (3-digit, lowercase)
All three should return the same response for #FF5733.
Response:
{
"hex": "#FF5733",
"rgb": { "r": 255, "g": 87, "b": 51, "css": "rgb(255, 87, 51)" },
"hsl": { "h": 11, "s": 100, "l": 60, "css": "hsl(11, 100%, 60%)" },
"cmyk": { "c": 0, "m": 66, "y": 80, "k": 0 },
"oklch": { "l": 0.63, "c": 0.19, "h": 27.5, "css": "oklch(0.63 0.19 27.5)" },
"luminance": 0.215,
"is_dark": false,
"name": "Coral Red",
"name_source": "css_extended",
"name_distance": 0.08
}
POST /api/convert
For converting arbitrary inputs that may not be hex â CSS color names, RGB triples, HSL values:
POST /api/convert
Content-Type: application/json
{
"input": "rgb(255, 87, 51)",
"from": "rgb"
}
Response: Same full color object.
Accept from values of: hex, rgb, hsl, cmyk, oklch, name. Validate the input format matches the declared from type and return a 422 with a clear error message on mismatch.
Django ViewSet Example
# apps/api/views.py
from django.http import JsonResponse
from django.views import View
from apps.colors.engine import ColorEngine
class ColorDetailView(View):
def get(self, request, hex_code: str):
try:
normalized = normalize_hex(hex_code)
except ValueError as e:
return JsonResponse(
{"error": "invalid_hex", "message": str(e)},
status=422
)
engine = ColorEngine(normalized)
data = {
"hex": f"#{normalized}",
"rgb": engine.to_rgb_dict(),
"hsl": engine.to_hsl_dict(),
"cmyk": engine.to_cmyk_dict(),
"oklch": engine.to_oklch_dict(),
"luminance": engine.relative_luminance(),
"is_dark": engine.is_dark(),
"name": engine.nearest_name(),
"name_source": engine.nearest_name_source(),
"name_distance": engine.nearest_name_distance(),
}
response = JsonResponse(data)
response["Cache-Control"] = "public, max-age=86400, stale-while-revalidate=604800"
return response
Cache color data aggressively â a given hex code always produces the same output, so the cache can be very long-lived.
Color Search and Autocomplete API
GET /api/search
Named color search powers autocomplete in color pickers and search fields. The query parameter is a partial color name:
GET /api/search?q=coral
GET /api/search?q=teal&limit=5
GET /api/search?q=%230d&source=css // Search by hex prefix
Response:
{
"results": [
{
"hex": "#FF7F50",
"name": "Coral",
"source": "css_named",
"score": 1.0
},
{
"hex": "#FF6B6B",
"name": "Light Coral",
"source": "css_extended",
"score": 0.85
},
{
"hex": "#E8735A",
"name": "Terra Cotta",
"source": "crayola",
"score": 0.62
}
],
"total": 3,
"query": "coral"
}
Search Implementation Considerations
For a small named color database (~2,000 entries), an in-memory prefix search with fuzzy matching is fast enough:
from difflib import SequenceMatcher
def search_colors(query: str, limit: int = 10) -> list[dict]:
q = query.lower().strip()
results = []
for color in NAMED_COLORS: # Pre-loaded list
name = color["name"].lower()
if q in name:
# Exact substring match: score based on position
pos = name.index(q)
score = 1.0 - (pos / len(name)) * 0.3
else:
# Fuzzy match
score = SequenceMatcher(None, q, name).ratio()
if score < 0.5:
continue
results.append({**color, "score": round(score, 3)})
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
For databases larger than 10,000 entries, use PostgreSQL's pg_trgm extension with trigram indexing for sub-millisecond fuzzy search:
-- Enable extension
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Add index
CREATE INDEX idx_color_name_trgm ON named_colors USING gin(name gin_trgm_ops);
-- Query
SELECT hex, name, source, similarity(name, 'coral') AS score
FROM named_colors
WHERE name % 'coral'
ORDER BY score DESC
LIMIT 10;
Autocomplete Optimization
For a live search-as-you-type autocomplete, optimize for low latency:
- Debounce on the client â 150ms minimum before firing the request.
- Set a minimum query length â reject queries shorter than 2 characters (return empty results immediately).
- Cache results by query â the same query from different users returns the same result. Use
Cache-Control: public, max-age=3600. - Return quickly on no results â respond immediately with
{"results": [], "total": 0}rather than running a full fuzzy scan.
Contrast Checking Endpoint
GET /api/contrast
The contrast API is the engine behind the Contrast Checker. It takes two hex codes and returns the WCAG contrast ratio with pass/fail status for each level:
GET /api/contrast?fg=FF5733&bg=FFFFFF
GET /api/contrast?fg=000000&bg=FFFFFF
Response:
{
"ratio": 3.02,
"ratio_formatted": "3.02:1",
"foreground": {
"hex": "#FF5733",
"luminance": 0.215
},
"background": {
"hex": "#FFFFFF",
"luminance": 1.0
},
"wcag": {
"aa_normal": false,
"aa_large": true,
"aaa_normal": false,
"aaa_large": false
},
"level": "AA Large",
"passes": true
}
The level field returns the highest WCAG level the pair achieves ("AAA", "AA", "AA Large", or "Fail"). The passes field is true if any WCAG level is met â useful for a simple green/red indicator.
Contrast Calculation
def relative_luminance(hex_code: str) -> float:
r, g, b = hex_to_rgb(hex_code)
def linearize(v: int) -> float:
s = v / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
def contrast_ratio(hex1: str, hex2: str) -> float:
L1 = relative_luminance(hex1)
L2 = relative_luminance(hex2)
lighter = max(L1, L2)
darker = min(L1, L2)
return round((lighter + 0.05) / (darker + 0.05), 2)
def wcag_result(ratio: float) -> dict:
return {
"aa_normal": ratio >= 4.5,
"aa_large": ratio >= 3.0,
"aaa_normal": ratio >= 7.0,
"aaa_large": ratio >= 4.5,
}
Suggesting Accessible Alternatives
A useful extension: given a foreground color that fails contrast against the background, suggest the minimum lightness adjustment that achieves WCAG AA:
def suggest_accessible_foreground(fg_hex: str, bg_hex: str, target_ratio: float = 4.5) -> str:
"""Return a lightness-adjusted version of fg_hex that passes target_ratio."""
hsl = rgb_to_hsl(*hex_to_rgb(fg_hex))
bg_lum = relative_luminance(bg_hex)
is_dark_bg = bg_lum < 0.179
# Try adjusting lightness in the direction that increases contrast
step = -2 if is_dark_bg else 2 # Darken on light bg, lighten on dark bg
current_l = hsl[2]
for _ in range(50):
current_l = max(0, min(100, current_l + step))
adjusted_hex = hsl_to_hex(hsl[0], hsl[1], current_l)
ratio = contrast_ratio(adjusted_hex, bg_hex)
if ratio >= target_ratio:
return adjusted_hex
# Return maximum contrast if target is unreachable
return '#000000' if not is_dark_bg else '#FFFFFF'
Rate Limiting for Color APIs
Why Color APIs Need Rate Limiting
Color APIs might seem low-risk, but they can be targets for: - Enumeration attacks: Iterating through all 16.7 million hex codes to harvest the full color name database. - Scraping: Using your conversion logic as a free computational resource. - Abuse: High-volume requests from a single client that degrade service for others.
Implementation Strategy
For a Django API, django-ratelimit provides decorator-based rate limiting with Redis backend:
# settings.py
RATELIMIT_USE_CACHE = 'default' # Uses Django's cache backend (Redis recommended)
# views.py
from django_ratelimit.decorators import ratelimit
@ratelimit(key='ip', rate='100/m', method='GET', block=True)
def color_detail_view(request, hex_code: str):
...
Rate Limit Response Format
When a rate limit is exceeded, return 429 Too Many Requests with a clear response body and a Retry-After header:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1708012345
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "You have exceeded the rate limit of 100 requests per minute.",
"retry_after": 60
}
Tiered Rate Limits
A sensible tiered structure:
| Tier | Limit | Key |
|---|---|---|
| Anonymous | 60 requests / minute | IP address |
| API key (free) | 300 requests / minute | API key |
| API key (paid) | 3000 requests / minute | API key |
API keys are passed as Authorization: Bearer {key} or X-API-Key: {key}. Use the API key as the rate limit key for authenticated requests:
@ratelimit(key='header:x-api-key', rate='300/m', method='GET')
@ratelimit(key='ip', rate='60/m', method='GET')
def color_detail_view(request, hex_code: str):
# django-ratelimit checks both; the looser limit wins for authenticated requests
...
Response Format Best Practices
Consistent Error Schema
Every error response â validation failure, rate limit, not found â should follow the same schema:
{
"error": "invalid_hex",
"message": "The value 'gggggg' is not a valid hex color. Expected a 3 or 6 digit hex string.",
"field": "hex_code",
"value": "gggggg"
}
| Field | Purpose |
|---|---|
error |
Machine-readable error code (snake_case) |
message |
Human-readable explanation |
field |
Which input field caused the error (for validation errors) |
value |
The invalid value (helps debugging) |
Pagination for Collection Endpoints
The search endpoint with limit and offset:
{
"results": [...],
"pagination": {
"total": 47,
"limit": 10,
"offset": 0,
"next": "/api/search?q=blue&limit=10&offset=10",
"prev": null
}
}
Always include total so clients can render pagination controls without knowing the full result set.
Versioning Strategy
Version your API in the URL path, not in a header. URL versioning is explicit, cacheable by CDNs, and does not require clients to set custom headers:
/api/v1/color/FF5733 # Version 1
/api/v2/color/FF5733 # Version 2 (when breaking changes are needed)
Maintain previous versions for at least one year after a major version release. Use deprecation headers on old versions:
Deprecation: Sun, 01 Jan 2026 00:00:00 GMT
Sunset: Sun, 01 Jan 2027 00:00:00 GMT
Link: </api/v2/color/FF5733>; rel="successor-version"
CORS Configuration
For a public API consumed by browser clients:
# settings.py (using django-cors-headers)
CORS_ALLOWED_ORIGINS = [
"https://yoursite.com",
"https://app.yoursite.com",
]
# Or for a fully public API:
CORS_ALLOW_ALL_ORIGINS = True
# Always restrict methods and headers
CORS_ALLOW_METHODS = ['GET', 'POST', 'OPTIONS']
CORS_ALLOW_HEADERS = ['Content-Type', 'Authorization', 'X-API-Key']
Caching Architecture
Color data is highly cacheable. The color object for #FF5733 will never change â the conversion math is deterministic. Structure your cache strategy accordingly:
| Endpoint | Cache-Control | CDN TTL |
|---|---|---|
/api/color/{hex} |
public, max-age=86400, stale-while-revalidate=604800 |
1 day |
/api/search?q={q} |
public, max-age=3600 |
1 hour |
/api/contrast?fg=X&bg=Y |
public, max-age=86400 |
1 day |
/api/palette/{hex} |
public, max-age=86400 |
1 day |
Add an ETag header based on the hex code for conditional requests:
response["ETag"] = f'"{hex_code}"'
response["Last-Modified"] = "Thu, 01 Jan 2026 00:00:00 GMT" # Static â content never changes
Clients that send If-None-Match: "FF5733" get a 304 Not Modified response with no body â saving bandwidth on repeat requests.
Key Takeaways
- The canonical color object should include all format representations (hex, RGB, HSL, CMYK, OKLCH) in a single response â never make clients make multiple requests to get different formats.
- Normalize hex input aggressively: strip
#, expand 3-digit, uppercase. Validate with a regex before any processing. - The contrast endpoint is the core of any accessibility tooling â return WCAG ratios, per-level pass/fail, and the highest passing level in a single response.
- Named color search works reliably with in-memory prefix search for small databases; use PostgreSQL
pg_trgmfor large ones. - Rate limit by IP for unauthenticated requests (60/min is reasonable); use API keys for higher tiers.
- Every error response must follow the same schema with
error(machine-readable) andmessage(human-readable) at minimum. - Color data is highly cacheable â 24-hour CDN cache is appropriate for conversion endpoints; use
ETagandstale-while-revalidatefor efficient cache invalidation. - Try the Color Converter and Contrast Checker to see these API endpoints in action.