SkySolverOpen the solver
API · v1

The SkySolver API

Plate solve a photo of the night sky, or a list of its stars, and draw its annotation, as SVG to lay over the photo or as JSON to draw yourself: the same solver and the same annotations as the playground.

Overview

No key
Free and anonymous: no account or API key.
Rate-limited
Per client address, by how heavy the endpoint is.
Nothing kept
Images, star lists and results are not stored or logged.
  • Base URL https://skysolver.app/api/v1. Requests and answers are JSON (UTF-8); the annotation can also come as SVG.
  • Three endpoints: solve an image sent as base64, solve star positions you measured yourself, and annotate a solved image.
  • CORS is open: you can call the API from a web page.
  • Versions: breaking changes get a new path (/api/v2); v1 only gains optional fields. The OpenAPI document describes every model.
  • Fair use: the service is free and runs on a small server. For large batches, solve star lists you detect yourself, or ask at [email protected].

Quick start

Solve a photo, then get its annotation as an SVG of the photo's size, to lay over it:

shell
# 1. solve: the body is {"image": "<the file in base64>"}
base64 < photo.jpg | jq -Rs '{image: .}' \
  | curl -s https://skysolver.app/api/v1/solve/image \
      -H 'Content-Type: application/json' --data-binary @- > solution.json

# 2. annotate: the whole solution goes in as the sky model
jq '{solution: ., format: "svg"}' solution.json \
  | curl -s https://skysolver.app/api/v1/annotate \
      -H 'Content-Type: application/json' --data-binary @- > annotation.svg

The same in JavaScript (Node 18 or later; in a browser only reading the file differs):

javascript
import { readFile } from "node:fs/promises";

const post = (path, body) => fetch("https://skysolver.app/api/v1" + path, {
  method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});

// in a browser: FileReader's readAsDataURL(file) gives a data: URL, which works as it is
const image = (await readFile("photo.jpg")).toString("base64");
const solution = await (await post("/solve/image", { image })).json();
if (solution.solved) {
  const answer = await post("/annotate", { solution, time: "2024-10-12T21:34:05+02:00" });
  const svg = await answer.text();
}

And in Python:

python
import base64, json, urllib.request

API = "https://skysolver.app/api/v1"

