{"openapi":"3.1.0","info":{"title":"Routely API","version":"1.0.0","description":"Field service management platform API. All request bodies are validated with Zod schemas. Rate limits are enforced per-IP.","contact":{"name":"Routely Support","url":"https://routelyos.com"}},"servers":[{"url":"https://routelyos.com","description":"Production"}],"paths":{"/api/geocode":{"get":{"operationId":"geocode","summary":"Forward geocode an address","description":"Geocode a free-text address query via Nominatim. Returns up to 5 lat/lng results.","tags":["Public"],"parameters":[{"name":"q","in":"query","required":true,"description":"Address or place name to geocode (1-500 chars)","schema":{"type":"string","minLength":1,"maxLength":500}}],"responses":{"200":{"description":"Geocode results","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"lat":{"type":"number"},"lng":{"type":"number"},"label":{"type":"string"}},"required":["lat","lng","label"]}}},"required":["results"]}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/coverage":{"post":{"operationId":"coverage","summary":"Generate drive-time coverage polygons","description":"Generate isochrone polygons via Valhalla for a given origin. Returns real drive-time GeoJSON coverage areas.","tags":["Public"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number","minimum":-90,"maximum":90,"description":"Latitude of origin"},"lng":{"type":"number","minimum":-180,"maximum":180,"description":"Longitude of origin"},"maxDrive":{"type":"number","minimum":1,"maximum":180,"default":60,"description":"Maximum drive time in minutes"},"contours":{"type":"array","items":{"type":"number"},"description":"Custom contour values in minutes"}}}}}},"responses":{"200":{"description":"Coverage polygons","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"polygons":{"type":"array","items":{"type":"object","properties":{"minutes":{"type":"number"},"coords":{"type":"array","items":{"type":"array","items":{"type":"number"}}}}}},"source":{"type":"string"},"generatedAt":{"type":"string","format":"date-time"},"requestedMinutes":{"type":"number"},"actualMinutes":{"type":"number"}}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"Valhalla upstream error"}}}},"/api/isochrone":{"post":{"operationId":"isochrone","summary":"Raw Valhalla isochrone proxy","description":"Generate GeoJSON isochrone contours for drive-time analysis. Proxies to Valhalla.","tags":["Public"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["lat","lng"],"properties":{"lat":{"type":"number","minimum":-90,"maximum":90},"lng":{"type":"number","minimum":-180,"maximum":180},"time":{"type":"number","minimum":1,"maximum":180,"default":60,"description":"Drive time in minutes"},"contours":{"type":"array","items":{"type":"object","properties":{"time":{"type":"number"},"color":{"type":"string","maxLength":20}},"required":["time","color"]},"maxItems":10}}}}}},"responses":{"200":{"description":"GeoJSON FeatureCollection from Valhalla"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/vitals":{"get":{"operationId":"vitalsHealth","summary":"Health check","description":"Returns service status, timestamp, and version.","tags":["Public"],"responses":{"200":{"description":"Health status","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"status":{"type":"string","enum":["ok"]},"timestamp":{"type":"string","format":"date-time"},"version":{"type":"string"}}}}}}}}}},"post":{"operationId":"vitalsStore","summary":"Store web vitals metric","description":"Store a Core Web Vitals measurement (LCP, FID, CLS, etc.) to Supabase.","tags":["Public"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"metric":{"type":"string","maxLength":100,"default":"unknown"},"value":{"type":"number","default":0},"page":{"type":"string","maxLength":500,"default":"/"},"id":{"type":"string","maxLength":200,"nullable":true}}}}}},"responses":{"200":{"description":"Metric received","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"received":{"type":"boolean","const":true}}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/reports":{"get":{"operationId":"reports","summary":"Generate business reports","description":"Generate P&L, jobs, workers, revenue, or coverage reports. Filterable by franchise.","tags":["Public"],"parameters":[{"name":"type","in":"query","schema":{"type":"string","enum":["pnl","jobs","workers","revenue","coverage"],"default":"pnl"}},{"name":"franchise","in":"query","schema":{"type":"string","maxLength":200}}],"responses":{"200":{"description":"Report data"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/customer/verify":{"post":{"operationId":"customerVerify","summary":"Email verification (send or verify code)","description":"Two-step email verification. Call with email only to send a 6-digit code. Call with email + code to verify.","tags":["Customer"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","maxLength":254},"code":{"type":"string","minLength":6,"maxLength":6,"description":"6-digit verification code. Omit to request a new code."}}}}}},"responses":{"200":{"description":"Code sent or verified","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"oneOf":[{"type":"object","properties":{"sent":{"type":"boolean","const":true}}},{"type":"object","properties":{"verified":{"type":"boolean","const":true},"proof":{"type":"string","description":"Short-lived signed proof of email ownership (`rlyc_…`, 30-minute TTL). Present only on a successful code verification. Send it as `Authorization: Bearer <proof>` to GET /api/account and to POST /api/maintenance-plans; those endpoints resolve the caller's email from it and refuse any request naming a different one. There is no revocation — the TTL is the only expiry."}}}]}}}}}},"400":{"description":"Invalid or expired code"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/customer/cancel":{"post":{"operationId":"customerCancel","summary":"Cancel a booking with optional refund","description":"Cancel a customer booking. Processes Stripe refund if charged, cancels setup intent for card-on-file. Sends email/SMS notification.","tags":["Customer"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string","maxLength":200},"paymentId":{"type":"string","maxLength":200,"nullable":true},"paymentStatus":{"type":"string","enum":["charged","card_on_file","pending"],"nullable":true},"amount":{"type":"number","minimum":0,"maximum":99999,"nullable":true},"email":{"type":"string","format":"email","maxLength":254,"nullable":true},"phone":{"type":"string","maxLength":50,"nullable":true},"type":{"type":"string","maxLength":200,"nullable":true},"dateISO":{"type":"string","maxLength":50,"nullable":true}}}}}},"responses":{"200":{"description":"Cancellation result","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"refunded":{"type":"boolean"},"refundId":{"type":"string"},"setupIntentCancelled":{"type":"boolean"},"message":{"type":"string"}},"required":["refunded","setupIntentCancelled","message"]}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/jobs/create":{"post":{"operationId":"jobCreate","summary":"Create a new service job","description":"Create a job in Supabase. Logs activity, fires customer welcome email. Public endpoint for booking flow.","tags":["Jobs"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["customerName","address","type","price"],"properties":{"customerName":{"type":"string","maxLength":200},"customerEmail":{"type":"string","format":"email","maxLength":200},"customerPhone":{"type":"string","maxLength":50},"address":{"type":"string","maxLength":500},"type":{"type":"string","maxLength":200,"description":"Service type (e.g. Gutter Cleaning)"},"price":{"type":"number","minimum":0,"maximum":99999},"preferredDate":{"type":"string","maxLength":30,"nullable":true},"preferredTime":{"type":"string","maxLength":30,"nullable":true},"addOns":{"type":"array","items":{"type":"string","maxLength":100},"default":[]},"franchiseId":{"type":"string","maxLength":100,"nullable":true},"notes":{"type":"string","maxLength":1000,"nullable":true},"paymentId":{"type":"string","maxLength":200,"nullable":true},"paymentStatus":{"type":"string","maxLength":50,"nullable":true},"gutterLength":{"type":"number","nullable":true},"stories":{"type":"number","nullable":true}}}}}},"responses":{"201":{"description":"Job created","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"job":{"type":"object"},"message":{"type":"string"}}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/stripe/checkout":{"post":{"operationId":"stripeCheckout","summary":"Create Stripe Checkout Session","description":"Create a Stripe Checkout Session to pay for an existing booking. The charge is the job's own price (after any promo/referral and tax); `amount` must match it. Never creates a job: an unknown job is 404, a job with no price 409.","tags":["Stripe"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount","jobId"],"properties":{"amount":{"type":"number","minimum":2500,"maximum":999999,"description":"Amount in cents; must match the server-computed price"},"jobId":{"type":"string","format":"uuid","description":"The existing job being paid for"},"customerName":{"type":"string","maxLength":200},"customerEmail":{"type":"string","format":"email","maxLength":254},"jobDescription":{"type":"string","maxLength":500},"returnUrl":{"type":"string","maxLength":2000}}}}}},"responses":{"200":{"description":"Checkout session created","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Stripe Checkout redirect URL"},"sessionId":{"type":"string"}},"required":["url","sessionId"]}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/stripe-webhook":{"post":{"operationId":"stripeWebhook","summary":"Stripe webhook receiver","description":"Receives Stripe webhook events. Verifies signature, processes payments, triggers franchise payouts and notifications.","tags":["Stripe"],"parameters":[{"name":"stripe-signature","in":"header","required":true,"schema":{"type":"string"},"description":"Stripe webhook signature header"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Raw Stripe event object","properties":{"id":{"type":"string"},"type":{"type":"string"},"data":{"type":"object","properties":{"object":{"type":"object"}}}},"required":["id","type","data"]}}}},"responses":{"200":{"description":"Event acknowledged","content":{"application/json":{"schema":{"type":"object","properties":{"received":{"type":"boolean","const":true},"eventId":{"type":"string"}}}}}},"401":{"description":"Invalid webhook signature"}}}},"/api/stripe/connect/onboard":{"post":{"operationId":"connectOnboard","summary":"Start franchise Connect onboarding","description":"Create or retrieve a Stripe Connect Standard account for a franchise and return the onboarding URL.","tags":["Stripe Connect"],"security":[{"hqAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["franchiseId"],"properties":{"franchiseId":{"type":"string","maxLength":100}}}}}},"responses":{"200":{"description":"Onboarding URL","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"url":{"type":"string","format":"uri"}},"required":["url"]}}}}}},"401":{"description":"Unauthorized"},"404":{"description":"Franchise not found"}}}},"/api/stripe/connect/status":{"get":{"operationId":"connectStatus","summary":"Check franchise Connect status","description":"Retrieve Stripe Connect onboarding and charges status for a franchise.","tags":["Stripe Connect"],"security":[{"hqAuth":[]}],"parameters":[{"name":"franchiseId","in":"query","required":true,"schema":{"type":"string","maxLength":100}}],"responses":{"200":{"description":"Connect status","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"connected":{"type":"boolean"},"accountId":{"type":"string","nullable":true},"chargesEnabled":{"type":"boolean"},"payoutsEnabled":{"type":"boolean"},"detailsSubmitted":{"type":"boolean"},"onboardingComplete":{"type":"boolean"}}}}}}}},"401":{"description":"Unauthorized"},"404":{"description":"Franchise not found"}}}},"/api/stripe/connect/dashboard":{"get":{"operationId":"connectDashboard","summary":"Franchise payout dashboard","description":"Retrieve transfer history, totals, and pending balance for a franchise.","tags":["Stripe Connect"],"security":[{"hqAuth":[]}],"parameters":[{"name":"franchiseId","in":"query","required":true,"schema":{"type":"string","maxLength":100}}],"responses":{"200":{"description":"Transfer history and summary","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"transfers":{"type":"array","items":{"type":"object"}},"totalPaid":{"type":"number"},"totalPending":{"type":"number"},"balance":{"type":"number"}}}}}}}},"401":{"description":"Unauthorized"}}}},"/api/invoices/generate":{"post":{"operationId":"invoiceGenerate","summary":"Generate invoice from job","description":"Build an invoice from a job ID. Fetches job from Supabase, generates line items, and stores the invoice record.","tags":["Invoices"],"security":[{"hqAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string","maxLength":200}}}}}},"responses":{"200":{"description":"Existing draft invoice updated"},"201":{"description":"Invoice generated"},"401":{"description":"Unauthorized"},"404":{"description":"Job not found"}}}},"/api/invoices/send":{"post":{"operationId":"invoiceSend","summary":"Send invoice via email or SMS","description":"Send an existing invoice to the customer and mark it as sent.","tags":["Invoices"],"security":[{"hqAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["invoiceId","method"],"properties":{"invoiceId":{"type":"string","maxLength":200},"method":{"type":"string","enum":["email","sms"]}}}}}},"responses":{"200":{"description":"Invoice sent"},"401":{"description":"Unauthorized"},"404":{"description":"Invoice not found"}}}},"/api/billing/recurring":{"get":{"operationId":"billingRecurringList","summary":"List recurring billing schedules","tags":["Billing"],"security":[{"hqAuth":[]}],"parameters":[{"name":"franchiseId","in":"query","schema":{"type":"string","maxLength":200}},{"name":"active","in":"query","schema":{"type":"string","enum":["true","false"]}}],"responses":{"200":{"description":"Billing schedules","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"schedules":{"type":"array","items":{"type":"object"}},"count":{"type":"number"}}}}}}}},"401":{"description":"Unauthorized"}}},"post":{"operationId":"billingRecurringCreate","summary":"Create recurring billing schedule","tags":["Billing"],"security":[{"hqAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["recurringScheduleId","customerId","customerName","serviceType","intervalMonths","price"],"properties":{"recurringScheduleId":{"type":"string","maxLength":200},"franchiseId":{"type":"string","maxLength":200,"nullable":true},"customerId":{"type":"string","maxLength":200},"customerName":{"type":"string","maxLength":200},"customerEmail":{"type":"string","format":"email","maxLength":200},"serviceType":{"type":"string","maxLength":200},"intervalMonths":{"type":"integer","minimum":1,"maximum":24},"price":{"type":"number","minimum":0,"maximum":99999},"autoCharge":{"type":"boolean","default":false},"stripeCustomerId":{"type":"string","maxLength":200,"nullable":true},"nextBillingDate":{"type":"string","maxLength":30,"nullable":true}}}}}},"responses":{"201":{"description":"Billing schedule created"},"401":{"description":"Unauthorized"},"409":{"description":"Duplicate schedule"}}}},"/api/campaigns/send":{"post":{"operationId":"campaignSend","summary":"Send bulk campaign","description":"Send email/SMS campaign to customers from completed jobs. Supports audience targeting.","tags":["Campaigns"],"security":[{"hqAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["type","messageTemplate"],"properties":{"type":{"type":"string","enum":["email","sms","both"]},"targetAudience":{"type":"string","enum":["all","new","returning"],"default":"all"},"messageTemplate":{"type":"string","maxLength":5000,"description":"Message template. Supports {name} and {email} placeholders."},"subject":{"type":"string","maxLength":200},"franchiseId":{"type":"string","maxLength":100,"nullable":true}}}}}},"responses":{"200":{"description":"Campaign results","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"sent":{"type":"number"},"failed":{"type":"number"},"total":{"type":"number"}}}}}}}},"401":{"description":"Unauthorized"}}}},"/api/dev/health":{"get":{"operationId":"systemHealth","summary":"Full system health check","description":"Tests connectivity to Supabase, Stripe, email, Sentry, GitHub, and Vercel. Returns latency measurements and service status.","tags":["Health"],"responses":{"200":{"description":"System health report","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":true},"data":{"type":"object","properties":{"status":{"type":"string","enum":["healthy","degraded","unhealthy"]},"timestamp":{"type":"string","format":"date-time"},"version":{"type":"string"},"checks":{"type":"object"}}}}}}}}}}}},"components":{"securitySchemes":{"hqAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Supabase access token from an admin session, sent as `Authorization: Bearer <token>`. A session cookie is also accepted as a fallback, but this app does not issue one."},"cronSecret":{"type":"http","scheme":"bearer","description":"CRON_SECRET Bearer token for scheduled jobs"},"stripeSignature":{"type":"apiKey","in":"header","name":"stripe-signature","description":"Stripe webhook signature"}},"responses":{"RateLimited":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","const":false},"error":{"type":"string"}}}}}}}},"tags":[{"name":"Public","description":"No authentication required"},{"name":"Customer","description":"Customer-facing portal endpoints"},{"name":"Jobs","description":"Job creation and management"},{"name":"Stripe","description":"Payment processing"},{"name":"Stripe Connect","description":"Franchise onboarding and payouts"},{"name":"Invoices","description":"Invoice generation and delivery"},{"name":"Billing","description":"Recurring billing schedules"},{"name":"Campaigns","description":"Bulk customer messaging"},{"name":"Health","description":"System health monitoring"}]}