API Reference

Dev API base URL (Azure App Service):

https://evergrn-api-dev-c7dxhkf3ctcgdqby.canadacentral-01.azurewebsites.net

Local dev runs on http://localhost:3000 (web client proxied via Vite; mobile uses the Azure URL directly).

Routes marked [auth] require Authorization: Bearer <token> header. Routes marked [user] or [provider] enforce role β€” the other role gets 403.

All body-receiving endpoints run Zod schema validation before reaching route logic. Invalid payloads return 400 { error: "<field>: <message>" }. Validation rules are listed under each endpoint.


Auth β€” /auth

POST /auth/register

Register a customer account.

Body:     { email, password, name, address, zip }
Response: { token, user: { id, email, name } }
Errors:   409 if email taken

Validation:

Field Rule
email Valid email format; normalized to lowercase
password Min 8 characters
name Required; max 100 characters
address Required; max 300 characters
zip Exactly 5 digits

POST /auth/login

Body:     { email, password }
Response: { token, user: { id, email, name } }
Errors:   401 Invalid credentials

Validation:

Field Rule
email Valid email format; normalized to lowercase
password Required (non-empty)

POST /auth/provider/register

Body:     { email, password, name, phone?, serviceZips: string | string[], services: string | string[] }
Response: { token, provider: { id, email, name } }
Errors:   409 if email taken
Notes:    serviceZips and services accept a single value or an array β€” both are normalized to arrays

Validation:

Field Rule
email Valid email format; normalized to lowercase
password Min 8 characters
name Required; max 100 characters
phone Optional; max 20 characters
serviceZips Single 5-digit ZIP or array of 5-digit ZIPs; at least 1 required
services One or more of: lawncare, snowplowing, handyman, home_cleaning; at least 1 required

POST /auth/provider/login

Body:     { email, password }
Response: { token, provider: { id, email, name } }
Errors:   401 Invalid credentials

Validation: Same as POST /auth/login.

POST /auth/forgot-password

Rate-limited: 5 requests per 15 minutes per IP.

Body:     { email, role: 'user' | 'provider' }
Response: { message: 'If an account exists...' }  β€” always 200, never reveals existence
Side:     generates 256-bit token, stores SHA-256 hash in PasswordResetToken, sends email
Token expiry: 1 hour; any existing token for that email+role is deleted first

Validation:

Field Rule
email Valid email format
role Must be "user" or "provider"

POST /auth/reset-password

Body:     { token, newPassword }
Response: { message, role: 'user' | 'provider' }
Errors:   400 if token invalid/expired
Side:     updates User or Provider password (bcrypt 12 rounds), deletes token (single-use)

Validation:

Field Rule
token Required (non-empty string)
newPassword Min 8 characters

Jobs β€” /jobs [auth]

GET /jobs

Returns different sets depending on role:

Response: { jobs: Job[] }

POST /jobs [user]

Customer creates a new job. Address and ZIP are copied from the customer's profile, or from a saved address if addressId is provided.

Body:     { service, description?, preferredAt? (ISO), details? (object), addressId? (UserAddress.id), isAsap? (bool) }
Notes:    preferredAt is required unless isAsap=true. ASAP jobs sort to top of provider feed
          and trigger a "πŸ”΄ URGENT" push notification to matching providers.
Response: created Job object

Validation:

Field Rule
service One of: lawncare, snowplowing, handyman, home_cleaning
preferredAt Optional ISO date string β€” required if isAsap is not true
description Optional; max 2000 characters
details Optional JSON object
addressId Optional string
isAsap Optional boolean β€” if true, preferredAt is not required

GET /jobs/:id [auth]

Response: Job with nested customer, provider?, quotes[]
Access:   job owner, assigned provider, or matching provider (status PENDING or QUOTED)
Errors:   404 if not found or not authorized

POST /jobs/:id/cancel [user]

Body:     (empty)
Errors:   409 { code: 'WITHIN_CANCELLATION_WINDOW' } if within 48h of ACCEPTED acceptance
          400 if status is not PENDING/QUOTED/ACCEPTED
Response: updated Job

GET /jobs/:id/messages [auth]