def post(path, body):
    req = urllib.request.Request(API + path, data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as r:
        return r.read()

with open("photo.jpg", "rb") as f:
    image = base64.b64encode(f.read()).decode()
solution = json.loads(post("/solve/image", {"image": image}))
if solution["solved"]:
    print(solution["center"], solution["scale_arcsec"])
    svg = post("/annotate", {"solution": solution, "format": "svg"})

Limits

Each client address has a bucket of requests per endpoint: up to burst at once, refilled at a steady rate. A request beyond it is answered at once with 429 and Retry-After. Every answer carries RateLimit-Limit (the burst), RateLimit-Remaining, RateLimit-Reset (seconds until the bucket is full) and RateLimit-Policy.

EndpointBurstRefillRequest body
POST /solve/image101 a minute28 MB (a 20 MB file in base64)
POST /solve/stars3030 a minute4 MB
POST /annotate3030 a minute2 MB
GET /health6060 a minute–
  • Images: at most 20 MB and 120 megapixels. The solver searches for at most time_limit_ms (1–30 s, 10 s by default).
  • Star lists: 5 to 10,000 stars; the 3,000 brightest are used.
  • Busy: the server runs a few solves at a time (max_concurrent in health). Beyond that a request is answered at once with 429, error: "busy" and Retry-After: wait that long, plus a random second or so, and send it again.
  • GET /health returns the limits in force.

Conventions

  • Sky coordinates are degrees in the ICRS (J2000): RA 0–360, Dec −90–90.
  • Pixels count from 0 at the centre of the top-left pixel; x grows to the right and y down. The image's top-left corner is therefore (−0.5, −0.5) and its centre (width/2 − 0.5, height/2 − 0.5). FITS counts from 1: star lists can say so (pixel_origin), and fits_header uses FITS's own convention.
  • Times are ISO 8601 with a UTC offset or Z: 2024-10-12T21:34:05+02:00. A time without one is refused, never guessed.
  • Errors are {"error": code, "message": text}, with details for invalid fields: see Errors.

Solve an image

POST/api/v1/solve/image10 at once · 1 a minute

Detects the stars of an image and finds where in the sky it points: blind, with no hint of position, scale or rotation, for fields from a few arcminutes to all-sky panoramas. The image is decoded and solved in memory and not kept.

Parameters
image
stringrequired
The image file, base64-encoded (standard or URL-safe alphabet; a data:image/…;base64, prefix is fine). At most 20 MB decoded and 120 megapixels. Formats: JPEG, PNG (8/16-bit), TIFF (8/16/32-bit and float; uncompressed, LZW, ZIP, PackBits), FITS (integer or float; grey, RGB or a colour camera's raw mosaic), XISF (uncompressed or zlib), BMP, GIF, TGA and PSD. Linear data (stacks, FITS, XISF) are stretched before detection.
time_limit_ms
integeroptional
How long the solver may search before it gives up.
Default 10000Allowed 1000–30000
request
{
  "image": "/9j/4AAQSkZJRgABAQEASABIAAD…",
  "time_limit_ms": 10000
}

The answer is the solution, plus image (format, width, height) and capture: when and where the photo was taken, as its metadata says (EXIF, XMP, IPTC, FITS headers, XISF properties).

capture
time
object | null
The capture time, or null when the file does not have one.
└utc
string | null
The moment in UTC: null when the file gives a clock reading without its time zone (EXIF before 2.31, Lightroom's XMP).
└local
string
The clock reading as written: the camera's local time for EXIF, UTC for FITS.
└utc_offset
string | null
The clock's offset, +02:00; null when unknown.
└source
string
Where it was read: EXIF DateTimeOriginal, FITS DATE-OBS, XISF Observation:Time:Start…
└exposure_s
number
The exposure (or a stack's first start to last end) when known; the time is its middle then.
└note
string
Something to check: a stack timed by one of its frames, a camera that may not have had its zone set.
└date_only
boolean
true when the file gives a date without a time of day.
location
object | null
Where it was taken, from the GPS or the FITS site keywords: {lat, lon, height, source}, degrees east and north, metres.
other_dates
array
Other dates in the file that may not be the capture time (creation, modification), shaped like time.
response (shortened)
{
  "api_version": "1.0",
  "solved": true,
  "width": 2048,
  "height": 1366,
  "wcs": {
    "ctype": "RA---TAN-SIP",
    "crval": [57.56884791, 30.441625],
    "crpix": [1023.5, 682.5],
    "cd": [[0.0004834903827, 0.008644326623], [-0.008637593318, 0.0004834749004]],
    "sip": { "order": 3, "a": [[0, 2, -1.585329748e-7], …], "b": […], "inv_order": 4, "ap": […], "bp": […] }
  },
  "projection": null,
  "center": { "ra": 57.56884791, "dec": 30.441625 },
  "corners": [{ "name": "top-left", "x": -0.5, "y": -0.5, "ra": 49.44900035, "dec": 38.65911116 }, …],
  "fov": { "width_deg": 17.6401047, "height_deg": 11.79908067 },
  "scale_arcsec": 31.15610913,
  "rotation_deg": 266.7987974,
  "parity": "normal",
  "stats": { "stars": 3294, "matches": 972, "rms_px": 0.3723350842, "log_odds": 3198.350966, … },
  "fits_header": "SIMPLE  =                    T …",
  "matched": [[1988.17, 1290.68, 63.70586, 22.302973, 8.59], …],
  "timing_ms": { "solve": 153.9, "detect": 40.3, "decode": 507.7, "server": 844.4 },
  "image": { "format": "FITS", "width": 2048, "height": 1366 },
  "capture": {
    "time": { "utc": "2025-08-16T21:00:00.000Z", "local": "2025-08-16T23:00:00.000", "utc_offset": "+02:00",
              "source": "FITS DATE-OBS", "exposure_s": 60 },
    "location": { "lat": 52.2297, "lon": 21.0122, "height": 110, "source": "FITS SITELAT/SITELONG" },
    "other_dates": []
  }
}

Solve star positions

POST/api/v1/solve/stars30 at once · 30 a minute

Solves from star positions you measured yourself (SExtractor, SEP, photutils, PixInsight's StarDetector, your own centroids): the image never leaves your machine, and the request is a few kilobytes. This is how the playground solves.

Parameters
width
integerrequired
Image width in pixels.
Allowed 8–100000
height
integerrequired
Image height in pixels.
Allowed 8–100000
stars
arrayrequired
Star positions, each [x, y], [x, y, flux] or {"x": …, "y": …, "flux": …}. flux is any positive brightness measure (sum, peak, …): give it for every star or for none, and without it list the stars brightest first. 30 to 300 stars is plenty; the 3,000 brightest are used. Positions must lie in the image.
Allowed 5–10000 items
pixel_origin
integeroptional
0: the centre of the first pixel is (0, 0), as in numpy and OpenCV. 1: it is (1, 1), as in FITS, SExtractor, astrometry.net and DS9. Answers are always 0-based.
Default 0Allowed 0 · 1
time_limit_ms
integeroptional
How long the solver may search.
Default 10000Allowed 1000–30000
request
{
  "width": 6000,
  "height": 4000,
  "stars": [
    [3735.37, 69.14, 51230],
    [2120.04, 1987.51, 48810],
    { "x": 812.6, "y": 3020.9, "flux": 47002 },
    …
  ]
}

The answer is the solution.

The solution

Both solve endpoints answer with this object.

fields
solved
boolean
Whether a solution was found. A photo that cannot be solved is still a 200 answer, with solved: false and an error.
width, height
integer
The image size in pixels.
center
object | null
The sky at the middle of the image: {ra, dec} in degrees. null when the middle of an all-sky image is not on the sky.
corners
array
The image's four outer corners, top-left, top-right, bottom-right, bottom-left: each {name, x, y, ra, dec}, with ra and dec null where a corner is off the sky.
fov
object
{width_deg, height_deg}: the angular length of the image's horizontal and vertical middle lines.
scale_arcsec
number
Arcseconds per pixel at the centre.
rotation_deg
number
Where the image's up points: degrees from north through east (0: north is up, 90: east is up).
parity
string
Mirrored images (flipped in processing, or through a diagonal) are solved too.
Allowed normal · mirrored
wcs
object | null
The solution as TAN or TAN-SIP: crval (RA, Dec of the reference pixel), crpix (the reference pixel, 0-based), cd (degrees per pixel) and sip (distortion polynomials, terms [p, q, coefficient]). null for projection solutions.
projection
object | null
The solution as a projection model, for panoramas, fisheye and all-sky views that no TAN-SIP fits; null otherwise. Pass it, or the whole solution, to /annotate.
stats
object
stars (detected or given), matches, rms_px (how far the matched stars are from the model), log_odds (the evidence for the match, a natural logarithm: a solution needs 25 and 10 matched stars), and details of the search.
fits_header
string | null
The TAN-SIP solution as a FITS WCS header (80-character cards; FITS's 1-based CRPIX), ready to merge into a FITS file. null for projection solutions.
matched
array
The catalogue stars (Gaia DR3) the solution matched, up to a few thousand: [x, y, ra, dec, magnitude] each, the magnitude Gaia's G.
timing_ms
object
decode (reading the file), detect, solve, and the server's total for the request, in milliseconds.
error
string
Why there is no solution (when solved is false).
api_version
string
1.0

Annotate a solved image

POST/api/v1/annotate30 at once · 30 a minute

Draws the coordinate grid, constellations, deep-sky objects (15,000 of them: Messier, NGC, IC, Sharpless, Barnard, Lynds, van den Bergh, Caldwell) and, given a time, the Sun, the Moon and the planets, as the playground's Save image draws them: the same objects, labels and styles. You send the sky model, not the image. The annotation is not clipped to the sky: the playground finds the horizon in the photo itself.

Parameters
sky model
one ofrequired
Exactly one of corners, wcs, fits_header, projection or solution: see Sky models.
width, height
integeroptional
The image size in pixels. Required with corners, wcs and projection; taken from solution or the FITS header's NAXIS1/NAXIS2 otherwise.
Allowed 8–100000
format
stringoptional
svg: an SVG document of the image's size, transparent, to lay over the photo. json: the objects, bodies, lines and placed labels as data (JSON output).
Default "svg"Allowed svg · json
time
stringoptional
When the photo was taken (ISO 8601 with an offset or Z). With it, the Sun, the Moon, the planets and Pluto are drawn where they were (within 1″ for 1850–2150).
location
objectoptional
Where it was taken: the Moon moves by up to a degree with the observer's place (its parallax). Without one, the Moon is placed as seen from the Earth's centre.
└lat
numberrequired
Latitude, degrees north.
Allowed -90–90
└lon
numberrequired
Longitude, degrees east.
Allowed -180–180
└height
numberoptional
Metres above sea level.
Allowed -500–10000
layout_width
integeroptional
The annotation is laid out as for the photo shown this many pixels wide, then scaled to its size: line widths and font sizes are pixels of that view. Larger: smaller labels and more of them.
Default 1400Allowed 200–8000
detail
numberoptional
How much more to show, as zooming in does in the playground: 0 the default selection (prominent objects), 1 every object the filter lets through.
Default 0Allowed 0–1
view
objectoptional
Layers and styles: see Options.
filter
objectoptional
Which groups and magnitudes show: see Options.
edits
objectoptional
Single objects hidden or renamed: see Options.
request
{
  "solution": { "width": 2048, "height": 1366, "wcs": { … }, … },
  "format": "svg",
  "time": "2025-08-16T23:00:00+02:00",
  "location": { "lat": 52.23, "lon": 21.01 },
  "view": { "group_by": "catalog", "grid": { "on": false }, "catalogs": { "solar": { "color": "#ff8800", "size": 16 } } },
  "filter": { "catalogs": { "ldn": false, "lbn": false }, "magnitude": [-2, 10] },
  "edits": { "hidden": ["Mars"], "names": { "NGC 1499": "California" } }
}

With format: "svg" the answer is image/svg+xml: <svg width="2048" height="1366" viewBox="0 0 2048 1366">…, in the photo's pixels. A warning about the sky model, if any, comes in the SkySolver-Warning header.

Sky models

Send the one you have; all describe how the image's pixels map to the sky.

one of
corners
object
The sky at the image's centre and its four outer corners, each {ra, dec}: center, top_left, top_right, bottom_right, bottom_left. A gnomonic (TAN) projection is fitted to them; for wide-angle lenses that distort, the answer warns how far the corners are from it. Every corner must be less than 87° from the centre: wider views need the solver's projection.
wcs
object
TAN or TAN-SIP as the solver gives it: crval [RA, Dec], crpix [x, y] (0-based), cd [[CD1_1, CD1_2], [CD2_1, CD2_2]] in degrees per pixel, and optionally sip {order, a, b, inv_order, ap, bp} with terms [p, q, coefficient].
fits_header
string
A FITS WCS header as text (80-character cards, or one card per line): CTYPE1 = 'RA---TAN' or 'RA---TAN-SIP', CRVAL, CRPIX (1-based, as FITS has it), and CD, or PC with CDELT, or CDELT with CROTA2; SIP's A_*, B_*, AP_*, BP_*. What astrometry.net, ASTAP, PixInsight and this solver write.
projection
object
The solver's projection model (panoramas, fisheye and all-sky images), as /solve returns it.
solution
object
A whole /solve answer: its size and its wcs or projection are used.

JSON output

With format: "json": what the annotation draws, in image pixels (0-based, y down), and the labels as placed.

fields
width, height
integer
The image size the annotation was drawn for.
field
object
center {ra, dec}, scale_arcsec (at the centre), diagonal_deg (null for all-sky views).
layout
object
width (the layout_width used) and scale: image pixels per layout pixel, which sizes and widths were multiplied by.
objects
array
The deep-sky objects the filter and the view select, hidden ones included (shown: false).
└id, label
string
Designation (M 42, NGC 1977), as edits takes it, and the label drawn (M42).
└name, catalogue_name
string | null
The name drawn (Orion Nebula, or the one given in edits) and the catalogue's own.
└type, group, catalogs
string, string, array
Readable type, its group (emission…) and every catalogue group it is in.
└other_designations
array
Its other designations: ["Mel 22", "LBN 770"].
└ra, dec, mag
number
Position and magnitude (null when unknown).
└size_arcmin, position_angle
array, number
[major, minor] and the angle east of north, when known.
└x, y, radius_px
number
Where it is in the image, and its radius in pixels.
└outline, closed
array | null, boolean
Its shape as rings [x0, y0, x1, y1, …] in pixels (a traced outline, or the catalogue's ellipse); closed is false when the shape leaves an all-sky view's domain and only pieces of it are drawn.
└shown
boolean
false when edits.hidden hides it.
bodies
array
With time: the Sun, the Moon and the planets in the frame: id, name, kind, ra, dec, mag, diameter_arcsec, distance_au, phase (lit fraction), elongation_deg, topocentric (placed as seen from location), x, y, radius_px, outline, shown.
constellations
array
id, name, lines (polylines in pixels), stars, label_at.
grid
array
RA and Dec lines: kind (ra or dec), value (degrees), label, lines.
labels
array
Every label as placed, clear of the others: text, of (the object), kind, x, y (the text's baseline anchor), size, anchor (start, middle, end), color, opacity.
warnings, time, location
array, string, object
Warnings about the sky model; the time (UTC) and the place used.

Options

All optional, and partial: what you leave out keeps the playground's defaults. Values out of range, and keys that do not exist, are refused with 422 rather than ignored.

view
group_by
stringoptional
Objects are styled and filtered by type or by catalogue: types or catalogs below then apply.
Default "type"Allowed type · catalog
grid
objectoptional
The RA/Dec grid: on and a line style (default #96beff, dashed).
constellations
objectoptional
Stick figures and names: on and a line style.
shadow
objectoptional
A drop shadow under the whole annotation: on (off by default), color, opacity, blur (0–16 px), distance (0–12 px).
objects
booleanoptional
Draw the objects (and the Sun, Moon and planets).
Default true
labels
booleanoptional
Draw the labels.
Default true
types
objectoptional
A line style per type group, keyed as in Groups and styles: {"emission": {"color": "#ff99cc"}}.
catalogs
objectoptional
A line style per catalogue group.
a line style
color
stringoptional
Line, marker and label colour.
Allowed #rrggbb
opacity
numberoptional
Opacity of lines and markers; labels are drawn a little more opaque.
Allowed 0–1
width
numberoptional
Line width, in pixels of the layout view (see layout_width).
Allowed 0.3–6
dash
stringoptional
Line style.
Allowed solid · dashed · dotted · dashdot
size
numberoptional
Label font size, in pixels of the layout view.
Allowed 6–32
filter
types
objectoptional
Groups switched on or off (with group_by: "type"): {"dark": false}. All are on by default.
catalogs
objectoptional
The same for catalogues (with group_by: "catalog").
magnitude
string | arrayoptional
auto: Messier objects always, others by how prominent they are in the frame (size, or a magnitude limit that follows the image scale). A range: objects with a magnitude inside it; those without one (most nebulae) by size.
Default "auto"Allowed "auto" · [brightest, faintest] within -2–18
edits
hidden
arrayoptional
Objects to leave out, by designation as objects[].id gives it: ["NGC 1999", "Mars"].
names
objectoptional
Names to label objects with: {"NGC 1499": "California"}; an empty string labels an object by its designation alone.

Groups and styles

The keys of view.types, filter.types, view.catalogs and filter.catalogs, with their default style (colour, opacity, width, line, font size):

types keyGroupDefault
galaxyGalaxies#ffb35c0.8 · 1 px · solid · 10.5 px
openOpen clusters#7dd3fc0.8 · 1 px · solid · 10.5 px
globularGlobular clusters#fde68a0.85 · 1 px · solid · 10.5 px
emissionEmission nebulae#d8b4fe0.85 · 1 px · solid · 10.5 px
reflectionReflection nebulae#93c5fd0.8 · 1 px · solid · 10.5 px
planetaryPlanetary nebulae#6ee7b70.85 · 1 px · solid · 10.5 px
snrSupernova remnants#fda4af0.8 · 1 px · solid · 10.5 px
darkDark nebulae#cbd5e10.5 · 1 px · solid · 10.5 px
solarSolar system#d9f99d0.9 · 1.2 px · solid · 12 px
otherOther#e2e8f00.7 · 1 px · solid · 10.5 px
catalogs keyGroupDefault
messierMessier#facc150.9 · 1 px · solid · 12 px
ngcNGC#7dd3fc0.8 · 1 px · solid · 10.5 px
icIC#a5b4fc0.8 · 1 px · solid · 10.5 px
sharplessSharpless (Sh2)#d8b4fe0.85 · 1 px · solid · 10.5 px
barnardBarnard (B)#cbd5e10.55 · 1 px · solid · 10.5 px
ldnLynds dark (LDN)#94a3b80.5 · 1 px · solid · 10.5 px
lbnLynds bright (LBN)#f9a8d40.8 · 1 px · solid · 10.5 px
vdbvan den Bergh (vdB, vdBH)#93c5fd0.8 · 1 px · solid · 10.5 px
caldwellCaldwell#6ee7b70.8 · 1 px · solid · 10.5 px
solarSolar system#d9f99d0.9 · 1.2 px · solid · 12 px
otherOther#e2e8f00.7 · 1 px · solid · 10.5 px

Errors

Every error answer has the same shape: a code to act on, and a message to show. Invalid requests list each problem:

422
{
  "error": "invalid_request",
  "message": "view.grid.color: String should match pattern '^#[0-9a-fA-F]{6}$'",
  "details": [
    { "field": "view.grid.color", "problem": "String should match pattern '^#[0-9a-fA-F]{6}$'" },
    { "field": "filter.magnitude", "problem": "magnitude must be \"auto\" or [brightest, faintest] within -2..18" }
  ]
}
StatuserrorWhen
400invalid_imageThe image is not valid base64, or cannot be read.
400unsupported_formatThe file is not one of the formats above.
413payload_too_largeThe request body, the file or its pixel count is over the limit.
422invalid_requestA field is missing, of the wrong type, out of range or unknown; details lists each.
429rate_limitedYour client address used up its requests; retry_after says when to try again.
429busyThe server is solving as much as it can; retry after Retry-After.
500internal_errorSomething failed on our side.
503unavailableThe solver or the annotator is not ready.

An image that cannot be solved is not an error: the answer is 200 with solved: false.

Health

GET/api/v1/health60 at once · 60 a minute

The service's status, the star indexes and annotator workers loaded, and the limits in force.

response
{
  "status": "ok",
  "version": "1.0",
  "uptime_s": 86400.2,
  "solver": { "indexes": 4, "max_concurrent": 4, "in_flight": 0 },
  "annotator": { "ok": true, "workers": 2, "objects": 15333 },
  "limits": {
    "rates": {
      "image": { "burst": 10, "per_minute": 1 },
      "stars": { "burst": 30, "per_minute": 30 },
      "annotate": { "burst": 30, "per_minute": 30 },
      "light": { "burst": 60, "per_minute": 60 }
    },
    "image_mb": 20, "megapixels": 120, "stars": 10000, "time_limit_ms": 30000
  }
}

Changelog

  • 1.0 (16 October 2026): solve an image (base64) or star positions, annotate as SVG or JSON, with the Sun, Moon and planets.

Questions, a bug, a larger quota? [email protected]. By using the API you accept the terms; what it does with your data is in the privacy page.