Every endpoint on this page lives in one blueprint — src/routes/drone_routes.py (~1700 lines) — registered under url_prefix="/api/v1/" in src/main.py:223. It is the largest route file in the gateway and splits into three concerns:
  1. Drone CRUD — create/update/delete/list drones, subscription-gated at creation.
  2. /drone/action/* control commands — ~25 thin JWT handlers that delegate to DroneControlService, which dispatches MAVROS service calls and topic publishes over a pooled rosbridge WebSocket.
  3. Physical-drone bootstrap — two non-JWT endpoints (/drone/activate, /drone/pull) the drone itself calls to self-provision.
The handlers here are deliberately thin: they parse drone_id from the JSON body, resolve user_id from the JWT, and call one drone_control_service.<cmd>(...) method. All rosbridge framing, connection pooling, SITL IP translation, and the MAVLink command numbers live one layer down. For dispatch internals see DroneControlService & Rosbridge Dispatch.

Auth models on this blueprint

Almost everything is JWT Bearer (@jwt_required()). Two endpoints are exceptions — they authenticate the drone hardware, not a user:
EndpointAuth mechanismNotes
GET /drone/activate10-digit numeric token headerMatched via get_drone_by_token; not JWT. Returns 406 if the token is missing, not 10 digits, or unknown (drone_routes.py:1571-1582).
GET /drone/pullcheck_vpn_ip (source IP)Trusts a 10.71.x VPN source IP or X-Drone-IP header (src/middleware/drone_vpn.py). See VPN IP Authentication & Jumphost Routing.
Ownership is not re-checked on every command. CRUD handlers and the two arm endpoints resolve the drone with get_drone_by_id_and_user (owner-scoped), but most /drone/action/* handlers pass user_id + drone_id straight to the service. The connection pool (drone_mapping) is keyed by drone_id only and verifies ownership only when a new rosbridge connection is created — a cached connection is reused without re-checking. Preserve the ownership check when refactoring the pool. Detail in DroneControlService & Rosbridge Dispatch.

The command path

Drone CRUD

EndpointMethodSuccess codePurpose
/dronePOST201Create a drone (subscription-checked, may return 402).
/drone/{drone_id}PUT201Update name / type / mission_id.
/drone/{drone_id}DELETE201Delete a drone (async; tears down S3 assets + Janus room + containers).
/dronesGET201List the caller’s drones.
/drones/mission?missionId=GET201List drones assigned to a mission.
These older routes return raw jsonify bodies and use 201 for reads and updates (not 200) — e.g. GET /drones returns 201. The current Dashboard depends on these codes; changing them is a breaking change. Newer blueprints use the {success, data|error} envelope instead (see HTTP API Overview & Auth Models).

Create a drone (and the 402)

create_drone (drone_routes.py:34) counts the caller’s existing vehicles with get_drone_counts_by_type, then asks subscription_service.can_user_add_vehicle. If the plan does not allow another vehicle it returns HTTP 402 Payment Required with the reason — it does not raise:
// 402 Payment Required
{
  "message": "...human-readable...",
  "reason": "no_subscription",
  "subscription": { "...": "..." }
}
The reason string on a 402 is one of no_subscription (physical create with no active subscription), vehicle_limit_reached (paid plan at its ceiling), or subscription_inactive. Within-limit creates instead return the success codes with reason free_tier or allowed. Free tier is exactly 1 SITL vehicle; a physical drone requires an active Stripe subscription, and the paid SITL ceiling is vehicle_count + 1. See Stripe Billing & Vehicle Limits.
curl -X POST https://api.skyhub.ai/api/v1/drone \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{"type": "sitl", "name": "Test Copter", "vehicle_type": "copter"}'
type is physical | sitl; vehicle_type is copter | rover. A physical create allocates a drone IP and provisions a Janus room; a sitl create spawns a 3-container Docker stack and can block for up to ~80s. See SITL Drone Lifecycle.

Connection management

EndpointMethodPurpose
/drone/action/connection?id={drone_id}GETOpen (or return) the pooled rosbridge connection; polls up to 5s for it to go live, then 200 {drone_id} or 400.
/drone/action/connection?id={drone_id}DELETEClose the connection and drop it from the pool (200, or 500 on failure).
The UI is not required to call connection explicitly before a command — any /drone/action/* call lazily obtains/reconnects a pooled connection via get_client.

Control actions

All control actions are POST /drone/action/<name>, JWT Bearer, and take a JSON body whose only required field is drone_id. On success they return 200 with {"message": "successfully sent message"} (casing varies); on failure 500 with {"error": "..."}.
ActionExtra body fieldsMAVLink / MAVROS effect
armCOMMAND_LONG cmd 400 param1=1 (arm). Pre-pushes SITL video-room details.
arm_droneDuplicate of arm — same handler logic.
disarm_droneCOMMAND_LONG cmd 400 param1=0 (disarm).
takeoffaltitudeSet GUIDED → arm → CommandTOL takeoff.
landset_mode custom_mode LAND.
rtlset_mode custom_mode RTL.
guidedset_mode custom_mode GUIDED.
set_modemode_infoset_mode with mode_info as the raw custom_mode value.
moveposition_*, velocity_*, coordinate_frame, type_mask, yaw, yaw_ratePublish PositionTarget to /mavros/setpoint_raw/local.
goto_gps_locationlatitude, longitude, altitudePublish GlobalPositionTarget to /mavros/setpoint_raw/global.
set_paramparam_id, valueSet one ArduPilot param (with retry).
set_paramsparams ({name: value})Batch param set.
motortestmotor_id, percentageCOMMAND_LONG cmd 209 MAV_CMD_DO_MOTOR_TEST.
push_missionmission_idmission/push; auto-prepends a TAKEOFF waypoint.
push_geofencegeofence_idgeofence/push.
clear_fenceClear all fence items.
sync_geofencesReplace fence with all the user’s enabled geofences.
start_missiontakeoff_altitude?Push mission → GUIDED → arm → GUIDED takeoff.
video-sourcesource (CAMERA | TEST)Publish source change to the on-drone video module.

Arm / disarm (and the duplicate)

There are two functionally identical arm endpoints: POST /drone/action/arm (drone_routes.py:413) and POST /drone/action/arm_drone (drone_routes.py:1273). Both fetch the drone, and for SITL drones with a video_room_id they first push video-room details (status: START) so the core container has the room when it detects the ARMED state, then call drone_control_service.arm_drone. Disarm has only one endpoint, POST /drone/action/disarm_drone. All three ultimately send MAV_CMD_COMPONENT_ARM_DISARM (400) as a COMMAND_LONG.
curl -X POST https://api.skyhub.ai/api/v1/drone/action/arm \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"drone_id": 42}'

Flight modes and takeoff

land, rtl, and guided are fixed-mode shortcuts; set_mode is the general form.
set_mode body shape. The handler wraps whatever you send in mode_info directly as the MAVROS custom_mode field (drone_control_service.py:89), so mode_info must be the mode-name string — e.g. {"drone_id": 42, "mode_info": "AUTO"}. The Swagger docstring’s nested mode_info: {custom_mode: ...} object shape does not match the code and will produce a malformed command.
takeoff runs a three-step sequence — set GUIDED, arm, then CommandTOL to the requested altitude:
curl -X POST https://api.skyhub.ai/api/v1/drone/action/takeoff \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"drone_id": 42, "altitude": 10.0}'

Guided movement — move and goto_gps_location

These are the two footgun-prone bodies where the Swagger docstring and the actual keys the service reads diverge.
  • goto_gps_location reads latitude, longitude, altitude from the body (drone_control_service.py:433-435), not the lat/lng the docstring shows. Missing keys silently default to latitude=0, longitude=0, altitude=10.
  • move reads position_x/position_y/position_z, velocity_x/…, coordinate_frame (default 8), type_mask (default 4088), yaw, yaw_rate (drone_control_service.py:459-477), not the x/y/z the docstring shows. Every field is optional with a non-zero hardcoded default, so a body of just {"drone_id": 42} will command a move to a default position — validate inputs client-side.
curl -X POST https://api.skyhub.ai/api/v1/drone/action/goto_gps_location \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"drone_id": 42, "latitude": 47.3977, "longitude": 8.5456, "altitude": 30}'

Parameters

EndpointMethodPurpose
/drone/action/set_paramPOSTSet one param: {drone_id, param_id, value}.
/drone/action/set_paramsPOSTSet many: {drone_id, params: {NAME: value}}.
/drone/{drone_id}/params?params=CSVGETRead params (all if params omitted) → {params: [{param_id, value}]}.
/drone/{drone_id}/params/pullPOSTForce MAVROS to refresh its cache from the FCU → {success, param_received}.
GET /drone/{id}/params uses the batch GetParameters service (~1s for all params vs. minutes of individual reads). params/pull invokes the MAVROS ParamPull service before reads if the cache may be stale.
curl "https://api.skyhub.ai/api/v1/drone/42/params?params=FENCE_ENABLE,FENCE_ALT_MAX" \
  -H "Authorization: Bearer $JWT"

Motor test

motortest sends MAV_CMD_DO_MOTOR_TEST (209) as a COMMAND_LONG with motor_id (1-based) and percentage throttle:
curl -X POST https://api.skyhub.ai/api/v1/drone/action/motortest \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"drone_id": 42, "motor_id": 1, "percentage": 20}'

Missions and geofences

push_mission loads the mission’s stored MAVLink waypoints, auto-prepends a MAV_CMD_NAV_TAKEOFF (cmd 22) waypoint if the first stored command isn’t already a takeoff (drone_control_service.py:804), pushes via /mavros/mission/push, and fails loudly if wp_transfered doesn’t match the count sent. start_mission runs the full launch sequence (push mission → GUIDED → arm → GUIDED takeoff) and then relies on the frontend to switch to AUTO once altitude is reached — the server-side _monitor_takeoff_and_switch_to_auto helper exists but start_mission does not call it.
The older CLAUDE.md description of a server-generated reversed return path / RTL / speed waypoints is stale — the backend uploads the stored waypoints as-is plus the single TAKEOFF prepend. The waypoint payload format and how missions/geofences are persisted are covered in Missions & Geofences API and Mission & Geofence MAVLink Format.
curl -X POST https://api.skyhub.ai/api/v1/drone/action/push_mission \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"drone_id": 42, "mission_id": 7}'
# -> 200 {"message": "Mission uploaded successfully", "result": {"success": true, "waypoints_transferred": 5}}
push_geofence, clear_fence, and sync_geofences manage the on-FCU fence (sync_geofences replaces the fence with every enabled geofence for the user). These return {message, result} on 200 and {error} on 500.

Video source

POST /drone/action/video-source is the one action route using the newer {success, ...} envelope. It validates source against VIDEO_ROOM_SOURCES:
The allowed values are CAMERA and TEST (src/utils/drone_utils.py:3), returning 400 for anything else. The Swagger docstring’s enum: [main, thermal] is wrong. Video-room creation and the /video_room_* control topics are documented in Janus Video Rooms & On-Drone Video Control and Billing, Calendar, VPN, Video & Isaac Sim API.

Physical-drone bootstrap (non-JWT)

These two endpoints let a physical drone provision and update itself. Neither uses a user JWT.
1

GET /drone/activate — first boot

Authenticated by a 10-digit numeric token header. The gateway waits up to ~15s for the drone’s WireGuard VPN to come up (status_drone_vpn), fetches ECR docker-login credentials, presigns the correct compose file from INSTALLER_BUCKET (docker-compose.yml when DEPLOYMENT_ENVIRONMENT starts with dev, else docker-compose.prod.yml), then clears the activation token. Returns {username, password, repository, vpn, compose}. Errors: 406 (bad/unknown token), 500 (VPN timeout or failure).
2

GET /drone/pull — self-update

Authenticated purely by VPN source IP via check_vpn_ip. The gateway resolves the drone with get_drone_by_ip(request.vpn_ip), reads the SSM parameter /{RESOURCE_TAG}/pull_role, STS assume_role for 1 hour, and returns temporary AWS credentials ({accessKeyId, secretAccessKey, sessionToken, accountId, region}) so the drone can pull its own images from ECR.

Gotchas to preserve

/drone/action/arm and /drone/action/arm_drone are duplicates. Do not delete one without checking the Dashboard — both are live surface area. Disarm is only /drone/action/disarm_drone.
The inline Swagger for set_mode (mode_info object), move (x/y/z), goto_gps_location (lat/lng), and video-source (main/thermal) does not match the keys/values the code actually reads. Trust drone_control_service.py and drone_utils.py, not the docstrings.
Drone CRUD returns 201 even for GET /drones, and most action routes return bare {message}/{error} bodies. Only video-source uses the {success, ...} envelope. The UI is coupled to these exact shapes and codes.
start_mission only gets the vehicle airborne in GUIDED; the AUTO switch is a client responsibility. The unused _monitor_takeoff_and_switch_to_auto server helper is an alternate path, not the live one.

DroneControlService & Rosbridge Dispatch

Connection pooling, SITL IP translation, MAVLink command numbers, and telemetry emission behind every action.

Socket.IO Telemetry Streaming

Subscribe to live GPS, altitude, logs, and the combined dashboard stream after connecting.

Missions & Geofences API

Build and persist the missions and fences that push_mission / push_geofence upload.

SITL Drone Lifecycle

What a type: sitl create actually spins up in Docker.

Stripe Billing & Vehicle Limits

Why POST /drone can return 402 and how vehicle quotas are enforced.

VPN IP Authentication & Jumphost Routing

The check_vpn_ip model behind /drone/pull and other drone callbacks.