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 |
|---|---|
| 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 |
|---|---|
| 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 |
|---|---|
| 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 |
|---|---|
| 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:
- user: their own jobs, with quotes and review nested
- provider: jobs in their serviceZips matching their services (PENDING/QUOTED) + jobs they've already quoted on; adds
hasQuoted: boolflag
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