Response: { messages: [{ id, senderId, senderRole, senderName, content, createdAt }] }
Access:   job owner or assigned provider only

POST /jobs/:id/messages [auth]

Body:     { content, providerId? }
Blocked:  if job is CANCELLED (409)
          if thread has 250 or more messages (429)
Access:   job owner or assigned provider only
Response: created Message

Validation:

Field Rule
content Required; min 1 character; max 2000 characters
providerId Optional string β€” customer must supply this to specify which provider thread to reply in

POST /jobs/:id/messages/photo [auth]

Upload a photo attachment to a job message thread (multipart/form-data).

Field:    photo (single file, 10MB max, PNG/JPG/WebP)
Storage:  Azure Blob Storage β€” messages container β€” {jobId}-{timestamp}-{random}.{ext}
Response: created Message β€” content is "[IMAGE]<sasUrl>" (SAS URL with 1-year expiry)
Access:   job owner or assigned provider only

POST /jobs/:id/images [user]

Upload general job photos (multipart/form-data).

Field:    images (up to 5 files, 10MB each, PNG/JPG/WebP)
Storage:  Azure Blob Storage β€” jobs container β€” https://evergrnuploads.blob.core.windows.net/jobs/...
Response: { images: string[] } β€” full blob URL array

PATCH /jobs/:id/status [provider]

Provider advances job status.

Body:     { status, fromLat?, fromLng? }
On COMPLETED: captures Stripe PaymentIntent if present
Response: updated Job

Validation:

Field Rule
status Must be ON_THE_WAY, IN_PROGRESS, or COMPLETED
fromLat Optional number; -90 to 90 β€” used to calculate ETA when status is ON_THE_WAY
fromLng Optional number; -180 to 180

POST /jobs/:id/start [provider]

Upload before-photos and transition to IN_PROGRESS. Job must be ON_THE_WAY.

Field:    images (multipart, min 1 file, up to 10, 10MB each)
Response: updated Job with beforeImages
Errors:   400 if no photos, 400 if wrong status

POST /jobs/:id/complete [provider]

Upload after-photos and transition to COMPLETED. Job must be IN_PROGRESS.

Field:    images (multipart, min 1 file, up to 10)
Response: updated Job with afterImages
Side effect: captures Stripe PaymentIntent

GET /jobs/reportable [user]

Returns the customer's COMPLETED jobs where updatedAt is within the last 72 hours and no report has been filed yet.

Response: Job[] each with provider: { name, companyName }
Ordered:  updatedAt desc

POST /jobs/:id/report [user]

File a dispute report on a completed job. Enforced server-side: must be COMPLETED, within 72h, owned by the caller, no existing report.

Body:     { description }
Response: created JobReport { id, jobId, customerId, description, createdAt }
Errors:   403 if not owner, 409 if wrong status, 409 if outside 72h window, 409 if already reported

Validation:

Field Rule
description Required; min 1 character; max 5000 characters

POST /jobs/:id/tip [user]

Customer adds a tip after a COMPLETED job.

Body:     { amount }
Response: { success: true, tipAmount: number }
Errors:   402 on Stripe card failure, 409 if tip already submitted

Validation:

Field Rule
amount Number; min $1.00; max $25.00

POST /jobs/:id/recurring [user]

Request a recurring schedule from the assigned provider on a COMPLETED job.

Body:     { dayOfWeek, timeSlot, endDate }
Response: { requested: true }

Validation:

Field Rule
dayOfWeek Integer 0–6 (0 = Sunday, 6 = Saturday)
timeSlot "morning" (9 AM), "afternoon" (1 PM), or "evening" (5 PM)
endDate Valid date string β€” last occurrence date for the series

Quotes β€” /quotes [auth]

POST /quotes [provider]

Submit a quote on a PENDING job. One quote per provider per job. Provider must have a headshot and both sides of a driver's license uploaded before bidding.

Body:     { jobId, amount, note?, scheduledAt? }
Calc:     platformFee = amount Γ— 0.18; totalAmount = amount + platformFee
Side:     job status β†’ QUOTED
          sends quoteReceivedEmail to customer (ACS, fire-and-forget)
