{"version":"2.6.0","baseUrl":"https://graph.perk.city","lastUpdated":"2026-04-09","auth":"None required for public feed endpoints. Admin endpoints require x-admin-key header.","endpoints":{"businesses":{"list":{"method":"GET","path":"/api/feed/businesses","description":"List businesses with optional city/category/search filtering","params":{"city":{"type":"string","description":"City name (case-insensitive partial match)"},"category":{"type":"string","description":"Business category filter"},"search":{"type":"string","description":"Search business name"},"organization":{"type":"string","description":"Organization slug (e.g., boulder-chamber-of-commerce)"},"hasContent":{"type":"boolean","description":"Only businesses with events/offers"},"page":{"type":"number","default":1,"description":"Page number (1-indexed)"},"limit":{"type":"number","default":20,"max":100,"description":"Items per page"}}},"search":{"method":"GET","path":"/api/feed/businesses/search","description":"Full-text search across business names, categories, descriptions","params":{"q":{"type":"string","required":true,"description":"Search query"},"city":{"type":"string"},"category":{"type":"string"},"minRating":{"type":"number","description":"Minimum rating (1-5)"},"hasContent":{"type":"boolean"}}},"nearby":{"method":"GET","path":"/api/feed/businesses/nearby","description":"Find businesses within a radius of a lat/lng point","params":{"lat":{"type":"number","required":true},"lng":{"type":"number","required":true},"radius":{"type":"number","default":5,"description":"Distance"},"unit":{"type":"string","default":"miles","enum":["miles","km"]}}},"detail":{"method":"GET","path":"/api/feed/businesses/{id}","description":"Get business details with social media, posts, and hours"},"digest":{"method":"GET","path":"/api/feed/businesses/{id}/digest","description":"Get EVERYTHING for a business in one call: profiles, posts, events, offers, products, photos. Recommended for detail pages."}},"events":{"list":{"method":"GET","path":"/api/feed/events","description":"List events. Past non-recurring events are auto-excluded by default.","defaultBehavior":"Only returns future events and recurring events. No need to pass upcoming=true.","params":{"city":{"type":"string"},"businessId":{"type":"uuid"},"date":{"type":"YYYY-MM-DD","description":"Specific date — overrides default future filter"},"upcoming":{"type":"boolean","description":"Redundant with default behavior (future events already filtered)"},"active_now":{"type":"boolean","description":"Events happening right now"},"recurring":{"type":"boolean","description":"Only recurring events"},"source":{"type":"string","description":"ai, manual, api (comma-separated)"},"quality":{"type":"string","enum":["verified"],"description":"Filter scrape artifacts: excludes duplicate dates (10+), placeholder titles, low confidence (<0.6). Adds derivedCategory field."},"sort":{"type":"string","default":"date","enum":["date","confidence","created"]},"page":{"type":"number","default":1},"limit":{"type":"number","default":20,"max":100}}},"ical":{"method":"GET","path":"/api/feed/events/{id}/ical","description":"Export event as .ics calendar file","contentType":"text/calendar"}},"offers":{"list":{"method":"GET","path":"/api/feed/offers","description":"List offers/deals. Expired non-recurring offers are auto-excluded when active=true.","params":{"city":{"type":"string"},"businessId":{"type":"uuid"},"type":{"type":"string","enum":["happy_hour","daily_special","bogo","percentage_off","dollar_off","free_item","bundle","loyalty","seasonal"]},"active_now":{"type":"boolean","description":"Currently valid offers"},"day":{"type":"number","description":"Day of week 0-6 (0=Sunday) for recurring offers"},"source":{"type":"string","description":"ai, manual, api (comma-separated)"},"sort":{"type":"string","default":"created","enum":["created","confidence","validUntil"]},"page":{"type":"number","default":1},"limit":{"type":"number","default":20,"max":100}}}},"content":{"list":{"method":"GET","path":"/api/feed/content","description":"Unified endpoint for all content types. Auto-filters stale events and expired offers by default.","params":{"city":{"type":"string"},"businessId":{"type":"uuid"},"type":{"type":"string","enum":["event","offer","product","spotlight","job","announcement"]},"active_now":{"type":"boolean"},"upcoming":{"type":"boolean"},"urgency":{"type":"string","enum":["immediate","this_week","future","ongoing"]},"source":{"type":"string","description":"ai, manual, api (comma-separated)"},"page":{"type":"number","default":1},"limit":{"type":"number","default":20,"max":100}}}},"posts":{"list":{"method":"GET","path":"/api/feed/posts","description":"Social media posts with optional media attachments","params":{"city":{"type":"string"},"businessId":{"type":"uuid"},"platform":{"type":"string","enum":["instagram","facebook"]},"analyzed":{"type":"boolean","description":"Filter by AI analysis status"},"includeMedia":{"type":"boolean","description":"Include media URLs (images/videos)"},"page":{"type":"number","default":1},"limit":{"type":"number","default":20,"max":100}}}},"hashtags":{"list":{"method":"GET","path":"/api/feed/hashtags","description":"Trending hashtags from social posts","params":{"city":{"type":"string"},"search":{"type":"string","description":"Search hashtags"},"limit":{"type":"number","default":20}}}},"cityIntelligence":{"get":{"method":"GET","path":"/api/city-intelligence/{city}","description":"Full city intelligence dashboard: businesses, content, social stats, trending"}},"cityScopedFeeds":{"enrichment":{"method":"GET","path":"/api/feed/{city}/enrichment","description":"Batch endpoint: posts + content + offers for a city in one response","params":{"city":{"type":"string","in":"path","description":"City slug (e.g., boulder-co, superior)"},"limit":{"type":"number","default":100,"max":200,"description":"Max items per section"},"active_now":{"type":"boolean","description":"Filter to currently active content"}}},"businessSummary":{"method":"GET","path":"/api/feed/{city}/businesses/summary","description":"Lightweight business list for maps/cards (~200 bytes per business)","params":{"city":{"type":"string","in":"path"},"limit":{"type":"number","default":500,"max":500},"page":{"type":"number","default":1}}},"guides":{"method":"GET","path":"/api/feed/{city}/guides","description":"Curated walking/thematic guides for a city","params":{"city":{"type":"string","in":"path"},"limit":{"type":"number","default":20,"max":50},"category":{"type":"string","enum":["food","culture","outdoors","shopping","nightlife"]},"featured":{"type":"boolean","description":"Only show featured guides"}}},"campaigns":{"method":"GET","path":"/api/feed/{city}/campaigns","description":"Active campaigns/challenges for a city","params":{"city":{"type":"string","in":"path"},"limit":{"type":"number","default":10,"max":50}}},"stories":{"method":"GET","path":"/api/feed/{city}/stories","description":"Editorial hero story slides for a city. Filters by publish status and active date window.","params":{"city":{"type":"string","in":"path"}}},"neighborhoods":{"method":"GET","path":"/api/feed/{city}/neighborhoods","description":"Active neighborhoods with center coordinates for map filtering and exploration.","params":{"city":{"type":"string","in":"path"}}}},"imageProxy":{"resize":{"method":"GET","path":"/api/feed/image-proxy","description":"Image proxy with resize — delegates to provider-native transforms (Supabase, Google, Cloudinary)","params":{"url":{"type":"string","required":true,"description":"Source image URL (must be on allowlist)"},"w":{"type":"number","description":"Target width (max 2000)"},"h":{"type":"number","description":"Target height (max 2000)"},"fit":{"type":"string","enum":["cover","contain"],"default":"cover"},"q":{"type":"number","default":80,"description":"Quality 1-100"}}}},"docs":{"get":{"method":"GET","path":"/api/docs","description":"This endpoint — complete API documentation","params":{"section":{"type":"string","enum":["endpoints","types","changelog","all"],"default":"all"}}}}},"types":{"contentTypes":{"event":{"description":"Future, attendable happening (show, class, tasting, market). Past events are auto-reclassified to spotlight.","fields":{"title":"string — Event name (grounded in source post text)","date":"YYYY-MM-DD","startTime":"HH:MM or null (only if explicitly stated in post — never 00:00)","endTime":"HH:MM or null","venue":"string or null","performer":"string or null","category":"string (music, comedy, class, community, etc.)","isRecurring":"boolean","recurrenceRule":"string (e.g., WEEKLY;BYDAY=TH)","fuzzyDateDisplay":"string (e.g., \"This Saturday\", \"Every Thursday\")","coverImageUrl":"string or null"}},"offer":{"description":"Currently valid deal with a concrete benefit (% off, $ off, BOGO, free item). Vague \"great value\" posts are classified as spotlight instead. Expired offers are auto-reclassified to spotlight.","fields":{"title":"string","type":"happy_hour | daily_special | bogo | percentage_off | dollar_off | free_item | bundle | loyalty | seasonal","discount":"string (e.g., \"$5 off\", \"50% off\")","validUntil":"YYYY-MM-DD or null","terms":"string or null","daysOfWeek":"number[] (0=Sunday, 6=Saturday) for recurring","timeStart":"HH:MM or null","timeEnd":"HH:MM or null","isRecurring":"boolean","fuzzyDateDisplay":"string (e.g., \"Mon-Fri 4-6pm\")"}},"product":{"description":"Specific item or service being featured (menu item, new arrival, treatment)","fields":{"name":"string","price":"number or null","category":"string (food, drink, retail_item, service, experience)","description":"string or null","isNew":"boolean"}},"spotlight":{"description":"Business showcase content: team highlights, atmosphere, behind-the-scenes, customer stories, milestones, event recaps, expired deals. Catch-all for promotable content that is not a specific event/offer/product.","fields":{"headline":"string","spotlightType":"team | atmosphere | behind_the_scenes | customer_story | milestone | community | menu_highlight | service_feature | transformation | vibe","description":"string or null","tags":"string[]"}},"job":{"description":"Employment opportunity","fields":{"title":"string","type":"full_time | part_time | seasonal","payInfo":"string or null","applyMethod":"dm | email | in_person | link"}},"announcement":{"description":"Operational updates: closures, openings, hours changes, relocations","fields":{"headline":"string","type":"closure | emergency | grand_opening | holiday_hours | relocation | renovation","date":"YYYY-MM-DD or null","details":"string or null"}}},"sourceTypes":{"ai":"AI-extracted from social media posts (GPT-4o-mini). Confidence-based publishing.","manual":"Created by admin in the UI. Auto-published (trusted).","api":"Created programmatically via API. Trust level varies.","import":"Bulk imported. Flagged for review."},"confidence":{"description":"AI confidence score (0.0-1.0) indicating extraction quality. Post-AI validation may adjust scores down.","calibration":{"0.95-1.0":"Explicit date + time + title all clearly stated in source post","0.80-0.94":"Clear content with date but minor ambiguity (inferred time, approximate date)","0.65-0.79":"Likely valid but some details are vague or inferred from context","0.50-0.64":"Uncertain — core details not determinable from the post","below 0.50":"Discarded (not saved)"},"thresholds":{">=0.75":"Auto-published (is_active=true)","0.50-0.74":"Published but flagged for review (needs_review=true)","<0.50":"Discarded entirely"},"penalties":{"fabricated_time":"Capped at 0.80 if 00:00 time detected without time in caption","ungrounded_title":"Capped at 0.65 if <30% of title words appear in caption","past_tense_event":"Capped at 0.60 if past-tense language + past date","vague_offer":"Capped at 0.55 if no concrete benefit (%, $, free, BOGO) in caption"}},"responseFormat":{"success":{"success":true,"data":"[ ... items ... ]","pagination":{"page":"number (1-indexed)","limit":"number","total":"number (total matching items)","totalPages":"number"}},"error":{"error":"string (error message)"}},"mediaUrls":{"description":"All media URLs are permanent Supabase Storage URLs. Expired Instagram/Facebook CDN links are never returned. If no storage URL exists, the media field is null."}},"changelog":[{"version":"2.6.0","date":"2026-04-09","title":"City-Scoped APIs, Batch Enrichment & Quality Filtering","changes":["Batch enrichment: GET /api/feed/{city}/enrichment — posts + content + offers in one response (saves 3 round-trips)","Business summary: GET /api/feed/{city}/businesses/summary — lightweight list/map view (~200 bytes/business)","Polymorphic field normalization: what_people_say and best_time_to_visit always return object form","Offer-business linking: backfilled business_id on extracted_content; offers now include businessName, businessCategory","Event quality filtering: ?quality=verified excludes scrape artifacts, placeholder titles, adds derivedCategory","City guides: GET /api/feed/{city}/guides — curated walking/thematic guides with business stops","City campaigns: GET /api/feed/{city}/campaigns — active campaigns/challenges for a city","Image proxy: GET /api/feed/image-proxy — provider-native resize (Supabase, Google, Cloudinary)","All city-scoped endpoints use shared parseCitySlug utility for consistent slug handling","City stories: GET /api/feed/{city}/stories — editorial hero slides with date window filtering","City neighborhoods: GET /api/feed/{city}/neighborhoods — geographic zones for map filtering"]},{"version":"2.5.0","date":"2026-04-07","title":"Pipeline Hardening & Feed API Enhancements","changes":["Business avatars: all feed responses include business.avatarUrl (persistent Supabase Storage URL)","Details mode: ?details=true expands source posts with caption, engagement, media","Pipeline cost alignment: all phases use verified COST_RATES (were 5-33x off)","Retry resilience: Apify calls and media downloads wrapped with exponential backoff retries","Budget enforcement: maxBudget config stops pipeline if estimated cost exceeds limit","Refresh mode: PIPELINE_PRESETS.refresh(city) — skip discovery, scrape last 14 days only","New config: skipDiscovery, maxDaysBack, onlyBusinessIds on PipelineConfig","Pipeline run tracking: demo_runs table populated with estimated + actual costs on completion","4 composite database indexes for scalability at 50K+ businesses","Dead code cleanup: 21 temp scripts + 2 stale API routes deleted (~5K LOC)","Facebook scraping now respects maxDaysBack parameter (was only applied to Instagram)"]},{"version":"2.4.0","date":"2026-04-02","title":"Extraction Quality Overhaul","changes":["Events must be future and attendable — past event recaps auto-reclassified to spotlight","Offers must have concrete benefit (%, $, BOGO, free) — vague offers penalized or reclassified","Post-AI validation layer: strips fabricated 00:00 times, validates title grounding, catches expired offers","Confidence calibration: 0.95+ only when date+time+title all explicit, penalties for fabricated fields","Feed APIs auto-filter stale events and expired offers by default (no need for upcoming=true)","All media URLs guaranteed to be persistent Supabase Storage URLs (CDN links never returned)","sanitizeMediaUrls() applied to all API routes including admin endpoints","New content type \"spotlight\" for business showcases, event recaps, and expired deals"]},{"version":"2.3.1","date":"2026-04-01","title":"Boulder Chamber Refresh & Media Storage","changes":["Scraped 566 Boulder Chamber IG profiles (last 14 days): 1,836 new posts","All media downloaded to Supabase Storage (3,624 files)","Added maxDaysBack parameter to scrape-social API route","Fixed mediaUrl() helper to filter expired Instagram/Facebook CDN URLs"]},{"version":"2.3.0","date":"2026-01-31","title":"Text-Only GPT-4o-mini Extraction","changes":["Switched from GPT-4o vision to GPT-4o-mini text-only (97% cost reduction)","Uses Instagram alt text descriptions instead of image analysis","Same 89% extraction rate as vision approach","Cost: $0.0134 per 100 posts (was ~$2.00 with vision)"]},{"version":"2.2.0","date":"2026-01-28","title":"Compound Extraction & Entity Resolution","changes":["Compound extraction: single post can produce multiple content types (event + offer)","Entity extraction: people, bands, brands, venues, organizations","Geotag resolution: conflict resolution between platform geotags and text mentions","Fuzzy deduplication: prevents duplicate extractions from similar posts","Source type tracking: ai, manual, api, import origins","Time range columns: starts_at/ends_at for time-based feed filtering"]},{"version":"2.1.0","date":"2026-01-15","title":"Category-Aware Extraction","changes":["Business category injected into prompts (restaurant vs retail vs service)","Review tag context from Google Places reviews for better classification","Confidence thresholds: auto-publish, review queue, discard tiers"]}]}