The Gateway is a Flask + Flask-SocketIO application that exposes roughly 113 HTTP endpoints plus a Socket.IO namespace. Together they form the entire external contract between the SkyHub Dashboard, physical drones and their on-drone gamepad/upload services, and Stripe. HTTP handles auth, drone CRUD and control, missions, geofences, video rooms, VPN config, assets, executions/reports, billing, calendar scheduling, and Isaac Sim control; Socket.IO carries real-time telemetry (documented separately on Socket.IO Telemetry Streaming). This page covers the cross-cutting conventions every client needs: the base path, how the 12 blueprints are registered, the response envelope, and the three coexisting authentication models. For the wider service picture see the Gateway Service Overview.
Route handlers are intentionally thin — they validate input, pick an auth model, and delegate to a service wired in src/application/app.py. The business logic lives in the service layer, not the routes.

Base path

Every HTTP endpoint lives under /api/v1. All 12 blueprints are registered with url_prefix="/api/v1/" in src/main.py:222-233, and each route rule inside a blueprint also starts with /, so the effective base is /api/v1 (the doubled slash collapses).
src/main.py:222-233
app.register_blueprint(auth_routes, url_prefix="/api/v1/")
app.register_blueprint(drone_routes, url_prefix="/api/v1/")
app.register_blueprint(execution_routes, url_prefix="/api/v1/")
app.register_blueprint(geofence_routes, url_prefix="/api/v1/")
app.register_blueprint(isaac_sim_routes, url_prefix="/api/v1/")
app.register_blueprint(mission_routes, url_prefix="/api/v1/")
app.register_blueprint(video_room_routes, url_prefix="/api/v1/")
app.register_blueprint(vpn_routes, url_prefix="/api/v1/")
app.register_blueprint(asset_routes, url_prefix="/api/v1/")
app.register_blueprint(user_routes, url_prefix="/api/v1/")
app.register_blueprint(billing_routes, url_prefix="/api/v1/")
app.register_blueprint(calendar_routes, url_prefix="/api/v1/")
In production the Dashboard’s environment.url points at https://<host>/api/v1; requests land on the public WireGuard jumphost’s nginx on port 5000, which TLS-terminates and proxies to the Gateway container. See VPN IP Authentication & Jumphost Routing.

The 12 blueprints

BlueprintFileEndpointsPrimary auth
auth_routessrc/routes/auth_routes.py~9Public + JWT (login, refresh, logout, register, activate, reset)
drone_routessrc/routes/drone_routes.py~29JWT Bearer, plus /drone/activate (token header) and /drone/pull (VPN IP)
execution_routessrc/routes/execution_routes.py~15Split: VPN-IP drone callbacks + JWT UI routes
geofence_routessrc/routes/geofence_routes.py~11JWT Bearer
isaac_sim_routessrc/routes/isaac_sim_routes.py~4JWT Bearer
mission_routessrc/routes/mission_routes.py~9JWT Bearer
video_room_routessrc/routes/video_room_routes.py~4JWT Bearer, except POST /video_room (video-room token)
vpn_routessrc/routes/vpn_routes.py~5JWT Bearer
asset_routessrc/routes/asset_routes.py~8JWT Bearer, except upload pair (VPN IP)
user_routessrc/routes/user_routes.py1JWT Bearer
billing_routessrc/routes/billing_routes.py~10JWT Bearer, plus /billing/pricing (public) and /billing/webhook (Stripe-Signature)
calendar_routessrc/routes/calendar_routes.py~7JWT Bearer
geofence_routes is registered (src/main.py:225) even though the top-level CLAUDE.md architecture summary omits geofences. Trust the blueprint list above.
Deep-dive pages break these out by concern: Authentication & JWT Lifecycle, Drone Management & Control Actions, Executions, Assets & Reports, Missions & Geofences, and Billing, Calendar, VPN, Video & Isaac Sim.

Response envelope

Newer routes (auth, executions, assets, billing, calendar, vpn, user) use two helpers from src/utils/common_helper.py that return a (dict, status_code) tuple:
src/utils/common_helper.py:4-25
def get_success_response(data=_UNSET, message=None, status_code=200):
    response = {"success": True}
    if data is not _UNSET:
        response["data"] = data
    if message is not None:
        response["message"] = message
    return response, status_code


