Pesti Est · API v0.2.0

HTTP API reference

Catalog, machine ingest, and admin endpoints (app package v0.2.0). Paths are relative to your deployment (for example https://budapest-night.vercel.app).

Overview

Pesti Est exposes JSON APIs for the public catalog, a machine ingest pipeline secured by INGEST_API_KEY, and browser session APIs for the admin console. Unless noted, request and response bodies use application/json with UTF-8.

  • Public routes are read-only and safe to call from browsers or edge caches (no secrets required).
  • Admin routes require an HTTP-only cookie set by POST /api/admin/login; use a browser or forward Cookie from the same origin.
  • Ingest is intended for servers, ETL jobs, or trusted partners — never expose INGEST_API_KEY in client-side code.
  • Prices are stored in HUF on providers and menu items; the web app reads currencyRates from GET /api/public/site for EUR/USD display.

Venue & event URLs (web app)

These paths are implemented by the Next.js app (not separate JSON resources). They open the venue sheet or a shareable full page and accept either a canonical slug or a legacy key.

  • /venue/{slug} — venue profile (query ?from=venues|events|… controls back navigation).
  • /venue/{slug}/full — full-page venue layout with share chrome.
  • /event/{slug} and /event/{slug}/full — timed event profiles.
  • Locale prefix optional: /hu/venue/budapest-park uses Hungarian copy; slug resolution checks all locales.

Canonical slugs must not embed the wrong Budapest district (e.g. use budapest-park for the Ferencváros open-air park, not prov-budapest-park-ferencvaros). Set locales.en.slug on ingest; logic lives in src/lib/venueSlug.ts. Legacy prov-* URL segments still resolve but the app redirects to the canonical slug.

Timed events link to hosts via venueIds (internal ids). The venue UI lists upcoming events whose venueIds include that provider.

Public catalog (read)

These endpoints read from MongoDB when MONGODB_URI is configured; otherwise they fall back to built-in defaults where noted.

GET/api/public/providers

Auth: None

Returns Provider[]. Mongo _id is stripped from each object.

Query: locale — optional en | hu | es | it | he | ar (overlays locales[locale] on name, descriptions, and slug). Public venue links should use locales.en.slug when set, otherwise the canonical slug algorithm in getCanonicalVenueSlug() — not raw internal ids in marketing URLs.

503 if the database is not configured.

GET/api/public/events

Auth: None

Returns PublicNightEvent[] — timed concerts and ticketed shows (not venue listings). Each event includes venues (resolved host snapshots) and venuesResolved.

Query: locale (same as providers); upcoming=0 to include past/cancelled; borough to filter by district. Default: upcoming scheduled events only, sorted by startsAt.

Stored venueIds must reference existing prov-* ids. On ingest, venueLinks and district fields sync from the primary host.

GET/api/public/menu-items

Auth: None

Flat menu board for Eat & Drink: dishes and drinks with prices, each row linked to a host venue via venue (VenueLink).

Query: locale (optional, e.g. it — resolves item name and section titles from locales), tag (canonical menu tag), q (search name, venue, section, address, category), kind (food | drink | other), borough, categories (comma-separated), limit (max 500, default 120).

400 if tag is not a canonical tag. Empty catalog returns { items: [], providersWithMenu: 0, tourReadiness: {...} } when DB is missing.

Example response
{
  "items": [
    {
      "id": "prov-cafe:espresso",
      "name": "Espresso",
      "kind": "drink",
      "tags": ["coffee", "specialty-coffee"],
      "price": { "amount": 890, "currency": "HUF", "unit": "each", "source": "published" },
      "providerId": "prov-cafe",
      "providerName": "Example Café",
      "category": "Cafés",
      "borough": "Erzsébetváros",
      "neighborhood": "Gozsdu Udvar",
      "address": "1075 Budapest, Kazinczy utca 1",
      "venue": { "id": "prov-cafe", "name": "Example Café", "category": "Cafés", "...": "VenueLink" },
      "sectionTitle": "Coffee",
      "source": "venue",
      "eventTitle": null,
      "venueResolved": true
    }
  ],
  "total": 42,
  "providersWithMenu": 8,
  "tourReadiness": {
    "palinka": { "eligible": 2, "ready": false, "stopCount": 3 },
    "foodie": { "eligible": 1, "ready": false, "stopCount": 3 },
    "coffee": { "eligible": 3, "ready": true, "stopCount": 3 }
  }
}

Canonical tags: palinka, coffee, specialty-coffee, goulash, hungarian, street-food, and others in src/data/menuTags.ts.

GET/api/public/tours/{tourId}

Auth: None

Generates a themed three-stop tour from venues with published menu items matching the template tags. Templates: palinka, foodie, coffee.

Query: seed — optional shuffle seed (defaults to tour id + timestamp).

404 unknown tourId. 422 { "error": "not_enough_venues" } when fewer than three eligible venues. Check readiness via tourReadiness on menu-items.

Example response
{
  "tourId": "palinka",
  "seed": "palinka-1715000000",
  "templateId": "palinka",
  "stops": [
    {
      "providerId": "prov-cellar",
      "providerName": "Example Cellar",
      "category": "Restaurants",
      "borough": "Erzsébetváros",
      "neighborhood": "Jewish Quarter",
      "address": "1075 Budapest, ...",
      "website": "https://...",
      "image": "https://i.ibb.co/...",
      "highlightItems": [
        { "name": "House plum pálinka (4 cl)", "priceLabel": "1,490 Ft" }
      ]
    }
  ]
}
GET/api/public/meetup-groups

Auth: None

Returns PublicMeetupGroup[]: each row includes venues and events resolved from live catalogs plus stored venueLinks / eventLinks snapshots. _id stripped.

503 if the database is not configured.

GET/api/public/locations

Auth: None

Returns a borough → neighborhoods map: Record<Borough, string[]>. If the locations collection is empty or DB is unavailable, the app falls back to static neighborhood lists from the codebase.

GET/api/public/site

Auth: None

Returns the marketing shell document for _id: "main", or merged defaults when missing. Includes currencyRates ({ hufPerEur, hufPerUsd }) used by the header currency switcher (HUF is canonical in Mongo provider/event documents).

Shape (SiteDoc)
interface SiteDoc {
  _id: "main";
  logoUrl: string;
  homeHeroUrl: string;
  discoverHeroUrl: string;
  homeHeroTitle: string;
  homeHeroSubtitle: string;
  homeHeroPrimaryCta: string;
  homeHeroSecondaryCta: string;
  homeHeroTagline: string;
  homeCategoriesTitle: string;
  neighborhoodSectionTitle: string;
  /** Use "{borough}" as a placeholder for the selected borough name. */
  popularNeighborhoodsCaption: string;
  guidesSectionTitle: string;
  guidesViewAllLabel: string;
  guidesViewAllHref?: string;
  guides: SiteGuide[];
  howItWorksSectionTitle: string;
  howItWorksSteps: SiteHowStep[];
  trustPillars: SiteTrustPillar[];
  trustLines: string[];
  popularPicksSectionTitle: string;
  popularPicksViewAllLabel: string;
  newsletterTitle: string;
  newsletterSubtitle: string;
  newsletterPlaceholder: string;
  newsletterCta: string;
  newsletterFinePrint: string;
  sidebarTitle: string;
  sidebarBody: string;
  sidebarCtaLabel: string;
  homePopularPickProviderNames: string[];
  homePopularMeetupGroupId: string;
  /** Fixed HUF conversion rates for the public currency switcher (defaults 350 / 300). */
  currencyRates: { hufPerEur: number; hufPerUsd: number };
  calculator: SiteCalculatorCopy;
  account: SiteAccountSettings;
}

interface SiteCalculatorCopy {
  title: string;
  subtitle: string;
  clearAllCta: string;
  emptyTitle: string;
  emptyMessage: string;
  asideTitle: string;
  asideSubtitle: string;
  asideFootnote: string;
  providerLinePriceSuffix: string;
  estimatedTotalLabel: string;
}

/** My Account, family prefs, neighborhood preview, alerts — full shape in `src/types/site.ts`. */
interface SiteAccountSettings {
  page: { title: string; subtitle: string };
  navTabs: { id: string; label: string }[];
  saved: { tabId: string; title: string; filterChips: { label: string; categoryFilter: string }[]; /* … */ };
  activityPlan: { tabId: string; title: string; priceUnits: { class: string; week: string; party: string; visit: string }; /* … */ };
  familyPreferences: { tabId: string; sections: { id: string; label: string; options: string[]; defaultSelected: string[] }[]; /* … */ };
  neighborhood: { tabId: string; title: string; nearbyNeighborhoods: string[]; /* … */ };
  alerts: { tabId: string; options: string[]; frequencyChoices: string[]; /* … */ };
  privacy: { headline: string; supportEmail: string; /* … */ };
}

type SiteTone = "orange" | "teal" | "pink" | "amber" | "blue";
type SiteIconKey =
  | "map-pin" | "list-checks" | "heart" | "shield-check" | "compass" | "users" | "calculator";

interface SiteGuide {
  id?: string;
  title: string;
  desc: string;
  borough: Borough;
  neighborhood: string;
  imageUrl: string;
  tone: SiteTone;
  ctaLabel?: string;
  ctaHref?: string;
}

interface SiteHowStep {
  step: number;
  title: string;
  desc: string;
  tone: SiteTone;
  icon: SiteIconKey;
}

interface SiteTrustPillar {
  title: string;
  desc: string;
  tone: SiteTone;
  icon: SiteIconKey;
}

Entity references

VenueLink (events, menus, API rows)
interface VenueLink {
  id: string;              // prov-...
  name: string;
  category: "Venues" | "Parties" | "Restaurants" | "Cafés";
  borough: Borough;
  neighborhood: string;
  address: string;
  website?: string;
  menuUrl?: string;
}
Provider
interface Provider {
  id: string;
  name: string;
  category: "Venues" | "Parties" | "Restaurants" | "Cafés";
  borough: "Belváros" | "Terézváros" | "Erzsébetváros" | "Ferencváros" | "Buda" | "Óbuda" | "Újbuda";
  neighborhood: string;
  address: string;
  activityTypes: string[];
  ageRanges: ("All ages" | "Family" | "18+" | "21+" | "Late night")[];
  dayTimeTags: ("Weekday" | "Weekend" | "Morning" | "Afternoon" | "Evening" | "Late night")[];
  pricePerClass: number;
  /** Canonical storage currency is HUF. Legacy EUR rows are converted on read until migrated. */
  priceCurrency?: "HUF" | "EUR";
  shortDescription: string;
  longDescription: string;
  rating: number;
  reviewCount: number;
  badges: ("Featured" | "Popular" | "New" | "Staff Pick" | "Hidden Gem" | "Weekend Vibes")[];
  image: string;
  email: string;
  website: string;
  phone: string;
  announcementTitle?: string;
  announcementDescription?: string;
  announcementBadge?: string;
  galleryImages?: string[];
  bookingEnabled?: boolean;
  /** Optional published food & drink menu; menu.venueLink is computed on ingest. */
  menu?: VenueMenu;
  /** Optional dated packages (not timed events in the Events calendar). */
  eventOfferings?: EventOffering[];
  /** Union of item tags — computed on ingest; do not send in payloads. */
  menuTags?: string[];
  /**
   * Localized copy + URL slugs. Base document fields = English.
   * Set locales.en.slug to the canonical public path segment (district-neutral, e.g. budapest-park).
   * Also provide hu, es, it, he, ar on curated ingest. See localeIngestRules + venueSlug.ts.
   */
  locales?: Partial<Record<"en" | "hu" | "es" | "it" | "he" | "ar", {
    name?: string;
    shortDescription?: string;
    longDescription?: string;
    slug?: string;
    address?: string;
    announcementTitle?: string;
    announcementDescription?: string;
    announcementBadge?: string;
    image?: string;
  }>>;
}
VenueMenu & MenuItem
interface VenueMenu {
  menuUrl?: string;
  sourceUrls: string[];   // https official menu sources
  lastVerifiedAt: string; // YYYY-MM-DD
  sections: MenuSection[];
  /** Set on ingest from the provider row — do not author in payloads. */
  venueLink?: VenueLink;
}

interface MenuSection {
  id: string;
  title: string;
  kind: "food" | "drink" | "mixed";
  items: MenuItem[];
}

interface MenuItem {
  id: string;
  kind: "food" | "drink" | "other";
  name: string;             // English canonical
  description?: string;
  locales?: { hu, es, it, he, ar: { name: string; description?: string } };  // required on ingest
  price?: { amount: number; currency: "HUF" | "EUR"; unit?: "each" | "glass" | "bottle" | "portion" | "ticket"; source: "published" | "estimated" };
  tags: string[];         // canonical tags — see GET /api/public/menu-items
  dietary?: ("vegan" | "vegetarian" | "gluten-free")[];
}
NightEvent & PublicNightEvent
interface NightEvent {
  id: string;               // event-...
  title: string;
  shortDescription: string;
  longDescription: string;
  startsAt: string;         // ISO 8601 with offset, e.g. 2026-08-01T20:00:00+02:00
  endsAt: string;
  timezone?: string;        // default Europe/Budapest
  doorsOpenAt?: string;
  /** Host provider ids; first = primary host (location + cards). */
  venueIds: string[];
  /** Snapshots written on ingest — do not author in payloads. */
  venueLinks?: VenueLink[];
  borough: Borough;
  neighborhood: string;
  entryFees: { id: string; label: string; amount: number; currency: "HUF" | "EUR" | "FREE"; source: "published" | "estimated"; notes?: string }[];
  activityTypes: string[];
  ageRanges: AgeRange[];
  dayTimeTags: DayTimeTag[];
  badges: FeaturedBadge[];
  image: string;
  galleryImages?: string[];
  website: string;
  bookingUrl: string;
  email: string;
  phone: string;
  status: "scheduled" | "cancelled" | "sold_out" | "postponed";
  locales?: Partial<Record<"hu" | "es" | "it" | "he" | "ar", { title: string; shortDescription: string; longDescription: string; slug: string }>>;
}

/** GET /api/public/events adds resolved hosts: */
interface PublicNightEvent extends NightEvent {
  venues: VenueLink[];
  venuesResolved: boolean;
}
MeetupGroup (Borough same as Provider)
interface MeetupGroup {
  id: string;
  name: string;
  borough: Borough;
  neighborhood: string;
  groupType: "Art & Gallery" | "Live Culture" | "Food & Wine Circle" | "Nightlife Crew" | "Local Creators";
  ageRange: "All ages" | "18+" | "21+" | "Family" | "Late night";
  cadence: "Weekly" | "Monthly" | "Weekend" | "Pop-up";
  instagram: string;
  website: string;
  description: string;
  initials: string;
  icon: "stroller" | "skyline" | "heart" | "coffee" | "playground" | "community";
  palette: "teal" | "orange" | "beige" | "charcoal";
  coverImageUrl?: string;
  /** Host venues (prov-*) — curators send ids only; ingest writes venueLinks. */
  venueIds?: string[];
  /** Organized timed events (event-*) — curators send ids only; ingest writes eventLinks. */
  eventIds?: string[];
  venueLinks?: VenueLink[];
  eventLinks?: MeetupEventLink[];
}

interface MeetupEventLink {
  id: string;
  title: string;
  startsAt: string;
  endsAt: string;
  borough: Borough;
  neighborhood: string;
  status: EventStatus;
}

/** GET /api/public/meetup-groups */
interface PublicMeetupGroup extends MeetupGroup {
  venues: VenueLink[];
  events: MeetupEventLink[];
  venuesResolved: boolean;
  eventsResolved: boolean;
}

Machine ingest (full CMS via API)

Use INGEST_API_KEY for headless content management: read catalog and settings, bulk replace collections, patch singletons, upload images to ImgBB, and mirror everything the admin UI can change in MongoDB. Stored raster URLs in provider, meet-up, and site documents must be https:// on imgbb.com (e.g. i.ibb.co) or empty; other hosts are rejected.

Provider locales: root fields are English. Every provider upsert should include locales for hu, es, it, he, and ar (each with name, shortDescription, longDescription, slug). Also set locales.en.slug to the district-neutral canonical URL segment. Public reads accept ?locale= on providers and events. See src/lib/curator/localeIngestRules.ts and src/lib/curator/eventLocaleIngestRules.ts.

Menus (Eat & Drink): attach menu to an existing prov-* via provider + patch or upsert. Do not send menuTags or menu.venueLink. Specialist prompt: scripts/cursor-curator-menu-prompt.txt · rules: src/lib/curator/menuIngestRules.ts.

Timed events: use resource: "event" (not provider category Events). Upsert host venues first in the same operations array. Ticket tiers go in entryFees (HUF/EUR), not pricePerClass on the venue. Prompt: scripts/cursor-curator-events-prompt.txt.

GET/api/cron/curator

Auth: Bearer CRON_SECRET (Vercel Cron)

Optional automation: when CURATOR_ENABLED=true, runs Serper search → fetches an official page → OpenAI JSON → Zod validate → dedupe → Mongo provider upsert (same as ingest). Requires SERPER_API_KEY and CURATOR_OPENAI_API_KEY. Response JSON includes steps. Schedule in vercel.json.

401 if the bearer token does not match CRON_SECRET. Returns 200 with a descriptive body for skip/config errors so crons do not retry endlessly.

GET/api/ingest

Auth: Bearer INGEST_API_KEY or header X-Ingest-Key: <key>

Returns a compact JSON summary of ingest capabilities and limits (same authentication as POST /api/ingest).

POST/api/ingest/upload

Auth: Bearer INGEST_API_KEY or header X-Ingest-Key: <key>

Same behavior as POST /api/admin/upload, but for API clients: multipart/form-data with field file. Requires IMGBB_API_KEY on the server.

Success: { "url": string, "displayUrl": string }.

POST/api/ingest

Auth: Bearer INGEST_API_KEY or header X-Ingest-Key: <key>

Batch read + write operations for providers, timed events, meetup groups, site, and locations. Up to 100 operations per request. Each result may include data for successful reads or write metadata (e.g. { "replaced": 12 }, { "deletedCount": 3 }).

503 if INGEST_API_KEY is not set. 401 if the key is missing or wrong. 503 if MongoDB is unavailable.

Request: either a single operation object or { "operations": [ ... ] }.

Batch example (reads + writes)
{
  "operations": [
    { "resource": "providers", "action": "list" },
    { "resource": "provider", "action": "get", "id": "my-studio" },
    { "resource": "site", "action": "get" },
    { "resource": "locations", "action": "list" },
    {
      "resource": "provider",
      "action": "upsert",
      "document": { "id": "my-studio", "...": "full Provider fields" }
    },
    {
      "resource": "providers",
      "action": "replaceAll",
      "documents": [{ "id": "a", "name": "..." }]
    },
    { "resource": "providers", "action": "deleteMany", "ids": ["legacy-1", "legacy-2"] },
    {
      "resource": "site",
      "action": "put",
      "document": { "logoUrl": "https://...", "...": "full SiteDoc fields" }
    },
    {
      "resource": "locations",
      "action": "replace",
      "locations": [
        { "borough": "Belváros", "neighborhoods": ["Inner City", "Jewish Quarter"] }
      ]
    },
    { "resource": "events", "action": "list" },
    {
      "resource": "event",
      "action": "upsert",
      "document": {
        "id": "event-example-2026",
        "venueIds": ["prov-host-arena"],
        "startsAt": "2026-08-01T20:00:00+02:00",
        "...": "NightEvent fields — upsert host venue first"
      }
    },
    {
      "resource": "provider",
      "action": "patch",
      "id": "prov-cafe",
      "patch": {
        "menu": {
          "menuUrl": "https://cafe.hu/menu",
          "sourceUrls": ["https://cafe.hu/menu"],
          "lastVerifiedAt": "2026-05-16",
          "sections": [{ "id": "drinks", "title": "Drinks", "kind": "drink", "items": [] }]
        }
      }
    }
  ]
}
Single operation (shorthand)
{
  "resource": "provider",
  "action": "upsert",
  "document": { "id": "solo-provider", "name": "Example", "...": "remaining Provider fields" }
}

Read actions (successful results include data)

  • providers + listProvider[] (_id stripped).
  • provider + get + id → one provider or error provider not found.
  • meetupGroups + list / meetupGroup + get — same pattern.
  • site + getSiteDoc (defaults merged if missing).
  • locations + list → raw Mongo rows { borough, neighborhoods }[].
  • events + listNightEvent[].
  • event + get + id → one event or error.

Write actions

  • provider: upsert, patch, delete (by id). Menu patches recompute menuTags and menu.venueLink; linked events refresh host snapshots when the venue changes.
  • event: upsert, patch, delete. Every venueIds[] entry must exist before the event is saved. Ingest writes venueLinks and syncs district from venueIds[0]. Do not send venueLinks in payloads.
  • providers: upsertMany (bulk by id), replaceAll (clears collection then inserts array; max 2000 docs), deleteMany with ids: string[] (max 500 ids).
  • meetupGroup / meetupGroups: same as providers (including replaceAll / deleteMany).
  • site: patch (partial merge) or put with full document (replaces _id: "main").
  • locations: replace — deletes all rows, then inserts the provided array.

Response (JSON): per-operation results with optional data. HTTP 200 when every operation succeeded; 422 when any operation failed.

Example response
{
  "ok": true,
  "results": [
    { "index": 0, "ok": true, "data": [ { "id": "...", "name": "..." } ] },
    { "index": 1, "ok": true, "data": { "id": "my-studio", "name": "..." } },
    { "index": 2, "ok": false, "error": "provider not found" }
  ]
}

curl example (replace the host and key):

curl -sS -X POST "https://budapest-night.vercel.app/api/ingest" \
  -H "Authorization: Bearer $INGEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"resource":"provider","action":"patch","id":"my-id","patch":{"rating":5}}'

Admin console APIs

Used by /admin. Authenticate with POST /api/admin/login, then call other routes from the same origin with the session cookie.

POST/api/admin/login

Auth: None (sets cookie on success)

Body: { "password": string } matching ADMIN_PASSWORD.

200 { "ok": true } and sets HTTP-only cookie. 401 invalid password. 500 if admin password env is missing.

POST/api/admin/logout

Auth: None

Clears the admin session cookie. Returns { "ok": true }.

POST/api/admin/upload

Auth: Admin session cookie

multipart/form-data with field name file (image blob). Uploads to ImgBB using IMGBB_API_KEY.

Success: { "url": string, "displayUrl": string }. Errors 400 missing file, 401 not logged in, 500 missing ImgBB key, 502 ImgBB failure.

GET/api/admin/providers

Auth: Admin session

Returns raw Mongo documents (includes _id).

POST/api/admin/providers

Auth: Admin session

Full replace/upsert by id. Body: full Provider (+ optional _id ignored).

PATCH/api/admin/providers

Auth: Admin session

Body: { "id": string, ...partial fields }$set style merge.

DELETE/api/admin/providers?id=<id>

Auth: Admin session

Deletes one provider by id query param.

GET/api/admin/meetup-groups

Auth: Admin session

Raw meetup group documents.

POST/api/admin/meetup-groups

Auth: Admin session

Upsert full MeetupGroup by id.

PATCH/api/admin/meetup-groups

Auth: Admin session

Partial update by id.

DELETE/api/admin/meetup-groups?id=<id>

Auth: Admin session

Deletes one meetup group by id query param.

GET/api/admin/site

Auth: Admin session

Returns SiteDoc (or defaults).

PATCH/api/admin/site

Auth: Admin session

JSON partial patch merged into _id: "main".

GET/api/admin/locations

Auth: Admin session

Array of { borough, neighborhoods } rows.

PUT/api/admin/locations

Auth: Admin session

Body: { "locations": LocRow[] } — replaces the entire locations collection.

Common errors and environment

HTTPTypical cause
400Malformed JSON or missing required fields (ingest, login).
401Admin cookie missing/invalid, wrong admin password, or wrong ingest key.
422Ingest: one or more operations failed (see results[].error).
500 / 502Missing server env (ImgBB, admin password), or upstream API/upload errors.
503Mongo not configured, or ingest key not configured on server.

Environment variables (server)

  • MONGODB_URI, optional MONGODB_DB
  • ADMIN_PASSWORD, optional ADMIN_SESSION_SECRET
  • INGEST_API_KEY — required for POST /api/ingest
  • Optional INGEST_BASE_URL — for local ingest scripts only (see scripts/ingest-listing-automation.cjs); not required on Vercel.
  • IMGBB_API_KEY — admin image upload

npm run vercel:env:push syncs Mongo, ImgBB, admin, session, ingest, optional NEXT_PUBLIC_IMG_BB_*, and optional curator keys (see scripts/sync-vercel-env.cjs). Run npm run env:generate locally to mint ingest/admin secrets into .env.local. See .env.example for the full list.