This is the lookup page for the entire external contract of the SkyHub Gateway Service: every HTTP route across the 12 Flask blueprints plus the Socket.IO telemetry namespace. It is intentionally terse and table-heavy. For the narrative — auth flows, body semantics, and the reasoning behind each group — follow the links into /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.
TagMechanismWhere enforcedUsed by
JWTAuthorization: Bearer <access_token> — HS256, flask-jwt-extended@jwt_required()The Dashboard UI
JWT-refreshBearer refresh token@jwt_required(refresh=True)Token refresh only
VPN-IPSource 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
TokenPath/header token or Bearer video-room token or Stripe-Signatureper-route manual checkActivation, video-room create, Stripe webhook
PublicnoneLogin, pricing, webhook, docs
VPN-IP is the sole auth for drone callbacks (/drone/pull, /executions/{start,complete,log,current}, /authenticate_upload, /complete_upload). check_vpn_ip trusts remote_addr / X-Real-IP / X-Forwarded-For beginning with 10.71., plus the X-Drone-IP header (SKYHUB_SITL_* container names when ENABLE_SITL, or local IPs in dev). There is no JWT on these routes — anything that can set those headers behind the proxy is trusted. Preserve the jumphost/nginx source-IP handling described in /gateway/security/vpn-middleware-jumphost.

Response envelope (read this before parsing bodies)

Two envelope conventions coexist and you must handle both:
  • Newer routes (auth, executions, assets, billing, calendar, user) use get_success_response/get_error_response from src/utils/common_helper.py:
    • success → {"success": true, "data": ..., "message": ...}
    • error → {"success": false, "error": {"code": <int>, "message": ...}} with the same HTTP status.
  • Older routes (drone, mission, geofence) return raw jsonify(...) with bare fields and frequently return 201 for reads and updates (e.g. GET /drones, PATCH /mission/point/{id}).
  • vpn returns raw jsonify(...) with bare fields (e.g. {url}, {status}, {droneId: {is_active}}) — no success/data envelope. See the vpn_routes table below.
