Creating observations via the API¶
This guide shows how to submit observations programmatically — resolve a target, build the request, pin it to a telescope, and retrieve the resulting frames — for both sidereal targets (deep-sky objects) and non-sidereal targets (asteroids, comets, satellites) under rate tracking.
It assumes you've read Authentication and Conventions. The web observation editor builds the exact same object — this is its programmatic equivalent.
The model¶
A single POST creates the whole observation. One ObservationCreate body
bundles four things:
graph LR
O[Observation] --> T[Target<br/>what to point at]
O --> C[OpticalImagingConfiguration<br/>tracking + sizing models]
O --> R[Requests[]<br/>filter + exposure per pass]
O --> G[Funding<br/>observingGrantIds + instrumentIds]
- Target — a name plus a
position(a catalog object, fixed coordinates, or a full orbit). - Configuration —
trackingMode(siderealortarget), dithering, and the brightness/saturation models that size exposures. - Requests — one or more exposure passes (filter + exposure spec + repeat count).
- Funding — the observing grant(s) that pay for it and the instrument(s) to use. Choosing these pins the observation to a specific telescope.
There is no draft step
Observations are created in one call from a complete spec — there's no
create-draft-then-publish handshake and no kind/iterations fields. Set
everything in the ObservationCreate body and POST once.
Install the SDK¶
The Python and TypeScript SDKs ship the typed schemas (and, for TS, generated
HTTP clients). Direct curl works too — the wire format is plain camelCase JSON.
Two rules that cause most 4xx errors
- The wire format is camelCase (
observingGrantIds,positionType,trackingMode). The TS SDK is camelCase already; with the Python SDK, serialize withby_alias=True(see Conventions). - Send only what you set. The create schemas carry many defaults — with
the Python SDK, dump with
exclude_unset=Trueso you don't post stale defaults.
Step 1 — Find your submission context¶
You need an observing grant that funds the telescope, and that telescope's imager and filter ids. (You do not need an owner id — it's derived from the URL entity and validated against the grant.)
import os, httpx
client = httpx.Client(
base_url="https://api.skynetgo.org/v1",
headers={"Authorization": f"Bearer {os.environ['SKYNET_TOKEN']}"},
timeout=30.0,
)
me = client.get("/me").raise_for_status().json()
slug = me["slug"]
# Telescopes you can observe with → ids:
scopes = client.get("/me/observing-access/telescopes").raise_for_status().json()
telescope_id = next(t["id"] for t in scopes if t["name"] == "PROMPT-CTIO-6")
# A grant that can fund this telescope:
grants = client.get("/observing-grants",
params={"telescopeId": telescope_id}).raise_for_status().json()
grant_id = grants["items"][0]["id"]
# The telescope's optical imager + its filters:
detail = client.get(f"/users/{slug}/telescopes/{telescope_id}/detail").raise_for_status().json()
imager = next(i for i in detail["instruments"] if i["instrumentType"] == "opticalImager")
instrument_id = imager["id"]
filter_ids = [f["id"] for w in imager["filterWheels"]
for opt in w["options"] for f in opt["filters"]]
curl -s "${auth[@]}" "$SKYNET/me/observing-access/telescopes" | jq '.[] | {id,name}'
# PROMPT-CTIO-6 = id 4
curl -s "${auth[@]}" "$SKYNET/observing-grants?telescopeId=4" | jq '.items[].id'
curl -s "${auth[@]}" "$SKYNET/me" | jq -r '.slug'
curl -s "${auth[@]}" "$SKYNET/users/<slug>/telescopes/4/detail" \
| jq '.instruments[] | select(.instrumentType=="opticalImager")
| {id, filters: [.filterWheels[].options[].filters[].id]}'
Keep the telescopeId, one grantId, the imager instrumentId, and the
filterId(s) you want (e.g. "lum").
Step 2 — Resolve a target¶
Named objects — asteroids, comets, satellites, solar-system bodies¶
GET /catalog-objects/search?q= resolves a name, designation, or catalog number
to a ready-to-submit position block (covering SIMBAD stars/DSOs, NORAD
satellites with live TLEs, MPC orbits and comets, and major bodies).
Deep-sky objects observable now¶
GET /common-targets?telescopeIds= ranks visible DSOs (galaxies, nebulae,
clusters) by brightness and hours-visible for a telescope, returning raDeg /
decDeg you submit as a fixed position.
curl -s "${auth[@]}" "$SKYNET/common-targets?telescopeIds=4" \
| jq '[.[] | {name, targetType, raDeg, decDeg, maxAlt: .observability[0].maxAltitude}]
| sort_by(-.maxAlt)[:10]'
Picking good non-sidereal targets
The catalog resolves a name to an id, but not which movers are up, sunlit,
and moving fast tonight. Compute that with an external ephemeris
(e.g. Skyfield + CelesTrak TLEs for
satellites, or JPL Horizons for
asteroids), then feed the winning names back through
catalog-objects/search. Higher apparent rate = a better rate-tracking test.
Step 3 — Build and submit¶
POST /users/{slug}/observations. The body below is the minimal valid shape;
see the Observation,
OpticalImagingConfiguration, and
OpticalImagingRequest schemas for every
field.
from skynet_sdk.schemas import (
ObservationCreate, Target, OpticalImagingRequestCreate,
CatalogPositionCreate, FixedPositionCreate, EquatorialCoordinatesCreate,
SourceBrightnessModelCreate,
)
from skynet_sdk.schemas.observation.base import OpticalImagingConfiguration
from skynet_sdk.enums import (
ObservationType, TargetPositionType, CoordinateType, BrightnessModelType,
ObservationTrackingMode, DitherStrategy,
)
# --- pick ONE position ---
# (a) catalog object (asteroid / satellite), from Step 2:
position = CatalogPositionCreate(position_type=TargetPositionType.catalog,
catalog_object_id=1860)
# (b) fixed DSO:
# position = FixedPositionCreate(
# position_type=TargetPositionType.fixed,
# coordinates=EquatorialCoordinatesCreate(
# coordinate_type=CoordinateType.equatorial, ra_deg=49.575, dec_deg=-66.500))
config = OpticalImagingConfiguration(
tracking_mode=ObservationTrackingMode.target, # 'target' for movers, 'sidereal' for DSOs
dither_strategy=DitherStrategy.none,
temporal_offset_sec=0.0,
max_tiles=1, tile_overlap=0.0,
# brightness/saturation models size exposures (required for SNR / well-depth):
brightness_model=SourceBrightnessModelCreate(
model_type=BrightnessModelType.source, magnitude_mag=14.0, filter_id="V"),
saturation_model=SourceBrightnessModelCreate(
model_type=BrightnessModelType.source, magnitude_mag=8.0, filter_id="V"),
)
request = OpticalImagingRequestCreate(
request_type=ObservationType.optical_imaging, order=0,
filter_specifier_ids=["lum"],
exposure_time_sec=10.0, # set exactly ONE of: exposure_time_sec | target_snr | target_full_well_fraction
sample_count=3,
)
obs = ObservationCreate(
name="Eros rate-track test",
target=Target(name="Eros", position=position),
optical_imaging_configuration=config,
requests=[request],
observing_grant_ids=[grant_id], # pins the telescope
instrument_ids=[instrument_id],
)
created = client.post(
f"/users/{slug}/observations",
content=obs.model_dump_json(by_alias=True, exclude_unset=True),
headers={"Content-Type": "application/json"},
).raise_for_status().json()
print("created observation", created["id"])
// Method names follow the generated openapi-typescript bindings; the body is camelCase.
const created = await observations.create(slug, {
name: 'Eros rate-track test',
target: {
name: 'Eros',
position: { positionType: 'catalog', catalogObjectId: 1860 },
// or a fixed DSO:
// position: { positionType: 'fixed',
// coordinates: { coordinateType: 'equatorial', raDeg: 49.575, decDeg: -66.5 } },
},
opticalImagingConfiguration: {
trackingMode: 'target', // 'target' for movers, 'sidereal' for DSOs
ditherStrategy: 'none',
temporalOffsetSec: 0.0,
maxTiles: 1,
tileOverlap: 0.0,
brightnessModel: { modelType: 'source', magnitudeMag: 14.0, filterId: 'V' },
saturationModel: { modelType: 'source', magnitudeMag: 8.0, filterId: 'V' },
},
requests: [{
requestType: 'opticalImaging',
order: 0,
filterSpecifierIds: ['lum'],
exposureTimeSec: 10.0, // exactly one sizing mode
sampleCount: 3,
}],
observingGrantIds: [grantId], // pins the telescope
instrumentIds: [instrumentId],
});
console.log('created observation', created.id);
curl -s "${auth[@]}" -X POST "$SKYNET/users/<slug>/observations" -d '{
"name": "Eros rate-track test",
"target": {
"name": "Eros",
"position": { "positionType": "catalog", "catalogObjectId": 1860 }
},
"opticalImagingConfiguration": {
"trackingMode": "target",
"ditherStrategy": "none",
"temporalOffsetSec": 0.0,
"maxTiles": 1,
"tileOverlap": 0.0,
"brightnessModel": { "modelType": "source", "magnitudeMag": 14.0, "filterId": "V" },
"saturationModel": { "modelType": "source", "magnitudeMag": 8.0, "filterId": "V" }
},
"requests": [
{ "requestType": "opticalImaging", "order": 0,
"filterSpecifierIds": ["lum"], "exposureTimeSec": 10.0, "sampleCount": 3 }
],
"observingGrantIds": [58],
"instrumentIds": [4]
}' | jq '{id, name, status}'
The choices that matter¶
| Field | Meaning |
|---|---|
opticalImagingConfiguration.trackingMode |
sidereal for stars/DSOs; target for non-sidereal rate tracking of asteroids/comets/satellites. |
repointingStrategy, temporalOffsetSec |
(on the config) repoint policy and lead-ahead offset for fast movers. |
| request exposure spec | Set exactly one of exposureTimeSec, targetSnr, targetFullWellFraction. SNR / well-depth need the config's brightnessModel to size against. |
observingGrantIds + instrumentIds |
Bind the observation to one telescope. There's no telescopeId on the body — the grant and imager determine it. |
sampleCount, and the scheduling-policy fields on the body (epochCount, cadence*, copies*) |
How many frames per pass, and how passes repeat over time. Defaults give a single epoch, ASAP. See the scheduling guide. |
Dry-run before a batch
POST /observation-specs:plan takes an observation spec and returns the
exposure estimate, tiling, and feasibility without creating anything —
use it to sanity-check exposure time and visibility before submitting many.
Step 4 — Track progress and download results¶
Frames land as assets under the observation. Poll incrementally with
createdAfter, transfer any that are still node-resident, then download.
OBS=<observation_id>
# New frames since a cutoff (newest-first by default):
curl -s "${auth[@]}" "$SKYNET/observations/$OBS/assets?createdAfter=2026-06-28T00:00:00Z" \
| jq '.items[] | {id, name, role, transferState, qaStatus}'
# If transferState != "transferred", request it off the node, then poll the one asset:
curl -s "${auth[@]}" -X POST "$SKYNET/observations/$OBS/assets/<assetId>/request-transfer"
curl -s "${auth[@]}" "$SKYNET/observations/$OBS/assets/<assetId>" | jq '{transferState, copyStatus}'
# One-hop download (307-redirects to storage; follow with -L):
curl -sL "${auth[@]}" "$SKYNET/observations/$OBS/assets/<assetId>/data" -o frame.fits
Useful asset filters: role / roleExclude, qaStatus / serverQaStatus,
transferState, isTransferrable, name, taskId. For realtime delivery
instead of polling, subscribe over the WebSocket streams.
Sharp edges¶
Known gotchas
- camelCase + minimal payloads — the top-two rules above; most 422s trace to a snake_case key or a posted default.
- Exposure mode is exclusive — sending more than one of
exposureTimeSec/targetSnr/targetFullWellFractionis rejected. Well-depth is a sizing target, not a saturation guard (that's the config'ssaturationModel). target.positionreads backnullon the observation read endpoints today — you can submit a position but shouldn't rely on reading it back to confirm; trust the create response'sid.- POSTs aren't idempotent — capture the returned
idand check before retrying on a network error (see Idempotency).
Next steps¶
- Conventions — pagination, errors, discriminators.
- Scheduling & repetition — epochs, cadence, copies.
- Observation schema and the Observation API reference — every field and endpoint.
- WebSocket streams — push delivery of new assets and state.