This page documents five self-contained corners of the gateway HTTP surface that don’t fit the drone/telemetry/mission core: billing (billing_routes.py), calendar (calendar_routes.py), VPN config/status (vpn_routes.py), video rooms (video_room_routes.py), and Isaac Sim EC2 control (isaac_sim_routes.py). All routes register under url_prefix="/api/v1/" in src/main.py:222-233.For the shared response envelope, base path, and the three auth models see HTTP API Overview. For JWT issuance see Authentication & JWT Lifecycle. The business logic behind these endpoints lives in Stripe Billing & Vehicle Limits, Janus Video Rooms, and Platform Services.
billing_routes.py proxies Stripe Checkout / Customer Portal and syncs subscription + payment state through a signature-verified webhook. Pricing is hardcoded at €120 per vehicle per year (PRICE_PER_VEHICLE_CENTS = 12000 in src/service/subscription_service.py:13) and is deliberately separate from the Stripe STRIPE_PRICE_ID line item — changing the price means updating both. All billing routes except pricing and webhook require JWT and use the {success, data} / {success, error} envelope from src/utils/common_helper.py.
create_checkout raises ValueError (“User already has an active subscription. Use update instead.”) if a subscription is already active — the route maps that to HTTP 400. To change vehicle count on an existing subscription use PUT /billing/subscription/vehicles, not checkout.
can_user_add_vehicle in subscription_service.py:438 is the same gate that POST /api/v1/drone uses to return 402 when over-plan (see Drone Management & Control Actions). The rules:
Free tier: exactly 1 SITL vehicle, no subscription required.
Physical vehicles: require an active subscription; limit is subscription.vehicle_count.
SITL with a subscription: limit is subscription.vehicle_count + 1 (the +1 is the free-tier slot).
The response is {can_add, reason, message, subscription} where reason is one of free_tier, allowed, no_subscription, subscription_inactive, or vehicle_limit_reached.
The webhook is the one billing endpoint Stripe (not the UI) calls. stripe_webhook reads the raw body and the Stripe-Signature header and hands both to subscription_service.handle_webhook_event, which calls stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET).
Signature verification is skipped only when STRIPE_WEBHOOK_SECRET is unset andIS_LOCAL_ENVIRONMENT is true (subscription_service.py:238-250). In any non-local environment a missing secret raises ValueError, which the route returns as HTTP 400 “Invalid payload”. Never run a non-local deployment without the secret configured.
Handled event types and their effects (subscription_service.py:257-264):invoice.paid is idempotent — it dedupes on stripe_invoice_id/stripe_payment_intent_id and swallows IntegrityError on concurrent inserts, so Stripe retries never double-charge the ledger. Unrecognised event types return {status: "ignored"} with a 200 so Stripe stops retrying.
calendar_routes.py is 7 JWT endpoints for scheduling missions against a drone, with optional iCal recurrence. All datetimes are ISO 8601; parse_iso_datetime (calendar_routes.py:16) normalizes a trailing Z to +00:00 and stamps naive datetimes as UTC. Logic lives in src/service/calendar_service.py.
title, scheduled_time, drone_id, and mission_id are all required on create (validated by get_missing_field); drone_id and mission_id must belong to the caller. A duplicate title at the same time returns 400 (“An event with this title already exists at the specified time”).
Set recurrence_rule to an iCal RRULE string (e.g. "FREQ=WEEKLY;COUNT=10"); it is validated at write time with dateutil.rrule.rrulestr(rule, dtstart=scheduled_time). Occurrences are not stored — get_events expands them on read via rule.between(start, end, inc=True) (calendar_service.py:159). Only exceptions (a per-occurrence status or linked execution) are persisted as CalendarEventOccurrence rows.
DELETE .../occurrences does not add an EXDATE to the rule or remove the instance from expansion. It upserts an occurrence exception with status cancelled (calendar_service.delete_occurrence), so the occurrence still appears in GET /calendar/events but carries status: "cancelled". Setting an occurrence back to scheduled via the status endpoint deletes its exception row, returning it to the default.
Occurrence operations reject non-recurring events (400) and validate that occurrence_time is a real instance of the rule (400 “The specified time is not a valid occurrence”). Errors whose message contains “not found” map to 404, otherwise 400. Expanded occurrences carry is_occurrence: true and original_scheduled_time. The underlying tables are covered in Database Schema Overview.
vpn_routes.py hands the UI presigned WireGuard config downloads and live connection status. Unlike billing/calendar these routes return raw jsonify bodies (e.g. {"url": ...}, {"status": ...}, or {"message": ...} on error) rather than the {success, ...} envelope — preserve those shapes. The backing VPN_Service is lazily built via app.get_vpn_service().
Method & path
Returns
Notes
GET /user/vpn
{url}
Presigned S3 GET of user/{id}/access.conf
GET /drone/<drone_id>/vpn
{url}
Presigned S3 GET of drone/{id}/access.conf
GET /user/vpn/status
{status: bool}
400 if the user has no allocated IP
GET /drone/<drone_id>/vpn/status
{status: bool}
400 if the drone has no IP
GET /drones_status
{ "<drone_id>": {is_active} }
Batch status for all the user’s drones
Config URLs are presigned from VPN_BUCKET with a deliberately short 30-second expiry (vpn_service.py:16). Status endpoints proxy an HTTP call to the User VPN service at VPN_SERVICE_IP:VPN_SERVICE_PORT (default port 5050) with a 3s timeout; any failure degrades to status: false rather than erroring.
GET /drones_status only queries the VPN service for physical drones; SITL drones are merged in afterwards keyed by str(drone.id) and are always reported {is_active: true} (vpn_routes.py:226). An empty drone list returns {} with 200, not 404.
video_room_routes.py controls the Janus VideoRoom that a drone streams into. VideoService creates the room (id == drone.id, H264 baseline 42e01f, 6 Mbps, record: false) and DroneControlService pushes room details / start-stop commands to the drone over the rosbridge topics /video_room_details and /video_room_state. Full pipeline: Janus Video Rooms & On-Drone Video Control and the Janus SFU.
Stop, recreate the Janus room, re-push details, restart
POST /video_room is not@jwt_required(). It authenticates the drone by Authorization: Bearer <video_room_token>, matched via get_drones_by_video_room_id_and_token(room_number, token). A missingAuthorization header returns HTTP 402 (“Authorization token is required”) — an unusual use of Payment Required for an auth error — and an unmatched token returns 400. This is the drone’s own room-management credential, minted as a JWT over identity=drone.id at drone creation, not a user session token.
start with ?update=<truthy> re-publishes {room_number, room_password, room_mgmt_token, status: "START"} to the drone before streaming; without it, it just sends the start command. restart (an async def handler) stops the stream, sleeps 0.3s, calls video_service.recreate(drone) to destroy and rebuild the Janus room, re-pushes details, then starts — used to recover a wedged room. The start/stop/restart routes wrap failures as HTTP 400 with the exception string. The UI side of this is App State & Video (Janus/WebRTC).
isaac_sim_routes.py starts, stops, and reports on a single hardcoded GPU EC2 instance used for Isaac Sim. IsaacSimService (src/service/isaac_sim_service.py) is instantiated at module import time (isaac_sim_routes.py:16), so its boto3 EC2 client is built before the first request. All four routes are JWT and return raw jsonify bodies.
Method & path
Returns
GET /isaac-sim/status
Live state, public/private IP, runtime, and a rolling cost_estimate
POST /isaac-sim/start
Starts the instance (200 on success/info, 400 if not in a startable state)
POST /isaac-sim/stop
Stops it; message includes session total_cost
GET /isaac-sim/metadata
Static specs ({status: "success", data: {...}})
The target instance is fixed in code: INSTANCE_ID = "i-0d2c57e141afa83ed", type g6e.xlarge, region eu-central-1, with an NVIDIA L40S GPU (48 GB), 4 vCPU / 16 GB RAM, 150 GB gp3 (isaac_sim_service.py:20-23, get_instance_metadata). Cost is estimated client-side in the service at a $0.95/hr on-demand rate plus EBS/transfer.
start/stop first call get_instance_status; if the instance is already in the target state they return {status: "info"} with 200, and an un-actionable state (e.g. pending) returns {status: "error"} with 400. This EC2 instance is distinct from the on-drone Isaac ROS Visual SLAM stack — see the Isaac Sim scheduler tables in Isaac Sim Tables (Raw SQL). The AWSInstanceService/InstanceSchedulerService auto-shutdown path is dormant and not wired into the app.