> For the complete documentation index, see [llms.txt](https://mobile-calendar.gitbook.io/v1/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mobile-calendar.gitbook.io/v1/rest-api/reviews.md).

# Reviews

reviews

## List guest reviews from Booking.com and Airbnb in one normalized shape

> Reviews from BOTH connected portals in a single, uniform list — this is the endpoint to build a review-answering workflow on. Read \`ratingNormalized\` (always 0-10, Airbnb's 1-5 doubled) rather than \`rating\`, because a raw 4 is a good review on Airbnb and a bad one on Booking.com. To find work: \`needsResponse=true\` returns only reviews the portal still accepts a reply for and where nothing has been sent yet; add \`hasText=true\` to skip score-only Booking.com reviews, which cannot be replied to at all. Sort with \`orderBy=rating\&orderType=ASC\` to handle the worst ones first. Data comes from a local mirror kept fresh by Airbnb webhooks and an hourly Booking.com poll, so this endpoint does NOT call the portals and is cheap to poll; use POST /reviews/{provider}/sync to force a refresh. Reads only — replying is a separate call. Required scope: \`reviews.read\`.

```json
{"openapi":"3.0.0","info":{"title":"Public REST API v1 – mobile-calendar.com","version":"1.0.0"},"tags":[{"name":"reviews","description":"reviews"}],"servers":[{"url":"https://api.mobile-calendar.com/v1/public","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","description":"Pass the token in header: Authorization: Bearer <JWT>","bearerFormat":"JWT","scheme":"bearer"}},"schemas":{"ReviewsResponse":{"required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Review"}},"meta":{"$ref":"#/components/schemas/MetaWithPagination"}},"type":"object"},"Review":{"description":"A guest review from an OTA, in one shape regardless of which portal it came from. The portals differ in ways that matter: Airbnb rates 1-5 and is two-way (the host also reviews the guest), Booking.com rates 1-10, is one-way, and splits the text into a headline plus separate 'liked'/'disliked' fields. Everything portal-specific stays in `providerData`; the fields above it are the same for both.","required":["provider","reviewId","rating","ratingScale","ratingNormalized","hasText","canRespond","responseStatus"],"properties":{"provider":{"description":"Which portal the review came from.","type":"string","enum":["booking","airbnb"]},"reviewId":{"description":"Review id AT THE PORTAL — pass it back to the response/draft endpoints together with `provider`.","type":"string"},"roomId":{"description":"mobile-calendar room the review is about. NULL when it could not be resolved (the Airbnb listing is not mapped, or the Booking reservation is not in this account) — do not treat NULL as 'no room', it means 'unknown'.","type":"integer","nullable":true},"scopeId":{"description":"Portal-side property scope: Booking `hotelId`, Airbnb `listingId`.","type":"string","nullable":true},"rating":{"description":"Score AS THE PORTAL GIVES IT — read together with `ratingScale`.","type":"number","format":"float","nullable":true},"ratingScale":{"description":"Maximum of `rating`: 5 for Airbnb, 10 for Booking.com.","type":"integer","enum":[5,10]},"ratingNormalized":{"description":"Score rescaled to 0-10 for BOTH portals (Airbnb is doubled). Compare and filter on this one — a raw 4 means a good review on Airbnb and a terrible one on Booking.com. `minRating`/`maxRating` use this scale.","type":"number","format":"float","nullable":true},"text":{"description":"Readable review text. Airbnb: the public review verbatim. Booking.com: headline plus the 'Plus:' / 'Minus:' sections joined with newlines (raw fields stay in `providerData`).","type":"string","nullable":true},"hasText":{"description":"false = a score with no written comment. Booking.com allows those and REFUSES replies to them, so filter on `hasText=true` when looking for reviews to answer.","type":"boolean"},"language":{"description":"Language the review was written in (ISO 639-1). Booking.com only — Airbnb does not report it, so this is always null there.","type":"string","nullable":true},"reviewerName":{"description":"Reviewer's name. Booking.com only — the Airbnb mirror keeps just an opaque reviewer id.","type":"string","nullable":true},"reviewerRole":{"description":"Who wrote it. Always `guest` on Booking.com. On Airbnb `host` means the review YOU wrote about a guest — those are not answerable.","type":"string","enum":["guest","host"],"nullable":true},"guestName":{"description":"Guest of the mobile-calendar reservation the review belongs to (the reservation's client — the same name the panel shows in the Guest column); falls back to Booking.com `reviewerName`. null when the reservation is not in this account. Use it to find a review by the guest's name.","type":"string","nullable":true},"reservationNo":{"description":"Number of that reservation as shown in the panel (`reservationNo`), null when not matched.","type":"integer","nullable":true},"canRespond":{"description":"Whether the portal currently accepts a reply. false when a reply already exists, the review has no text, or (Airbnb) it is still hidden by the double-blind period.","type":"boolean"},"responseStatus":{"description":"Local reply lifecycle: `none` nothing yet, `draft` saved locally and NOT sent, `pending` sent and awaiting the portal's moderation (Booking ~48 h), `published` confirmed live.","type":"string","enum":["none","draft","pending","published"]},"responseText":{"description":"Reply already sent to the portal.","type":"string","nullable":true},"responseDraft":{"description":"Locally saved draft — visible in the panel for a human to approve, never sent by itself.","type":"string","nullable":true},"respondedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"description":"When the review appeared at the portal.","type":"string","format":"date-time","nullable":true},"providerData":{"description":"Raw portal fields, different per provider. Airbnb: `categoryRatings` (per-category scores), `privateFeedback` (seen only by the host), `hidden`, `canReview`, `reservationConfirmationCode`. Booking.com: `headline`, `positive`, `negative`, `scoring` (sub-scores), `reviewerCountryCode`, `reviewerIsGenius`, `reservationId`. Use it when the extra nuance helps you write a better reply.","type":"object"}},"type":"object"},"MetaWithPagination":{"description":"Pagination parameters with request tracking in list responses.","required":["totalItems","currentPage","totalPages","itemsPerPage","ruid"],"properties":{"totalItems":{"type":"integer","minimum":0},"currentPage":{"type":"integer","minimum":1},"totalPages":{"type":"integer","minimum":0},"itemsPerPage":{"type":"integer","minimum":1},"ruid":{"description":"Request Unique ID","type":"string","format":"uuid"}},"type":"object"},"ApiProblem401":{"description":"Missing authentication or invalid credentials.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"401 Unauthorized","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblemBase":{"description":"RFC 7807 Problem Details (base schema).","required":["type","title","status","detail","instance"],"properties":{"type":{"description":"RFC 7807 base — DO NOT use directly in endpoints.\nIn endpoints reference ApiProblem400/401/403/409/422/429/500.","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"},"ApiProblem403":{"description":"Authenticated user but no access.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"403 Forbidden","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem422":{"description":"Input data validation error.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"422 Unprocessable Entity (validation)","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"},"errors":{"description":"Map: field name → error message(s)","type":"object","additionalProperties":{"$ref":"#/components/schemas/ErrorValue"}}},"type":"object"}]},"ErrorValue":{"description":"Single error message or list of messages.","oneOf":[{"description":"Error value in error map can be:\n- single string, or\n- array of strings (multiple messages for one field).","type":"string"},{"type":"array","items":{"type":"string"}}]}}},"paths":{"/reviews":{"get":{"tags":["reviews"],"summary":"List guest reviews from Booking.com and Airbnb in one normalized shape","description":"Reviews from BOTH connected portals in a single, uniform list — this is the endpoint to build a review-answering workflow on. Read `ratingNormalized` (always 0-10, Airbnb's 1-5 doubled) rather than `rating`, because a raw 4 is a good review on Airbnb and a bad one on Booking.com. To find work: `needsResponse=true` returns only reviews the portal still accepts a reply for and where nothing has been sent yet; add `hasText=true` to skip score-only Booking.com reviews, which cannot be replied to at all. Sort with `orderBy=rating&orderType=ASC` to handle the worst ones first. Data comes from a local mirror kept fresh by Airbnb webhooks and an hourly Booking.com poll, so this endpoint does NOT call the portals and is cheap to poll; use POST /reviews/{provider}/sync to force a refresh. Reads only — replying is a separate call. Required scope: `reviews.read`.","operationId":"listReviews","parameters":[{"name":"provider","in":"query","description":"Limit to one portal. Omit for both.","required":false,"schema":{"type":"string","enum":["booking","airbnb"]}},{"name":"needsResponse","in":"query","description":"true = only reviews awaiting a reply (portal still accepts one AND nothing sent yet). The main filter for an answering workflow.","required":false,"schema":{"type":"boolean"}},{"name":"responseStatus","in":"query","description":"Filter by reply lifecycle. `draft` finds replies waiting for a human to approve.","required":false,"schema":{"type":"string","enum":["none","draft","pending","published"]}},{"name":"from","in":"query","description":"Only reviews created on or after this date (YYYY-MM-DD).","required":false,"schema":{"type":"string","format":"date"}},{"name":"to","in":"query","description":"Only reviews created on or before this date (YYYY-MM-DD).","required":false,"schema":{"type":"string","format":"date"}},{"name":"minRating","in":"query","description":"Lowest score to include, ON THE NORMALIZED 0-10 SCALE — one threshold means the same thing for both portals.","required":false,"schema":{"type":"number","format":"float","maximum":10,"minimum":0}},{"name":"maxRating","in":"query","description":"Highest score to include, on the normalized 0-10 scale. `maxRating=6` is a good 'unhappy guests' cut-off.","required":false,"schema":{"type":"number","format":"float","maximum":10,"minimum":0}},{"name":"hasText","in":"query","description":"true = only reviews with a written comment. Booking.com allows score-only reviews and refuses replies to them.","required":false,"schema":{"type":"boolean"}},{"name":"language","in":"query","description":"Review language (ISO 639-1). Booking.com only — Airbnb does not report it, so using this filter EXCLUDES Airbnb reviews entirely.","required":false,"schema":{"type":"string","maxLength":64}},{"name":"roomId","in":"query","description":"Room id, or a list of them. Reviews whose room could not be resolved (`roomId: null`) are NOT returned when this filter is used.","required":false,"schema":{"oneOf":[{"type":"integer"},{"type":"array","items":{"type":"integer"}}]}},{"name":"scopeId","in":"query","description":"Portal-side property scope: Booking `hotelId` or Airbnb `listingId`.","required":false,"schema":{"type":"string","maxLength":64}},{"name":"reviewerRole","in":"query","description":"`guest` (default meaning: reviews about you) or `host` (Airbnb only: reviews YOU wrote about guests). Asking for `host` excludes Booking.com, which has no such direction.","required":false,"schema":{"type":"string","enum":["guest","host"]}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"maximum":200,"minimum":1}},{"name":"orderBy","in":"query","description":"`rating` sorts on the normalized score, so both portals interleave correctly.","required":false,"schema":{"type":"string","default":"createdAt","enum":["createdAt","rating"]}},{"name":"orderType","in":"query","required":false,"schema":{"type":"string","default":"DESC","enum":["ASC","DESC"]}}],"responses":{"200":{"description":"Reviews","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewsResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem401"}}}},"403":{"description":"Insufficient scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem403"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem422"}}}}}}}}}
```

## Review counters and average ratings per portal

> Per portal, split like the panel: \`aboutProperty\` = guest reviews of the property (\`reviews\` = scored reviews — the number to report, \`averageRating\` in the portal's own scale: Airbnb x/5, Booking.com x/10, \`needsResponse\` = the reply work queue, \`pending\` = unscored Airbnb entries that are NOT reviews yet) and \`aboutGuests\` = reviews the host wrote about guests (Airbnb only). Never add the two directions together. Computed from the local mirror, so it is fast and safe to poll. Set \`includeProviderStats=true\` to ALSO fetch each portal's own official aggregate rating live — that adds two calls to the OTAs, so leave it off for routine polling. A portal that fails to answer is reported in \`meta.providerErrors\` instead of failing the request. Required scope: \`reviews.read\`.

```json
{"openapi":"3.0.0","info":{"title":"Public REST API v1 – mobile-calendar.com","version":"1.0.0"},"tags":[{"name":"reviews","description":"reviews"}],"servers":[{"url":"https://api.mobile-calendar.com/v1/public","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","description":"Pass the token in header: Authorization: Bearer <JWT>","bearerFormat":"JWT","scheme":"bearer"}},"schemas":{"ReviewStatsResponse":{"required":["data"],"properties":{"data":{"properties":{"summary":{"description":"Counters computed from the local mirror — no calls to the portals, safe to poll.","properties":{"airbnb":{"$ref":"#/components/schemas/ReviewProviderSummary"},"booking":{"$ref":"#/components/schemas/ReviewProviderSummary"}},"type":"object"},"providerStats":{"description":"Present only with `includeProviderStats=true`: the portals' own aggregate ratings, fetched live. Airbnb returns `rating_stats` per listing, Booking.com returns `scores` for the property. A portal that fails to answer lands in `meta.providerErrors` instead of failing the whole request.","type":"object","nullable":true}},"type":"object"}},"type":"object"},"ReviewProviderSummary":{"description":"Counters split the same way as the panel (Communication → Reviews): reviews OF THE PROPERTY written by guests vs. reviews the host wrote ABOUT GUESTS (Airbnb only).","required":["aboutProperty","total","needsResponse","averageRating"],"properties":{"aboutProperty":{"description":"Guest reviews of the property — the numbers of the portal tile in the panel.","properties":{"reviews":{"description":"Reviews WITH a score — the count the panel shows. This is the answer to 'how many reviews do I have'.","type":"integer"},"averageRating":{"description":"Mean score in the portal's OWN scale (`ratingScale`): Airbnb 1-5, Booking.com 1-10. Quote it with its scale, e.g. 4.0/5.","type":"number","format":"float","nullable":true},"ratingScale":{"type":"integer","enum":[5,10]},"averageRatingNormalized":{"description":"The same mean on the common 0-10 scale — only for comparing portals.","type":"number","format":"float","nullable":true},"needsResponse":{"description":"Reviews still waiting for the property's reply.","type":"integer"},"pending":{"description":"Airbnb entries without a score yet (double-blind: not written or still hidden) — NOT reviews to count.","type":"integer"}},"type":"object"},"aboutGuests":{"description":"Airbnb only (null for Booking.com): reviews the host writes about guests.","properties":{"total":{"type":"integer"},"written":{"type":"integer"},"toWrite":{"description":"Still possible to write (14-day window open).","type":"integer"},"expired":{"type":"integer"}},"type":"object","nullable":true},"total":{"description":"Legacy: ALL mirrored rows of this portal in both directions (guest→property, host→guest, unscored Airbnb entries). Not the number of reviews — use `aboutProperty.reviews`.","type":"integer"},"needsResponse":{"description":"Legacy alias of `aboutProperty.needsResponse`.","type":"integer"},"averageRating":{"description":"Legacy: guest reviews' mean on the normalized 0-10 scale (= `aboutProperty.averageRatingNormalized`).","type":"number","format":"float","nullable":true}},"type":"object"},"ApiProblem401":{"description":"Missing authentication or invalid credentials.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"401 Unauthorized","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblemBase":{"description":"RFC 7807 Problem Details (base schema).","required":["type","title","status","detail","instance"],"properties":{"type":{"description":"RFC 7807 base — DO NOT use directly in endpoints.\nIn endpoints reference ApiProblem400/401/403/409/422/429/500.","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"},"ApiProblem403":{"description":"Authenticated user but no access.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"403 Forbidden","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem422":{"description":"Input data validation error.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"422 Unprocessable Entity (validation)","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"},"errors":{"description":"Map: field name → error message(s)","type":"object","additionalProperties":{"$ref":"#/components/schemas/ErrorValue"}}},"type":"object"}]},"ErrorValue":{"description":"Single error message or list of messages.","oneOf":[{"description":"Error value in error map can be:\n- single string, or\n- array of strings (multiple messages for one field).","type":"string"},{"type":"array","items":{"type":"string"}}]}}},"paths":{"/reviews/stats":{"get":{"tags":["reviews"],"summary":"Review counters and average ratings per portal","description":"Per portal, split like the panel: `aboutProperty` = guest reviews of the property (`reviews` = scored reviews — the number to report, `averageRating` in the portal's own scale: Airbnb x/5, Booking.com x/10, `needsResponse` = the reply work queue, `pending` = unscored Airbnb entries that are NOT reviews yet) and `aboutGuests` = reviews the host wrote about guests (Airbnb only). Never add the two directions together. Computed from the local mirror, so it is fast and safe to poll. Set `includeProviderStats=true` to ALSO fetch each portal's own official aggregate rating live — that adds two calls to the OTAs, so leave it off for routine polling. A portal that fails to answer is reported in `meta.providerErrors` instead of failing the request. Required scope: `reviews.read`.","operationId":"getReviewStats","parameters":[{"name":"includeProviderStats","in":"query","description":"true = also fetch the portals' own aggregate ratings live (slower).","required":false,"schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Review statistics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewStatsResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem401"}}}},"403":{"description":"Insufficient scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem403"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem422"}}}}}}}}}
```

## Get a single review with the full portal-specific detail

> The same normalized shape as the list, but worth fetching before writing a reply: \`providerData\` carries the nuance the list summarises away — Airbnb's per-category scores and the private feedback only the host sees, Booking.com's separate 'liked'/'disliked' text and sub-scores. Required scope: \`reviews.read\`.

```json
{"openapi":"3.0.0","info":{"title":"Public REST API v1 – mobile-calendar.com","version":"1.0.0"},"tags":[{"name":"reviews","description":"reviews"}],"servers":[{"url":"https://api.mobile-calendar.com/v1/public","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","description":"Pass the token in header: Authorization: Bearer <JWT>","bearerFormat":"JWT","scheme":"bearer"}},"schemas":{"ReviewResponse":{"required":["data"],"properties":{"data":{"$ref":"#/components/schemas/Review"}},"type":"object"},"Review":{"description":"A guest review from an OTA, in one shape regardless of which portal it came from. The portals differ in ways that matter: Airbnb rates 1-5 and is two-way (the host also reviews the guest), Booking.com rates 1-10, is one-way, and splits the text into a headline plus separate 'liked'/'disliked' fields. Everything portal-specific stays in `providerData`; the fields above it are the same for both.","required":["provider","reviewId","rating","ratingScale","ratingNormalized","hasText","canRespond","responseStatus"],"properties":{"provider":{"description":"Which portal the review came from.","type":"string","enum":["booking","airbnb"]},"reviewId":{"description":"Review id AT THE PORTAL — pass it back to the response/draft endpoints together with `provider`.","type":"string"},"roomId":{"description":"mobile-calendar room the review is about. NULL when it could not be resolved (the Airbnb listing is not mapped, or the Booking reservation is not in this account) — do not treat NULL as 'no room', it means 'unknown'.","type":"integer","nullable":true},"scopeId":{"description":"Portal-side property scope: Booking `hotelId`, Airbnb `listingId`.","type":"string","nullable":true},"rating":{"description":"Score AS THE PORTAL GIVES IT — read together with `ratingScale`.","type":"number","format":"float","nullable":true},"ratingScale":{"description":"Maximum of `rating`: 5 for Airbnb, 10 for Booking.com.","type":"integer","enum":[5,10]},"ratingNormalized":{"description":"Score rescaled to 0-10 for BOTH portals (Airbnb is doubled). Compare and filter on this one — a raw 4 means a good review on Airbnb and a terrible one on Booking.com. `minRating`/`maxRating` use this scale.","type":"number","format":"float","nullable":true},"text":{"description":"Readable review text. Airbnb: the public review verbatim. Booking.com: headline plus the 'Plus:' / 'Minus:' sections joined with newlines (raw fields stay in `providerData`).","type":"string","nullable":true},"hasText":{"description":"false = a score with no written comment. Booking.com allows those and REFUSES replies to them, so filter on `hasText=true` when looking for reviews to answer.","type":"boolean"},"language":{"description":"Language the review was written in (ISO 639-1). Booking.com only — Airbnb does not report it, so this is always null there.","type":"string","nullable":true},"reviewerName":{"description":"Reviewer's name. Booking.com only — the Airbnb mirror keeps just an opaque reviewer id.","type":"string","nullable":true},"reviewerRole":{"description":"Who wrote it. Always `guest` on Booking.com. On Airbnb `host` means the review YOU wrote about a guest — those are not answerable.","type":"string","enum":["guest","host"],"nullable":true},"guestName":{"description":"Guest of the mobile-calendar reservation the review belongs to (the reservation's client — the same name the panel shows in the Guest column); falls back to Booking.com `reviewerName`. null when the reservation is not in this account. Use it to find a review by the guest's name.","type":"string","nullable":true},"reservationNo":{"description":"Number of that reservation as shown in the panel (`reservationNo`), null when not matched.","type":"integer","nullable":true},"canRespond":{"description":"Whether the portal currently accepts a reply. false when a reply already exists, the review has no text, or (Airbnb) it is still hidden by the double-blind period.","type":"boolean"},"responseStatus":{"description":"Local reply lifecycle: `none` nothing yet, `draft` saved locally and NOT sent, `pending` sent and awaiting the portal's moderation (Booking ~48 h), `published` confirmed live.","type":"string","enum":["none","draft","pending","published"]},"responseText":{"description":"Reply already sent to the portal.","type":"string","nullable":true},"responseDraft":{"description":"Locally saved draft — visible in the panel for a human to approve, never sent by itself.","type":"string","nullable":true},"respondedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"description":"When the review appeared at the portal.","type":"string","format":"date-time","nullable":true},"providerData":{"description":"Raw portal fields, different per provider. Airbnb: `categoryRatings` (per-category scores), `privateFeedback` (seen only by the host), `hidden`, `canReview`, `reservationConfirmationCode`. Booking.com: `headline`, `positive`, `negative`, `scoring` (sub-scores), `reviewerCountryCode`, `reviewerIsGenius`, `reservationId`. Use it when the extra nuance helps you write a better reply.","type":"object"}},"type":"object"},"ApiProblem401":{"description":"Missing authentication or invalid credentials.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"401 Unauthorized","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblemBase":{"description":"RFC 7807 Problem Details (base schema).","required":["type","title","status","detail","instance"],"properties":{"type":{"description":"RFC 7807 base — DO NOT use directly in endpoints.\nIn endpoints reference ApiProblem400/401/403/409/422/429/500.","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"},"ApiProblem403":{"description":"Authenticated user but no access.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"403 Forbidden","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem404":{"description":"Resource specified in the request was not found.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"404 Not Found","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]}}},"paths":{"/reviews/{provider}/{reviewId}":{"get":{"tags":["reviews"],"summary":"Get a single review with the full portal-specific detail","description":"The same normalized shape as the list, but worth fetching before writing a reply: `providerData` carries the nuance the list summarises away — Airbnb's per-category scores and the private feedback only the host sees, Booking.com's separate 'liked'/'disliked' text and sub-scores. Required scope: `reviews.read`.","operationId":"getReview","parameters":[{"name":"provider","in":"path","description":"Portal the review belongs to — take it from the review's `provider` field.","required":true,"schema":{"type":"string","enum":["booking","airbnb"]}},{"name":"reviewId","in":"path","description":"Review id at the portal (the `reviewId` field).","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Review","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem401"}}}},"403":{"description":"Insufficient scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem403"}}}},"404":{"description":"Review not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem404"}}}}}}}}}
```

## Import reviews from a portal into the local mirror

> Forces a full import of the portal's reviews for every listing/hotel on the account. Normally unnecessary — Airbnb pushes changes by webhook, Booking.com is polled hourly, and the first read of a never-imported account triggers an import by itself. Use it right after connecting a property, or when you suspect the mirror is stale. Can take a while on accounts with many reviews. Required scope: \`reviews.read\`.

```json
{"openapi":"3.0.0","info":{"title":"Public REST API v1 – mobile-calendar.com","version":"1.0.0"},"tags":[{"name":"reviews","description":"reviews"}],"servers":[{"url":"https://api.mobile-calendar.com/v1/public","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","description":"Pass the token in header: Authorization: Bearer <JWT>","bearerFormat":"JWT","scheme":"bearer"}},"schemas":{"ReviewSyncResponse":{"required":["data"],"properties":{"data":{"properties":{"stored":{"description":"Reviews imported or refreshed.","type":"integer"},"listings":{"description":"Airbnb only: how many listings were covered.","type":"integer","nullable":true},"hotels":{"description":"Booking.com only: per-hotel outcome, including hotels that could not be reached (`isConnected: false` with an `error`).","type":"array","items":{"type":"object"},"nullable":true},"message":{"description":"Present when there was nothing to import: `NO_LISTINGS` (Airbnb) or `NO_HOTELS` (Booking.com) — the portal is simply not connected on this account.","type":"string","nullable":true}},"type":"object"}},"type":"object"},"ApiProblem401":{"description":"Missing authentication or invalid credentials.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"401 Unauthorized","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblemBase":{"description":"RFC 7807 Problem Details (base schema).","required":["type","title","status","detail","instance"],"properties":{"type":{"description":"RFC 7807 base — DO NOT use directly in endpoints.\nIn endpoints reference ApiProblem400/401/403/409/422/429/500.","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"},"ApiProblem403":{"description":"Authenticated user but no access.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"403 Forbidden","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem422":{"description":"Input data validation error.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"422 Unprocessable Entity (validation)","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"},"errors":{"description":"Map: field name → error message(s)","type":"object","additionalProperties":{"$ref":"#/components/schemas/ErrorValue"}}},"type":"object"}]},"ErrorValue":{"description":"Single error message or list of messages.","oneOf":[{"description":"Error value in error map can be:\n- single string, or\n- array of strings (multiple messages for one field).","type":"string"},{"type":"array","items":{"type":"string"}}]}}},"paths":{"/reviews/{provider}/sync":{"post":{"tags":["reviews"],"summary":"Import reviews from a portal into the local mirror","description":"Forces a full import of the portal's reviews for every listing/hotel on the account. Normally unnecessary — Airbnb pushes changes by webhook, Booking.com is polled hourly, and the first read of a never-imported account triggers an import by itself. Use it right after connecting a property, or when you suspect the mirror is stale. Can take a while on accounts with many reviews. Required scope: `reviews.read`.","operationId":"syncReviews","parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["booking","airbnb"]}}],"responses":{"200":{"description":"Import finished","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewSyncResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem401"}}}},"403":{"description":"Insufficient scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem403"}}}},"422":{"description":"Portal rejected the import","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem422"}}}}}}}}}
```

## Publish the property's reply to a guest review

> Sends the reply to the portal, where it appears PUBLICLY under the review. This cannot be undone or edited: both portals refuse a second reply to the same review, which comes back as 409 (\`REVIEW\_ALREADY\_RESPONDED\` / \`REVIEW\_ALREADY\_REPLIED\`). Check \`canRespond\` first — a reply is refused when the review has no written text (Booking.com, 422 \`REVIEW\_HAS\_NO\_TEXT\`) or when Airbnb still hides it during the double-blind period (422 \`REVIEW\_NOT\_PUBLIC\`). If a human should approve the wording before it goes public, save it with the draft endpoint instead. Booking.com moderates replies for roughly 48 h, so \`responseStatus\` stays \`pending\` until the portal confirms. Required scope: \`reviews.respond\`.

```json
{"openapi":"3.0.0","info":{"title":"Public REST API v1 – mobile-calendar.com","version":"1.0.0"},"tags":[{"name":"reviews","description":"reviews"}],"servers":[{"url":"https://api.mobile-calendar.com/v1/public","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","description":"Pass the token in header: Authorization: Bearer <JWT>","bearerFormat":"JWT","scheme":"bearer"}},"schemas":{"ReviewRespondRequest":{"description":"Reply published under the guest's review. One shot: both portals refuse a second reply to the same review (409).","required":["response"],"properties":{"response":{"description":"The reply text, in the language of the review. Written in the property's name and PUBLIC.","type":"string","maxLength":5000}},"type":"object"},"ReviewActionResponse":{"required":["data"],"properties":{"data":{"properties":{"review":{"$ref":"#/components/schemas/Review"}},"type":"object"}},"type":"object"},"Review":{"description":"A guest review from an OTA, in one shape regardless of which portal it came from. The portals differ in ways that matter: Airbnb rates 1-5 and is two-way (the host also reviews the guest), Booking.com rates 1-10, is one-way, and splits the text into a headline plus separate 'liked'/'disliked' fields. Everything portal-specific stays in `providerData`; the fields above it are the same for both.","required":["provider","reviewId","rating","ratingScale","ratingNormalized","hasText","canRespond","responseStatus"],"properties":{"provider":{"description":"Which portal the review came from.","type":"string","enum":["booking","airbnb"]},"reviewId":{"description":"Review id AT THE PORTAL — pass it back to the response/draft endpoints together with `provider`.","type":"string"},"roomId":{"description":"mobile-calendar room the review is about. NULL when it could not be resolved (the Airbnb listing is not mapped, or the Booking reservation is not in this account) — do not treat NULL as 'no room', it means 'unknown'.","type":"integer","nullable":true},"scopeId":{"description":"Portal-side property scope: Booking `hotelId`, Airbnb `listingId`.","type":"string","nullable":true},"rating":{"description":"Score AS THE PORTAL GIVES IT — read together with `ratingScale`.","type":"number","format":"float","nullable":true},"ratingScale":{"description":"Maximum of `rating`: 5 for Airbnb, 10 for Booking.com.","type":"integer","enum":[5,10]},"ratingNormalized":{"description":"Score rescaled to 0-10 for BOTH portals (Airbnb is doubled). Compare and filter on this one — a raw 4 means a good review on Airbnb and a terrible one on Booking.com. `minRating`/`maxRating` use this scale.","type":"number","format":"float","nullable":true},"text":{"description":"Readable review text. Airbnb: the public review verbatim. Booking.com: headline plus the 'Plus:' / 'Minus:' sections joined with newlines (raw fields stay in `providerData`).","type":"string","nullable":true},"hasText":{"description":"false = a score with no written comment. Booking.com allows those and REFUSES replies to them, so filter on `hasText=true` when looking for reviews to answer.","type":"boolean"},"language":{"description":"Language the review was written in (ISO 639-1). Booking.com only — Airbnb does not report it, so this is always null there.","type":"string","nullable":true},"reviewerName":{"description":"Reviewer's name. Booking.com only — the Airbnb mirror keeps just an opaque reviewer id.","type":"string","nullable":true},"reviewerRole":{"description":"Who wrote it. Always `guest` on Booking.com. On Airbnb `host` means the review YOU wrote about a guest — those are not answerable.","type":"string","enum":["guest","host"],"nullable":true},"guestName":{"description":"Guest of the mobile-calendar reservation the review belongs to (the reservation's client — the same name the panel shows in the Guest column); falls back to Booking.com `reviewerName`. null when the reservation is not in this account. Use it to find a review by the guest's name.","type":"string","nullable":true},"reservationNo":{"description":"Number of that reservation as shown in the panel (`reservationNo`), null when not matched.","type":"integer","nullable":true},"canRespond":{"description":"Whether the portal currently accepts a reply. false when a reply already exists, the review has no text, or (Airbnb) it is still hidden by the double-blind period.","type":"boolean"},"responseStatus":{"description":"Local reply lifecycle: `none` nothing yet, `draft` saved locally and NOT sent, `pending` sent and awaiting the portal's moderation (Booking ~48 h), `published` confirmed live.","type":"string","enum":["none","draft","pending","published"]},"responseText":{"description":"Reply already sent to the portal.","type":"string","nullable":true},"responseDraft":{"description":"Locally saved draft — visible in the panel for a human to approve, never sent by itself.","type":"string","nullable":true},"respondedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"description":"When the review appeared at the portal.","type":"string","format":"date-time","nullable":true},"providerData":{"description":"Raw portal fields, different per provider. Airbnb: `categoryRatings` (per-category scores), `privateFeedback` (seen only by the host), `hidden`, `canReview`, `reservationConfirmationCode`. Booking.com: `headline`, `positive`, `negative`, `scoring` (sub-scores), `reviewerCountryCode`, `reviewerIsGenius`, `reservationId`. Use it when the extra nuance helps you write a better reply.","type":"object"}},"type":"object"},"ApiProblem401":{"description":"Missing authentication or invalid credentials.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"401 Unauthorized","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblemBase":{"description":"RFC 7807 Problem Details (base schema).","required":["type","title","status","detail","instance"],"properties":{"type":{"description":"RFC 7807 base — DO NOT use directly in endpoints.\nIn endpoints reference ApiProblem400/401/403/409/422/429/500.","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"},"ApiProblem403":{"description":"Authenticated user but no access.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"403 Forbidden","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem404":{"description":"Resource specified in the request was not found.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"404 Not Found","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem409":{"description":"Conflict with resource state or integrity constraints.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"409 Conflict","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem422":{"description":"Input data validation error.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"422 Unprocessable Entity (validation)","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"},"errors":{"description":"Map: field name → error message(s)","type":"object","additionalProperties":{"$ref":"#/components/schemas/ErrorValue"}}},"type":"object"}]},"ErrorValue":{"description":"Single error message or list of messages.","oneOf":[{"description":"Error value in error map can be:\n- single string, or\n- array of strings (multiple messages for one field).","type":"string"},{"type":"array","items":{"type":"string"}}]}}},"paths":{"/reviews/{provider}/{reviewId}/response":{"post":{"tags":["reviews"],"summary":"Publish the property's reply to a guest review","description":"Sends the reply to the portal, where it appears PUBLICLY under the review. This cannot be undone or edited: both portals refuse a second reply to the same review, which comes back as 409 (`REVIEW_ALREADY_RESPONDED` / `REVIEW_ALREADY_REPLIED`). Check `canRespond` first — a reply is refused when the review has no written text (Booking.com, 422 `REVIEW_HAS_NO_TEXT`) or when Airbnb still hides it during the double-blind period (422 `REVIEW_NOT_PUBLIC`). If a human should approve the wording before it goes public, save it with the draft endpoint instead. Booking.com moderates replies for roughly 48 h, so `responseStatus` stays `pending` until the portal confirms. Required scope: `reviews.respond`.","operationId":"respondToReview","parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["booking","airbnb"]}},{"name":"reviewId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewRespondRequest"}}}},"responses":{"200":{"description":"Reply sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewActionResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem401"}}}},"403":{"description":"Insufficient scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem403"}}}},"404":{"description":"Review not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem404"}}}},"409":{"description":"A reply already exists for this review","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem409"}}}},"422":{"description":"Validation error or the portal refuses a reply","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem422"}}}}}}}}}
```

## Save a reply draft locally without publishing it

> Stores the text as a draft (\`responseStatus\` becomes \`draft\`) and sends NOTHING to the portal — it shows up in the panel, where a person can read, edit and publish it. This is the safe half of an automated workflow: draft the answer, let a human release it. Find pending drafts with \`responseStatus=draft\` on the list. An empty string clears the draft. Required scope: \`reviews.respond\`.

```json
{"openapi":"3.0.0","info":{"title":"Public REST API v1 – mobile-calendar.com","version":"1.0.0"},"tags":[{"name":"reviews","description":"reviews"}],"servers":[{"url":"https://api.mobile-calendar.com/v1/public","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","description":"Pass the token in header: Authorization: Bearer <JWT>","bearerFormat":"JWT","scheme":"bearer"}},"schemas":{"ReviewDraftRequest":{"description":"Saves the reply locally WITHOUT sending it — it shows up in the panel for a human to review and publish. Use this when a reply should be approved before it goes public.","required":["draft"],"properties":{"draft":{"description":"Draft text. An empty string clears the existing draft.","type":"string","maxLength":5000}},"type":"object"},"ReviewActionResponse":{"required":["data"],"properties":{"data":{"properties":{"review":{"$ref":"#/components/schemas/Review"}},"type":"object"}},"type":"object"},"Review":{"description":"A guest review from an OTA, in one shape regardless of which portal it came from. The portals differ in ways that matter: Airbnb rates 1-5 and is two-way (the host also reviews the guest), Booking.com rates 1-10, is one-way, and splits the text into a headline plus separate 'liked'/'disliked' fields. Everything portal-specific stays in `providerData`; the fields above it are the same for both.","required":["provider","reviewId","rating","ratingScale","ratingNormalized","hasText","canRespond","responseStatus"],"properties":{"provider":{"description":"Which portal the review came from.","type":"string","enum":["booking","airbnb"]},"reviewId":{"description":"Review id AT THE PORTAL — pass it back to the response/draft endpoints together with `provider`.","type":"string"},"roomId":{"description":"mobile-calendar room the review is about. NULL when it could not be resolved (the Airbnb listing is not mapped, or the Booking reservation is not in this account) — do not treat NULL as 'no room', it means 'unknown'.","type":"integer","nullable":true},"scopeId":{"description":"Portal-side property scope: Booking `hotelId`, Airbnb `listingId`.","type":"string","nullable":true},"rating":{"description":"Score AS THE PORTAL GIVES IT — read together with `ratingScale`.","type":"number","format":"float","nullable":true},"ratingScale":{"description":"Maximum of `rating`: 5 for Airbnb, 10 for Booking.com.","type":"integer","enum":[5,10]},"ratingNormalized":{"description":"Score rescaled to 0-10 for BOTH portals (Airbnb is doubled). Compare and filter on this one — a raw 4 means a good review on Airbnb and a terrible one on Booking.com. `minRating`/`maxRating` use this scale.","type":"number","format":"float","nullable":true},"text":{"description":"Readable review text. Airbnb: the public review verbatim. Booking.com: headline plus the 'Plus:' / 'Minus:' sections joined with newlines (raw fields stay in `providerData`).","type":"string","nullable":true},"hasText":{"description":"false = a score with no written comment. Booking.com allows those and REFUSES replies to them, so filter on `hasText=true` when looking for reviews to answer.","type":"boolean"},"language":{"description":"Language the review was written in (ISO 639-1). Booking.com only — Airbnb does not report it, so this is always null there.","type":"string","nullable":true},"reviewerName":{"description":"Reviewer's name. Booking.com only — the Airbnb mirror keeps just an opaque reviewer id.","type":"string","nullable":true},"reviewerRole":{"description":"Who wrote it. Always `guest` on Booking.com. On Airbnb `host` means the review YOU wrote about a guest — those are not answerable.","type":"string","enum":["guest","host"],"nullable":true},"guestName":{"description":"Guest of the mobile-calendar reservation the review belongs to (the reservation's client — the same name the panel shows in the Guest column); falls back to Booking.com `reviewerName`. null when the reservation is not in this account. Use it to find a review by the guest's name.","type":"string","nullable":true},"reservationNo":{"description":"Number of that reservation as shown in the panel (`reservationNo`), null when not matched.","type":"integer","nullable":true},"canRespond":{"description":"Whether the portal currently accepts a reply. false when a reply already exists, the review has no text, or (Airbnb) it is still hidden by the double-blind period.","type":"boolean"},"responseStatus":{"description":"Local reply lifecycle: `none` nothing yet, `draft` saved locally and NOT sent, `pending` sent and awaiting the portal's moderation (Booking ~48 h), `published` confirmed live.","type":"string","enum":["none","draft","pending","published"]},"responseText":{"description":"Reply already sent to the portal.","type":"string","nullable":true},"responseDraft":{"description":"Locally saved draft — visible in the panel for a human to approve, never sent by itself.","type":"string","nullable":true},"respondedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"description":"When the review appeared at the portal.","type":"string","format":"date-time","nullable":true},"providerData":{"description":"Raw portal fields, different per provider. Airbnb: `categoryRatings` (per-category scores), `privateFeedback` (seen only by the host), `hidden`, `canReview`, `reservationConfirmationCode`. Booking.com: `headline`, `positive`, `negative`, `scoring` (sub-scores), `reviewerCountryCode`, `reviewerIsGenius`, `reservationId`. Use it when the extra nuance helps you write a better reply.","type":"object"}},"type":"object"},"ApiProblem401":{"description":"Missing authentication or invalid credentials.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"401 Unauthorized","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblemBase":{"description":"RFC 7807 Problem Details (base schema).","required":["type","title","status","detail","instance"],"properties":{"type":{"description":"RFC 7807 base — DO NOT use directly in endpoints.\nIn endpoints reference ApiProblem400/401/403/409/422/429/500.","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"},"ApiProblem403":{"description":"Authenticated user but no access.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"403 Forbidden","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem404":{"description":"Resource specified in the request was not found.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"404 Not Found","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem422":{"description":"Input data validation error.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"422 Unprocessable Entity (validation)","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"},"errors":{"description":"Map: field name → error message(s)","type":"object","additionalProperties":{"$ref":"#/components/schemas/ErrorValue"}}},"type":"object"}]},"ErrorValue":{"description":"Single error message or list of messages.","oneOf":[{"description":"Error value in error map can be:\n- single string, or\n- array of strings (multiple messages for one field).","type":"string"},{"type":"array","items":{"type":"string"}}]}}},"paths":{"/reviews/{provider}/{reviewId}/draft":{"put":{"tags":["reviews"],"summary":"Save a reply draft locally without publishing it","description":"Stores the text as a draft (`responseStatus` becomes `draft`) and sends NOTHING to the portal — it shows up in the panel, where a person can read, edit and publish it. This is the safe half of an automated workflow: draft the answer, let a human release it. Find pending drafts with `responseStatus=draft` on the list. An empty string clears the draft. Required scope: `reviews.respond`.","operationId":"saveReviewDraft","parameters":[{"name":"provider","in":"path","required":true,"schema":{"type":"string","enum":["booking","airbnb"]}},{"name":"reviewId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewDraftRequest"}}}},"responses":{"200":{"description":"Draft saved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewActionResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem401"}}}},"403":{"description":"Insufficient scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem403"}}}},"404":{"description":"Review not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem404"}}}},"422":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem422"}}}}}}}}}
```

## Write the host's review of a guest (Airbnb only)

> Publishes YOUR review OF THE GUEST on Airbnb. This is not a reply — it is a permanent public statement about a person, shown on their profile and used by other hosts when deciding whether to accept them, which is why it needs its own \`reviews.create\` scope. Booking.com has no equivalent. Airbnb requires the complete set: per-category scores, a recommendation and public text. The \`reviewId\` is the host-side review entry (\`reviewerRole: host\`, \`providerData.canReview: true\`) — the list returns those with \`reviewerRole=host\`. Required scope: \`reviews.create\`.

```json
{"openapi":"3.0.0","info":{"title":"Public REST API v1 – mobile-calendar.com","version":"1.0.0"},"tags":[{"name":"reviews","description":"reviews"}],"servers":[{"url":"https://api.mobile-calendar.com/v1/public","description":"Production"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","description":"Pass the token in header: Authorization: Bearer <JWT>","bearerFormat":"JWT","scheme":"bearer"}},"schemas":{"GuestReviewRequest":{"description":"The host's review OF THE GUEST (Airbnb only — Booking.com has no such direction). This is a permanent public statement about a person attached to their profile, which is why it sits behind its own `reviews.create` scope. Airbnb requires the full set below; anything missing is rejected.","required":["categoryRatings","isRecommended","publicReview"],"properties":{"categoryRatings":{"description":"Per-category scores, 1-5 each. Category names come from Airbnb (e.g. `cleanliness`, `communication`, `respect_house_rules`) — take them from the review's `providerData.categoryRatings` rather than inventing them.","type":"array","items":{"required":["category","rating"],"properties":{"category":{"type":"string"},"rating":{"type":"integer","maximum":5,"minimum":1},"comment":{"type":"string","nullable":true}},"type":"object"},"maxItems":20,"minItems":1},"isRecommended":{"description":"Would you host this guest again.","type":"boolean"},"publicReview":{"description":"PUBLIC text shown on the guest's profile.","type":"string","maxLength":5000},"privateFeedback":{"description":"Private note to the guest — not published.","type":"string","maxLength":5000,"nullable":true}},"type":"object"},"ReviewActionResponse":{"required":["data"],"properties":{"data":{"properties":{"review":{"$ref":"#/components/schemas/Review"}},"type":"object"}},"type":"object"},"Review":{"description":"A guest review from an OTA, in one shape regardless of which portal it came from. The portals differ in ways that matter: Airbnb rates 1-5 and is two-way (the host also reviews the guest), Booking.com rates 1-10, is one-way, and splits the text into a headline plus separate 'liked'/'disliked' fields. Everything portal-specific stays in `providerData`; the fields above it are the same for both.","required":["provider","reviewId","rating","ratingScale","ratingNormalized","hasText","canRespond","responseStatus"],"properties":{"provider":{"description":"Which portal the review came from.","type":"string","enum":["booking","airbnb"]},"reviewId":{"description":"Review id AT THE PORTAL — pass it back to the response/draft endpoints together with `provider`.","type":"string"},"roomId":{"description":"mobile-calendar room the review is about. NULL when it could not be resolved (the Airbnb listing is not mapped, or the Booking reservation is not in this account) — do not treat NULL as 'no room', it means 'unknown'.","type":"integer","nullable":true},"scopeId":{"description":"Portal-side property scope: Booking `hotelId`, Airbnb `listingId`.","type":"string","nullable":true},"rating":{"description":"Score AS THE PORTAL GIVES IT — read together with `ratingScale`.","type":"number","format":"float","nullable":true},"ratingScale":{"description":"Maximum of `rating`: 5 for Airbnb, 10 for Booking.com.","type":"integer","enum":[5,10]},"ratingNormalized":{"description":"Score rescaled to 0-10 for BOTH portals (Airbnb is doubled). Compare and filter on this one — a raw 4 means a good review on Airbnb and a terrible one on Booking.com. `minRating`/`maxRating` use this scale.","type":"number","format":"float","nullable":true},"text":{"description":"Readable review text. Airbnb: the public review verbatim. Booking.com: headline plus the 'Plus:' / 'Minus:' sections joined with newlines (raw fields stay in `providerData`).","type":"string","nullable":true},"hasText":{"description":"false = a score with no written comment. Booking.com allows those and REFUSES replies to them, so filter on `hasText=true` when looking for reviews to answer.","type":"boolean"},"language":{"description":"Language the review was written in (ISO 639-1). Booking.com only — Airbnb does not report it, so this is always null there.","type":"string","nullable":true},"reviewerName":{"description":"Reviewer's name. Booking.com only — the Airbnb mirror keeps just an opaque reviewer id.","type":"string","nullable":true},"reviewerRole":{"description":"Who wrote it. Always `guest` on Booking.com. On Airbnb `host` means the review YOU wrote about a guest — those are not answerable.","type":"string","enum":["guest","host"],"nullable":true},"guestName":{"description":"Guest of the mobile-calendar reservation the review belongs to (the reservation's client — the same name the panel shows in the Guest column); falls back to Booking.com `reviewerName`. null when the reservation is not in this account. Use it to find a review by the guest's name.","type":"string","nullable":true},"reservationNo":{"description":"Number of that reservation as shown in the panel (`reservationNo`), null when not matched.","type":"integer","nullable":true},"canRespond":{"description":"Whether the portal currently accepts a reply. false when a reply already exists, the review has no text, or (Airbnb) it is still hidden by the double-blind period.","type":"boolean"},"responseStatus":{"description":"Local reply lifecycle: `none` nothing yet, `draft` saved locally and NOT sent, `pending` sent and awaiting the portal's moderation (Booking ~48 h), `published` confirmed live.","type":"string","enum":["none","draft","pending","published"]},"responseText":{"description":"Reply already sent to the portal.","type":"string","nullable":true},"responseDraft":{"description":"Locally saved draft — visible in the panel for a human to approve, never sent by itself.","type":"string","nullable":true},"respondedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"description":"When the review appeared at the portal.","type":"string","format":"date-time","nullable":true},"providerData":{"description":"Raw portal fields, different per provider. Airbnb: `categoryRatings` (per-category scores), `privateFeedback` (seen only by the host), `hidden`, `canReview`, `reservationConfirmationCode`. Booking.com: `headline`, `positive`, `negative`, `scoring` (sub-scores), `reviewerCountryCode`, `reviewerIsGenius`, `reservationId`. Use it when the extra nuance helps you write a better reply.","type":"object"}},"type":"object"},"ApiProblem401":{"description":"Missing authentication or invalid credentials.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"401 Unauthorized","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblemBase":{"description":"RFC 7807 Problem Details (base schema).","required":["type","title","status","detail","instance"],"properties":{"type":{"description":"RFC 7807 base — DO NOT use directly in endpoints.\nIn endpoints reference ApiProblem400/401/403/409/422/429/500.","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"},"ApiProblem403":{"description":"Authenticated user but no access.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"403 Forbidden","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem404":{"description":"Resource specified in the request was not found.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"404 Not Found","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"}},"type":"object"}]},"ApiProblem422":{"description":"Input data validation error.","allOf":[{"$ref":"#/components/schemas/ApiProblemBase"},{"properties":{"type":{"description":"422 Unprocessable Entity (validation)","type":"string","format":"uri"},"title":{"type":"string"},"status":{"type":"integer"},"detail":{"type":"string"},"instance":{"type":"string"},"errors":{"description":"Map: field name → error message(s)","type":"object","additionalProperties":{"$ref":"#/components/schemas/ErrorValue"}}},"type":"object"}]},"ErrorValue":{"description":"Single error message or list of messages.","oneOf":[{"description":"Error value in error map can be:\n- single string, or\n- array of strings (multiple messages for one field).","type":"string"},{"type":"array","items":{"type":"string"}}]}}},"paths":{"/reviews/airbnb/{reviewId}/guest-review":{"post":{"tags":["reviews"],"summary":"Write the host's review of a guest (Airbnb only)","description":"Publishes YOUR review OF THE GUEST on Airbnb. This is not a reply — it is a permanent public statement about a person, shown on their profile and used by other hosts when deciding whether to accept them, which is why it needs its own `reviews.create` scope. Booking.com has no equivalent. Airbnb requires the complete set: per-category scores, a recommendation and public text. The `reviewId` is the host-side review entry (`reviewerRole: host`, `providerData.canReview: true`) — the list returns those with `reviewerRole=host`. Required scope: `reviews.create`.","operationId":"createGuestReview","parameters":[{"name":"reviewId","in":"path","description":"Airbnb id of the host-side review entry to fill in.","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestReviewRequest"}}}},"responses":{"200":{"description":"Guest review published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewActionResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem401"}}}},"403":{"description":"Insufficient scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem403"}}}},"404":{"description":"Review not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem404"}}}},"422":{"description":"Validation error or Airbnb refused the review","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiProblem422"}}}}}}}}}
```
