SubscriptionService (src/service/subscription_service.py) is the Gateway’s Stripe integration. It owns three concerns: the Stripe money flow (customers, Checkout, Customer Portal, subscription mutations, payment history), the webhook sink that keeps the local Subscription/Payment rows in sync with Stripe, and the vehicle-quota gate (can_user_add_vehicle) that every drone-creation request runs through before a physical or SITL drone is provisioned. The service is instantiated once as a module-global singleton in the composition root (src/application/app.py:51) and injected into billing_routes and drone_routes. Its constructor sets stripe.api_key = settings.STRIPE_SECRET_KEY at build time — see Service Layer & get_service Factory for how singletons are wired.
This page covers the service logic and pricing/quota model. The HTTP request/response contract for each /billing/* endpoint is documented alongside the other account APIs in Billing, Calendar, VPN, Video & Isaac Sim API.

Pricing model

Pricing is hardcoded in Python, in two module constants at the top of src/service/subscription_service.py:
src/service/subscription_service.py:12-18
# Price per vehicle per year in cents (€120.00 = 12000 cents)
PRICE_PER_VEHICLE_CENTS = 12000
PRICE_PER_VEHICLE_EUR = 120

# Payment history limits
DEFAULT_PAYMENT_HISTORY_LIMIT = 20
MAX_PAYMENT_HISTORY_LIMIT = 100
The billing model is EUR 120 per vehicle slot per year. Users buy vehicle slots up front; adding or removing slots changes the Stripe subscription quantity (line-item quantity), not the price.
The actual amount Stripe charges is defined by the Stripe Price object referenced by the STRIPE_PRICE_ID env var — not by either hardcoded constant. Of the two, only PRICE_PER_VEHICLE_EUR is actually used: it’s the value returned by get_pricing_info() (subscription_service.py:528, the /billing/pricing display payload). PRICE_PER_VEHICLE_CENTS is currently dead code — a grep of src/ shows it is referenced nowhere. If you change the price you must update both the Stripe Price in the Stripe Dashboard and PRICE_PER_VEHICLE_EUR, or the marketing/UI price will drift from what customers are actually billed.
get_pricing_info() (subscription_service.py:525) returns a static, unauthenticated marketing payload (price, currency EUR, billing_period: "year", and a features list) served by GET /api/v1/billing/pricing.

HTTP surface

All billing routes are registered under /api/v1/ (src/main.py:232). Every endpoint requires a Bearer JWT except /billing/pricing (public) and /billing/webhook (authenticated by Stripe signature instead).
Method & pathAuthService callPurpose
GET /api/v1/billing/pricingnoneget_pricing_info()Static pricing/features payload
GET /api/v1/billing/subscriptionJWTget_subscription()Current user’s subscription (or null)
POST /api/v1/billing/checkoutJWTcreate_checkout_session(user, vehicle_count)Start a Stripe Checkout session
POST /api/v1/billing/portalJWTcreate_portal_session(user)Open the Stripe Customer Portal
PUT /api/v1/billing/subscription/vehiclesJWTupdate_vehicle_count(user_id, n)Change subscription quantity (prorated)
POST /api/v1/billing/subscription/cancelJWTcancel_subscription(user_id, at_period_end)Cancel at period end or immediately
POST /api/v1/billing/subscription/reactivateJWTreactivate_subscription(user_id)Undo a pending period-end cancel
GET /api/v1/billing/payments?limit=JWTget_payment_history(user_id, limit)Invoice/receipt history (clamped 1–100)
GET /api/v1/billing/can-add-vehicle?vehicle_type=JWTcan_user_add_vehicle(...)Pre-flight quota check for the UI
POST /api/v1/billing/webhookStripe signaturehandle_webhook_event(payload, sig)Stripe event ingestion

Vehicle-limit enforcement

The most load-bearing method is can_user_add_vehicle(user_id, vehicle_counts, vehicle_type) (subscription_service.py:438). It is the gate on drone creation and is called in two places:
  1. Enforced at POST /api/v1/drone (src/routes/drone_routes.py:99-108) — before any physical or SITL drone is provisioned. A denial returns HTTP 402 Payment Required with the reason, message, and subscription from the check.
  2. Advisory at GET /api/v1/billing/can-add-vehicle (billing_routes.py:465) — so the Dashboard can grey out the “add vehicle” button before the user tries.
Both callers first compute current counts with generic_drone_service.get_drone_counts_by_type(user_id) (src/service/drone_service.py:147), which returns {"physical": n, "sitl": n, "total": n} from two COUNT queries on the Drone table.
1

SITL, no active subscription (free tier)

If sitl_count >= 1 → deny with reason: "no_subscription". Otherwise allow with reason: "free_tier" (message "Free simulated vehicle (1/1)"). Every user gets exactly one free SITL drone.
2

SITL, active subscription

Limit is max_sitl = subscription.vehicle_count + 1 (the +1 is the free-tier slot). If sitl_count >= max_sitl → deny reason: "vehicle_limit_reached", else allow.
3

Physical, no subscription row

Deny with reason: "no_subscription" — physical vehicles always require a subscription.
4

Physical, subscription not active

Deny with reason: "subscription_inactive" (message includes subscription.status.value, e.g. past_due).
5

Physical, active subscription

Limit is subscription.vehicle_count. If physical_count >= subscription.vehicle_count → deny reason: "vehicle_limit_reached", else allow.
The effective quota per user:
Vehicle typeNo subscriptionActive subscription (vehicle_count = N)
sitl1 (free tier)N + 1
physical0 (denied)N
is_active() (src/models/subscription.py:72) treats both ACTIVE and TRIALING as active. A past_due, unpaid, incomplete, or canceled subscription is inactive, so physical-vehicle creation is denied and SITL falls back to the free-tier rule.
The method docstring says “Per subscription: 1 physical vehicle + 1 SITL vehicle”, but the actual code is quantity-driven: physical limit is subscription.vehicle_count, SITL limit is subscription.vehicle_count + 1. Trust the code, not the docstring. Note also that Subscription.can_add_vehicle() on the model (subscription.py:76) is a separate, simpler helper (current < vehicle_count) that is not used by this enforcement path — the route calls the service method.

Subscription lifecycle methods

Returns the existing stripe_customer_id if the user already has a Subscription row with one. Otherwise it calls stripe.Customer.create(...) (with user_id/username metadata), and either updates the existing row or inserts a new Subscription with status INCOMPLETE. Committed immediately (subscription_service.py:34).
Requires STRIPE_PRICE_ID (raises ValueError("STRIPE_PRICE_ID not configured") otherwise). Refuses if the user already has an active subscription ("User already has an active subscription. Use update instead."). Creates a mode="subscription" Checkout session with one line item (price = STRIPE_PRICE_ID, quantity = vehicle_count), a success_url of {STRIPE_SUCCESS_URL}?session_id={CHECKOUT_SESSION_ID}, and metadata (user_id, vehicle_count) on both the session and subscription_data so webhooks can recover the user. Returns {checkout_url, session_id} (subscription_service.py:62).
Opens a Stripe Customer Portal session for stripe_customer_id. return_url is STRIPE_CANCEL_URL or STRIPE_SUCCESS_URL (the cancel URL is assumed to be the billing page). Raises if no subscription/customer exists (subscription_service.py:112).
Changes the quantity on the live Stripe subscription. Requires an active subscription with a stripe_subscription_id and new_vehicle_count >= 1. Retrieves the subscription, modifies items[0].quantity, and uses proration_behavior="create_prorations" so mid-cycle changes are charged/credited. Then mirrors vehicle_count locally (subscription_service.py:137).
Default is a soft cancelstripe.Subscription.modify(..., cancel_at_period_end=True); the subscription stays active until period end and the local status is unchanged (the eventual customer.subscription.deleted webhook flips it to CANCELED). With at_period_end=False it calls stripe.Subscription.cancel(...) and immediately sets local status CANCELED + canceled_at. The route passes at_period_end=not immediate (billing_routes.py:311).
Undoes a pending period-end cancel by setting cancel_at_period_end=False in Stripe and clearing local canceled_at (subscription_service.py:211).
Returns Payment.to_dict() rows for the user, newest first. limit is defensively coerced to an int and clamped to 1..100 (MAX_PAYMENT_HISTORY_LIMIT). Note the route also validates 1..100 and rejects out-of-range values with 400 before this clamp ever runs (billing_routes.py:411).

Webhook processing

handle_webhook_event(payload, sig_header) (subscription_service.py:228) is the single entry point for POST /api/v1/billing/webhook. It verifies the signature, decodes the event, and dispatches on event["type"] through a handler table.

Signature verification

src/service/subscription_service.py:238-250
if not settings.STRIPE_WEBHOOK_SECRET:
    if not settings.IS_LOCAL_ENVIRONMENT:
        logger.error("STRIPE_WEBHOOK_SECRET not configured in non-local environment, rejecting webhook")
        raise ValueError("Webhook signature verification not configured")
    logger.warning(
        "STRIPE_WEBHOOK_SECRET not configured, skipping signature verification (local environment only)"
    )
    event = stripe.Event.construct_from(...)
else:
    event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET)
Signature verification is only skipped when STRIPE_WEBHOOK_SECRET is unset and IS_LOCAL_ENVIRONMENT is true (i.e. DEPLOYMENT_ENVIRONMENT=local, src/application/settings.py:11-12). In any non-local environment a missing secret makes every webhook fail with ValueError → HTTP 400. Never deploy a non-local environment without STRIPE_WEBHOOK_SECRET. This is one of the three auth models in the platform (JWT Bearer, VPN-source-IP, and token/signature) — see Authentication & Security Model.

Event dispatch table

Unhandled event types return {"status": "ignored"} (still HTTP 200, so Stripe does not retry).
Stripe eventHandlerEffect on local state
checkout.session.completed_handle_checkout_completedSets stripe_subscription_id, vehicle_count, status → ACTIVE (reads user_id/vehicle_count from session metadata)
customer.subscription.created_handle_subscription_createdInserts a Subscription if none, then syncs from Stripe
customer.subscription.updated_handle_subscription_updatedLooks up by stripe_subscription_id, syncs status/quantity/period
customer.subscription.deleted_handle_subscription_deletedStatus → CANCELED, sets canceled_at
invoice.paid_handle_invoice_paidIdempotently inserts a Payment row
invoice.payment_failed_handle_invoice_payment_failedStatus → PAST_DUE
_update_subscription_from_stripe (subscription_service.py:381) maps the Stripe status string to the SubscriptionStatus enum (unknown → INCOMPLETE), reads vehicle_count from items[0].quantity, and converts the current_period_start/end and canceled_at UNIX timestamps to timezone-aware datetimes.

Idempotent payment recording

invoice.paid is designed to be safe under Stripe’s at-least-once retry semantics. It dedupes on either stripe_invoice_id or stripe_payment_intent_id, and additionally catches IntegrityError (both columns are unique) to survive concurrent inserts:
src/service/subscription_service.py:343-369
existing_payment = Payment.query.filter(
    (Payment.stripe_invoice_id == invoice_id) | (Payment.stripe_payment_intent_id == payment_intent_id)
).first()
if existing_payment:
    logger.info(f"Payment already recorded for invoice {invoice_id}, skipping")
    return
try:
    payment = Payment(...)  # amount from invoice.amount_paid, currency default "eur"
    self._db.session.add(payment)
    self._db.session.commit()
except IntegrityError:
    self._db.session.rollback()
    logger.info(f"Payment already exists for invoice {invoice_id} (concurrent insert), skipping")

Data model

Two tables back this service (src/models/subscription.py); full schema in Database Schema Overview.
  • subscription — one row per user (user_id is unique). Holds stripe_customer_id, stripe_subscription_id, status (SubscriptionStatus enum stored by value), vehicle_count (the Stripe line-item quantity, default 0), billing-period bounds, and canceled_at. is_active() → status in {ACTIVE, TRIALING}.
  • payment — receipt history. amount is in cents (integer), currency defaults "eur", and stripe_invoice_id/stripe_payment_intent_id are both unique (the idempotency backbone). to_dict() adds an amount_formatted string like "€120.00".

Configuration

Env varDefaultRole
STRIPE_SECRET_KEY""API key; set on stripe.api_key in the service constructor
STRIPE_PUBLISHABLE_KEY""Front-end publishable key (not used server-side here)
STRIPE_PRICE_ID""Required for checkout; the Stripe Price that defines the real charge
STRIPE_WEBHOOK_SECRET""Required in non-local envs; enables signature verification
STRIPE_SUCCESS_URLhttps://skyhub.ai/billing/successCheckout success redirect (gets ?session_id= appended)
STRIPE_CANCEL_URLhttps://skyhub.ai/billing/cancelCheckout cancel redirect + portal return URL
DEPLOYMENT_ENVIRONMENTserverlocalIS_LOCAL_ENVIRONMENT; only value that lets webhook signature checks be skipped
Defined in src/application/settings.py:148-153. See the full list in Gateway Environment Variables.
stripe.api_key is set once, when the singleton is constructed at import time (subscription_service.py:32). Rotating STRIPE_SECRET_KEY requires a process restart — changing the env var on a running container has no effect.

Gotchas for future editors

  • Two sources of truth for price. PRICE_PER_VEHICLE_EUR (the display value from get_pricing_info()) is independent of the Stripe STRIPE_PRICE_ID (actual charge) — keep them in sync. PRICE_PER_VEHICLE_CENTS is dead code (referenced nowhere); it drives nothing.
  • Quota math is vehicle_count-based, not “1 physical + 1 SITL”. Physical limit = vehicle_count; SITL limit = vehicle_count + 1. The free tier is exactly 1 SITL and applies to users with no active subscription.
  • Enforcement lives at drone creation, returning HTTP 402. If you add a new provisioning path, it must also call can_user_add_vehicle — the check is not centralized in the service layer’s save() methods (see SITL Drone Lifecycle and Service Layer Overview).
  • Webhook signature bypass is environment-gated. A non-local env with a missing STRIPE_WEBHOOK_SECRET rejects all webhooks; do not rely on the local bypass in staging/prod.
  • invoice.paid is idempotent by design (dedup + IntegrityError catch). Preserve both unique constraints on payment.stripe_invoice_id and payment.stripe_payment_intent_id.
  • Subscription.can_add_vehicle() on the model is dead-ish code relative to this flow — the route uses the service’s can_user_add_vehicle. Don’t confuse the two when refactoring.