Response: created Quote with platformFee and totalAmount
Errors:   400 verification_required if provider docs not uploaded, 404 if job not found,
          409 if job not PENDING, 409 if already quoted

Validation:

Field Rule
jobId Required; non-empty string
amount Positive number greater than 0; max $100,000
note Optional; max 1000 characters
scheduledAt Optional valid date string

POST /quotes/:id/accept [user]

Customer accepts a quote.

Body:     (empty)
Tx:       rejects all other quotes β†’ sets job ACCEPTED + providerId
Side:     attempts Stripe PaymentIntent auth (non-blocking β€” logs error, doesn't fail)
          sends quoteAcceptedEmail to provider (ACS, fire-and-forget)
Response: { quote, job }

GET /quotes/market-rate?service=<service> [provider]

Query:    service (string)
Response: { marketRate: number | null } β€” median of last 50 accepted quotes for that service, rounded to $5

GET /quotes/job/:jobId [user]

Get all quotes for a job the customer owns.

Response: Quote[] each with provider: { name, phone, companyName, companyUrl, insuranceInfo, logoUrl, averageRating }

Providers β€” /providers [auth]

GET /providers/me [provider]

Response: {
  id, email, name, phone, companyName, companyUrl, insuranceInfo, logoUrl,
  services, serviceZips, headshots, idDocFront, idDocBack, availability,
  isPremium, premiumSince,
  idCheckStatus, idCheckName, idCheckDOB, idCheckExpiry, idCheckState, idCheckFlags, idCheckAt,
  insDocUrl, insCheckStatus, insCheckCarrier, insCheckPolicy, insCheckEffective,
  insCheckExpiry, insCheckCoverage, insCheckFlags, insCheckAt,
  createdAt
}

POST /providers/me/push-token [provider]

Save the device's Expo push token. Called automatically after mobile login.

Body:     { token }
Response: { ok: true }

Validation:

Field Rule
token Required; non-empty string

GET /providers/me/notification [provider]

Poll for a queued in-app banner (sent via /admin/notify). Returns and clears the queued item atomically.

Response: { icon, title, body } or null

PATCH /providers/me [provider]

Body:     { name?, phone?, serviceZips?, services?, companyName?, companyUrl? }
Response: updated Provider
Notes:    insuranceInfo is NOT settable via this endpoint β€” it is populated automatically by the
          insurance document OCR (POST /providers/me/insurance-document)

POST /providers/me/logo [provider]

Upload/replace provider logo (multipart/form-data).

Field:    logo (single file, 5MB max, PNG/JPG/WebP)
Storage:  Azure Blob Storage β€” logos container β€” {providerId}{ext} (overwrites previous)
Response: { logoUrl: string } β€” full blob URL

GET /providers/me/jobs [provider]

Response: Job[] assigned to this provider, ordered by preferredAt asc

GET /providers/me/schedule [provider]

Active and upcoming jobs with their scheduled times.

Response: [{ id, service, address, zip, status, customerName, scheduledAt, amount }]
Includes: ACCEPTED, ON_THE_WAY, IN_PROGRESS jobs
scheduledAt: pulled from the accepted Quote.scheduledAt
zip: customer ZIP code β€” included so providers can distinguish jobs across multiple service areas

POST /providers/me/preferred-pro/apply [provider]

Instantly activates Preferred Pro status for the provider. No manual approval step.

Body:     (empty)
Response: { ok: true, message: 'Welcome to Preferred Pro!' }
Errors:   409 { code: 'already_member' } if already a Preferred Pro
Side:     sets isPremium=true, premiumSince=now, preferredProAppliedAt=now on Provider

GET /providers/me/insights [provider]

Analytics dashboard data.

Response: {
  averageRating,
  totalReviews,
  avgTurnaroundMs,
  incomeByService: { [service]: total },
  topZip: { zip, total } | null,
  annualIncomeByService: { [year]: { [service]: total } },
  membershipFee: { owed: bool, amount: number, month?, reason? },
  pendingIncome: {
    scheduled: number,
    scheduledByService: { [service]: number },
    awaitingTransfer: number,
    awaitingByService: { [service]: number }
  },
  recentReviews: [{ id, jobId, rating, comment, customerName, service, createdAt }]
}

POST /providers/me/headshots [provider]

Upload a headshot (one per staff member; can have multiple). Headshot + both ID doc sides are required before placing bids.

Field:    headshot (single file, 8MB max, PNG/JPG/WebP)
Storage:  Azure Blob Storage β€” headshots container β€” {providerId}-{timestamp}.{ext}
Response: updated Provider (PROVIDER_SELF_SELECT)

DELETE /providers/me/headshots/:index [provider]

Remove a headshot by its position in the headshots array.

Params:   :index β€” zero-based array position
Response: updated Provider
Errors:   400 if index is out of range

POST /providers/me/id-documents [provider]

Upload driver's license front image. OCR runs in background (fire-and-forget) after HTTP response.

Fields:   front (optional, 10MB max), back (optional, 10MB max) β€” multipart
Storage:  Azure Blob Storage β€” id-docs container β€” {providerId}-front/back-{timestamp}.{ext}
Response: updated Provider with idCheckStatus: 'pending' (OCR result arrives in ~5s as idCheck* fields)
Errors:   400 if no files provided

Background OCR validation (Azure Document Intelligence prebuilt-idDocument):

Check Failure behavior
confidence < 0.6 idCheckStatus: 'failed', flag unreadable
docType not a government ID idCheckStatus: 'failed', flag not_a_license
no dateOfBirth field flag no_dob
expiry date in past flag expired β†’ hard fail
no_dob + no_expiry both present hard fail
All clear idCheckStatus: 'passed', idCheck* fields populated

idCheckStatus values: pending (OCR running) β†’ passed | review (near_expiry) | failed (expired / not_a_license / unreadable) | error (OCR service unavailable)

POST /providers/me/insurance-document [provider]

Upload insurance certificate image. OCR runs in background after HTTP response.

Field:    insuranceDoc (single file, 10MB max, PNG/JPG/PDF) β€” multipart
Storage:  Azure Blob Storage β€” insurance-docs container β€” {providerId}-{timestamp}.{ext}
Response: { insDocUrl, insCheckStatus: 'pending' }

Background OCR (Azure Document Intelligence prebuilt-read): Extracts carrier name, policy number, effective date, expiry date, and coverage amount from ACORD-style certificate images using regex on the raw extracted text. Auto-fills insuranceInfo if β‰₯ 2 fields are extracted successfully.

Field extracted Stored as
Carrier name (INSURER A: ...) insCheckCarrier
Policy number insCheckPolicy
Effective date insCheckEffective (ISO format)
Expiry date insCheckExpiry (ISO format)
Coverage amount (General Aggregate) insCheckCoverage

insCheckStatus values: pending β†’ active (expiry in future) | expired | review (near expiry) | error

GET /providers/me/smart-bundles [provider]

Returns up to 3 bundled job groupings for the next 7 days, routed by nearest-neighbor algorithm.

Response: Bundle[] where Bundle = {
  id: string,
  date: "YYYY-MM-DD",
  jobCount: number,
  items: [{ routeOrder, estimatedEarnings, estimatedPrice, job: { id, service, address, zip, preferredAt, lat, lng, customer } }],
  projectedEarnings,
  avgDailyEarnings,
  boostDollars,
  boostPct
}
Notes: Only shows jobs with no scheduling conflicts; jobs requiring lat/lng with missing coords placed at end of route

POST /providers/me/smart-bundles/accept [provider]

Quote on all jobs in a bundle in one call.

Body:     { jobs: [{ jobId: string, amount: number }] }
Response: { accepted: number, skipped: number }
Notes:    amount clamped to $25–$500; jobs that are no longer open are skipped; platform fee applied automatically

GET /providers/me/availability [provider]

Get the provider's weekly availability schedule.

Response: { mon: { start: "HH:MM", end: "HH:MM", on: bool }, tue: ..., ... }
Notes:    Defaults to Mon–Fri 8AM–5PM if not yet set

PUT /providers/me/availability [provider]

Set the provider's weekly availability schedule.

Body:     { availability: { mon: { start, end, on }, ... } }
Response: updated availability object
Errors:   400 if availability is not an object

GET /providers/me/premium-insights [provider β€” Preferred Pro only]

Advanced analytics: ZIP expansion opportunities, pricing trends vs. market, lost bid analysis, insurance win rates, service expansion suggestions.

Requires: isPremium = true (403 otherwise)
Response: {
  currentZips: string[],
  zipExpansion: [{ zip, openJobs, projectedMonthlyIncome, topService, serviceBreakdown }],
  pricingTrends: { [service]: { myAvg, marketAvg, winRate, totalBids, wonBids, trend[] } },
  lostBids: [{ service, myAmount, winningAmount, delta, date }],
  insuranceOpportunity: { totalCompleted, wonByInsured, pct, providerHasInsurance },
  serviceExpansion: [{ service, openJobs, estimatedMonthlyIncome }]
}

GET /providers/me/notifications [provider]

Persistent notification history (last 50).

Response: Notification[] β€” { id, title, body, jobId?, read, createdAt }

POST /providers/me/notifications/read [provider]

Mark all notifications read.

Response: { ok: true }

POST /providers/me/support [provider]

Submit a support ticket from within the mobile app.

Body:     { subject, body, category: 'technical' | 'feedback' }
Response: { ok: true }
Side:     Sends email to kdavis@evergrn.co with provider name + subject + body + category

Validation:

Field Rule
subject Required; non-empty string
body Required; non-empty string
category technical or feedback

Payments β€” /payments [user]

POST /payments/setup-intent

Create Stripe SetupIntent for saving a card.

Response: { clientSecret } β€” pass to Stripe Elements
Side:     creates Stripe customer if not exists, stores stripeCustomerId on User

POST /payments/payment-method

Save the confirmed PaymentMethod from the SetupIntent flow.

Body:     { paymentMethodId }
Side:     attaches PM to Stripe customer, stores stripePaymentMethodId on User
Response: { brand, last4, expMonth, expYear }

Validation:

Field Rule
paymentMethodId Required; non-empty string (Stripe pm_... ID)

GET /payments/method

Retrieve the customer's saved card summary.

Response: { brand, last4, expMonth, expYear } or null
Side:     if card was deleted in Stripe, clears stripePaymentMethodId from User and returns null

GET /payments/premium/status [user]

Get the customer's premium subscription status.

Response: { isPremium: bool, premiumSince: DateTime?, stripeSubscriptionId: string? }

POST /payments/premium/subscribe [user]

Start a $9.99/month customer premium subscription.

Body:     (empty)
Requires: saved payment method (stripePaymentMethodId must be set)
Side:     creates Stripe Subscription, sets User.isPremium=true, User.premiumSince=now
Response: { isPremium: bool, subscriptionId: string, status: string }
Errors:   400 if no saved card, 200 + message if already subscribed

POST /payments/premium/cancel [user]

Cancel the premium subscription at end of current billing period.

Body:     (empty)
Side:     sets cancel_at_period_end on the Stripe Subscription (does not immediately revoke access)
Response: { message }
Errors:   400 if no active subscription

Reviews β€” /reviews [user]

POST /reviews

Body:     { jobId, rating, comment? }
Requires: job must be COMPLETED with an assigned provider; no existing review
Response: created Review

Validation:

Field Rule
jobId Required; non-empty string
rating Integer 1–5
comment Optional; max 2000 characters

Users β€” /users [user]

GET /users/me

Response: { id, email, name, address, zip, lat, lng }

PATCH /users/me

Update location pin.

Body:     { lat?, lng? }
Response: updated User

Validation:

Field Rule
lat Optional number; -90 to 90
lng Optional number; -180 to 180

POST /users/me/push-token [user]

Save the device's Expo push token. Called automatically after mobile login.

Body:     { token }
Response: { ok: true }

Validation:

Field Rule
token Required; non-empty string

GET /users/me/notification [user]

Poll for a queued in-app banner (sent via /admin/notify). Returns and clears the queued item atomically.

Response: { icon, title, body } or null

GET /users/me/addresses [user]

List all saved addresses for the customer.

Response: UserAddress[] β€” isPrimary address first

POST /users/me/addresses [user]

Add a new saved address.

Body:     { label?, address, zip }
Response: UserAddress[] β€” full updated list
Errors:   403 premium_required if free-tier customer already has 1 address

Validation:

Field Rule
label Optional; max 100 characters (defaults to "Address" if omitted)
address Required; max 300 characters
zip Exactly 5 digits

PATCH /users/me/addresses/:id/primary [user]

Set an address as the primary (used as default for booking). Unsets any existing primary.

Response: UserAddress[] β€” full updated list

DELETE /users/me/addresses/:id [user]

Remove a saved address. Cannot delete the primary address or the last remaining address.

Response: UserAddress[] β€” full updated list
Errors:   400 if address is primary, 400 if only one address remains

PATCH /users/me/addresses/:id [user]

Update property detail fields for a saved address (used by providers to estimate job scope).

Body:     { drivewayLength?, lawnAcres? }
Response: updated UserAddress

Validation:

Field Rule
drivewayLength Optional non-negative number
lawnAcres Optional non-negative number

POST /users/me/addresses/:id/photos [user]

Upload property photos for a saved address.

Fields:   streetViewPhoto (single, 10MB), serviceAreaPhotos (up to 10 files, 10MB each) β€” multipart
Storage:  Azure Blob Storage β€” addresses container
Response: updated UserAddress with photo URLs
Errors:   400 if no files provided, 404 if address not found or not owned

DELETE /users/me/addresses/:id/photos [user]

Clear all photos (streetViewPhoto + serviceAreaPhotos) for an address.

Response: updated UserAddress with null/empty photo fields

GET /users/me/favorites [user β€” premium only]

List favorite providers.

Requires: isPremium = true (403 otherwise)
Response: Provider[] β€” { id, name, companyName, logoUrl, headshots, services }

POST /users/me/favorites/:providerId [user β€” premium only]

Save a provider as a favorite.

Requires: isPremium = true (403 otherwise)
Response: { favorited: true }
Notes:    Idempotent β€” safe to call multiple times

DELETE /users/me/favorites/:providerId [user]

Remove a provider from favorites.

Response: { favorited: false }

GET /users/me/spend-summary [user β€” premium only]

Year-to-date spend analytics broken down by month, service, and top provider.

Requires: isPremium = true (403 otherwise)
Response: {
  ytdTotal: number,
  ytdJobs: number,
  byMonth: [{ month: "YYYY-MM", total, jobs }],
  byService: [{ service, total, jobs }],
  topProvider: { id, name, companyName } | null
}

GET /users/me/notifications [user]

Persistent notification history (last 50).

Response: Notification[] β€” { id, title, body, jobId?, read, createdAt }

POST /users/me/notifications/read [user]

Mark all notifications as read.

Response: { ok: true }

PATCH /users/me/seasonal-alerts [user]

Toggle seasonal alert opt-in. Enabling requires premium.

Body:     { enabled: bool }
Response: { seasonalAlertsEnabled: bool }
Errors:   403 premium_required if enabling without premium

Validation:

Field Rule
enabled Required boolean

GET /users/me/pro-notes [user]

Get all private notes the customer has written about providers.

Response: { [providerId]: noteText }  β€” map of all saved notes

PUT /users/me/pro-notes/:providerId [user β€” premium only]

Create or update a private note about a provider.

Body:     { note: string }
Requires: isPremium = true (403 otherwise)
Response: { ok: true }
Notes:    Idempotent upsert on (userId, providerId)

Validation:

Field Rule
note Required; max 1000 characters

DELETE /users/me/pro-notes/:providerId [user]

Delete the private note for a provider.

Response: { ok: true }

POST /users/me/support [user]

Submit a support ticket from within the mobile app.

Body:     { subject, body, category: 'technical' | 'feedback' }
Response: { ok: true }
Side:     Sends email to kdavis@evergrn.co with customer name + subject + body + category

Validation:

Field Rule
subject Required; non-empty string
body Required; non-empty string
category technical or feedback

Services β€” /services

GET /services/available

Response: { services: string[], providerCount: number, zip: string }
Logic:    find providers whose serviceZips includes the customer's zip

GET /services/check-zip?zip=XXXXX

Check whether a given ZIP code is served by any active provider.

Query:    zip (5-digit US ZIP code)
Response: { covered: bool, zip: string }

Weather β€” /weather [auth]

GET /weather?zip=XXXXX

Returns a 14-day forecast for the given ZIP code. Used by the provider ScheduleScreen to show weather icons per day header, and by the customer BookScreen date strip.

Query:    zip (5-digit US ZIP code)
Response: [{ date: 'YYYY-MM-DD', icon: string (emoji), maxF: number, minF: number }]
Cache:    1-hour in-memory cache per ZIP β€” avoids repeated geocoding + Open-Meteo calls
Source:   zippopotam.us (ZIP β†’ lat/lng, free, no key) + Open-Meteo (forecast, free, no key)

Drive-Time β€” /drive-time

GET /drive-time

Calculate driving ETA and distance between two points. Uses OSRM (open-source routing, no API key needed). No auth required.

Query (option A): fromLat, fromLng, toLat, toLng  β€” decimal coordinates
Query (option B): fromAddress, toAddress           β€” geocoded via Nominatim
Response: { minutes: number, distanceMi: string }
Errors:   400 if not enough params; 422 if geocoding fails or no route found; 504 if OSRM times out (7s)

Staff β€” /staff

Internal support portal. Not customer-facing. Staff tokens are separate from user/provider tokens β€” they carry type: "staff" in the JWT payload and expire in 12 hours.

Staff roles: customer_support, professional_support, technical_support, internal_audit

POST /staff/login

Body:     { email, password }
Response: { token (12h, type:'staff', role: StaffRole), staff: { id, name, email, role } }
Errors:   401 Invalid credentials

GET /staff/me [staff β€” any role]

Response: { id, name, email, role, createdAt }

GET /staff/customers [customer_support]

Search customers by name or email.

Query:    q (optional search string)
Response: User[] (select: id, name, email, address, zip, createdAt, stripeCustomerId) β€” max 100, ordered newest first

GET /staff/customers/:id [customer_support]

Full customer account detail including jobs, payments, and elevation history.

Response: { user, jobs (with quotes + review), payments, elevations }
Notes:    Active elevations older than 15 min are auto-deactivated on read

GET /staff/providers [professional_support]

Search providers by name, email, or company name.

Query:    q (optional)
Response: Provider[] (select: id, name, email, companyName, services, serviceZips, isPremium, premiumSince, premiumBilledMonth, createdAt) β€” max 100

GET /staff/providers/:id [professional_support]

Full provider account with earnings ledger, anticipated income, and elevation history.

Response: {
  provider,
  jobs (with payment, review, quotes),
  ledger: [{ month, jobs, paidOut, inHold, pendingCapture, grossEarnings, premiumFeeDeducted, netEarnings }],
  anticipatedJobs, anticipatedTotal,
  elevations
}
Notes:    $39 Preferred Pro fee is reflected in netEarnings if isPremium; active elevations auto-deactivate after 15 min

POST /staff/elevation [customer_support | professional_support]

Request elevated access to a user or provider account. Triggers an approval email to the admin.

Body:     { targetId, targetRole: 'user'|'provider', reason }
Response: created ElevationRequest (status: PENDING)
Errors:   400 if missing fields, 404 if account not found,
          409 if an active/pending elevation already exists for this staff+target
Side:     sends HTML email to kdavis@evergrn.co with approve/deny links

GET /staff/elevation/:id/approve

Admin clicks approval link in email. No auth header required β€” link is one-time-use by design.

Response: HTML confirmation page
Errors:   404 if not found, plain-text if already resolved
Side:     ElevationRequest.status β†’ APPROVED

GET /staff/elevation/:id/deny

Admin clicks deny link in email.

Response: HTML confirmation page
Side:     ElevationRequest.status β†’ REJECTED

GET /staff/elevation [staff β€” any role]

List the current staff member's elevation requests.

Response: ElevationRequest[] β€” newest first

POST /staff/impersonate [customer_support | professional_support]

Activate an approved elevation and receive a 15-minute impersonation token.

Body:     { elevationId }
Response: { token (15m, role: 'user'|'provider', elevationId), identity: { id, name, email, role } }
Errors:   403 if elevation is not APPROVED or not owned by caller, 404 if not found
Side:     ElevationRequest.status β†’ ACTIVE; all subsequent API calls with this token are logged in AuditEvent
Notes:    Token expires in 15 min; elevation auto-deactivates on next read after 15 min window passes

PATCH /staff/elevation/:id/deactivate [staff β€” any role]

Manually end an active impersonation session early.

Response: updated ElevationRequest (status: DEACTIVATED)
Errors:   403 if not owned by caller

GET /staff/tickets [technical_support]

List support tickets.

Query:    status (optional β€” defaults to OPEN + IN_PROGRESS)
Response: SupportTicket[] ordered oldest first, with assignedTo: { name, email }

PATCH /staff/tickets/:id [technical_support]

Update a ticket's status or internal notes.

Body:     { status?: TicketStatus, notes?: string }
Side:     status IN_PROGRESS β†’ sets assignedToId to calling staff member
          status RESOLVED or CLOSED β†’ sets resolvedAt
Response: updated SupportTicket

GET /staff/list [admin]

List all staff accounts.

Response: Staff[] β€” { id, name, email, role, createdAt } ordered newest first

POST /staff/create [admin]

Create a new staff account.

Body:     { name, email, password, role: StaffRole }
Response: created Staff (no password field)
Errors:   400 if missing/invalid fields, 409 if email taken

POST /staff/impersonate-staff [admin] [GODMODE]

Spoof a staff token for testing support flows.

Body:     { staffId }
Response: { token, staff: { id, name, email, role } }

GET /staff/audit/sessions [internal_audit]

Full audit view: all staff members, all elevation sessions, all AuditEvent records per session.

Response: Staff[] each with sessions: ElevationRequest[] each with events: AuditEvent[]

Admin β€” /admin [GODMODE β€” dev only]

POST /admin/login

Rate-limited: 10 requests per 15 minutes per IP.

Body:     { email, password }
Response: { token (24h, role:'admin'), admin: { id, email } }
Errors:   401, 429 (rate limit)

GET /admin/users [admin]

Response: { users: User[], providers: Provider[] }

POST /admin/impersonate [admin]

Body:     { targetId, targetRole: 'user'|'provider' }
Response: { token (2h), identity: { id, email, name, role } }

DELETE /admin/accounts/:id?role=user|provider [admin]

Cascade-deletes an account.

user:     deletes reviews, messages, quotes, payments, jobs, then User
provider: reassigns jobs to PENDING (clears providerId), deletes quotes, reviews, then Provider
Response: { ok: true }

POST /admin/generate [admin]

Body:     { customers: int, professionals: int, requestsPerCustomer: int }
Response: { created: { customers, professionals, jobs }, note: 'password is Test1234!' }

POST /admin/notify [admin]

Enqueue an in-app banner notification for a specific user or provider. Delivered on the next poll cycle (within 30s on mobile). Queue is in-memory β€” cleared on server restart.

Body:     { userId, role: 'user'|'provider', title, body, icon? }
Response: { ok: true, queued: { userId, role, title } }

Webhooks β€” /webhooks

Stripe events. Raw body required (no JSON parsing). Verified with STRIPE_WEBHOOK_SECRET. Mounted before express.json() to preserve raw request body.

Event Action
payment_intent.succeeded Payment.status β†’ PAID
payment_intent.payment_failed Payment.status β†’ FAILED
payment_intent.canceled Payment.status β†’ FAILED
payment_intent.amount_capturable_updated no-op
customer.subscription.deleted User.isPremium β†’ false; clears stripeSubscriptionId + premiumSince
customer.subscription.updated Syncs isPremium to subscription active/trialing status

Waitlist β€” /waitlist

Public endpoint β€” no auth required. Used by the coming-soon static page at www.evergrn.co. Rate-limited: 5 requests per 15 minutes per IP. CORS open (allows *) to support cross-origin calls from the static site.

POST /waitlist

Body:     { name, email, zip (5-digit), role: 'customer' | 'provider' }
Response: { ok: true }
Side:     upserts WaitlistEmail record (update on duplicate email)
          sends waitlistConfirmationEmail to the registrant (ACS, fire-and-forget)
Errors:   400 if email/name/zip/role invalid

Health Check

GET /health

Response: { status: 'ok' }
Auth:     none required