/gateway/api/*.
Everything HTTP lives under the base path
/api/v1. All 12 blueprints register with url_prefix="/api/v1/" in src/main.py:222-233, and every route rule also begins with /, so the effective base is /api/v1 (the doubled slash collapses). Interactive Swagger 2.0 UI is served at GET /api/docs/ and the raw spec at GET /apispec.json (both public, generated by flasgger).Auth models at a glance
Three distinct authentication models coexist on the same app. The table below defines the short tags used in every endpoint table on this page. See/gateway/api/overview and /architecture/auth-and-security for the full model.
| Tag | Mechanism | Where enforced | Used by |
|---|---|---|---|
| JWT | Authorization: Bearer <access_token> — HS256, flask-jwt-extended | @jwt_required() | The Dashboard UI |
| JWT-refresh | Bearer refresh token | @jwt_required(refresh=True) | Token refresh only |
| VPN-IP | Source IP starts with 10.71. (or X-Drone-IP header) — no token at all | @check_vpn_ip (src/middleware/drone_vpn.py) | On-drone / gamepad callbacks |
| Token | Path/header token or Bearer video-room token or Stripe-Signature | per-route manual check | Activation, video-room create, Stripe webhook |
| Public | none | — | Login, pricing, webhook, docs |
Response envelope (read this before parsing bodies)
Two envelope conventions coexist and you must handle both:- Newer routes (
auth,executions,assets,billing,calendar,user) useget_success_response/get_error_responsefromsrc/utils/common_helper.py:- success →
{"success": true, "data": ..., "message": ...} - error →
{"success": false, "error": {"code": <int>, "message": ...}}with the same HTTP status.
- success →
- Older routes (
drone,mission,geofence) return rawjsonify(...)with bare fields and frequently return201for reads and updates (e.g.GET /drones,PATCH /mission/point/{id}). vpnreturns rawjsonify(...)with bare fields (e.g.{url},{status},{droneId: {is_active}}) — nosuccess/dataenvelope. See thevpn_routestable below.
Authentication — auth_routes
src/routes/auth_routes.py · full lifecycle in /gateway/api/authentication. Access tokens expire in 10 min, refresh tokens in 12 hr (src/main.py).
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| GET | /api/v1/auth | JWT | Validate access token → 200 {message: "valid token"} |
| GET | /api/v1/auth/refresh | JWT-refresh | Issue new {access_token, refresh_token} |
| POST | /api/v1/login | Public | Body {username(email), password} → 200 {access_token, refresh_token} / 401. On failure the body is a serialized ({"error": ...}, 401) tuple — shape differs from success. |
| POST | /api/v1/verify-email | Public* | Body {email} → sends verification mail. Gated by ENABLE_REGISTRATION (403 when off). |
| POST | /api/v1/register/<token> | Token (email) | Body {password} → create account. Gated by ENABLE_REGISTRATION. |
| DELETE | /api/v1/logout | JWT | Adds jti to the in-memory BLOCKLIST (auth_routes.py:17). |
| POST | /api/v1/activate/<int:user_id>/<string:token> | Token (path) | Sets is_active; returns {access_token, refresh_token} carrying an extra level claim / 401. |
| POST | /api/v1/reset-password | Public | Body {email} → sends reset mail. |
| POST | /api/v1/update-password | JWT | Body {password} → update current user’s password. |
Current user — user_routes
src/routes/user_routes.py.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| GET | /api/v1/user | JWT | Current user to_dict(). Latent bug: the 404/500 paths call get_error_response("...", 404) with args reversed (code/message swapped), so those paths raise. |
Drones & control actions — drone_routes
src/routes/drone_routes.py (~1700 lines). Narrative + body detail in /gateway/api/drones-and-actions; the underlying dispatch is /gateway/services/drone-control. All /drone/action/* commands take {drone_id} at minimum and return 200/500 unless noted.
CRUD & connection
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| POST | /api/v1/drone | JWT | Body {type: physical|sitl, name, vehicle_type: copter|rover}. 402 when over subscription limit (can_user_add_vehicle). async def. |
| PUT | /api/v1/drone/<int:drone_id> | JWT | Body {type, name, mission_id} → 201. |
| DELETE | /api/v1/drone/<int:drone_id> | JWT | Delete drone → 201. async def. |
| GET | /api/v1/drones | JWT | List user’s drones → 201 (not 200). |
| GET | /api/v1/drones/mission?missionId= | JWT | List drones by mission → 201. |
| GET | /api/v1/drone/action/connection?id= | JWT | Open/return pooled rosbridge connection (polls up to 5s) → 200 {drone_id} / 400. |
| DELETE | /api/v1/drone/action/connection?id= | JWT | Close the pooled connection. |
Flight commands (/drone/action/*)
| Method · Path | Body (beyond drone_id) | Purpose |
|---|---|---|
POST /drone/action/arm | — | Arm (pre-pushes SITL video room). |
POST /drone/action/arm_drone | — | Duplicate of /arm (both call arm_drone). |
POST /drone/action/disarm_drone | — | Disarm (the only disarm route). |
POST /drone/action/takeoff | {altitude} | GUIDED takeoff. |
POST /drone/action/land | — | Land. |
POST /drone/action/rtl | — | Return-to-launch mode. |
POST /drone/action/guided | — | Switch to GUIDED. |
POST /drone/action/set_mode | {mode_info:{custom_mode}} | Set arbitrary flight mode. |
POST /drone/action/set_param | {param_id, value} | Set one ArduPilot param. |
POST /drone/action/set_params | {params:{name:value}} | Set multiple params. |
POST /drone/action/motortest | {motor_id, percentage} | Individual motor test. |
POST /drone/action/move | {x, y, z} | Goto local position (setpoint). |
POST /drone/action/goto_gps_location | {lat, lng, altitude} | Goto GPS position. |
POST /drone/action/push_mission | {mission_id} | Upload waypoints (auto-prepends TAKEOFF cmd 22, verifies wp_transfered) → 200 {message, result}. |
POST /drone/action/start_mission | {takeoff_altitude?} | GUIDED takeoff; frontend switches to AUTO. |
POST /drone/action/push_geofence | {geofence_id} | Upload one geofence to FCU. |
POST /drone/action/clear_fence | — | Clear all fence items. |
POST /drone/action/sync_geofences | — | Replace fence with all enabled geofences. |
POST /drone/action/video-source | {source} | Update video source. Allowed values are CAMERA, TEST (drone_utils.VIDEO_ROOM_SOURCES) → 400 on anything else. Swagger’s [main, thermal] enum is stale. |
Params & drone bootstrap
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| GET | /api/v1/drone/<int:drone_id>/params?params=csv | JWT | Read params → 200 {params:[{param_id,value}]}. |
| POST | /api/v1/drone/<int:drone_id>/params/pull | JWT | Force MAVROS to re-cache params from FCU → 200 {success, param_received}. |
| GET | /api/v1/drone/activate | Token (header) | Physical-drone bootstrap. 10-digit token header. Waits ≤15s for VPN up, then returns {username, password, repository, vpn, compose} (ECR login + presigned VPN config + presigned docker-compose) and clears the activation token. 406/500 on failure. |
| GET | /api/v1/drone/pull | VPN-IP | Drone self-update: SSM pull_role + STS assume_role → temporary 1h AWS creds {accessKeyId, secretAccessKey, sessionToken, accountId, region}. |
Executions, logs & report emails — execution_routes
src/routes/execution_routes.py. Split by auth: drone/gamepad callbacks are VPN-IP, UI reads are JWT. Detail in /gateway/api/executions-assets-reports and /gateway/services/executions-reports.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| POST | /api/v1/executions/start | VPN-IP | On ARM. Body {mission_id?} → 200 execution / 404 drone not found. |
| POST | /api/v1/executions/<int:execution_id>/complete | VPN-IP | On land/disarm. Body {status: completed|aborted|error, notes?}. Enqueues or defers the report. 400/403/404. |
| POST | /api/v1/executions/<int:execution_id>/log | VPN-IP | Body {asset_id} attaches the .bin; triggers async pymavlink analysis (bounded ThreadPoolExecutor). |
| GET | /api/v1/executions/current | VPN-IP | In-progress execution for the calling VPN drone → 200 execution|null. |
| GET | /api/v1/executions/vehicle/<int:drone_id>?limit&offset | JWT | List executions for a drone (ownership checked) → 200/403. |
| GET | /api/v1/executions?limit&offset | JWT | List all executions for the user. |
| GET | /api/v1/executions/<int:execution_id> | JWT | Execution detail with assets → 200/404. |
| DELETE | /api/v1/executions/<int:execution_id> | JWT | Delete execution + linked assets (S3 + DB) + archive. |
| POST | /api/v1/executions/<int:execution_id>/analyze-log | JWT | Synchronous manual pymavlink analysis → flight stats. |
| GET | /api/v1/executions/<int:execution_id>/archive | JWT | Presigned ZIP URL (generates if missing) → 200 {status, url, expires_at, ...}. |
| POST | /api/v1/executions/<int:execution_id>/archive | JWT | Regenerate the archive ZIP. |
| POST | /api/v1/executions/<int:execution_id>/send-report | JWT | Body {email} → email the execution report. |
| GET | /api/v1/executions/<int:execution_id>/flight-stats | JWT | Stored flight stats → 200/404. |
| GET | /api/v1/user/report-emails | JWT | {email, extra_emails, all_recipients}. |
| PUT | /api/v1/user/report-emails | JWT | Body {extra_emails: csv} (validated) → 200/400. |
The report is deferred if assets are still uploading:
complete_execution marks it pending, and complete_upload dispatches it once the last pending asset lands. See the pipeline in /gateway/services/executions-reports.Assets & media — asset_routes
src/routes/asset_routes.py. Upload pair is VPN-IP (drone), listing/download/delete are JWT. S3 layout and HLS rewriting in /gateway/services/assets-archives.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| POST | /api/v1/authenticate_upload | VPN-IP | Body {file_name, checksum?, asset_type: video|image|logs, mime_type?, execution_id?} → presigned S3 PUT + asset row {success, url, asset_id}. 400/401/409/500. |
| POST | /api/v1/complete_upload | VPN-IP | Body {asset_id} → mark ready (thumbnail for images); may dispatch a deferred report → {success, asset_id, user_id, drone_id}. |
| GET | /api/v1/assets/user | JWT | Asset counts grouped by vehicle. |
| GET | /api/v1/assets/<int:vehicle_id> | JWT | All assets for a vehicle. |
| GET | /api/v1/assets/video/<int:vehicle_id> | JWT | Video assets only → 200/404. |
| GET | /api/v1/assets/<int:vehicle_id>/video/<int:asset_id> | JWT | Rewritten HLS playlist, raw body Content-Type: application/vnd.apple.mpegurl. |
| GET | /api/v1/assets/<int:vehicle_id>/download/<int:asset_id> | JWT | Presigned download URL {download_url, file_name, asset_type}. |
| POST | /api/v1/assets/<int:vehicle_id>/delete | JWT | Body {ids:[int]} → delete S3 + DB. 400 on empty ids. |
asset_routes imports get_*_response from utils.common_helper while most routes import from src.utils.common_helper — two module paths for the same helper; keep both importable.Missions — mission_routes
src/routes/mission_routes.py. MAVLink waypoint format detail in /gateway/data/mission-waypoint-format.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| POST | /api/v1/mission | JWT | Body {name} → 201 {id, name, mission_points:[]}. |
| POST | /api/v1/mission/point | JWT | Array body [{mission_id, lat, lng, altitude, order}] — uses element [0] → 201. |
| PATCH | /api/v1/mission/point/<int:mission_point_id> | JWT | Body {lat, lng, altitude, order} → 201 / 404. |
| GET | /api/v1/mission/point/<int:mission_point_id> | JWT | Get one point → 200/404. |
| GET | /api/v1/missions | JWT | List missions with points → 200. |
| GET | /api/v1/mission/<int:mission_id> | JWT | Mission with points → 200/404. |
| DELETE | /api/v1/mission/<int:mission_id> | JWT | Delete mission. 400 if still assigned to a drone (FK). |
| DELETE | /api/v1/mission/point/<int:mission_point_id> | JWT | Delete point → 201. |
| PATCH | /api/v1/mission/<int:mission_id>/points | JWT | Batch update [{id, lat, lng, altitude, order}] → 201. |
Geofences — geofence_routes
src/routes/geofence_routes.py. Registered in main.py even though the CLAUDE.md overview omits geofences. Same MAVLink-fence semantics as /gateway/data/mission-waypoint-format.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| POST | /api/v1/geofence | JWT | Body {name, type: polygon|circle, fence_type: inclusion|exclusion, enabled?} → 201. |
| POST | /api/v1/geofence/point | JWT | Array body [{geofence_id, lat, lng, order}] — uses [0] → 201. |
| GET | /api/v1/geofences | JWT | List geofences. |
| GET | /api/v1/geofence/<int:geofence_id> | JWT | Geofence with points → 200/404. |
| PATCH | /api/v1/geofence/<int:geofence_id> | JWT | Body {name, type, fence_type, enabled}. |
| PATCH | /api/v1/geofence/<int:geofence_id>/toggle | JWT | Toggle enabled. |
| DELETE | /api/v1/geofence/<int:geofence_id> | JWT | Delete geofence. |
| GET | /api/v1/geofence/point/<int:point_id> | JWT | Get one point. |
| PATCH | /api/v1/geofence/point/<int:point_id> | JWT | Body {lat, lng, order}. |
| DELETE | /api/v1/geofence/point/<int:point_id> | JWT | Delete one point. |
| PATCH | /api/v1/geofence/<int:geofence_id>/points | JWT | Batch update [{id, lat, lng, order}]. |
Video rooms — video_room_routes
src/routes/video_room_routes.py. Janus room + on-drone control in /gateway/services/video-rooms.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| POST | /api/v1/video_room | Token (Bearer) | Create Janus room. Auth is the drone’s video_room_token as Authorization: Bearer — NOT JWT (matched via get_drones_by_video_room_id_and_token). Body {room_number}. Returns 402 when the token is missing. async def. |
| GET | /api/v1/video_room/<int:drone_id>/start?update= | JWT | Start stream (optionally re-push room details) → 200/400. |
| GET | /api/v1/video_room/<int:drone_id>/stop | JWT | Stop stream. |
| GET | /api/v1/video_room/<int:drone_id>/restart | JWT | Stop, recreate Janus room, restart stream. async def. |
VPN config & status — vpn_routes
src/routes/vpn_routes.py. Presigned WireGuard config URLs + status proxy. See /gateway/security/vpn-middleware-jumphost and /ecosystem/user-vpn.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| GET | /api/v1/user/vpn | JWT | Presigned user WireGuard config URL → 200 {url}. |
| GET | /api/v1/drone/<int:drone_id>/vpn | JWT | Presigned drone VPN config URL → 200 {url}. |
| GET | /api/v1/drone/<int:drone_id>/vpn/status | JWT | Drone VPN connection status → 200 {status}. |
| GET | /api/v1/user/vpn/status | JWT | User VPN connection status. |
| GET | /api/v1/drones_status | JWT | Connection status for all user drones → 200 {droneId:{is_active}}. SITL drones always report is_active: true. |
Billing — billing_routes
src/routes/billing_routes.py. Stripe integration; pricing and gating in /gateway/services/billing.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| GET | /api/v1/billing/pricing | Public | {data:{price_per_vehicle, currency, billing_period, ...}}. |
| GET | /api/v1/billing/subscription | JWT | Current subscription {data|null}. |
| POST | /api/v1/billing/checkout | JWT | Body {vehicle_count>=1} → {checkout_url, session_id}. |
| POST | /api/v1/billing/portal | JWT | Stripe customer-portal session {portal_url}. |
| PUT | /api/v1/billing/subscription/vehicles | JWT | Body {vehicle_count>=1} → change quantity. |
| POST | /api/v1/billing/subscription/cancel | JWT | Body {immediate?} → cancel (at period end unless immediate). |
| POST | /api/v1/billing/subscription/reactivate | JWT | Undo a cancel-at-period-end. |
| GET | /api/v1/billing/payments?limit | JWT | Payment history (limit clamped 1–100). |
| GET | /api/v1/billing/can-add-vehicle?vehicle_type | JWT | {data:{can_add, reason, message, subscription}}. |
| POST | /api/v1/billing/webhook | Token (sig) | Stripe webhook. Stripe-Signature verified (checkout/subscription/invoice events). 400 on invalid signature. |
Calendar — calendar_routes
src/routes/calendar_routes.py. ISO-8601 datetimes normalized to UTC; recurrence via iCal RRULE.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| GET | /api/v1/calendar/events?start&end&drone_id | JWT | List events in a range (expands recurrences) → 200 {data:[event]}. |
| GET | /api/v1/calendar/events/<int:event_id> | JWT | One event → 200/404. |
| POST | /api/v1/calendar/events | JWT | Body {title, scheduled_time, drone_id, mission_id, description?, status?, recurrence_rule?} → 201. |
| PUT | /api/v1/calendar/events/<int:event_id> | JWT | Update (optionally link execution_id). |
| DELETE | /api/v1/calendar/events/<int:event_id> | JWT | Delete event. |
| PUT | /api/v1/calendar/events/<int:event_id>/occurrences/status | JWT | Body {occurrence_time, status, execution_id?} → status of one recurring occurrence. |
| DELETE | /api/v1/calendar/events/<int:event_id>/occurrences?occurrence_time | JWT | Delete one occurrence (adds EXDATE). |
Isaac Sim EC2 control — isaac_sim_routes
src/routes/isaac_sim_routes.py. Controls a single EC2 instance; IsaacSimService() is instantiated at module import (line 16). See /gateway/services/platform-services.
| Method | Path | Auth | Purpose & notes |
|---|---|---|---|
| GET | /api/v1/isaac-sim/status | JWT | Instance status/cost {status, instance_id, public_ip, ...}. |
| POST | /api/v1/isaac-sim/start | JWT | Start the instance. |
| POST | /api/v1/isaac-sim/stop | JWT | Stop the instance (returns a cost summary). |
| GET | /api/v1/isaac-sim/metadata | JWT | Instance metadata (type, GPU, cost) {status, data}. |
Socket.IO telemetry namespace
Real-time telemetry rides a single Flask-SocketIO namespace (gevent async mode). Handlers live insrc/routes/socket_routes.py; emission is DroneControlService._emit_* in src/service/drone_control_service.py. Full narrative in /gateway/api/telemetry-socketio and the client side in /dashboard/telemetry-socketio.
Handshake: connect with the JWT in the query param ?token=<jwt> (e.g. wss://<host>/socket.io/?token=<access_token>). handle_connect decodes HS256, reads sub, and stores it as session["user_id"].
Client → Server events
| Event | Payload | Behavior |
|---|---|---|
connect | ?token=<jwt> (query) | Decode HS256, store user_id; emits connected or error+disconnect. |
subscribe_telemetry | {drone_id, stream_type: gps|logs|relalt|system|dashboard} | Joins drone_{drone_id}_{stream_type} and starts the matching rosbridge subscription. |
unsubscribe_telemetry | {drone_id, stream_type} | Leaves the room; only stops the rosbridge topic when DO_UNSUBSCRIBE is true. |
disconnect | — | Room-based cleanup (automatic). |
Server → Client events
| Event | Payload | Emitted when |
|---|---|---|
connected | {status, user_id} | Handshake succeeds. |
subscription_confirmed | {status: subscribed|unsubscribed, stream_type, drone_id} | After (un)subscribe. |
telemetry_data | {type, drone_id, data} | Per rosbridge message, to room drone_{id}_{suffix} / drone_{id}_dashboard. |
error | {message} | Auth or subscription failure. |
Only
dashboard delivers meaningful data in practice. Selecting it fans out to GPS, GPS_RAW, home position, rel-alt, /rosout logs, VFR_HUD, IMU, and /diagnostics, and the server injects a computed yaw into the GPS payload (VFR_HUD heading first, else IMU quaternion → compass). The single-stream rooms (gps/logs/relalt/system) exist but are effectively dead in the current UI.Status-code & contract quirks (do not “fix” blindly)
201 returned for reads and updates
201 returned for reads and updates
GET /drones, GET /drones/mission, PATCH /mission/point/{id}, DELETE /mission/point/{id}, PATCH /mission/{id}/points all return 201. The Dashboard expects these — changing them breaks the UI.402 Payment Required is overloaded
402 Payment Required is overloaded
POST /drone returns 402 when over the subscription limit (legitimate), but POST /video_room also returns 402 when the auth token is missing — an unusual use of Payment Required for an auth error.Duplicate arm endpoints, single disarm
Duplicate arm endpoints, single disarm
POST /drone/action/arm and POST /drone/action/arm_drone are functionally identical (both call arm_drone and pre-push the SITL video room). Disarm is only POST /drone/action/disarm_drone.Swagger enum drift on video-source
Swagger enum drift on video-source
Swagger documents
video-source as [main, thermal], but the code accepts only ["CAMERA", "TEST"] (drone_utils.VIDEO_ROOM_SOURCES); anything else → 400. Trust the code.user_routes error paths raise
user_routes error paths raise
GET /user calls get_error_response("User not found", 404) with code/message reversed. Because get_error_response(code, message) returns (..., code), the 404/500 branches return a string status and raise — a latent bug to fix deliberately, not by accident.async def handlers
async def handlers
create_drone, delete_drone, create_video_room, and restart_video_stream are Flask async def (require flask[async]/gevent); restart_video_stream mixes a sync time.sleep.Where to go next
HTTP API Overview & Auth Models
Base path, blueprint registration, envelope, and the three auth models in narrative form.
Drone Management & Actions
Body semantics for every
/drone/action/* command.Socket.IO Telemetry
Stream types, dashboard fan-out, and room-naming security.
Environment Variables
ENABLE_REGISTRATION, ENABLE_SITL, JWT/Socket.IO tuning, and the rest.Database Schema
The models behind these payloads.
Redis Channels & MAVLink Ports
The out-of-band gamepad/telemetry channels.

