Skip to content

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).
  • ConfigurationtrackingMode (sidereal or target), 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.

pip install skynet-sdk httpx
npm install skynet-sdk
export SKYNET=https://api.skynetgo.org/v1
export TOK=$SKYNET_TOKEN          # see Authentication
auth=(-H "Authorization: Bearer $TOK" -H "Content-Type: application/json")

Two rules that cause most 4xx errors

  1. The wire format is camelCase (observingGrantIds, positionType, trackingMode). The TS SDK is camelCase already; with the Python SDK, serialize with by_alias=True (see Conventions).
  2. Send only what you set. The create schemas carry many defaults — with the Python SDK, dump with exclude_unset=True so 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).

hit = client.get("/catalog-objects/search", params={"q": "Eros"}).raise_for_status().json()[0]
position = hit["position"]   # {"positionType": "catalog", "catalogObjectId": 1860, ...}
const [hit] = await catalogObjects.search({ q: 'Eros' });
const position = hit.position; // { positionType: 'catalog', catalogObjectId: 1860, ... }
curl -s "${auth[@]}" "$SKYNET/catalog-objects/search?q=Eros" \
  | jq '.[0].position'   # → {positionType:"catalog", catalogObjectId:1860, ...}

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 / targetFullWellFraction is rejected. Well-depth is a sizing target, not a saturation guard (that's the config's saturationModel).
  • target.position reads back null on the observation read endpoints today — you can submit a position but shouldn't rely on reading it back to confirm; trust the create response's id.
  • POSTs aren't idempotent — capture the returned id and check before retrying on a network error (see Idempotency).

Next steps