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.

Auth models on this page

Most endpoints here are ordinary @jwt_required() Bearer routes, but three break that mould — memorize them:
EndpointAuthNotes
GET /billing/pricingpublicNo token; anyone can read pricing
POST /billing/webhookStripe-SignatureHMAC-verified against STRIPE_WEBHOOK_SECRET
POST /video_roomvideo-room tokenAuthorization: Bearer <video_room_token>, NOT a user JWT
Everything else on this page is JWT Bearer and scoped to the calling user (get_jwt_identity()).

Billing (Stripe)

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.
Method & pathAuthBody / queryPurpose
GET /billing/pricingpublicStatic pricing info (price_per_vehicle: 120, currency: EUR, billing_period: year, feature list)
GET /billing/subscriptionJWTCurrent subscription to_dict(), or data: null if none
POST /billing/checkoutJWT{vehicle_count >= 1}Create Stripe Checkout session → {checkout_url, session_id}
POST /billing/portalJWTCreate Stripe Customer Portal session → {portal_url}
PUT /billing/subscription/vehiclesJWT{vehicle_count >= 1}Change subscribed quantity (Stripe create_prorations)
POST /billing/subscription/cancelJWT{immediate?: bool}Cancel at period end (default) or immediately
POST /billing/subscription/reactivateJWTUndo a scheduled cancel-at-period-end
GET /billing/payments?limitJWTlimit 1–100 (default 20)Payment history, newest first
GET /billing/can-add-vehicle?vehicle_typeJWTvehicle_type = physical|sitlQuota check used before drone creation
POST /billing/webhookStripe-Signatureraw Stripe eventSync subscription/payment state
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.

Vehicle limits (can-add-vehicle)

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.

Webhook signature verification

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 and IS_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

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.
Method & pathBody / queryPurpose
GET /calendar/events?start&end&drone_idstart, end required (ISO 8601); drone_id optionalList events in range, expanding recurrences
GET /calendar/events/<event_id>Single event
POST /calendar/events{title, scheduled_time, drone_id, mission_id, description?, status?, recurrence_rule?}Create (201)
PUT /calendar/events/<event_id>any of the above + execution_id?Update; link an execution on completion
DELETE /calendar/events/<event_id>Delete event + its occurrence exceptions
PUT /calendar/events/<event_id>/occurrences/status{occurrence_time, status, execution_id?}Override status of one recurrence instance
DELETE /calendar/events/<event_id>/occurrences?occurrence_timeoccurrence_time (ISO 8601)Cancel one recurrence instance
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”).

Recurring events (RRULE)

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 storedget_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 config & status

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 & pathReturnsNotes
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.
The VPN plane, WireGuard subnets (10.70.0.0/16 users, 10.71.0.0/16 drones), and the source-IP trust model that authenticates drone callbacks are detailed in VPN IP Authentication & Jumphost Routing and the User VPN service.

Video rooms

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.
Method & pathAuthPurpose
POST /video_roomvideo-room tokenDrone-side: create/ensure its Janus room
GET /video_room/<drone_id>/start?updateJWTStart streaming (optionally re-push room details first)
GET /video_room/<drone_id>/stopJWTStop streaming
GET /video_room/<drone_id>/restartJWTStop, 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 missing Authorization 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 EC2 control

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 & pathReturns
GET /isaac-sim/statusLive state, public/private IP, runtime, and a rolling cost_estimate
POST /isaac-sim/startStarts the instance (200 on success/info, 400 if not in a startable state)
POST /isaac-sim/stopStops it; message includes session total_cost
GET /isaac-sim/metadataStatic 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.

Response envelope quick reference

Because this page spans several route generations, envelope shapes differ — do not “normalize” them without updating the UI:

Envelope routes

billing_routes, calendar_routes, video_room_routes use get_success_response / get_error_response{success: true, data} or {success: false, error: {code, message}}.

Raw jsonify routes

vpn_routes and isaac_sim_routes return bare bodies ({url}, {status}, or {status, data}) with the HTTP status carrying the meaning. No success key.
See Authentication & Security Model for how these three auth models fit together across the platform, and Gateway Environment Variables for STRIPE_*, VPN_BUCKET, VPN_SERVICE_IP/PORT, and JANUS_URL.