Do not “normalize” these status codes in a refactor — the current Dashboard depends on the exact codes (including the 201-for-reads). Known quirks are flagged inline in the tables 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).
MethodPathAuthPurpose & notes
GET/api/v1/authJWTValidate access token → 200 {message: "valid token"}
GET/api/v1/auth/refreshJWT-refreshIssue new {access_token, refresh_token}
POST/api/v1/loginPublicBody {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-emailPublic*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/logoutJWTAdds 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-passwordPublicBody {email} → sends reset mail.
POST/api/v1/update-passwordJWTBody {password} → update current user’s password.
BLOCKLIST is a per-process set() marked TODO (auth_routes.py:17). Logout revocation is not persisted and not shared across gunicorn workers, and the Socket.IO handshake never consults it — a logged-out token still works on other workers and on Socket.IO.

Current user — user_routes

src/routes/user_routes.py.
MethodPathAuthPurpose & notes
GET/api/v1/userJWTCurrent 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

MethodPathAuthPurpose & notes
POST/api/v1/droneJWTBody {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>JWTBody {type, name, mission_id}201.
DELETE/api/v1/drone/<int:drone_id>JWTDelete drone → 201. async def.
GET/api/v1/dronesJWTList user’s drones → 201 (not 200).
GET/api/v1/drones/mission?missionId=JWTList drones by mission → 201.
GET/api/v1/drone/action/connection?id=JWTOpen/return pooled rosbridge connection (polls up to 5s) → 200 {drone_id} / 400.
DELETE/api/v1/drone/action/connection?id=JWTClose the pooled connection.

Flight commands (/drone/action/*)

Method · PathBody (beyond drone_id)Purpose
POST /drone/action/armArm (pre-pushes SITL video room).
POST /drone/action/arm_droneDuplicate of /arm (both call arm_drone).
POST /drone/action/disarm_droneDisarm (the only disarm route).
POST /drone/action/takeoff{altitude}GUIDED takeoff.
POST /drone/action/landLand.
POST /drone/action/rtlReturn-to-launch mode.
POST /drone/action/guidedSwitch 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_fenceClear all fence items.
POST /drone/action/sync_geofencesReplace 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

MethodPathAuthPurpose & notes
GET/api/v1/drone/<int:drone_id>/params?params=csvJWTRead params → 200 {params:[{param_id,value}]}.
POST/api/v1/drone/<int:drone_id>/params/pullJWTForce MAVROS to re-cache params from FCU → 200 {success, param_received}.
GET/api/v1/drone/activateToken (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/pullVPN-IPDrone 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.
MethodPathAuthPurpose & notes
POST/api/v1/executions/startVPN-IPOn ARM. Body {mission_id?}200 execution / 404 drone not found.
POST/api/v1/executions/<int:execution_id>/completeVPN-IPOn land/disarm. Body {status: completed|aborted|error, notes?}. Enqueues or defers the report. 400/403/404.
POST/api/v1/executions/<int:execution_id>/logVPN-IPBody {asset_id} attaches the .bin; triggers async pymavlink analysis (bounded ThreadPoolExecutor).
GET/api/v1/executions/currentVPN-IPIn-progress execution for the calling VPN drone → 200 execution|null.
GET/api/v1/executions/vehicle/<int:drone_id>?limit&offsetJWTList executions for a drone (ownership checked) → 200/403.
GET/api/v1/executions?limit&offsetJWTList all executions for the user.
GET/api/v1/executions/<int:execution_id>JWTExecution detail with assets → 200/404.
DELETE/api/v1/executions/<int:execution_id>JWTDelete execution + linked assets (S3 + DB) + archive.
POST/api/v1/executions/<int:execution_id>/analyze-logJWTSynchronous manual pymavlink analysis → flight stats.
GET/api/v1/executions/<int:execution_id>/archiveJWTPresigned ZIP URL (generates if missing) → 200 {status, url, expires_at, ...}.
POST/api/v1/executions/<int:execution_id>/archiveJWTRegenerate the archive ZIP.
POST/api/v1/executions/<int:execution_id>/send-reportJWTBody {email} → email the execution report.
GET/api/v1/executions/<int:execution_id>/flight-statsJWTStored flight stats → 200/404.
GET/api/v1/user/report-emailsJWT{email, extra_emails, all_recipients}.
PUT/api/v1/user/report-emailsJWTBody {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.
MethodPathAuthPurpose & notes
POST/api/v1/authenticate_uploadVPN-IPBody {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_uploadVPN-IPBody {asset_id} → mark ready (thumbnail for images); may dispatch a deferred report → {success, asset_id, user_id, drone_id}.
GET/api/v1/assets/userJWTAsset counts grouped by vehicle.
GET/api/v1/assets/<int:vehicle_id>JWTAll assets for a vehicle.
GET/api/v1/assets/video/<int:vehicle_id>JWTVideo assets only → 200/404.
GET/api/v1/assets/<int:vehicle_id>/video/<int:asset_id>JWTRewritten HLS playlist, raw body Content-Type: application/vnd.apple.mpegurl.
GET/api/v1/assets/<int:vehicle_id>/download/<int:asset_id>JWTPresigned download URL {download_url, file_name, asset_type}.
POST/api/v1/assets/<int:vehicle_id>/deleteJWTBody {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.
MethodPathAuthPurpose & notes
POST/api/v1/missionJWTBody {name}201 {id, name, mission_points:[]}.
POST/api/v1/mission/pointJWTArray body [{mission_id, lat, lng, altitude, order}] — uses element [0]201.
PATCH/api/v1/mission/point/<int:mission_point_id>JWTBody {lat, lng, altitude, order}201 / 404.
GET/api/v1/mission/point/<int:mission_point_id>JWTGet one point → 200/404.
GET/api/v1/missionsJWTList missions with points → 200.
GET/api/v1/mission/<int:mission_id>JWTMission with points → 200/404.
DELETE/api/v1/mission/<int:mission_id>JWTDelete mission. 400 if still assigned to a drone (FK).
DELETE/api/v1/mission/point/<int:mission_point_id>JWTDelete point → 201.
PATCH/api/v1/mission/<int:mission_id>/pointsJWTBatch 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.
MethodPathAuthPurpose & notes
POST/api/v1/geofenceJWTBody {name, type: polygon|circle, fence_type: inclusion|exclusion, enabled?}201.
POST/api/v1/geofence/pointJWTArray body [{geofence_id, lat, lng, order}] — uses [0]201.
GET/api/v1/geofencesJWTList geofences.
GET/api/v1/geofence/<int:geofence_id>JWTGeofence with points → 200/404.
PATCH/api/v1/geofence/<int:geofence_id>JWTBody {name, type, fence_type, enabled}.
PATCH/api/v1/geofence/<int:geofence_id>/toggleJWTToggle enabled.
DELETE/api/v1/geofence/<int:geofence_id>JWTDelete geofence.
GET/api/v1/geofence/point/<int:point_id>JWTGet one point.
PATCH/api/v1/geofence/point/<int:point_id>JWTBody {lat, lng, order}.
DELETE/api/v1/geofence/point/<int:point_id>JWTDelete one point.
PATCH/api/v1/geofence/<int:geofence_id>/pointsJWTBatch 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.
MethodPathAuthPurpose & notes
POST/api/v1/video_roomToken (Bearer)Create Janus room. Auth is the drone’s video_room_token as Authorization: BearerNOT 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=JWTStart stream (optionally re-push room details) → 200/400.
GET/api/v1/video_room/<int:drone_id>/stopJWTStop stream.
GET/api/v1/video_room/<int:drone_id>/restartJWTStop, 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.
MethodPathAuthPurpose & notes
GET/api/v1/user/vpnJWTPresigned user WireGuard config URL → 200 {url}.
GET/api/v1/drone/<int:drone_id>/vpnJWTPresigned drone VPN config URL → 200 {url}.
GET/api/v1/drone/<int:drone_id>/vpn/statusJWTDrone VPN connection status → 200 {status}.
GET/api/v1/user/vpn/statusJWTUser VPN connection status.
GET/api/v1/drones_statusJWTConnection 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.
MethodPathAuthPurpose & notes
GET/api/v1/billing/pricingPublic{data:{price_per_vehicle, currency, billing_period, ...}}.
GET/api/v1/billing/subscriptionJWTCurrent subscription {data|null}.
POST/api/v1/billing/checkoutJWTBody {vehicle_count>=1}{checkout_url, session_id}.
POST/api/v1/billing/portalJWTStripe customer-portal session {portal_url}.
PUT/api/v1/billing/subscription/vehiclesJWTBody {vehicle_count>=1} → change quantity.
POST/api/v1/billing/subscription/cancelJWTBody {immediate?} → cancel (at period end unless immediate).
POST/api/v1/billing/subscription/reactivateJWTUndo a cancel-at-period-end.
GET/api/v1/billing/payments?limitJWTPayment history (limit clamped 1–100).
GET/api/v1/billing/can-add-vehicle?vehicle_typeJWT{data:{can_add, reason, message, subscription}}.
POST/api/v1/billing/webhookToken (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.
MethodPathAuthPurpose & notes
GET/api/v1/calendar/events?start&end&drone_idJWTList events in a range (expands recurrences) → 200 {data:[event]}.
GET/api/v1/calendar/events/<int:event_id>JWTOne event → 200/404.
POST/api/v1/calendar/eventsJWTBody {title, scheduled_time, drone_id, mission_id, description?, status?, recurrence_rule?}201.
PUT/api/v1/calendar/events/<int:event_id>JWTUpdate (optionally link execution_id).
DELETE/api/v1/calendar/events/<int:event_id>JWTDelete event.
PUT/api/v1/calendar/events/<int:event_id>/occurrences/statusJWTBody {occurrence_time, status, execution_id?} → status of one recurring occurrence.
DELETE/api/v1/calendar/events/<int:event_id>/occurrences?occurrence_timeJWTDelete 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.
MethodPathAuthPurpose & notes
GET/api/v1/isaac-sim/statusJWTInstance status/cost {status, instance_id, public_ip, ...}.
POST/api/v1/isaac-sim/startJWTStart the instance.
POST/api/v1/isaac-sim/stopJWTStop the instance (returns a cost summary).
GET/api/v1/isaac-sim/metadataJWTInstance metadata (type, GPU, cost) {status, data}.

Socket.IO telemetry namespace

Real-time telemetry rides a single Flask-SocketIO namespace (gevent async mode). Handlers live in src/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"].
Two Socket.IO security facts a refactor must not silently change:
  1. Rooms are drone-scoped, not user-scoped. Subscribe joins f"drone_{drone_id}_{stream_type}" (socket_routes.py:123) and emission targets f"drone_{drone_id}_{suffix}". There is no ownership check on subscribe, so any authenticated socket that knows a drone_id receives that drone’s telemetry. The CLAUDE.md user_{user_id}_drone_{drone_id}_{stream_type} scheme is wrong — trust the real room strings.
  2. The handshake reads the token only from request.args["token"] and never consults the HTTP BLOCKLIST, so a logged-out token still authenticates on Socket.IO.

Client → Server events

EventPayloadBehavior
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.
disconnectRoom-based cleanup (automatic).

Server → Client events

EventPayloadEmitted 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)

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.
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.
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 documents video-source as [main, thermal], but the code accepts only ["CAMERA", "TEST"] (drone_utils.VIDEO_ROOM_SOURCES); anything else → 400. Trust the code.
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.
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.