A Sky API Built for One Sentence
A canvas can draw the sky, but it cannot tell everyone what is there. The sentence I added for accessibility became a small public API—and forced useful decisions about privacy, caching, validation, CORS, and what an unsupported endpoint should promise.
The canvas needed one sentence
Here for the code? Jump to the request examples or the compact API reference.
The star field behind this site is not a stock animation. It calculates star and constellation positions from a catalogue, an approximate visitor location and an observation time, rather than scattering points at random. If someone chooses to share a more exact location, the drawing moves there.
The home page's tagline, “Some dots are worth connecting,” and the star field behind it arrived as one idea, not a slogan and an illustration of it: real stars over an approximate visitor location, with lines through the ones that make constellations. I have lived under enough different skies—Pittsburgh and Kuala Lumpur among them—to want the one on this page to be the visitor's, not a screensaver's.
That is useful if you can see the canvas. A canvas does not give a screen reader a useful description of the pixels it draws, and this one is deliberately marked as decorative. The page therefore needed a complementary sentence about the current sky:
Overhead now: Cygnus, Lacerta, Pegasus, Cepheus, Vulpecula, Andromeda, and 24 more.
Producing that sentence meant answering a small, reusable question: which constellation figures are above a given point on Earth at a given instant, ordered from highest to lowest?
That question became GET /api/sky.
Accessibility created the endpoint, but publishing an endpoint asks more than whether its JSON is correct. Its URL can enter logs. Its response can enter a shared cache. Browser code has an origin. Callers make mistakes. Limits need to fail honestly. Once the sentence became an API, those became part of the design too.
Compute one sky in two places
The browser draws individual stars, constellation lines and motion. The server produces a smaller textual view from the same catalogue and coordinate model. It would have been easy to implement the astronomy twice: one version shaped for canvas rendering and another shaped for an HTTP handler.
Easy isn't the same as stable. Two implementations can accumulate different corrections, boundary decisions and bugs. The visual sky and its accessible description can then disagree even when given the same inputs.
Instead, both runtimes import the same astronomy module. Given an instant and longitude, it calculates local sidereal time. Given sidereal time, latitude, right ascension and declination, it calculates altitude and azimuth. The canvas projects those results into pixels; the API reduces them into named constellation figures.
The catalogue is shared too. Star positions and constellation lines are generated reproducibly from credited source data, committed with the application and served as content-hashed assets. The server reads the same committed constellation figures at startup. There is no upstream astronomy service to disagree with, slow down or become unavailable at request time.
This is less code than maintaining two skies, but the more important property is traceability: equal observer coordinates and observation times produce consistent underlying calculations in both runtimes.
Shared maths does not synchronize the presentations. Scrolling deliberately advances the decorative canvas by up to four hours beyond now. The sentence is a snapshot from its API response, requested on page load and when the visitor chooses a more exact location; it does not refresh on every scroll or as the page sits open. The response's meta.at identifies that snapshot's observation time. Sharing the model prevents divergent implementations, not differences caused by choosing different inputs.
Give coarse and precise locations different transports
A city-level position is useful in a URL. It can be copied, inspected, cached and repeated:
curl "https://www.themadhatters.com/api/sky?lat=29.76&lon=-95.37&minAltitude=20"
That request asks for the sky over Houston now. The response permits storage by browsers and shared caches and sets a freshness lifetime of 300 seconds:
Cache-Control: public, max-age=300
A browser-provided position deserves a different path. On an HTTPS page, the site asks for geolocation only after someone chooses use my exact location. It keeps the full position in the browser for drawing, rounds latitude and longitude to two decimal places for the description, and sends those values in a JSON body:
const POSITION_TIMEOUT_MS = 8_000;
const POSITION_MAX_AGE_MS = 10 * 60 * 1_000;
const REQUEST_TIMEOUT_MS = 8_000;
const TRANSMITTED_COORDINATE_DECIMALS = 2;
const { coords } = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: false,
timeout: POSITION_TIMEOUT_MS,
maximumAge: POSITION_MAX_AGE_MS,
});
});
const input = {
lat: Number(coords.latitude.toFixed(TRANSMITTED_COORDINATE_DECIMALS)),
lon: Number(coords.longitude.toFixed(TRANSMITTED_COORDINATE_DECIMALS)),
minAltitude: 20,
};
const response = await fetch("https://www.themadhatters.com/api/sky", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
credentials: "omit",
cache: "no-store",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Sky API returned ${response.status}`);
}
const [rawMediaType] = (response.headers.get("Content-Type") ?? "").split(";", 1);
const mediaType = rawMediaType.trim().toLowerCase();
if (mediaType !== "application/json") {
throw new Error("Sky API did not return JSON");
}
const sky = await response.json();
Note: This example targets ES2022+ JavaScript and a browser supporting AbortSignal.timeout() (Baseline 2024). It requires HTTPS and geolocation permission. Run it inside an async function or JavaScript module, following an explicit user action.
Handle a rejected permission, request failure or timeout in the interface. Geolocation acquisition has an eight-second timeout; waiting for permission can take longer. The separate eight-second request timeout also covers reading the response body. AbortSignal.timeout() measures active time, so it can pause while the document is suspended rather than enforce a strict wall-clock deadline. No automatic retry repeats a declined choice.
Putting the coordinates in the body keeps them out of copied URLs, intermediary request lines and ordinary URL logs. The server also rejects query parameters on this POST, but rejection cannot erase a URL that a caller has already sent or a proxy has already logged. The response reinforces the storage policy:
Cache-Control: private, no-store
TLS protects the request in transit to our server; the server still receives the rounded position long enough to answer. no-store instructs HTTP caches, not every logging or analytics system. Our access logs contain request metadata, not bodies, and application diagnostics do not record the coordinates from the body. A client integrating the API needs the same care in its own handling.
This POST performs a read-only calculation and creates no stored object. That is an application contract: HTTP does not classify POST as a safe or inherently idempotent method, and general clients cannot assume those properties from its name. POST responses can be cacheable under specific conditions; this endpoint expressly forbids storage. HTTP method semantics, POST.
Let the contract reject guesses
Both methods accept the same four values:
latis required and ranges from -90 to 90 degrees, north positive.lonis required and ranges from -180 to 180 degrees, east positive.atis an optional date-time with an offset. It defaults to now.minAltitudeis optional, ranges from -90 to 90 degrees and defaults to zero.
Unknown members are rejected rather than silently stripped. A caller who writes minimumAltitude should receive a 400 Bad Request, not a plausible answer that ignored the intended filter. JSON coordinates must be numbers: null, false and "29.76" are errors, not alternative spellings of a location. Query parameters arrive as text and use explicit decimal parsing; empty values, hexadecimal values and repeated parameters are rejected. Defaults apply to omitted fields, not invalid ones.
The POST body is limited to 1 KiB before JSON parsing; the examples need far less. Dates need a real calendar date and an explicit offset, so an impossible date cannot quietly roll into the following month.
A successful response has one envelope. This is the complete result for Chicago with a 75-degree filter:
curl "https://www.themadhatters.com/api/sky?lat=41.88&lon=-87.63&at=2026-09-06T04%3A00%3A00Z&minAltitude=75"
{
"data": {
"visible": [
{ "id": "Cyg", "name": "Cygnus", "altitude": 83.73 },
{ "id": "Lac", "name": "Lacerta", "altitude": 77.84 },
{ "id": "Peg", "name": "Pegasus", "altitude": 75.38 }
]
},
"meta": {
"observer": { "latitude": 41.88, "longitude": -87.63 },
"at": "2026-09-06T04:00:00.000Z",
"siderealTime": 317.6357,
"total": 3,
"minAltitude": 75
}
}
visible is sorted by descending altitude. A figure's altitude is the highest altitude among its line vertices, rounded to two decimal places; the filter compares that reported value with minAltitude. “Visible” means geometrically at or above that threshold. It does not account for daylight, clouds, terrain, light pollution or whether every star in the figure can be seen. This is not a navigation or safety instrument.
The astronomy is a display approximation. I rotate fixed catalogue coordinates using mean sidereal time, with UTC as the time input. I do not update those coordinates for precession or stellar proper motion, or apply nutation or atmospheric refraction. Accepting a historical or future at is an input-format capability, not a validated accuracy range. The distinction between mean and apparent sidereal time is one example of what a more precise model must address. USNO's sidereal-time explanation.
Ask the same instant from two hemispheres
A fixed instant makes an example repeatable. At 2026-09-06T04:00:00Z, with a 20-degree minimum altitude, São Paulo and Frankfurt produce conspicuously different answers:
curl "https://www.themadhatters.com/api/sky?lat=-23.55&lon=-46.63&at=2026-09-06T04%3A00%3A00Z&minAltitude=20"
curl "https://www.themadhatters.com/api/sky?lat=50.11&lon=8.68&at=2026-09-06T04%3A00%3A00Z&minAltitude=20"
The highest figures over São Paulo begin with Sculptor, Aquarius and Cetus. Over Frankfurt they begin with Perseus, Camelopardalis and Auriga. These are city-centre coordinates, intentionally coarse enough to be ordinary GET parameters.
My own former addresses make up the rest of the test set: Pittsburgh, Severna Park, Kuala Lumpur, Singapore, Chicago, San Diego and Houston. I didn't choose them for coverage, but they run from Chicago's 42° north to Singapore's one degree off the equator, and for every one of them I have stood outside and looked up. Familiar skies gave me useful sanity checks.
An altitude filter can make the answer small enough for a compact display. This request asks only for figures reaching at least 60 degrees over Singapore at the same instant:
curl "https://www.themadhatters.com/api/sky?lat=1.35&lon=103.82&at=2026-09-06T04%3A00%3A00Z&minAltitude=60"
It returns seven: Sextans, Hydra, Leo, Cancer, Crater, Monoceros and Virgo. The instant happens to be daytime in Singapore, which is another reminder that altitude and naked-eye visibility are different claims.
Freshness comes before revalidation
Good API caching starts with freshness. Cache-Control: public, max-age=300 permits a cache to reuse the coarse GET response while its age is below 300 seconds. For a response whose at defaults to now, ordinary fresh reuse trades a little recency for fewer requests. A cache hit saves more work than revalidation because it need not contact the server. Freshness.
Freshness is not a storage deadline. A cache may retain a stale response for later validation, or evict a response before it becomes stale. Five minutes is not an absolute ceiling on the age of a returned calculation either: our policy does not prohibit stale reuse when a cache is disconnected or the client explicitly permits it, for example with max-stale. Read meta.at when the age of the calculation matters. Adding must-revalidate would prohibit that stale fallback, trading availability for stricter reuse; I have not chosen that policy here. Serving stale responses.
When a stored response becomes stale, an entity tag can validate it. The API supplies an ETag for the selected JSON representation. A caller returns that opaque value, including its quotes, in If-None-Match. With curl 7.68.0 or later, a complete exchange is:
sky_url="https://www.themadhatters.com/api/sky?lat=41.88&lon=-87.63&at=2026-09-06T04%3A00%3A00Z&minAltitude=75"
# Save the successful 200 response and its entity tag.
curl --silent --show-error --fail --max-time 10 \
--etag-save sky.etag --output sky.json \
--write-out '%{http_code}\n' "$sky_url"
# Send If-None-Match using the saved tag. Keep sky.json intact.
curl --silent --show-error --fail --max-time 10 \
--etag-compare sky.etag --dump-header - --output /dev/null \
"$sky_url"
The first command prints 200. If the representation remains unchanged, the second receives 304 Not Modified with no JSON body; the saved sky.json remains the usable response. If the representation changes, the second receives 200 instead, and this demonstration discards the replacement body. Run the first command again to save a new body and tag together. These curl flags handle validators; they do not turn curl into a complete HTTP cache. curl entity-tag options.
Browsers and general-purpose HTTP caches normally own this exchange. The 304 carries the current entity tag and cache policy so the cache can update its metadata. Here, revalidation still calculates and hashes the sky; it saves response bytes, while a fresh cache hit can also save computation. A 304 must never disguise an invalid request or an exhausted quota: a 400 or 429 remains an error even when the request carries a conditional field. HEAD follows the same conditional rules as GET and never returns response content. Conditional request evaluation, 304 responses.
The fixed at matters to this demonstration. When time defaults to now, calculating a later instant changes meta.at and therefore the selected representation. Its five-minute freshness still prevents needless calls, but an older entity tag does not validate a different representation.
The condition matters too. If-None-Match uses weak comparison: a matching tag from a list, including its W/ form, validates a GET or HEAD; * matches an existing representation. If-Match uses strong comparison and requires a current matching tag, or * when a representation exists. A failed If-Match produces 412 Precondition Failed and is evaluated before If-None-Match. Although lost-update protection is its familiar use, If-Match also applies to reads. The endpoint honors both conditions. Entity-tag preconditions.
Compression makes that distinction concrete. nginx can negotiate a zstd-compressed response using Accept-Encoding. The decoded JSON is unchanged, but the encoded bytes are a different representation. Vary: Accept-Encoding tells caches to account for that selection. Our compressed response carries a weak entity tag: the origin's tag still identifies equivalent content, but does not promise byte-for-byte equality across encodings. Entity tags and content coding.
This fixed-time request includes figures below the horizon too, giving compression a larger response to work with:
curl --silent --show-error --fail --max-time 10 \
--header 'Accept-Encoding: zstd' --dump-header - --output /dev/null \
"https://www.themadhatters.com/api/sky?lat=41.88&lon=-87.63&at=2026-09-06T04%3A00%3A00Z&minAltitude=-90"
When zstd is selected, look for Content-Encoding: zstd, Vary: Accept-Encoding and an ETag beginning with W/. The command discards the encoded body, so it does not require a zstd decoder; clients that read the body must negotiate an encoding they can decode. Keep the same URL and encoding preference when repeating the request. Return the complete tag in If-None-Match to revalidate it. That weak tag cannot satisfy If-Match, even when its opaque value matches: never strip W/ to pretend a weak validator is strong.
Revalidation is more than a status code. A 304 must carry the Vary and validator metadata that the corresponding 200 would send. Check those fields through the public proxy too: a passing application test cannot establish what survives compression at the edge. 304 response requirements.
The API does not send Last-Modified. That field is useful when a server can reliably track when the selected representation changed—an article's editorial revision or a database record's update time, for example. It is not limited to files. A computed response could use it too, provided its timestamp accounts for every dependency that can change the result. I do not maintain that history for the catalogue and calculation, so the current representation's entity tag is the validator. If-Modified-Since and If-Unmodified-Since are ignored here because there is no modification date to compare. Modification dates, date preconditions.
Three caching directives are easy to confuse. private permits storage in a private cache, such as a browser's, while forbidding shared-cache storage. no-cache permits storage but requires successful validation before reuse. no-store forbids HTTP caches from storing the request or response. The POST uses private, no-store; no-store supplies the decisive restriction. Cache-Control response directives.
That POST processes the enclosed coordinates; it does not select or modify an existing representation of /api/sky. Selection preconditions therefore do not apply to the computation. It returns no entity tag and never returns 304. Conditional handling belongs to the resource's semantics, not to a blanket “hash every response” hook. When to evaluate preconditions.
CORS is permission to read, not identity
The endpoint accepts cross-origin browser calls without credentials. Its CORS policy allows any origin on exactly /api/sky, permits GET, HEAD and POST, and allows Content-Type, If-Match and If-None-Match request fields. It exposes the entity tag and rate-limit guidance to browser code. No origin-specific response is selected, so this policy does not require Vary: Origin.
That openness is deliberate. The representation is already public, there is no cookie or account authority to protect, and asking people to register an origin would conflict with inviting small experiments.
CORS does not authenticate a site. A server-side program can send an Origin header or omit it, and it is not bound by a browser's response-sharing rules. An origin allowlist would let me curate which browser pages can read the response; it would not identify every caller or stop abuse. Validation, bounded work and rate limiting remain the availability controls. Use credentials: "omit"; mode: "no-cors" would give application code an unreadable opaque response. CORS protocol.
The JSON POST needs a CORS preflight unless the browser already holds a usable permission. The browser's dedicated preflight cache can retain our narrow permission for up to Access-Control-Max-Age: 86400 seconds, subject to its own cap. This is separate from the HTTP response cache: OPTIONS responses are not HTTP-cacheable. Our OPTIONS response uses Cache-Control: private, no-store, while Access-Control-Max-Age governs preflight reuse. An ordinary OPTIONS request also returns 204 No Content and Allow: GET, HEAD, POST, OPTIONS. Preflight cache, OPTIONS semantics.
Rate limits should fail as rate limits
No key is required. Casual use is welcome under a limit of 60 requests per minute per apparent client IP address. Shared networks can share an address and therefore a budget. Requests to this path share one application budget: GET, HEAD, POST, OPTIONS, invalid input and application-handled unsupported methods all count. The edge independently enforces a 60-per-minute rate with a burst allowance of 20. These are two enforcement layers, not two quotas to spend, and operators can temporarily lower the application ceiling.
When a limit is exceeded, the response is 429 Too Many Requests with Cache-Control: private, no-store. Retry-After gives a delay in seconds here; the edge uses a conservative 60-second delay. A general HTTP client should also understand its HTTP-date form. The application's legacy X-RateLimit-* fields are implementation-specific hints, not standardized HTTP fields. Their units are listed in the reference below. 429, Retry-After.
Those hints are not an SLA. A client should still bound its retries, add jitter when many requests could resume together, stop retrying permanent 400 errors and tolerate an unavailable service. If a preflight itself is rejected, browser fetch reports a network failure and does not expose its 429 or Retry-After to the page. A cache can also satisfy a GET without consuming an origin request at all; rate-limit headers on a cached response are consequently not a live view of the server's budget.
Public, useful and unsupported
The Sky API is public but unsupported. That means:
- Reasonable no-key experimentation is invited.
- There is no uptime, response-time, support or permanence guarantee.
- We intend to make compatible, additive changes where practical, but reserve the ability to change or withdraw the endpoint.
- Callers must not evade limits, interfere with availability or present the result as suitable for navigation or safety-critical use.
- Attribution to The Mad Hatters for API use is appreciated but not required, and must not imply endorsement. This does not replace the separate terms for redistributing upstream catalogue data.
The site has no accounts, forms, database, email provider or runtime outbound network access. If you have a real use case, need more volume or want to tell us what you built, reach out at info@themadhatters.com with your needs.
Compact API reference
The base endpoint is https://www.themadhatters.com/api/sky. No key is required; cross-origin browser calls must omit credentials. Responses containing data or application errors use application/json.
GET /api/sky?lat&lon[&at][&minAltitude]accepts query parameters and no request body. Successful results useCache-Control: public, max-age=300and an entity tag.HEADaccepts the same query and conditions as GET, with no response content.POST /api/skyaccepts an uncompressed UTF-8 JSON object withContent-Type: application/json, no query parameters and at most 1,024 body bytes. An explicitcharset=utf-8is accepted; other declared charsets are not. OmitContent-Encoding; explicitidentityis also accepted, but compressed request bodies are not. Responses useCache-Control: private, no-store, with no entity tag.OPTIONS /api/skyreturns204withAllow: GET, HEAD, POST, OPTIONS, including when used as a valid CORS preflight. Other recognized methods return405with the sameAllowfield.
The input contract is the same for both calculation transports:
lat: required number, -90 through 90 decimal degrees; north is positive.lon: required number, -180 through 180 decimal degrees; east is positive.at: optional date-time string, defaulting to request-time now. UseYYYY-MM-DDTHH:mm:ss[.fraction]Zor replaceZwith an explicit±HH:MMoffset. Lowercasetandzare accepted. Invalid calendar dates and leap seconds are rejected. The response normalizes the instant to UTC with millisecond precision. Encode a literal+as%2Bin any query value, including a positive offset.minAltitude: optional number, -90 through 90 degrees; defaults to zero. The filter includes figures whose reported peak altitude is at least this value.
For numeric query fields, decimal notation may include a sign, decimal point or exponent; whitespace, hexadecimal and duplicate parameters are invalid. JSON requires actual numbers, not numeric strings. Unknown fields are errors. Send unique JSON member names: the JSON parser keeps the last occurrence if a name is repeated.
data.visible contains { id, name, altitude } entries, highest first. meta.observer echoes the accepted latitude and longitude; meta.at is the computed instant; meta.siderealTime is local sidereal time in degrees; meta.total is the full filtered count; and meta.minAltitude is the applied filter. Altitudes are rounded to two decimal places and sidereal time to four; these are output precision, not guarantees of astronomical accuracy. An empty visible array is a successful result with total: 0.
Normal outcomes are 200 OK, 204 No Content for OPTIONS, and 304 Not Modified for an unchanged conditional GET or HEAD. Handle failures by status:
400 Bad Request: missing, malformed, out-of-range, unknown or otherwise invalid input. Correct the request before retrying.405 Method Not Allowed: use a method listed inAllow.412 Precondition Failed: the GET or HEAD representation does not satisfyIf-Match.413 Content Too Large: reduce the POST body to the size limit or less.415 Unsupported Media Type: send uncompressed JSON with the supported media type and charset. A rejection caused by request content coding includesAccept-Encoding: identity.429 Too Many Requests: wait at least the indicatedRetry-Afterdelay before a bounded retry.5xx: server or gateway failure. Bound retries and tolerate unavailability; a gateway may return a different media type or body shape.
For example, lat=91 returns 400 with an application error object:
{
"statusCode": 400,
"code": "FST_ERR_VALIDATION",
"error": "Bad Request",
"message": "querystring/lat must be <= 90"
}
code is present on some errors, not every error; human-readable messages may change. The HTTP status is the first branching decision. Never parse JSON from a 204, 304 or HEAD response. API errors are not stored in HTTP caches.
On an origin response, X-RateLimit-Limit is the application window's request ceiling, X-RateLimit-Remaining is the remaining request count, and X-RateLimit-Reset is the number of seconds until that application window resets—not a Unix timestamp. These hints describe the application layer, can be absent on an edge rejection, and can be stale on a cache hit. Retry-After is the waiting instruction on a 429.
The response schema is the current contract. New metadata may be added compatibly; clients should ignore response members they do not understand. The endpoint is public but unsupported, with no SLA or permanence guarantee.
Part two needs state
The calculation needs no persisted records. Today's application rate-limit counters live in process memory, and nginx maintains its own counters. API keys would add persistent identities and permissions to that design. Issuance, hashing, lookup, revocation and per-key quotas all need an owner and a failure policy; a string checked into configuration is not a key-management system.
Part 2 will introduce API keys with Redis and follow the consequences through the whole design: how keys are stored, how limits move from apparent IP addresses to principals, what happens when Redis is unavailable, and how a site that currently needs no database expands its capability budget without pretending nothing changed. Look for that follow-up in about a month; the timing is an estimate, not a fixed publication date.
For now, the no-key endpoint is enough for small ideas. If you need keys or more volume sooner, reach out with your needs.
Sources and further reading
- RFC 9110: HTTP Semantics for validators, conditional requests, status codes and method semantics.
- RFC 9111: HTTP Caching for freshness, stored responses and revalidation.
- RFC 6585 for
429 Too Many Requests. - MDN: Last-Modified for the date validator and its relationship to entity tags.
- Fetch Standard: CORS protocol for the browser response-sharing model.
- d3-celestial, the credited source from which the committed constellation figures and star catalogue are derived.
- USNO: Computing Approximate Sidereal Time for mean versus apparent sidereal time and the limits of approximate calculations.