{
  "openapi": "3.1.0",
  "info": {
    "title": "Imerzn Public API",
    "version": "1.0.0",
    "summary": "Curated read-only surface for AI booking agents and third-party discovery clients.",
    "description": "The Imerzn Public API exposes a small, stable subset of the imerzn.com data model for external consumers: search by destination, retrieve experience detail, list Localites by geography, and read blog content. Booking, payment, admin, and user-account surfaces are intentionally excluded from this spec.\n\nThis is Phase 1 of imerzn's MCP-readiness rollout. Phase 2 will publish an MCP server wrapper at /.well-known/mcp/manifest.json exposing these same endpoints as MCP tools. Phase 3 will register a Claude/ChatGPT plugin.\n\nStability: fields marked required in this spec are guaranteed to persist. Fields not documented here MAY change without notice.",
    "contact": {
      "name": "Imerzn Engineering",
      "email": "engineering@imerzn.com",
      "url": "https://imerzn.com"
    },
    "license": {
      "name": "Proprietary — read-only access permitted per robots.txt.",
      "url": "https://imerzn.com/terms"
    }
  },
  "servers": [
    {
      "url": "https://imerzn.com",
      "description": "Production via Apache on standard HTTPS (:443). First server by convention — OpenAI Actions / Smithery / mcp.so clients hit the first entry; keep it reachable from any egress."
    },
    {
      "url": "https://imerzn.com:2525",
      "description": "Production BE direct (port 2525). Fallback for internal probes bypassing Apache."
    }
  ],
  "tags": [
    {
      "name": "search",
      "description": "Discovery endpoints — filter experiences by geography, price, or category."
    },
    {
      "name": "detail",
      "description": "Single-item lookups — retrieve one experience, localite, or blog by identifier."
    },
    {
      "name": "content",
      "description": "Editorial content — blog articles authored by imerzn staff and vetted localites."
    }
  ],
  "paths": {
    "/user/get-experiences-by-location": {
      "get": {
        "operationId": "search_experiences",
        "tags": ["search"],
        "summary": "Search experiences by country / state / city",
        "description": "Returns a list of published experiences whose geography matches the supplied filters. Matching is case-insensitive LIKE on country/state/city columns. Includes native imerzn posts, Bokun-sourced activities, and Oddball games.",
        "parameters": [
          {
            "name": "country",
            "in": "query",
            "required": true,
            "description": "Country name or canonical slug (e.g. 'France', 'united-states').",
            "schema": { "type": "string" }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "description": "State/region name if narrowing beyond country.",
            "schema": { "type": "string" }
          },
          {
            "name": "city",
            "in": "query",
            "required": false,
            "description": "City name if narrowing beyond state.",
            "schema": { "type": "string" }
          },
          {
            "name": "number_of_rows",
            "in": "query",
            "required": false,
            "description": "Page size cap. Defaults to 20. Maximum 200.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 20 }
          }
        ],
        "responses": {
          "200": {
            "description": "A list of experience summaries.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ExperienceListResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/user/experience/{id}": {
      "get": {
        "operationId": "get_experience_detail",
        "tags": ["detail"],
        "summary": "Retrieve one experience by identifier",
        "description": "The identifier format identifies the source: numeric IDs are native imerzn posts, `p-{partner}-a{activity}` are Bokun activities, and `{slug}` strings resolve against Oddball games.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Experience identifier. See description for shape.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Experience detail record.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ExperienceDetail" }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/user/list-localites": {
      "get": {
        "operationId": "list_local_guides",
        "tags": ["search"],
        "summary": "List Localites (local guides) by geography",
        "description": "Returns verified, active Localites whose home city/country matches the supplied filters.",
        "parameters": [
          {
            "name": "country",
            "in": "query",
            "required": false,
            "description": "Country to filter by.",
            "schema": { "type": "string" }
          },
          {
            "name": "city",
            "in": "query",
            "required": false,
            "description": "City to filter by.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "A list of Localite profile summaries.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/LocaliteListResponse" }
              }
            }
          },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/user/get-blogs": {
      "get": {
        "operationId": "list_blog_articles",
        "tags": ["content"],
        "summary": "List published blog articles",
        "description": "Returns editorial blog posts filtered by category or destination. Blog content is long-form editorial matched to destinations — high value for AI answer-engine grounding on travel queries.",
        "parameters": [
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Category slug (e.g. 'culture', 'food', 'adventure').",
            "schema": { "type": "string" }
          },
          {
            "name": "country",
            "in": "query",
            "required": false,
            "description": "Country the blog is about.",
            "schema": { "type": "string" }
          },
          {
            "name": "number_of_rows",
            "in": "query",
            "required": false,
            "description": "Page size cap. Defaults to 20. Maximum 200.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 20 }
          }
        ],
        "responses": {
          "200": {
            "description": "A list of blog articles.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BlogListResponse" }
              }
            }
          },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/traveller/bokun-quote": {
      "post": {
        "operationId": "check_availability",
        "tags": ["availability"],
        "summary": "Live Bokun quote with group discount + per-category pricing",
        "description": "Returns a live price quote for a specific Bokun-sourced experience on a specific date with a specific passenger breakdown. Honours group discounts (bigger groups → steeper per-pax discount), per-category pricing (Adult / Child / Infant tiers), rate selection (3-star / 4-star / 5-star hotels), and pickup/dropoff place IDs when the activity requires them. Returns a pre-filled `booking_url` that lands on the imerzn.com checkout with `?src=mcp` attribution. Phase 3 feature — requires `mcp_phase3_check_availability_enabled` DB flag ON (prod has this flipped since 2026-10-11).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/BokunQuoteRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Live quote with price breakdown and booking URL.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BokunQuoteResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/traveller/validate-promo-code": {
      "post": {
        "operationId": "validate_promo_code",
        "tags": ["availability"],
        "summary": "Pre-booking promo-code validation + discount math",
        "description": "Validates a promo code against a specific experience + total amount and returns the discounted final amount. Does NOT reserve the code — the final discount is re-applied at Stripe checkout. Use this before presenting a `booking_url` when a user mentions a promo code so you can quote the discounted total up front. Currently supports native (numeric) experience IDs only; Bokun `p-N-aM` IDs return an informative 'not supported' message rather than erroring.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/PromoCodeRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Promo code validation result with discount breakdown.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PromoCodeResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/mcp": {
      "post": {
        "operationId": "list_available_dates",
        "tags": ["availability"],
        "summary": "List open dates + time slots over a 1-90 day window",
        "description": "Returns a list of dates with available slots for an experience within the given window. Call this BEFORE `check_availability` when the user hasn't picked a date yet — it tells you which dates in the window have open slots. Window cap: 90 days (Bokun API limit). The imerzn backend exposes this tool via JSON-RPC at the shared `/mcp` endpoint — the request body is a standard MCP tools/call envelope. For `list_available_dates`, set `params.name` to `list_available_dates` and `params.arguments` to `{experience_id, from, to}` (ISO YYYY-MM-DD dates). Example: `{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"id\":1,\"params\":{\"name\":\"list_available_dates\",\"arguments\":{\"experience_id\":\"p-5287-a626642\",\"from\":\"2026-11-01\",\"to\":\"2026-12-01\"}}}`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/McpToolsCallRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "JSON-RPC envelope with the list_available_dates result inside result.content[0].text (parse as JSON).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/McpToolsCallResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ExperienceSummary": {
        "type": "object",
        "required": ["id", "title", "country"],
        "properties": {
          "id": { "type": "string", "description": "Stable identifier — numeric for native, p-N-aN for Bokun, slug for Oddball." },
          "title": { "type": "string", "description": "Display name of the experience." },
          "hero_image": { "type": "string", "format": "uri", "description": "URL to the hero photo." },
          "price": { "type": "number", "description": "Starting price (per person or per group; see currency)." },
          "currency": { "type": "string", "description": "ISO-4217 currency code (USD, EUR, GBP, INR, etc.)." },
          "country": { "type": "string" },
          "state": { "type": "string" },
          "city": { "type": "string" },
          "source": { "type": "string", "enum": ["native", "bokun", "oddball"], "description": "Inventory source." },
          "detail_url": { "type": "string", "format": "uri", "description": "Canonical URL for the human-readable detail page." },
          "createdAt": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp when the experience was first published. Clients can derive a 'New this week' freshness chip from this." },
          "avgRating": {
            "type": ["number", "null"],
            "description": "Average star rating (0-5) aggregated from imerzn-native reviews. Null for native rows with zero reviews. Not emitted on Bokun rows — see activity_rating. Available on list and detail endpoints as of 2026-10-02 (BE #1995 / #1998)."
          },
          "review_count": {
            "type": "integer",
            "default": 0,
            "description": "Count of imerzn-native reviews. Clients should hide a rating chip when review_count === 0 to avoid negative social proof. Not emitted on Bokun rows."
          },
          "activity_rating": {
            "type": ["number", "null"],
            "description": "External rating sourced from the Bokun partner — TripAdvisor review score when activity_rating_source === 'tripadvisor', otherwise the Bokun first-party review score. Null when the Bokun partner ships no rating."
          },
          "activity_review_count": {
            "type": ["integer", "null"],
            "description": "Review count paired with activity_rating. Null when activity_rating is null."
          },
          "activity_rating_source": {
            "type": "string",
            "enum": ["tripadvisor", "bokun"],
            "description": "Provenance of activity_rating. Clients should attribute visually: 'TripAdvisor' label + green star when tripadvisor; neutral label when bokun. Absent when the Bokun partner ships no rating."
          },
          "is_premium_placement": {
            "type": "boolean",
            "default": false,
            "description": "True when the Bokun partner has an active approved Premium Placement (PP) application. Clients should surface a 'FEATURED' / 'Partner Pick' badge on these cards. Honest signal — not fake demand. Lands with BE #1997 (PP flag migration) on 2026-10-02+. Absent on native and oddball rows."
          },
          "premium_until": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "ISO-8601 timestamp after which is_premium_placement should be treated as false by read clients. Null means 'no expiry set' — treat as perpetual. Companion to is_premium_placement."
          }
        }
      },
      "ExperienceDetail": {
        "allOf": [
          { "$ref": "#/components/schemas/ExperienceSummary" },
          {
            "type": "object",
            "properties": {
              "description": { "type": "string", "description": "Full description of the experience." },
              "duration_hours": { "type": "number", "description": "Duration in hours if the experience has a fixed length." },
              "provider": {
                "type": "object",
                "description": "The Localite or partner offering the experience.",
                "properties": {
                  "name": { "type": "string" },
                  "type": { "type": "string", "enum": ["Person", "Organization"] },
                  "url": { "type": "string", "format": "uri" }
                }
              }
            }
          }
        ]
      },
      "ExperienceListResponse": {
        "type": "object",
        "properties": {
          "experiences": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/ExperienceSummary" }
          },
          "total_count": { "type": "integer" }
        }
      },
      "LocaliteSummary": {
        "type": "object",
        "required": ["user_slug", "name"],
        "properties": {
          "user_slug": { "type": "string", "description": "URL slug for the Localite profile (used in /local-guides/{slug})." },
          "name": { "type": "string" },
          "profile_picture": { "type": "string", "format": "uri" },
          "city": { "type": "string" },
          "country": { "type": "string" },
          "host_since_year": { "type": "integer" }
        }
      },
      "LocaliteListResponse": {
        "type": "object",
        "properties": {
          "localites": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/LocaliteSummary" }
          },
          "total_count": { "type": "integer" }
        }
      },
      "BlogArticle": {
        "type": "object",
        "required": ["slug", "title"],
        "properties": {
          "slug": { "type": "string" },
          "title": { "type": "string" },
          "meta_description": { "type": "string" },
          "banner_image_url": { "type": "string", "format": "uri" },
          "author_display_name": { "type": "string" },
          "published_at": { "type": "string", "format": "date-time" },
          "detail_url": { "type": "string", "format": "uri", "description": "Canonical URL for the article." }
        }
      },
      "BlogListResponse": {
        "type": "object",
        "properties": {
          "blogs": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/BlogArticle" }
          },
          "total_count": { "type": "integer" }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": { "type": "string" },
          "message": { "type": "string" }
        }
      },
      "BokunQuoteRequest": {
        "type": "object",
        "required": ["bokun_synthetic_id", "start_date", "no_of_people"],
        "properties": {
          "bokun_synthetic_id": {
            "type": "string",
            "description": "Bokun experience ID in the imerzn p-{partner}-a{activity} shape (e.g. 'p-5287-a626642'). Native-only experiences are not quoted via this endpoint."
          },
          "start_date": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Array of start dates in MM-DD-YYYY format (Bokun requirement — NOT ISO). For a single-date quote send one element.",
            "example": ["11-15-2026"]
          },
          "start_time": {
            "type": "string",
            "description": "Preferred slot time (e.g. '09:00 AM' or '12:00 AM'). Most Bokun activities accept any time — real pricing is per-passenger, not per-slot.",
            "example": "09:00 AM"
          },
          "no_of_people": {
            "type": "integer",
            "minimum": 1,
            "description": "Total number of travellers. Must equal the sum of passenger entries if passengers[] is supplied."
          },
          "rate_id": {
            "type": "integer",
            "description": "Optional Bokun rate ID (from get_experience_detail bokun_rates[].id). Picks a specific pricing tier (e.g. 3-star / 4-star / 5-star hotels). Defaults to the cheapest rate when omitted."
          },
          "passengers": {
            "type": "array",
            "description": "Optional per-passenger breakdown with pricing-category selection. Each entry maps to a Bokun pricing_category_id (from get_experience_detail bokun_pricing_categories[].id). When omitted, all seats go to the default Adult category.",
            "items": {
              "type": "object",
              "properties": {
                "firstName": { "type": "string" },
                "lastName": { "type": "string" },
                "dateOfBirth": { "type": "string", "format": "date" },
                "pricingCategoryId": { "type": "integer" }
              }
            }
          },
          "pickup_place_id": {
            "type": "integer",
            "description": "Required when the activity has non-empty pickup_place_groups. See get_experience_detail.pickup_place_groups[].places[].id."
          },
          "dropoff_place_id": {
            "type": "integer",
            "description": "Required when the activity has non-empty dropoff_place_groups."
          }
        }
      },
      "BokunQuoteResponse": {
        "type": "object",
        "properties": {
          "success": { "type": "boolean" },
          "message": { "type": "string" },
          "data": {
            "type": "object",
            "description": "The live quote. Shape mirrors the MCP check_availability tool's return envelope.",
            "properties": {
              "price": { "type": "number", "description": "Discounted total amount." },
              "subtotal_before_discount": { "type": "number", "description": "Pre-discount total — present when a group discount applies." },
              "tax_amount": { "type": "number" },
              "total_amount_with_tax": { "type": "number" },
              "currency": { "type": "string", "description": "ISO-4217 currency code (usually 'USD')." },
              "line_items": {
                "type": "array",
                "description": "Per-pricing-category breakdown.",
                "items": {
                  "type": "object",
                  "properties": {
                    "category": { "type": "string", "description": "Human-readable name (e.g. 'Adult', 'Child', 'Infant')." },
                    "quantity": { "type": "integer" },
                    "unit_effective_amount": { "type": "number", "description": "Per-seat price after group discount." },
                    "unit_original_amount": { "type": "number", "description": "Per-seat list price (before discount)." },
                    "subtotal": { "type": "number" },
                    "discount_applied": { "type": "boolean" }
                  }
                }
              },
              "has_group_discount": { "type": "boolean" },
              "booking_url": {
                "type": "string",
                "format": "uri",
                "description": "Pre-filled imerzn.com booking URL — carries date, pax, rate, start_time, promo_code, pickup_place_id (when relevant), and ?src=mcp attribution."
              },
              "quoted_at": { "type": "string", "format": "date-time" }
            }
          }
        }
      },
      "PromoCodeRequest": {
        "type": "object",
        "required": ["coupon_code", "experience_id", "total_amount"],
        "properties": {
          "coupon_code": {
            "type": "string",
            "description": "The promo code the user mentioned (e.g. 'HOLIDAY10', 'SAVE20'). Case-sensitive on the BE side."
          },
          "experience_id": {
            "type": "integer",
            "description": "Native imerzn post ID (numeric only). Bokun p-N-aM IDs are not supported — endpoint returns a 'native-only' message."
          },
          "total_amount": {
            "type": "number",
            "minimum": 0,
            "description": "Current pre-discount total the user is about to pay. Required to compute the discounted final amount."
          },
          "tax_amount": {
            "type": "number",
            "minimum": 0,
            "default": 0,
            "description": "Tax portion of the total. Used in discount-math edge cases."
          },
          "no_of_people": {
            "type": "integer",
            "minimum": 1,
            "default": 1,
            "description": "Travellers in the booking. Some discount types scale per-passenger."
          }
        }
      },
      "PromoCodeResponse": {
        "type": "object",
        "properties": {
          "success": { "type": "boolean" },
          "message": {
            "type": "string",
            "description": "Human-readable outcome — e.g. 'Promo code applied' or 'Code expired' or 'This code only applies to native imerzn experiences'."
          },
          "data": {
            "type": "object",
            "properties": {
              "coupon": {
                "type": "object",
                "properties": {
                  "discount_type": { "type": "string", "enum": ["percentage", "flat"] },
                  "discount_value": { "type": "number" }
                }
              },
              "price": {
                "type": "object",
                "properties": {
                  "original_amount": { "type": "number" },
                  "discount_amount": { "type": "number" },
                  "final_amount": { "type": "number" }
                }
              }
            }
          }
        }
      },
      "McpToolsCallRequest": {
        "type": "object",
        "required": ["jsonrpc", "method", "params"],
        "description": "Standard MCP JSON-RPC tools/call envelope. Only used in this OpenAPI for list_available_dates — the one tool without a dedicated REST path.",
        "properties": {
          "jsonrpc": { "type": "string", "enum": ["2.0"] },
          "id": { "type": "integer", "default": 1 },
          "method": { "type": "string", "enum": ["tools/call"] },
          "params": {
            "type": "object",
            "required": ["name", "arguments"],
            "properties": {
              "name": {
                "type": "string",
                "enum": ["list_available_dates"],
                "description": "Only list_available_dates is wired through this OpenAPI path. check_availability + validate_promo_code have their own REST paths above."
              },
              "arguments": {
                "type": "object",
                "required": ["experience_id", "from", "to"],
                "properties": {
                  "experience_id": {
                    "type": "string",
                    "description": "Experience ID — numeric (native), p-N-aM (Bokun), or slug (Oddball)."
                  },
                  "from": { "type": "string", "format": "date", "description": "Window start (ISO YYYY-MM-DD)." },
                  "to":   { "type": "string", "format": "date", "description": "Window end (ISO YYYY-MM-DD). Max 90 days after from — Bokun API limit." }
                }
              }
            }
          }
        }
      },
      "McpToolsCallResponse": {
        "type": "object",
        "description": "Standard MCP JSON-RPC response. Payload is in result.content[0].text as a JSON-encoded string.",
        "properties": {
          "jsonrpc": { "type": "string", "enum": ["2.0"] },
          "id": { "type": "integer" },
          "result": {
            "type": "object",
            "properties": {
              "content": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "type": { "type": "string", "enum": ["text"] },
                    "text": {
                      "type": "string",
                      "description": "JSON-encoded body — parse this with JSON.parse to get { dates[], slots_by_date, capacity_by_date, source, window_capped_at }."
                    }
                  }
                }
              }
            }
          },
          "error": {
            "type": "object",
            "properties": {
              "code": { "type": "integer" },
              "message": { "type": "string" }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Missing or invalid query parameter.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "No record matches the supplied identifier.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "ServerError": {
        "description": "Unexpected internal error.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  },
  "externalDocs": {
    "description": "Imerzn homepage",
    "url": "https://imerzn.com"
  }
}
