Repository navigation
Capture Control API
The Capture Control API lets other programs on the observatory PC start and stop PFR Sentinel capture over HTTP — a NINA sequence, a PowerShell script, a home-automation tool, or plain curl. It rides on Sentinel's built-in Web Server, is switched off by default, and every request needs a bearer token. This page is the reference for writing your own clients. If you just want NINA to control Sentinel, see NINA Integration, which sets all of this up for you.
Both switches are in the Web Server card of the Output Settings tab:
- Turn on Enable Web Server.
- Turn on Enable Capture Control API.
The control routes only work while the web server is running. If you turn control on with the web server off, Sentinel warns: "Capture control needs the web server — enable it above." Changes take effect immediately on a running server; no restart is needed.
| Setting | Default | Description |
|---|---|---|
| Enable Capture Control API | Off | Turns the control routes on. With it off, every control request is refused with control_disabled. |
| API Token | (empty until first enabled) | The bearer token clients must send. Generated automatically the first time you enable the API. |
When control is on, the API reference served by Sentinel at /docs (the Open API Docs link in the same card) and the machine-readable spec at /openapi.json include the control routes. When it is off, they are left out.
The API Token row shows the token masked, with three buttons:
| Button | What it does |
|---|---|
| Show / Hide | Reveals or masks the token in the field. |
| Copy | Copies the token to the clipboard ("API token copied to clipboard"). |
| Regenerate | Creates a new token. The old one stops working immediately ("New API token generated — any tool using the old one must be updated."). |
Notes:
- The token is a random 43-character string. It is created on first enable, not at install, so a Sentinel that never uses the API never holds a credential.
- Turning the API off blanks the field and makes the server refuse control requests at once. The token itself is kept, so turning the API back on restores the same token and existing clients keep working.
- The NINA plugin and the bundled NINA script helpers read the token from Sentinel's settings on their own. You only need Copy for other tools.
- Treat the token like a password. Sentinel never writes it to its logs.
Control requests are checked more strictly than the read-only endpoints (/latest, /status):
| Check | Behaviour |
|---|---|
| Bearer token | Required on every control request, including from the same machine. Missing, malformed and wrong tokens all get the same 401 answer, so a client cannot tell which it was. |
| Host allow-list | The request's Host header must be an allowed name (below). Checked before the token. |
| Fail closed | With the API off there is no token, and every control request is refused. An empty token never means "open". |
| No CORS | Control responses carry no Access-Control-Allow-Origin header, and POST is not offered in CORS preflight, so a web page in a browser cannot drive capture. |
These Host values are always accepted: localhost, 127.0.0.1, ::1 and [::1]. The port in the header is ignored and the comparison is case-insensitive.
The web server's Host setting is also accepted if it names a specific address. If Host is 0.0.0.0, that isn't a real address, so it adds nothing to the list.
To accept further names (for example a LAN address or a DNS name), Sentinel 3.7.0 and later read an optional list, webserver_control_allowed_hosts, in the output section of Sentinel's config.json (in %LOCALAPPDATA%\PFRSentinel\). There is no control for it in the app; close Sentinel before editing the file. Requests with any other Host are refused with 403 host_not_allowed.
The web server's Host defaults to 127.0.0.1, so only programs on the same PC can reach it. Capture control is designed for that setup. Exposing it to your network means changing Host, allow-listing names as above, and accepting that the token travels over plain, unencrypted HTTP — only do this on a network you trust.
The control routes live under /capture by default. The base path can be changed only through the webserver_control_path key in the output section of config.json; the NINA plugin and script helpers follow that key automatically.
| Method | Path | Purpose |
|---|---|---|
POST |
/capture/start |
Start capture in Sentinel's configured mode (camera or directory watch). |
POST |
/capture/stop |
Stop capture. |
GET |
/capture |
Current capture state plus a readiness flag, without issuing a command. |
POST |
/nina/target |
Report the target your imaging rig is on, for the all-sky overlay (see below). Added in version v3.7.9. |
All of them need the Authorization: Bearer <token> header. A query string on the path is ignored on every route. A trailing slash is ignored only on the POST routes, so POST /capture/start/ works but GET /capture/ returns 404. A POST to any other path returns 404. /nina/target is a fixed path: it does not move with webserver_control_path.
The body is optional. An empty body uses the defaults, so a bare POST works. If you send one, it must be a JSON object of at most 4096 bytes:
| Field | Type | Default | Range | Description |
|---|---|---|---|---|
wait |
boolean | true |
— | When true, the request blocks until capture reaches the target state, fails, or the timeout passes. When false, it returns as soon as the command has been handed to Sentinel. Must be a real JSON boolean, not a string. |
timeout |
number | 30 |
1–300 | Seconds to wait when wait is true. Decimals are allowed. Out-of-range values are rejected with 400, not clamped. |
| Command | Target state |
|---|---|
| start | Capture is enabled and its state is capturing, waiting, outside_window or recovering (see Capture block for when each is reported). A scheduled capture waiting for its window counts as started. |
| stop | Capture is neither enabled nor running. |
While waiting for a start, Sentinel also watches for failure: if capture enters the error state, the camera is marked unrecoverable, or a new capture error is recorded after the command, the request returns failed straight away instead of running out the timeout.
If capture is already in the target state when the request arrives, Sentinel does nothing and answers 200 with already_running or already_stopped. A script or sequence can safely repeat Start, or send Stop twice while aborting.
| Field | Type | Description |
|---|---|---|
command |
string |
start or stop. |
result |
string | Outcome — see below. |
changed |
boolean | True only for started and stopped, when capture state actually changed. |
issued |
boolean | Whether the command was handed to Sentinel. False for the already_* no-ops. |
state |
string | Capture state at the time of the response (same values as capture.state below). |
running |
boolean | Whether capture is producing frames. |
enabled |
boolean | Whether capture is enabled in Sentinel. |
waited |
boolean | Whether the request blocked for the target state. |
wait_seconds |
number | Seconds spent waiting, to two decimals; 0 when it did not wait. |
message |
string | A plain-English outcome, safe to show a user. For failed, this is Sentinel's own error text when it has one (for example a camera error). |
result |
HTTP status | Meaning |
|---|---|---|
started |
200 | Capture was started and reached the running state. |
stopped |
200 | Capture was stopped and reached the stopped state. |
already_running |
200 | Start requested, capture was already running. Nothing changed. |
already_stopped |
200 | Stop requested, capture was already stopped. Nothing changed. |
pending |
200 | The command was issued with wait: false; the final state is not confirmed. Poll GET /capture. |
timeout |
504 | The command was issued, but the target state was not reached within timeout. The command still stands. |
failed |
500 | Capture reported a failure. Read message. |
Example:
{
"command": "start",
"result": "started",
"changed": true,
"issued": true,
"state": "capturing",
"running": true,
"enabled": true,
"waited": true,
"wait_seconds": 1.52,
"message": "Capture started."
}Sentinel acts on a command before it starts waiting. If your client gives up — you cancel the request, the connection drops, or your own HTTP timeout fires — the start or stop has usually already happened. Nothing is rolled back. Check GET /capture before retrying, and set your client's timeout comfortably above timeout (the bundled script uses timeout + 15 seconds).
Returns the current state without changing anything. Use it to pre-flight a client — for example to validate a sequence before the night — and to poll after a wait: false command.
| Field | Type | Description |
|---|---|---|
capture |
object | The capture block, identical to capture in /status (below). |
health |
object | The health block, identical to health in /status (below). |
control_ready |
boolean | Whether a start or stop would actually be accepted right now. false means control is on and the token is valid, but Sentinel's capture controls aren't connected to the server (this shouldn't happen; restart Sentinel) — a command would get 503 control_unavailable. |
timestamp |
string | Server time, ISO 8601. |
GET /capture goes through the same Host and token checks as the commands, so it also tells you the token is good. It never returns control_unavailable; it reports that condition as control_ready: false instead.
Tells Sentinel which target your main telescope is imaging, so the All-Sky Overlay can mark it and label it with its name. When it can, the overlay draws the imaging camera's field of view as a box with a small cross at its centre; when it can't — no field of view sent, part of the box below the horizon, or the box under 4 pixels across at the output size — it draws a reticle on the target instead. The NINA plugin sends this for you from the running sequence; this section is for writing your own client.
The route uses the same Host check, token and 4096-byte body limit as the capture routes, and like them it is refused with control_disabled while Enable Capture Control API is off. It does not start, stop or change capture.
The body is required and must be a JSON object with a target key:
{
"target": {
"name": "M31",
"ra_deg": 10.6847,
"dec_deg": 41.2687,
"epoch": "J2000",
"fov_w_deg": 2.13,
"fov_h_deg": 1.42,
"rotation_deg": 15.0,
"source": "nina"
}
}Send {"target": null} to clear the target.
| Field | Type | Required | Range | Description |
|---|---|---|---|---|
name |
string | yes | — | Shown next to the marker. Control characters are removed, runs of spaces become one, and the name is cut to 64 characters. A name with no visible characters is rejected. |
ra_deg |
number | yes | 0 to below 360 | Right ascension in decimal degrees (not hours), J2000. |
dec_deg |
number | yes | −90 to 90 | Declination in decimal degrees, J2000. |
epoch |
string | no | J2000 |
Only J2000 is accepted (any letter case). Anything else, such as JNOW, is rejected — convert to J2000 first. |
fov_w_deg |
number | no | above 0, at most 60 | Field of view along the camera's width, in degrees. Send it together with fov_h_deg, or leave both out to draw the marker without a box. |
fov_h_deg |
number | no | above 0, at most 60 | Field of view along the camera's height, in degrees. |
rotation_deg |
number | no | any | Camera rotation as a sky position angle (below). Stored as 0 to below 360, so 370 becomes 10. Leave it out for 0. |
source |
string | no | up to 32 characters | Who sent the target. Defaults to nina. |
Numbers must be real JSON numbers: strings, true/false, NaN and Infinity are rejected. Unknown fields are ignored.
Rotation convention. rotation_deg is the position angle of the camera's "up" (the direction of its height axis) on the sky, in degrees east of north. At 0 the long side of a landscape sensor runs east–west and the short side north–south; at 90 it runs north–south. A position angle and the same angle plus 180° draw the same box.
Sentinel only keeps the target in memory, and stops drawing it once nothing has arrived for a while (120 seconds by default — the Hide after (s) setting for the NINA target on the All-Sky Overlay settings), so a client that closes or crashes doesn't leave a stale marker up all night. Send the same target again every 30 seconds or so while it is current. A repeat is answered with changed: false and resets the timer. The target is also forgotten when Sentinel restarts.
| Field | Type | Description |
|---|---|---|
accepted |
boolean | Always true on a 200. |
changed |
boolean | Whether this target differs from the one already stored. false for a repeat. |
target |
object | The stored target after the clean-up above (without epoch), or null when cleared. Optional fields left out of the request are null, except source, which reads nina. |
age_s |
number | Seconds since the stored target was received — 0 on this response. |
message |
string |
Target stored., Target unchanged. or Target cleared.
|
{
"accepted": true,
"changed": true,
"target": {
"name": "M31",
"ra_deg": 10.6847,
"dec_deg": 41.2687,
"fov_w_deg": 2.13,
"fov_h_deg": 1.42,
"rotation_deg": 15.0,
"source": "nina"
},
"age_s": 0.0,
"message": "Target stored."
}A rejected request leaves the stored target as it was. Errors use the same code values as the capture routes (see Errors).
curl -X POST http://127.0.0.1:8080/nina/target \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"target": {"name": "M31", "ra_deg": 10.6847, "dec_deg": 41.2687, "fov_w_deg": 2.13, "fov_h_deg": 1.42, "rotation_deg": 15}}'These blocks appear in GET /capture and in the unauthenticated GET /status (see Web Server for the rest of /status).
| Field | Type | Description |
|---|---|---|
mode |
string |
camera, watch, or idle (not capturing). |
enabled |
boolean | Whether capture is enabled. Stays true while the camera is recovering. |
running |
boolean | Whether capture is producing frames right now. |
state |
string |
capturing, waiting, recovering, outside_window, stopped or error. waiting is only reported in headless mode, between frames. calibrating appears in the API reference but is never reported. |
interval_seconds |
number | Configured seconds between captures (camera mode); null otherwise. |
effective_interval_seconds |
number | Interval actually in force now, honouring a variable-rate schedule; null otherwise. |
schedule |
object | Scheduled-capture window — see below. null when no schedule applies, such as in watch mode or while capture is stopped in the desktop app. |
last_capture_age_seconds |
integer | Seconds since the last successful capture; null if none yet. |
next_capture_in_seconds |
integer | Estimated seconds until the next capture (camera running); null if not predictable. |
next_capture_expected_epoch |
number | Unix time of the next expected capture; null if not predictable. |
recovery |
object |
{in_progress, attempts, unrecoverable} — camera auto-recovery state. |
last_error |
string | Most recent capture error message, or null. |
last_error_epoch |
number | Unix time of that error, or null. Use it to tell a new failure from the same fault reported again — the message text alone cannot. |
Reflects the scheduled-capture settings on the Capture Settings tab.
| Field | Type | Description |
|---|---|---|
mode |
string |
always, gated (capture only inside the window) or variable (faster interval inside the window). |
source |
string |
fixed (the configured start and end times) or timelapse (the Timelapse recording window widened by the configured margin). |
start_time |
string | Window start, HH:MM. For timelapse, the start of the window currently in force. |
end_time |
string | Window end, HH:MM. |
window |
string | Human-readable label, e.g. 16:00 - 09:00, timelapse window ±15 min (17:45 - 06:15) or timelapse window (always on). |
in_window |
boolean | Whether now is inside the window. Always true in always mode. |
window_interval_seconds |
number | Seconds between captures inside the window in variable mode; null in other modes. |
| Field | Type | Description |
|---|---|---|
status |
string | One of the values below. |
reasons |
array of strings | Plain-English explanations; empty when ok. |
status |
Meaning |
|---|---|
ok |
Capture is running and producing fresh frames. |
idle |
Intentionally not capturing: capture is off, outside a gated schedule window, or enabled but not yet running. |
degraded |
Camera capture is running but no new frame has arrived for 3× the capture interval (at least 5 minutes). |
recovering |
Camera auto-recovery is in progress. |
error |
Capture failed, or the camera is unrecoverable and Sentinel needs restarting. |
Rejected requests return JSON with a machine-readable code. Branch on code, not on the message text:
{ "error": "Control API is not configured on this server.", "status": 503, "code": "control_disabled" }| HTTP | code |
Cause | Fix |
|---|---|---|---|
| 400 | bad_request |
Body is not valid UTF-8 JSON, not an object, wait is not a boolean, timeout is not a number between 1 and 300, a /nina/target field is missing or out of range, or Content-Length is invalid. The error text names the field. |
Fix the request. |
| 413 | body_too_large |
Body larger than 4096 bytes. | Send only the documented fields. |
| 401 | unauthorized |
Token missing, malformed (not Bearer <token>) or wrong. |
Copy the current token from the Output tab. |
| 403 | host_not_allowed |
Host header not on the allow-list. |
Call from the same machine, or see Host allow-list. |
| 503 | control_disabled |
Enable Capture Control API is off. | Turn it on in the Output tab. |
| 503 | control_unavailable |
Control is on and the token is valid, but Sentinel's capture controls aren't connected to the server (this shouldn't happen). Capture commands only; /nina/target never returns it. |
Restart Sentinel. |
| 500 | internal_error |
Unexpected error inside Sentinel. | Check Sentinel's Logs. |
The two 503 codes need opposite fixes — enable a setting versus restart Sentinel — which is why they are separate. Checks run in order: Host, then token, then body, so a request from a disallowed host gets 403 even if control is switched off.
The 500 failed and 504 timeout command outcomes are not in this table: they return the normal command response shown above, with result and message, and no code.
When Sentinel runs without its window (headless mode), the API behaves the same and uses the same settings. One difference: stop pauses the capture loop rather than closing Sentinel, so a later start resumes it. The desktop app stops and starts capture exactly as the Start/Stop button does.
If your client also shows the live frame from /latest, poll with If-None-Match to get 304 Not Modified when nothing has changed. Sentinel's ETag is an unquoted MD5 hex string, not a standard quoted entity tag. Send it back exactly as received — adding quotes means it never matches. Some HTTP libraries reject or drop an unquoted ETag, so read the raw header if yours does. Responses also carry X-PFR-Image-Age-Seconds, and X-PFR-Image-Stale: true once the frame is 5 minutes old.
Replace YOUR_TOKEN with the token from Copy, and 8080 if you changed the port.
In PowerShell, type curl.exe rather than curl. The body quoting below is for a Unix-style shell such as Git Bash.
# Pre-flight: is control ready?
curl -H "Authorization: Bearer YOUR_TOKEN" http://127.0.0.1:8080/capture
# Start, waiting up to 60 s for capture to run
curl -X POST http://127.0.0.1:8080/capture/start \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"wait": true, "timeout": 60}' \
--max-time 75
# Stop with the defaults (wait up to 30 s)
curl -X POST http://127.0.0.1:8080/capture/stop -H "Authorization: Bearer YOUR_TOKEN"This reads the port and token from Sentinel's own settings, so nothing needs pasting (run it as the same Windows user as Sentinel). The error-body handling shown is for PowerShell 7.
$cfg = Get-Content "$env:LOCALAPPDATA\PFRSentinel\config.json" -Raw | ConvertFrom-Json
$base = "http://127.0.0.1:$($cfg.output.webserver_port)"
$headers = @{ Authorization = "Bearer $($cfg.output.api_token)" }
# Pre-flight
$state = Invoke-RestMethod "$base/capture" -Headers $headers
"Ready: $($state.control_ready) State: $($state.capture.state) Health: $($state.health.status)"
# Start and wait up to 60 s
$body = @{ wait = $true; timeout = 60 } | ConvertTo-Json
try {
$r = Invoke-RestMethod "$base/capture/start" -Method Post -Headers $headers `
-ContentType 'application/json' -Body $body -TimeoutSec 75
"$($r.result): $($r.message)"
} catch {
# Non-2xx: the JSON body carries `code` (rejections) or `result` + `message` (failed / timeout)
$_.ErrorDetails.Message
}For a ready-made script with exit codes suited to NINA's External Script instruction, see NINA Integration.