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:
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

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:
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.
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

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": "..."}.

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.

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:

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.

Parameters

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.

Motor test

motortest sends MAV_CMD_DO_MOTOR_TEST (209) as a COMMAND_LONG with motor_id (1-based) and percentage throttle:

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.
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.