def get_error_response(code, message):
    return {"success": False, "error": {"code": code, "message": message}}, code
So a successful envelope looks like {"success": true, "data": {...}} and an error like {"success": false, "error": {"code": 404, "message": "..."}} with the HTTP status equal to code.
get_error_response(code, message) takes the status code first. src/routes/user_routes.py:57 calls it with the arguments reversed — get_error_response("User not found", 404) — so the 404/500 paths try to return a string as the HTTP status and raise. This is a latent bug; preserve or fix it deliberately, don’t copy the pattern.

Older routes return raw jsonify

The drone, mission, and geofence blueprints predate the envelope and return bare fields via flask.jsonify, frequently with non-standard status codes:
# src/routes/drone_routes.py:269  — a READ that returns 201
return jsonify(drones), 201

# src/routes/mission_routes.py:222-228  — a PATCH update that returns 201
return jsonify({ ... }), 201
Several reads and updates return 201 Created instead of 200: GET /drones, GET /drones/mission, PUT/DELETE /drone/<id>, and mission-point create/update/delete/batch. The current Dashboard depends on these codes — changing them to 200 will break existing clients. Treat 2xx as success rather than matching 200 exactly.
Login failure at src/routes/auth_routes.py:140-141 does jsonify({"error": "..."}, 401) and then sets response.status_code = 401. The 401 is serialized into the JSON body (as a tuple element) as well as the HTTP status, so the failure body shape differs from the success path. Parse the status code, not the body, to detect auth failures.
  • Envelope ({success, data|error}): auth (partially), execution, asset, billing, calendar, vpn, user.
  • Raw jsonify bare fields: drone, mission, geofence, and the older auth handlers (login/refresh/register).
  • When adding an endpoint, prefer get_success_response/get_error_response for consistency with the newer surface.
Most routes import the helpers from src.utils.common_helper, but src/routes/asset_routes.py imports them from utils.common_helper (no src. prefix). Both module paths must stay importable — don’t remove either.

Pagination

List endpoints that accept limit/offset (GET /executions and GET /executions/vehicle/<id>) clamp the parameters with clamp_paginationmax limit 100, offset floored at 0:
src/utils/common_helper.py:28-39
def clamp_pagination(limit: int, offset: int, max_limit: int = 100) -> tuple[int, int]:
    return max(1, min(limit, max_limit)), max(0, offset)
Requesting limit=5000 therefore returns at most 100 rows; there is no cursor pagination. GET /billing/payments is different: it accepts only limit (default 20, no offset) and does not use clamp_pagination — a limit outside 1..100 returns HTTP 400 ("Limit must be between 1 and 100") rather than being clamped.

The three auth models

Three distinct authentication models coexist. Every endpoint uses exactly one — pick the right one when adding a route. Deep behavior lives in Authentication & JWT Lifecycle and Authentication & Security Model.

JWT Bearer

The UI. Authorization: Bearer <jwt>, HS256, signed with JWT_SECRET_KEY. Access token 10 min, refresh 12 hr (src/main.py:247-248). Guards nearly all UI endpoints via @jwt_required().

VPN source IP

Drone & gamepad callbacks. The check_vpn_ip decorator (src/middleware/drone_vpn.py) trusts a source IP starting with 10.71. (the WireGuard drone plane) — no JWT.

Token / signature

Bootstrap & third parties: 10-digit drone activation token header, video-room Bearer token, and the Stripe-Signature header on the billing webhook.

1. JWT Bearer (the UI)

The Dashboard logs in via POST /api/v1/login, receives an access + refresh token, and attaches Authorization: Bearer <access> to every subsequent request. Handlers use @jwt_required() and read the caller via get_jwt_identity().
# Log in
curl -sX POST https://<host>/api/v1/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"[email protected]","password":"<redacted>"}'
# -> {"access_token":"<jwt>","refresh_token":"<jwt>"}

# Call an authenticated endpoint
curl -s https://<host>/api/v1/drones \
  -H 'Authorization: Bearer <jwt>'
