{"openapi":"3.0.3","info":{"title":"Server Hotels API","version":"0.1.0","description":"Hotel aggregation API — currently wired to **Agoda** (Affiliate Lite meta-search API).\n\nAuth: all `/api/v1/hotels/*`, `/api/v1/content/*`, `/api/v1/suppliers` endpoints require an `X-API-Key` header.\nUse the **Authorize** button (or the key bar at the top) to apply it once for every \"Try it out\".\n\nBooking auth: `/api/v1/bookings/*` endpoints require a JWT Bearer token with `scope=book`.\n\nPricing note: current credentials only return meta-search results (1 `dailyRate` per property + a `landingUrl` click-through). Direct booking requires an Agoda Demand API partnership."},"servers":[{"url":"/","description":"This gateway"}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"}},"schemas":{"PriceBlock":{"type":"object","properties":{"inclusive":{"type":"number"},"exclusive":{"type":"number"},"tax":{"type":"number"},"fees":{"type":"number"},"currency":{"type":"string","nullable":true}}},"Room":{"type":"object","properties":{"roomId":{"type":"string"},"supplier":{"type":"string"},"supplierRoomId":{"type":"string"},"bookingToken":{"type":"string","nullable":true,"description":"Opaque handle (Agoda blockId) for /precheck and /book. Pass back unchanged."},"bookingTokenBackup":{"type":"string","nullable":true},"roomName":{"type":"string","nullable":true},"translatedRoomName":{"type":"string","nullable":true},"parentRoomName":{"type":"string","nullable":true},"parentRoomId":{"type":"string","nullable":true},"ratePlanId":{"type":"string","nullable":true},"freeBreakfast":{"type":"boolean"},"freeWifi":{"type":"boolean","nullable":true},"freeCancellation":{"type":"boolean","nullable":true},"freeCancellationDate":{"type":"string","nullable":true},"inclusions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"translatedName":{"type":"string","nullable":true}}}},"remainingRooms":{"type":"integer","nullable":true},"normalBedding":{"type":"integer","nullable":true},"extraBeds":{"type":"integer","nullable":true},"roomTypeGuaranteed":{"type":"boolean"},"paymentModel":{"type":"string","nullable":true,"enum":["Agency","Merchant","MerchantCommission","MetaSearch",null]},"payAtHotel":{"type":"boolean"},"landingUrl":{"type":"string","nullable":true},"currency":{"type":"string","nullable":true},"totalPrice":{"$ref":"#/components/schemas/PriceBlock"},"perRoomPerNightRate":{"$ref":"#/components/schemas/PriceBlock"},"nightlyRates":{"type":"array","items":{"type":"object"}},"mandatorySurcharges":{"type":"array","items":{"type":"object"}},"excludedSurcharges":{"type":"array","items":{"type":"object"}},"taxes":{"type":"array","items":{"type":"object"}},"cancellation":{"type":"object","nullable":true},"promotion":{"type":"object","nullable":true},"estimatedCommission":{"type":"number","nullable":true}}},"Property":{"type":"object","properties":{"propertyId":{"type":"string","description":"Composite key \"{supplier}:{supplierPropertyId}\" — unique across suppliers"},"supplier":{"type":"string","description":"\"agoda\" or \"aggregator\" when merged across suppliers"},"supplierPropertyId":{"type":"string","description":"Clean supplier-native property ID (for display / lookups)"},"supplierHotelRef":{"type":"string","description":"Round-trip reference the supplier needs for booking (may differ from supplierPropertyId)"},"suppliers":{"type":"array","items":{"type":"string"},"description":"List of suppliers that returned this property"},"propertyName":{"type":"string","nullable":true},"translatedName":{"type":"string","nullable":true},"starRating":{"type":"number","nullable":true},"reviewScore":{"type":"number","nullable":true},"reviewCount":{"type":"integer","nullable":true},"lat":{"type":"number","nullable":true},"lng":{"type":"number","nullable":true},"address":{"type":"string","nullable":true},"imageUrl":{"type":"string","nullable":true},"images":{"type":"array","items":{"type":"string"}},"utcOffset":{"type":"string","nullable":true},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/Room"}},"supplierCount":{"type":"integer"},"bestPrice":{"$ref":"#/components/schemas/PriceBlock","nullable":true,"description":"Lowest totalPrice across rooms"}}},"SearchRequest":{"type":"object","required":["checkIn","checkOut"],"description":"Provide exactly ONE destination field: **cityId** | **cityName** | **hotelIds** | **area** | **landmarkId** | **geo**. Or use **hotelName + (cityId | cityName)** to search by hotel name.","properties":{"cityId":{"type":"integer","description":"Agoda cityId (see /content/cities)."},"cityName":{"type":"string","description":"Free-text city name; resolved to a cityId via content cache (exact / prefix / substring match, ties broken by activeHotels). Pair with `destinationCountry` to scope the lookup.","example":"Da Nang"},"destinationCountry":{"type":"string","description":"ISO 3166-1 alpha-2; scopes `cityName` resolution to one country. Note this is NOT `userCountry` (guest nationality).","example":"VN"},"hotelIds":{"type":"array","items":{"oneOf":[{"type":"integer"},{"type":"string"}]},"maxItems":500,"description":"Agoda hotelIds (from /content/hotels or /content/hotels/suggest)."},"hotelName":{"type":"string","minLength":2,"maxLength":150,"description":"Free-text hotel name. Resolved to hotelId(s) via fuzzy matching on master data. **Requires cityId or cityName for scope.** Use /content/hotels/suggest for autocomplete. Response includes data.resolution.matches.","example":"Sofitel Hanoi"},"area":{"type":"object","properties":{"id":{"type":"integer"},"radius":{"type":"number"}},"required":["id"]},"landmarkId":{"type":"integer"},"geo":{"type":"object","properties":{"latitude":{"type":"number"},"longitude":{"type":"number"},"radius":{"type":"number"}},"required":["latitude","longitude"]},"checkIn":{"type":"string","format":"date","example":"2026-06-08"},"checkOut":{"type":"string","format":"date","example":"2026-06-10"},"rooms":{"type":"integer","default":1,"minimum":1,"maximum":10},"adults":{"type":"integer","default":2,"minimum":1,"maximum":36},"children":{"type":"integer","default":0,"minimum":0,"maximum":35},"childrenAges":{"type":"array","items":{"type":"integer","minimum":0,"maximum":17},"default":[]},"language":{"type":"string","default":"en-us"},"currency":{"type":"string","default":"USD","example":"USD"},"userCountry":{"type":"string","default":"VN","description":"ISO 3166-1 alpha-2 country of the guest/booker (market). Default `VN`, or your tenant `defaultCountry` if configured. Affects promo eligibility, tax/fee inclusion, rate plans, and cancellation policy. MUST be identical across Search → Precheck → Book — Agoda may reject the booking or silently reprice if this value changes between calls.","example":"VN"},"maxResult":{"type":"integer","default":200,"minimum":1,"maximum":1000,"description":"Maximum properties to return. Default raised from 50 to 200 (Epic 2). Partners relying on the 50-result cap should pass `maxResult: 50` explicitly."},"sortBy":{"type":"string","enum":["PriceAsc","PriceDesc","Rating","Popularity","Distance"]},"discountOnly":{"type":"boolean"},"ratesPerProperty":{"type":"integer","default":1,"minimum":1,"maximum":25},"expandCityId":{"type":"boolean","description":"Controls cityId augmentation:\n- omitted (default): the aggregator first issues a raw Demand v4 cityId search (Agoda's full live inventory). If the raw count < maxResult, it auto-augments from master DB (top-N by popularityScore) and merges the results.\n- `false`: skip augmentation entirely — return only the raw Demand v4 cityId result (opt-out for callers that want exactly what Agoda returns).\n- `true`: force augmentation even if raw count >= maxResult.\n\nResponse `meta.augment` is `\"used\"` | `\"skipped\"` | `\"empty\"`."}}},"SearchEnvelope":{"type":"object","properties":{"ok":{"type":"boolean"},"traceId":{"type":"string"},"supplier":{"type":"string","example":"aggregator"},"operation":{"type":"string","example":"search"},"latencyMs":{"type":"integer"},"data":{"type":"object","properties":{"properties":{"type":"array","items":{"$ref":"#/components/schemas/Property"}},"suppliers":{"type":"object","additionalProperties":{"type":"object","properties":{"supplier":{"type":"string"},"count":{"type":"integer"},"roomCount":{"type":"integer"},"traceId":{"type":"string"},"latencyMs":{"type":"integer"},"searchId":{"type":"string","nullable":true},"error":{"type":"object","nullable":true}}}},"totals":{"type":"object","properties":{"totalProperties":{"type":"integer"},"totalRooms":{"type":"integer"},"totalSuppliers":{"type":"integer"},"skippedSuppliers":{"type":"array","items":{"type":"string"}},"degraded":{"type":"boolean"},"cached":{"type":"string","nullable":true,"enum":["fresh","stale",null]}}}}},"error":{"type":"object","nullable":true}}},"ErrorEnvelope":{"type":"object","properties":{"ok":{"type":"boolean","example":false},"traceId":{"type":"string"},"supplier":{"type":"string","nullable":true},"operation":{"type":"string"},"latencyMs":{"type":"integer"},"data":{"type":"object","nullable":true},"error":{"type":"object","properties":{"code":{"type":"string","example":"VALIDATION"},"message":{"type":"string"},"category":{"type":"string"},"retryable":{"type":"boolean"},"details":{"type":"array","items":{"type":"object"}}}}}},"ContentCountry":{"type":"object","properties":{"countryId":{"type":"integer"},"continentId":{"type":"integer"},"name":{"type":"string"},"translatedName":{"type":"string"},"iso3":{"type":"string"},"iso2":{"type":"string"},"activeHotels":{"type":"integer"},"lat":{"type":"number"},"lng":{"type":"number"}}},"ContentCity":{"type":"object","properties":{"cityId":{"type":"integer"},"countryId":{"type":"integer"},"name":{"type":"string"},"translatedName":{"type":"string"},"activeHotels":{"type":"integer"},"lat":{"type":"number"},"lng":{"type":"number"},"areaCount":{"type":"integer"}}},"ContentHotel":{"type":"object","properties":{"hotelId":{"type":"integer"},"name":{"type":"string"},"translatedName":{"type":"string"},"starRating":{"type":"number"},"continentId":{"type":"integer"},"countryId":{"type":"integer"},"cityId":{"type":"integer"},"areaId":{"type":"integer"},"lat":{"type":"number"},"lng":{"type":"number"},"url":{"type":"string"},"popularityScore":{"type":"integer"},"numberOfReviews":{"type":"integer"},"ratingAverage":{"type":"number"},"accommodationType":{"type":"string"},"singleRoomProperty":{"type":"boolean"}}},"PrecheckRequestBody":{"type":"object","required":["supplier","bookingToken","checkIn","checkOut","rooms","adults","propertyId","roomId","rate"],"properties":{"supplier":{"type":"string","enum":["agoda"],"description":"Supplier code."},"bookingToken":{"type":"string","minLength":1,"maxLength":600,"description":"Opaque blockId from search result (pass back unchanged)."},"checkIn":{"type":"string","format":"date","example":"2026-06-01"},"checkOut":{"type":"string","format":"date","example":"2026-06-02"},"rooms":{"type":"integer","minimum":1,"maximum":10},"adults":{"type":"integer","minimum":1,"maximum":36},"children":{"type":"integer","minimum":0,"maximum":35,"default":0},"childrenAges":{"type":"array","items":{"type":"integer","minimum":0,"maximum":17},"default":[]},"language":{"type":"string","default":"en-us"},"currency":{"type":"string","default":"USD"},"userCountry":{"type":"string","default":"VN"},"searchId":{"oneOf":[{"type":"string"},{"type":"integer","minimum":1}],"description":"The searchId returned by the most recent /api/v1/hotels/search call for this rate. SHOULD be forwarded so Agoda binds precheck/book to the priced rate plan from search. Currently optional (Phase 1) — will become required once telemetry confirms all clients send it.","example":"83920164"},"propertyId":{"oneOf":[{"type":"string","maxLength":20},{"type":"integer","minimum":1}],"description":"Agoda supplierPropertyId. Lift from /search response: data.properties[].supplierPropertyId. REQUIRED (Phase 1).","example":8740480},"roomId":{"oneOf":[{"type":"string","maxLength":20},{"type":"integer","minimum":1}],"description":"Agoda roomId. Lift from /search response: data.properties[].rooms[].roomId. REQUIRED.","example":105958345},"rate":{"type":"object","required":["inclusive","exclusive","currency"],"description":"Price from search. Lift from /search: data.properties[].rooms[].totalPrice. REQUIRED.","properties":{"inclusive":{"type":"number","description":"Total price including taxes and fees."},"exclusive":{"type":"number","description":"Net price excluding taxes and fees."},"currency":{"type":"string","example":"USD"}}},"paymentModel":{"type":"string","enum":["Agency","HotelBooking","Merchant","MerchantCommission"],"default":"Merchant","description":"From /search response: data.properties[].rooms[].paymentModel. Defaults to Merchant if omitted.","example":"Merchant"}}},"BookRequestBody":{"type":"object","required":["supplier","bookingToken","checkIn","checkOut","rooms","adults","propertyId","roomId","rate","bookerInfo","guests","paymentInfo"],"properties":{"supplier":{"type":"string","enum":["agoda"],"description":"Supplier code."},"bookingToken":{"type":"string","minLength":1,"maxLength":600,"description":"Opaque blockId from search result (pass back unchanged)."},"checkIn":{"type":"string","format":"date","example":"2026-06-01"},"checkOut":{"type":"string","format":"date","example":"2026-06-02"},"rooms":{"type":"integer","minimum":1,"maximum":10},"adults":{"type":"integer","minimum":1,"maximum":36},"children":{"type":"integer","minimum":0,"maximum":35,"default":0},"childrenAges":{"type":"array","items":{"type":"integer","minimum":0,"maximum":17},"default":[]},"language":{"type":"string","default":"en-us"},"currency":{"type":"string","default":"USD"},"userCountry":{"type":"string","default":"VN"},"searchId":{"oneOf":[{"type":"string"},{"type":"integer","minimum":1}],"description":"The searchId returned by the most recent /api/v1/hotels/search call for this rate. SHOULD be forwarded so Agoda binds precheck/book to the priced rate plan from search. Currently optional (Phase 1) — will become required once telemetry confirms all clients send it.","example":"83920164"},"propertyId":{"oneOf":[{"type":"string","maxLength":20},{"type":"integer","minimum":1}],"description":"Agoda supplierPropertyId. Lift from /search response: data.properties[].supplierPropertyId. REQUIRED (Phase 1).","example":8740480},"roomId":{"oneOf":[{"type":"string","maxLength":20},{"type":"integer","minimum":1}],"description":"Agoda roomId. Lift from /search response: data.properties[].rooms[].roomId. REQUIRED.","example":105958345},"rate":{"type":"object","required":["inclusive","exclusive","currency"],"description":"Price from search. Lift from /search: data.properties[].rooms[].totalPrice. REQUIRED.","properties":{"inclusive":{"type":"number","description":"Total price including taxes and fees."},"exclusive":{"type":"number","description":"Net price excluding taxes and fees."},"currency":{"type":"string","example":"USD"}}},"paymentModel":{"type":"string","enum":["Agency","HotelBooking","Merchant","MerchantCommission"],"default":"Merchant","description":"From /search response: data.properties[].rooms[].paymentModel. Defaults to Merchant if omitted.","example":"Merchant"},"tag":{"type":"string","maxLength":64,"description":"Optional trace tag forwarded to Agoda as bookingDetails.tag."},"bookerInfo":{"type":"object","required":["firstName","lastName","email","phone","phoneCountryCode"],"properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"email":{"type":"string","format":"email"},"phone":{"type":"string"},"phoneCountryCode":{"type":"string"},"title":{"type":"string","enum":["Mr.","Ms.","Mrs.","Dr.","Mr","Ms","Mrs","Dr"],"description":"Optional salutation."},"phoneAreaCode":{"type":"string","maxLength":10,"description":"Optional area code."},"newsletter":{"type":"boolean","default":false},"countryId":{"type":"integer","minimum":1,"description":"Agoda customer country ID. OPTIONAL per Agoda Demand v4. CAUTION: server currently strips this field (Agoda customer-country whitelist not yet known — every value tested returned 909/435). DLV should omit until Agoda CSM provides verified values."}}},"guests":{"type":"array","minItems":1,"items":{"type":"object","required":["firstName","lastName"],"properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"title":{"type":"string","enum":["Mr.","Ms.","Mrs.","Dr.","Mr","Ms","Mrs","Dr"],"description":"Optional per-guest salutation."}}}},"paymentInfo":{"type":"object","required":["cardType","cardNumber","expiryMonth","expiryYear","cardHolderFirstName","cardHolderLastName"],"description":"Either cvc or cvv is required (cvv preferred; cvc accepted for backward compatibility).","properties":{"cardType":{"type":"string"},"cardNumber":{"type":"string"},"expiryMonth":{"type":"string"},"expiryYear":{"type":"string"},"cvc":{"type":"string","description":"Backward-compatible alias for cvv. Provide cvc OR cvv."},"cvv":{"type":"string","description":"Card security code. Provide cvv OR cvc."},"cardHolderFirstName":{"type":"string"},"cardHolderLastName":{"type":"string"},"billingAddress":{"type":"object","required":["address","city","postCode","countryId"],"description":"Optional billing address forwarded to Agoda.","properties":{"address":{"type":"string"},"city":{"type":"string"},"postCode":{"type":"string"},"countryId":{"type":"integer","minimum":1,"example":38}}}}},"specialRequest":{"type":"string","maxLength":1000}}}}},"security":[{"ApiKeyAuth":[]}],"tags":[{"name":"Search","description":"Live availability across enabled suppliers."},{"name":"Bookings","description":"Precheck and book hotel reservations via Agoda."},{"name":"Content","description":"Hotel master data (countries, cities, hotels) pulled from Agoda data feeds."},{"name":"Meta","description":"Supplier capabilities, health, docs."},{"name":"Bookings","description":"Booking lifecycle. Auth: JWT scope=book (Bearer token)."}],"paths":{"/api/v1/health":{"get":{"tags":["Meta"],"security":[],"summary":"Gateway + aggregator health","responses":{"200":{"description":"ok","content":{"application/json":{"example":{"service":"api-gateway","status":"ok","aggregator":{"service":"hotel-aggregator","status":"ok"}}}}}}}},"/api/v1/hotels/search":{"post":{"tags":["Search"],"summary":"Search availability","description":"Fans out to every supplier that advertises `search` capability. Provide exactly ONE destination field. When `cityName` is used, the aggregator resolves it to a cityId (and includes `data.resolution` in the response so callers can audit which city was picked).","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchRequest"},"examples":{"by cityName (Phase 4)":{"value":{"cityName":"Da Nang","destinationCountry":"VN","checkIn":"2026-06-08","checkOut":"2026-06-10","rooms":1,"adults":2,"children":0,"language":"en-us","currency":"USD","userCountry":"VN","maxResult":100}},"by cityId":{"value":{"cityId":9395,"checkIn":"2026-06-08","checkOut":"2026-06-10","rooms":1,"adults":2,"children":0,"language":"en-us","currency":"USD","userCountry":"VN","maxResult":25}},"by Agoda hotelIds":{"value":{"hotelIds":[9400,12157],"checkIn":"2026-06-08","checkOut":"2026-06-10","rooms":1,"adults":2,"children":0,"language":"en-us","currency":"USD","userCountry":"VN"}}}}}},"responses":{"200":{"description":"aggregated results","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchEnvelope"},"example":{"ok":true,"traceId":"abc123","supplier":"aggregator","operation":"search","latencyMs":767,"data":{"properties":[{"propertyId":"agoda:12157","supplier":"agoda","supplierPropertyId":"12157","supplierHotelRef":"12157","suppliers":["agoda"],"propertyName":"Medhufushi Island Resort","translatedName":"Medhufushi Island Resort","starRating":4,"reviewScore":8.1,"reviewCount":451,"lat":2.884,"lng":73.575,"address":null,"imageUrl":"http://pix8.agoda.net/hotelImages/...","images":[],"utcOffset":"+05:00","rooms":[{"roomId":"3134583","supplier":"agoda","supplierRoomId":"3134583","bookingToken":"MDZlNDc0NTUtMTU2My0...","bookingTokenBackup":"06e47455-1563-...","roomName":"Deluxe Room","translatedRoomName":"Phòng Deluxe","parentRoomName":"Deluxe Room","parentRoomId":"3134583","ratePlanId":"617128","freeBreakfast":true,"freeWifi":true,"freeCancellation":true,"freeCancellationDate":"2026-05-30","inclusions":[{"id":1,"name":"Breakfast","translatedName":"Bữa sáng"},{"id":95,"name":"Free WiFi","translatedName":"WiFi miễn phí"}],"remainingRooms":38,"normalBedding":2,"extraBeds":0,"roomTypeGuaranteed":true,"paymentModel":"Merchant","payAtHotel":false,"landingUrl":null,"currency":"USD","totalPrice":{"inclusive":107.14,"exclusive":83.96,"tax":15.46,"fees":7.72,"currency":"USD"},"perRoomPerNightRate":{"inclusive":95.14,"exclusive":74.56,"tax":13.8,"fees":6.78,"currency":"USD"},"nightlyRates":[{"date":"2026-06-01","inclusive":95.14,"exclusive":74.56,"tax":13.8,"fees":6.78,"currency":"USD"}],"mandatorySurcharges":[{"id":278,"name":"Green Tax","method":"PB","amount":{"currency":"USD","inclusive":12,"exclusive":9.4,"tax":0,"fees":0}}],"excludedSurcharges":[{"id":255,"name":"Resort Fee","method":"PB","amount":{"currency":"USD","inclusive":30,"exclusive":30,"tax":0,"fees":0}}],"taxes":[{"id":"1","type":"Tax","description":"Sales tax","translatedDescription":"Thuế bán hàng","method":"PRPN","percent":16,"amount":13.8,"currency":"USD","base":"N"}],"cancellation":{"code":"1D1N_1N","text":"Free cancellation before 2026-05-30","translatedText":"Miễn phí huỷ trước 2026-05-30","deadlines":[{"before":"2026-05-30T00:00:00","penaltyInclusive":0,"penaltyExclusive":0,"penaltyTax":0,"penaltyFees":0},{"before":"2026-06-01T00:00:00","penaltyInclusive":95.14,"penaltyExclusive":74.56,"penaltyTax":13.8,"penaltyFees":6.78}],"parameters":[{"days":2,"charge":"N","value":0},{"days":1,"charge":"N","value":1}]},"promotion":{"id":196413638,"description":"40% discount!","savingAmount":108.2,"codeEligible":false},"estimatedCommission":12.5}],"supplierCount":1,"bestPrice":{"inclusive":107.14,"exclusive":83.96,"tax":15.46,"fees":7.72,"currency":"USD"}}],"suppliers":{"agoda":{"supplier":"agoda","count":1,"roomCount":1,"traceId":"x1","latencyMs":765,"searchId":null}},"totals":{"totalProperties":1,"totalRooms":1,"totalSuppliers":1,"skippedSuppliers":[],"degraded":false,"cached":null}},"error":null}}}},"400":{"description":"validation error / city name not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"},"example":{"ok":false,"traceId":"abc123","supplier":"aggregator","operation":"search","latencyMs":12,"data":null,"error":{"code":"VALIDATION","message":"Invalid request body","category":"validation","retryable":false}}}}},"502":{"description":"all suppliers failed (degraded)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchEnvelope"}}}}}}},"/api/v1/hotels/search-stream":{"post":{"tags":["Search"],"summary":"Search availability (SSE stream)","description":"Same request body as POST /hotels/search. Streams SSE events:\n\n- `event: resolution` — destination city/hotel resolved (once, when `cityName` was used)\n- `event: batch` — per-wave enriched properties as they arrive from Agoda\n- `event: envelope` — final deduplicated/ranked aggregate (same shape as /search response)\n- `data: [DONE]` — stream sentinel, connection closes after this\n\nOn cache HIT: all events are emitted back-to-back without waiting for the pipeline.\n\nClient disconnects abort the upstream aggregator request.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchRequest"},"example":{"cityName":"Da Nang","destinationCountry":"VN","checkIn":"2026-06-08","checkOut":"2026-06-10","rooms":1,"adults":2,"children":0,"language":"en-us","currency":"USD","userCountry":"VN","maxResult":100}}}},"responses":{"200":{"description":"Server-Sent Events stream","content":{"text/event-stream":{"example":"event: resolution\ndata: {\"cityName\":\"Da Nang\",\"cityId\":16440}\n\nevent: batch\ndata: {\"supplier\":\"agoda\",\"waveIndex\":0,\"properties\":[{\"propertyId\":\"agoda:12157\",\"starRating\":4,\"imageUrl\":\"https://cdn.agoda.net/...\",\"bestPrice\":{\"inclusive\":107.14}}]}\n\nevent: envelope\ndata: {\"envelope\":{\"ok\":true,\"traceId\":\"abc123\",\"supplier\":\"aggregator\",\"operation\":\"search\",\"latencyMs\":2900,\"data\":{\"properties\":[...],\"totals\":{\"totalProperties\":980}}}}\n\ndata: [DONE]"}}},"400":{"description":"Validation error forwarded from aggregator as JSON","content":{"application/json":{"example":{"ok":false,"traceId":"abc123","supplier":"aggregator","operation":"search","latencyMs":12,"data":null,"error":{"code":"VALIDATION","message":"Invalid request body","category":"validation","retryable":false}}}}}}}},"/api/v1/suppliers":{"get":{"tags":["Meta"],"summary":"List enabled suppliers + capabilities","responses":{"200":{"description":"ok","content":{"application/json":{"example":{"ok":true,"suppliers":[{"code":"agoda","ops":["search","contentSync"]}],"matrix":{"agoda":{"search":true,"book":false,"contentSync":true}}}}}}}}},"/api/v1/content/countries":{"get":{"tags":["Content"],"summary":"List countries","description":"Full country list from Agoda feed 2. Cached 7 days.","responses":{"200":{"description":"ok","content":{"application/json":{"example":{"ok":true,"traceId":"c1","cached":true,"count":222,"durationMs":10,"items":[{"countryId":38,"continentId":2,"name":"Vietnam","translatedName":"Vietnam","iso3":"VNM","iso2":"VN","activeHotels":118132,"lat":12.94,"lng":108.32}]}}}}}}},"/api/v1/content/cities":{"get":{"tags":["Content"],"summary":"List cities in a country","description":"Agoda feed 3. Requires either `countryId` or `countryIso2`. Cached 7 days per country.","parameters":[{"name":"countryId","in":"query","schema":{"type":"integer"},"example":38,"description":"Agoda country ID"},{"name":"countryIso2","in":"query","schema":{"type":"string"},"example":"VN","description":"ISO 3166-1 alpha-2 (resolved via /countries)"}],"responses":{"200":{"description":"ok","content":{"application/json":{"example":{"ok":true,"traceId":"c1","countryId":38,"cached":false,"count":3,"durationMs":1200,"items":[{"cityId":2758,"countryId":38,"name":"Hanoi","translatedName":"Hanoi","activeHotels":10744,"lat":21.03,"lng":105.85,"areaCount":25},{"cityId":13170,"countryId":38,"name":"Ho Chi Minh City","translatedName":"Ho Chi Minh City","activeHotels":15546,"lat":10.79,"lng":106.68,"areaCount":25},{"cityId":16440,"countryId":38,"name":"Da Nang","translatedName":"Da Nang","activeHotels":5534,"lat":16.07,"lng":108.21,"areaCount":12}]}}}}}}},"/api/v1/content/hotels":{"get":{"tags":["Content"],"summary":"List hotels in a city","description":"Agoda feed 5. Cached 24h per cityId. Use the returned `hotelId`s in /hotels/search.","parameters":[{"name":"cityId","in":"query","required":true,"schema":{"type":"integer"},"example":16440}],"responses":{"200":{"description":"ok","content":{"application/json":{"example":{"ok":true,"traceId":"c1","cityId":16440,"cached":false,"count":1,"durationMs":3200,"items":[{"hotelId":4974146,"name":"Rose Hotel Danang","translatedName":"Rose Hotel Danang","starRating":2,"continentId":2,"countryId":38,"cityId":16440,"areaId":94168,"lat":16.07,"lng":108.18,"url":"https://www.agoda.com/partners/partnersearch.aspx?hid=4974146","popularityScore":1524859,"numberOfReviews":16,"ratingAverage":3.1,"accommodationType":"Hotel","singleRoomProperty":false}]}}}}}}},"/api/v1/content/suggest":{"get":{"tags":["Content"],"summary":"Unified destination search (cities + hotels)","description":"Single autocomplete endpoint returning both cities and hotels. Replaces calling /content/cities + /content/hotels/suggest separately. **DLV migration:** replace `/ajax_hotel_cities.php?q=...` + `/content/hotels/suggest?q=...` with this one call. Filter by `type` field: `items.filter(x => x.type === \"city\")` or `\"hotel\"`.","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2},"description":"Search query (min 2 chars)","example":"Hanoi"},{"name":"countryIso2","in":"query","schema":{"type":"string"},"description":"Scope to country (required unless cityId given)","example":"VN"},{"name":"cityId","in":"query","schema":{"type":"integer"},"description":"Scope to a specific city (hotel-only results)"},{"name":"cityLimit","in":"query","schema":{"type":"integer","minimum":0,"maximum":20,"default":3},"description":"Max city results"},{"name":"hotelLimit","in":"query","schema":{"type":"integer","minimum":0,"maximum":20,"default":7},"description":"Max hotel results"}],"responses":{"200":{"description":"Mixed city + hotel results sorted by relevance score","content":{"application/json":{"example":{"ok":true,"traceId":"abc123","cached":false,"count":4,"items":[{"type":"city","cityId":1763,"name":"Hanoi","countryId":232,"activeHotels":847,"lat":21.03,"lng":105.83,"score":1.05},{"type":"hotel","hotelId":123,"name":"Hanoi Legacy Hotel","cityId":1763,"cityName":"Hanoi","starRating":4,"popularityScore":72,"score":1},{"type":"hotel","hotelId":456,"name":"Hanoi Old Quarter Hotel","cityId":1763,"cityName":"Hanoi","starRating":3,"popularityScore":58,"score":0.9}]}}}},"400":{"description":"QUERY_TOO_SHORT | MISSING_SCOPE"}}}},"/api/v1/content/hotels/suggest":{"get":{"tags":["Content"],"summary":"Hotel name autocomplete","description":"Fuzzy-match hotel names for autocomplete dropdowns. Returns hotels whose `name` or `translatedName` best matches `q`. Results cached 5 minutes. **DLV flow:** call this as user types → user picks a hotel → call /hotels/search with the returned `hotelId`.","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2,"maxLength":150},"description":"Hotel name query (min 2 chars)","example":"Sofitel"},{"name":"cityId","in":"query","schema":{"type":"integer"},"description":"Scope to a specific city (preferred)","example":9395},{"name":"countryIso2","in":"query","schema":{"type":"string"},"description":"Scope to a country (use when cityId unknown)","example":"VN"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":50,"default":10},"description":"Max results"}],"responses":{"200":{"description":"Matched hotels","content":{"application/json":{"example":{"ok":true,"traceId":"abc123","cached":false,"count":2,"items":[{"hotelId":123456,"name":"Sofitel Legend Metropole Hanoi","translatedName":null,"cityId":9395,"cityName":"Hanoi","starRating":5,"popularityScore":98,"lat":21.0245,"lng":105.8412,"score":0.92},{"hotelId":789012,"name":"Sofitel Saigon Plaza","translatedName":null,"cityId":1111,"cityName":"Ho Chi Minh City","starRating":5,"popularityScore":87,"lat":10.7769,"lng":106.7009,"score":0.78}]}}}},"400":{"description":"QUERY_TOO_SHORT | MISSING_SCOPE | INVALID_LIMIT"}}}},"/api/v1/content/hotel/{hotelId}":{"get":{"tags":["Content"],"summary":"Full hotel details","description":"Agoda feed 19 — combined hotel + address + description + facilities + pictures + room types.","parameters":[{"name":"hotelId","in":"path","required":true,"schema":{"type":"integer"},"example":4974146},{"name":"languageId","in":"query","schema":{"type":"integer"},"description":"Agoda language ID (see feed 13)"}],"responses":{"200":{"description":"ok"}}}},"/api/v1/content/sync-delta":{"post":{"tags":["Content"],"summary":"Trigger a delta sync (admin / ops)","description":"Manual kick for Agoda feed 32 (hotel changes). The daily scheduler runs this automatically at 03:00 Bangkok time for yesterday; use this endpoint to:\n- smoke-test the scheduler\n- replay a specific date inside Agoda's [today-7 .. today-1] Bangkok-tz window\n- force a refresh of the per-hotel full-info cache.\nResponse summarizes changed hotelIds by type (update / new / disabled) and how many cache keys were invalidated.","parameters":[{"name":"date","in":"query","schema":{"type":"string","format":"date"},"description":"YYYY-MM-DD; defaults to yesterday in Bangkok tz"}],"responses":{"200":{"description":"sync summary","content":{"application/json":{"example":{"ok":true,"mdate":"2026-04-18","changes":341,"byType":{"1":298,"2":12,"3":31},"invalidated":276,"durationMs":4120}}}}}}},"/api/v1/bookings/precheck":{"post":{"tags":["Bookings"],"summary":"Verify price + availability before booking","description":"Calls Agoda precheck with the same blockId/stay/occupancy used in search. Returns confirmed=true on success; confirmed=false with a reason (PRICE_CHANGED, UNAVAILABLE, SOLD_OUT) on business-level failure.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PrecheckRequestBody"}}}},"responses":{"200":{"description":"precheck result (confirmed or declined)","content":{"application/json":{"example":{"ok":true,"data":{"confirmed":true}}}}},"400":{"description":"validation error"},"502":{"description":"supplier error"}}}},"/api/v1/bookings":{"post":{"tags":["Bookings"],"summary":"Submit a hotel reservation","description":"Creates a booking after a successful /precheck. Idempotent: pass a unique `Idempotency-Key` header. Returns the booking record including status (confirmed | processing | failed).","parameters":[{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"},"description":"Client-generated UUID; requests with the same key return the original result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookRequestBody"}}}},"responses":{"200":{"description":"booking record","content":{"application/json":{"example":{"existing":false,"booking":{"id":"uuid","status":"confirmed"}}}}},"400":{"description":"validation error or missing Idempotency-Key"},"502":{"description":"supplier error"}}}},"/api/v1/content/sync-cities":{"post":{"tags":["Content"],"summary":"Trigger cities sync (admin / ops)","description":"Manual kick for Agoda Feed 3 → agoda_cities table. Runs automatically every 7 days and on startup if the table is empty. Use to force a refresh or after a disaster recovery. Requires admin scope.","responses":{"200":{"description":"triggered","content":{"application/json":{"example":{"ok":true,"message":"cities sync triggered"}}}}}}},"/api/v1/content/sync-countries":{"post":{"tags":["Content"],"summary":"Trigger countries sync (admin / ops)","description":"Manual kick for Agoda Feed 1 → agoda_countries table. Runs automatically every 30 days and on startup if the table is empty. Requires admin scope.","responses":{"200":{"description":"triggered","content":{"application/json":{"example":{"ok":true,"message":"countries sync triggered"}}}}}}},"/api/v1/bookings/{id}/cancel/preview":{"get":{"tags":["Bookings"],"summary":"Preview cancellation fee (non-destructive)","description":"Returns the penalty and refund amounts that would apply if the booking were cancelled NOW. Does NOT change booking status — safe to call multiple times.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Booking UUID"}],"responses":{"200":{"description":"Cancel fee quote","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"traceId":{"type":"string"},"bookingId":{"type":"string"},"preview":{"type":"object","properties":{"penaltyAmount":{"type":"number","description":"Amount charged on cancel"},"refundAmount":{"type":"number","description":"Amount refunded on cancel"},"currency":{"type":"string","description":"ISO 4217 currency code"},"deadline":{"type":"string","nullable":true,"description":"Free-cancel deadline (ISO 8601)"},"policyText":{"type":"string","nullable":true,"description":"Human-readable policy summary"},"quoteExpiresAt":{"type":"string","description":"Quote validity timestamp (ISO 8601)"}}}}}}}},"401":{"description":"Unauthorized"},"404":{"description":"Booking not found or wrong tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Booking not in confirmed status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"502":{"description":"Supplier error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/api/v1/bookings/{id}/cancel":{"post":{"tags":["Bookings"],"summary":"Cancel a booking (destructive)","description":"Cancels the booking. Transitions status confirmed → cancelled. Response includes normalized penalty/refund fields and the raw supplier payload for back-compat.","security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Booking UUID"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","maxLength":500}}}}}},"responses":{"200":{"description":"Booking cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"traceId":{"type":"string"},"booking":{"type":"object","description":"Updated booking record with cancellationPolicy field"},"supplierResult":{"type":"object","description":"Raw supplier response (back-compat)"},"penaltyAmount":{"type":"number","nullable":true},"refundAmount":{"type":"number","nullable":true},"currency":{"type":"string","nullable":true},"processedAt":{"type":"string","nullable":true,"description":"ISO 8601 timestamp"}}}}}},"401":{"description":"Unauthorized"},"404":{"description":"Booking not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Booking not cancellable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"502":{"description":"Supplier error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}}}}