Token revocation on logout is best-effort: BLOCKLIST is an in-memory set() in src/routes/auth_routes.py, so a revoked jti is not shared across gunicorn workers and is lost on restart. The Socket.IO handshake decodes the JWT manually and does not consult the blocklist at all. See Authentication & JWT Lifecycle.

2. VPN source IP (check_vpn_ip)

Drone- and gamepad-originated callbacks carry no JWT. The check_vpn_ip decorator authenticates purely by source IP — it accepts remote_addr, X-Real-IP, or X-Forwarded-For beginning with 10.71. (the drone WireGuard CIDR), sets request.vpn_ip, and returns 400 otherwise. In local/SITL mode it additionally honors an X-Drone-IP header (SITL container names when ENABLE_SITL, or safe local prefixes such as 127., 172.17., 192.168., 10.223.).
src/middleware/drone_vpn.py:40-47
if client_ip.startswith("10.71."):
    vpn_ip = client_ip
    logger.info(f"Picked Client IP : {vpn_ip}")

if not vpn_ip and x_real_ip.startswith("10.71."):
    vpn_ip = x_real_ip
The route then resolves the drone with drone_service.get_drone_by_ip(request.vpn_ip). Endpoints guarded this way: GET /drone/pull, POST /executions/start|<id>/complete|<id>/log, GET /executions/current, and POST /authenticate_upload|/complete_upload.
This is the only auth on those endpoints. Anything that can present a 10.71.x source IP — or set X-Drone-IP behind the proxy — is trusted. Keep these routes reachable only through the jumphost/VPN, never a public path.

3. Token / signature auth

Three endpoints authenticate with a shared secret rather than a user identity:
EndpointCredentialNotes
GET /api/v1/drone/activate10-digit token request headerPhysical-drone bootstrap; returns ECR creds + presigned compose + VPN link, then clears the token. 406 on bad token.
POST /api/v1/video_roomAuthorization: Bearer <video_room_token>Matched via get_drones_by_video_room_id_and_token; not a user JWT. Returns 402 when the token is missing.
POST /api/v1/billing/webhookStripe-Signature headerVerified against STRIPE_WEBHOOK_SECRET; verification is skipped only in local env. 400 on invalid signature.
POST /video_room returning 402 Payment Required for a missing token is an unusual choice — it is an auth failure, not a billing one. Preserved for client compatibility.

Interactive API docs (Swagger)

A Swagger 2.0 spec is generated from route docstrings by flasgger. The UI is served at /api/docs/ and the raw spec at /apispec.json (both public), configured in src/main.py:24-77 with basePath: /api/v1 and a Bearer security definition. Swagger UI at /api/docs/
The generated spec occasionally drifts from the code. For example, the Swagger enum for POST /drone/action/video-source lists [main, thermal], but the handler actually accepts ['CAMERA', 'TEST'] (drone_utils.VIDEO_ROOM_SOURCES) and returns 400 for anything else. When in doubt, trust the source, not /apispec.json.

Conventions to preserve

Older routes use 201 for reads/updates and 402 for a video-room auth error. The Dashboard matches on these exact codes. Normalize only alongside a coordinated frontend change.
Don’t mass-migrate raw-jsonify routes to the envelope without checking the consuming Angular services — response-shape changes are breaking. New endpoints should use the envelope.
POST /drone/action/arm and POST /drone/action/arm_drone are functionally identical (both arm and pre-push the SITL video room). Disarm exists only as POST /drone/action/disarm_drone. See Drone Management & Control Actions.
A few handlers are Flask async def (create_drone, delete_drone, create_video_room, restart_video_stream) and depend on the gevent worker. Keep the async signature when editing them.

See also

Authentication & JWT Lifecycle

Login/refresh/logout/activate flows, token expiry, the in-memory blocklist, and the activation level claim.

Socket.IO Telemetry Streaming

The real-time channel: ?token= handshake, room naming, and stream types.

VPN Middleware & Jumphost

How source-IP trust and jumphost routing actually work in production.

Full HTTP & Socket.IO Reference

The complete, per-endpoint API reference across the platform.