{"openapi":"3.0.3","info":{"title":"Repull API","version":"1.0.0","description":"The unified API for vacation rental tech. Connect to 50+ PMS platforms and 4 OTA channels through one REST API. Built-in AI operations for guest communication, pricing, and listing optimization.\n\n## Designed for AI agents\nEvery error response on this API includes machine-parseable fields so an LLM (Claude in MCP, Cursor, Cline, GPT, etc.) can self-recover without escalating to a human:\n- `error.code` — stable string identifier (e.g. `invalid_params`, `rate_limit_exceeded`)\n- `error.message` — human-readable cause\n- `error.fix` — exact recovery steps (e.g. \"Pass `check_in_after` as ISO 8601: `?check_in_after=2026-01-15`\")\n- `error.docs_url` — link to the canonical write-up at `https://repull.dev/docs/errors/{code}`\n- `error.request_id` — id to correlate with server-side logs\n- `error.field` / `error.value_received` / `error.valid_values` / `error.did_you_mean` — when the error is parameter-specific\n- `error.retry_after` — seconds to wait before retrying (rate-limit + transient upstream)\n\n`Access-Control-Expose-Headers` lists `x-request-id` and the `X-RateLimit-*` family so browsers can read them on cross-origin responses.\n\n## Quick Start\n1. Get an API key at https://repull.dev/dashboard\n2. Connect a PMS: `POST /v1/connect/{provider}`\n3. List properties: `GET /v1/properties`\n4. Get reservations: `GET /v1/reservations`\n\n## Authentication\nAll requests require a Bearer token:\n```\nAuthorization: Bearer sk_test_YOUR_API_KEY\n```\n\nSandbox keys start with `sk_test_`, production with `sk_live_`.\n\n## Request Correlation (X-Request-ID)\nEvery response carries an `X-Request-ID` header, e.g. `X-Request-ID: req_01HXY...`. Include this id in support tickets and bug reports — we can trace the full request lifecycle (auth, rate limit, handler, downstream calls, log row) from a single id.\n\nYou may set the header on the inbound request to forward your own trace id; we will echo it back instead of generating a new one. Accepted format: `^[\\\\w.-]{1,128}$`.\n\nThe id is also embedded in error envelopes as `request_id` so server-side log diffs work even when the response headers are stripped by an intermediate proxy.\n\n## Rate Limits\nThe public API enforces a per-API-key sliding-window rate limit on top of the per-tier monthly + daily-AI quotas.\n\n**Default policy:** 600 requests per 60 seconds, per API key. Sliding window — there is no fixed-minute boundary you can burst across.\n\nEvery response includes:\n\n| Header | Meaning |\n|---|---|\n| `X-RateLimit-Limit` | Requests permitted in the current window. |\n| `X-RateLimit-Remaining` | Requests left in the current window after this call. |\n| `X-RateLimit-Reset` | Unix epoch (seconds) when the next slot opens. |\n| `X-RateLimit-Policy` | Machine-readable policy descriptor, e.g. `600;w=60`. |\n| `Retry-After` | Seconds to wait before retrying. **Only present on 429 responses.** |\n\n**On 429 (rate_limit_exceeded):** the response body matches the standard error envelope with `code: \"rate_limit_exceeded\"`, plus `limit`, `window_seconds`, `retry_after`, and `request_id` fields. SDKs MUST honor `Retry-After` and use exponential backoff with jitter on subsequent retries — never a tight loop.\n\nRecommended backoff:\n```\nsleep_ms = (Retry-After * 1000) + random(0..250)\n```\n\nMonthly + daily-AI tier quotas (`free`, `starter`, `custom`) are enforced separately and also surface as 429s; they include `tier`, `scope`, and `resets_at` fields.\n\n## Plan Limits (402 — `listings_limit_exceeded`)\nThe Repull API also enforces a per-tier cap on **active listings**:\n\n| Tier | Active listings cap |\n|---|---|\n| `free` | 3 |\n| `starter` | 50 |\n| `custom` | unlimited |\n\nWhen a customer's active-listing count is above their tier cap, the API returns **`402 Payment Required`** with `error.code = \"listings_limit_exceeded\"` on every route EXCEPT:\n\n- `/v1/health` — uptime probes are never gated.\n- `/v1/usage/*` — so dashboards can render the over-cap state.\n- Any `DELETE` — so the customer can trim listings to get back under the cap without paying.\n\nUnlike 429, 402 is NOT a \"wait and retry\" condition — `Retry-After` is not set. The only paths back to 200 are:\n  1. `DELETE` enough listings to come back under the cap, or\n  2. Upgrade at `https://repull.dev/dashboard/billing`. The server-side usage cache is 60s, so the first 200 after an upgrade may take up to a minute.\n\nThe envelope mirrors `rate_limit_exceeded` for SDK ergonomics: `tier`, `limit`, `active_listings`, `upgrade_url`, plus the standard `code` / `message` / `fix` / `docs_url` / `request_id`.","contact":{"email":"ivan@vanio.ai","url":"https://repull.dev"},"license":{"name":"Proprietary"},"x-logo":{"url":"https://repull.dev/logo.svg"},"x-llm-friendly":true,"x-error-envelope":{"description":"Every 4xx/5xx response on this API uses the same error envelope. See `#/components/schemas/Error` for the schema and `#/components/responses/*` for canonical examples per status code.","required_fields":["code","message","fix","docs_url","request_id"],"optional_fields":["field","value_received","valid_values","did_you_mean","retry_after","support"],"docs_root":"https://repull.dev/docs/errors"}},"servers":[{"url":"https://api.repull.dev","description":"Production"},{"url":"http://repull.localhost:3000/api/repull","description":"Local development"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Properties","description":"List and manage vacation rental properties across all connected PMS platforms"},{"name":"Listings","description":"Create native Repull listings, generate AI content, and publish to multiple channels (Airbnb + Booking.com) from one API."},{"name":"Reservations","description":"Query, create, update, and cancel reservations"},{"name":"Availability","description":"Check and update property availability and pricing"},{"name":"Guests","description":"Guest profiles and contact information"},{"name":"Conversations","description":"Guest messaging across platforms"},{"name":"Reviews","description":"Guest reviews and responses"},{"name":"Connect","description":"Manage PMS/OTA connections. Supports 50+ platforms."},{"name":"Webhooks","description":"Subscribe to real-time events (reservation changes, messages, etc.)"},{"name":"Airbnb","description":"Direct Airbnb channel management — listings, pricing, availability, messaging, reviews"},{"name":"Booking.com","description":"Direct Booking.com channel management — properties, rates, content, messaging"},{"name":"VRBO","description":"VRBO channel management — listings and reservations"},{"name":"Plumguide","description":"Plumguide channel management — availability, pricing, bookings"},{"name":"AI","description":"AI-powered operations — guest responses, intent classification, listing generation, pricing suggestions"},{"name":"Pricing","description":"Atlas-powered pricing recommendations and per-listing strategy. Recommendations are pre-computed by the Atlas pricing model from comp data, demand signals, and event calendars; this surface lets you read them, apply or decline them, and tune the strategy that constrains the model."},{"name":"Markets","description":"Atlas market intelligence — per-city KPIs, comp distributions, calendar-level demand, and events. Sourced from Atlas (Vanio's 660-worker market-intelligence fleet) merged with the customer's own listing data."},{"name":"Atlas","description":"Per-listing Atlas intelligence — comp set with daily nightly pricing, and Atlas DNA segment intelligence (quality tiers, design styles, ADR uplift recommendations). Sourced from Atlas's comp database + DNA scoring pipeline."},{"name":"Billing","description":"Plan info, usage, and checkout"},{"name":"System","description":"Health checks and API status"},{"name":"Sandbox","description":"Test-mode fixtures for contract testing WITHOUT live provider accounts. A `sk_test_*` key seeds a deterministic set of fake listings, reservations, and connected provider accounts (Airbnb + Booking.com), reads them back through the normal endpoints, and resets them — all in a data space fully isolated from live inventory."},{"name":"schema","description":"Custom field-mapping schemas to reshape response payloads to your app's data model. Built-in schemas (`native`, `calry`, `calry-v1`) ship out of the box; create custom schemas with `POST /v1/schema/custom` and select one per request via the `X-Schema` header."},{"name":"Studio","description":"Vibe-coding studio — programmatic access for power users."},{"name":"KV","description":"Project-scoped key-value store. Persist user preferences, feature flags, and small JSON state from customer-built mini-apps without standing up a separate database. 64 KiB per key, 1 MiB per customer. Optional TTL."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key — `sk_test_*` for sandbox, `sk_live_*` for production","bearerFormat":"API Key"}},"schemas":{"SandboxFixtureRef":{"type":"object","description":"A seeded fixture: its stable reference key plus the synthetic id the read endpoints return for it.","properties":{"ref":{"type":"string","example":"sbx-listing-oceanview","description":"Stable reference key — constant across re-seeds."},"id":{"type":"string","example":"900000101","description":"Synthetic id (>= 900,000,000). Reference it against GET /v1/listings, /v1/reservations, /v1/connect."}},"required":["ref","id"]},"SandboxSeedResult":{"type":"object","description":"Result of provisioning the sandbox fixture set.","properties":{"customerId":{"type":"string","example":"1"},"seededAt":{"type":"string","format":"date-time"},"listings":{"type":"array","items":{"$ref":"#/components/schemas/SandboxFixtureRef"},"description":"3 sample listings."},"reservations":{"type":"array","items":{"$ref":"#/components/schemas/SandboxFixtureRef"},"description":"5 reservations across the lifecycle: created, modified, cancelled, date-changed, pending."},"connections":{"type":"array","items":{"$ref":"#/components/schemas/SandboxFixtureRef"},"description":"2 fake connected provider accounts: Airbnb + Booking.com."}},"required":["customerId","seededAt","listings","reservations","connections"]},"SandboxResetResult":{"type":"object","description":"Result of clearing the sandbox fixture set. Only ever deletes rows in the isolated sandbox data space.","properties":{"customerId":{"type":"string","example":"1"},"resetAt":{"type":"string","format":"date-time"},"deleted":{"type":"object","properties":{"listings":{"type":"integer","example":3},"reservations":{"type":"integer","example":5},"connections":{"type":"integer","example":2}},"required":["listings","reservations","connections"]}},"required":["customerId","resetAt","deleted"]},"Property":{"type":"object","description":"A vacation rental property in your Repull workspace. Backed by the core `listings` row — enriched per-PMS fields (bedrooms, property type, provider id, etc.) live in provider-specific detail tables and are NOT returned here.\n\nField availability differs by endpoint:\n- `channels` is returned by the list endpoint (`GET /v1/properties`) only.\n- `latitude`, `longitude`, `createdAt`, and `amenities` are returned by the detail endpoint (`GET /v1/properties/{id}`) only. `amenities` requires `?include=amenities`.","properties":{"id":{"type":"string","description":"Internal Repull property ID. Equal to the listing id (`listings.id`); the same integer is used as `listingId` on reservations and `propertyId` on availability."},"name":{"type":"string","description":"Property name","example":"Oceanview Suite #3"},"address":{"type":"string","nullable":true,"description":"Street address (from the listing's `street` field)."},"city":{"type":"string","nullable":true,"example":"Miami Beach"},"latitude":{"type":"number","nullable":true,"description":"Detail endpoint only.","example":25.7617},"longitude":{"type":"number","nullable":true,"description":"Detail endpoint only.","example":-80.1918},"currency":{"type":"string","nullable":true,"description":"ISO 4217 currency code for this property's pricing.","example":"USD"},"status":{"type":"string","enum":["active","inactive"],"description":"Derived from `listings.active`."},"lifecycleStatus":{"type":"string","nullable":true,"description":"The listing's lifecycle state (e.g. `live`, `draft`, `archived`)."},"createdAt":{"type":"string","format":"date-time","description":"When the property was created. Detail endpoint only."},"channels":{"type":"array","items":{"type":"string"},"description":"OTAs/channels this property is actively published on, as channel-name strings (e.g. `airbnb`, `booking`, `vrbo`). Empty array when the property has no active channel links. List endpoint (`GET /v1/properties`) only.","example":["airbnb","booking"]},"amenities":{"type":"array","items":{"$ref":"#/components/schemas/ListingAmenity"},"description":"Amenity rows for the property. Detail endpoint only, and **only present when the caller passes `?include=amenities`.** Empty array (`[]`) when the property has no amenity rows."}}},"PropertyAvailabilityDay":{"type":"object","description":"One calendar day in the availability window.","required":["date","available","price","minNights"],"properties":{"date":{"type":"string","format":"date","description":"The calendar date, ISO `YYYY-MM-DD`.","example":"2026-09-01"},"available":{"type":"boolean","description":"Whether the property is bookable on this date. `false` when the channel calendar marks the date unavailable (stop-sell / blocked)."},"price":{"type":"number","description":"Nightly price for this date in the property currency. Falls back to the property's default nightly price for dates with no explicit calendar override.","example":245},"minNights":{"type":"integer","description":"Minimum-stay requirement for a stay starting on this date.","example":2}}},"PropertyAvailability":{"type":"object","description":"Channel-agnostic availability calendar for a property over the requested window. Every date in `[from, to]` (inclusive) is present in `days`; dates with no explicit calendar row fall back to available at the default price.","required":["propertyId","currency","days"],"properties":{"propertyId":{"type":"string","description":"Repull property id (equal to `listings.id`), emitted as a string like every other id in the API.","example":"4118"},"currency":{"type":"string","description":"ISO 4217 currency code for the nightly prices in `days`.","example":"USD"},"days":{"type":"array","items":{"$ref":"#/components/schemas/PropertyAvailabilityDay"},"description":"Dense per-date calendar for the requested window (capped at 366 days), ordered ascending by date."}}},"ReservationPrimaryGuest":{"type":"object","description":"Inline guest summary resolved by JOIN-ing the `guests` table. Populated for every reservation that has a linked guest row; OMITTED entirely (not null) for owner-blocks / pre-arrival rows / partial-sync gaps. Always optional-chain in SDK consumers.","properties":{"id":{"type":"string","description":"Internal Repull guest ID. Use `GET /v1/guests/{id}` for the full profile."},"firstName":{"type":"string","nullable":true},"lastName":{"type":"string","nullable":true},"email":{"type":"string","format":"email","nullable":true,"description":"Primary email contact (or first non-primary if no primary set)."},"phone":{"type":"string","nullable":true,"description":"Primary phone contact (or first non-primary if no primary set)."},"language":{"type":"string","nullable":true,"description":"Guest's preferred language (BCP-47 / ISO 639-1)."}}},"ReservationOccupancy":{"type":"object","description":"Normalized guest counts for the stay. Mirrors the legacy `guestDetails.numberOf*` fields under canonical short names. Omitted when no count fields are present on the reservation.","properties":{"adults":{"type":"integer","nullable":true},"children":{"type":"integer","nullable":true},"infants":{"type":"integer","nullable":true},"pets":{"type":"integer","nullable":true},"total":{"type":"integer","nullable":true,"description":"Total guests (sum across all categories as reported by the source channel)."}}},"ReservationFinancials":{"type":"object","description":"Normalized money block. `totalPrice` is a `number` (NOT a decimal-as-string) — the legacy top-level `totalPrice` string field is kept on the parent for back-compat but is deprecated. This is the GUEST-side stay total (what the guest paid), NOT the host payout. For the host payout and the full host/guest financial breakdown (accommodation subtotal, cleaning fee, channel service fees, taxes, expected payout), use `GET /v1/channels/airbnb/transactions` and match on `confirmation_code`/`reservation_id`.","properties":{"totalPrice":{"type":"number","nullable":true,"description":"GUEST-side stay total in `currency` — what the guest paid, not the host payout. Number, not string. For host payout see `GET /v1/channels/airbnb/transactions`.","example":1250},"currency":{"type":"string","nullable":true,"description":"ISO 4217 currency code.","example":"USD"},"paymentStatus":{"type":"string","nullable":true,"description":"Payment lifecycle status (e.g. `pending`, `paid`, `refunded`)."}}},"Reservation":{"type":"object","description":"A booking/reservation from a connected PMS. Identical shape between list-row (`GET /v1/reservations`) and detail (`GET /v1/reservations/{id}`) — SDK consumers can use the same type for both.\n\nThe canonical (post-2026-05) shape uses nested `primaryGuest`, `occupancy`, `financials` blocks. The legacy flat fields (`guestId`, `totalPrice`, `currency`, `guestDetails`) remain populated for back-compat and are marked `deprecated` here. New consumers should read from the nested blocks; existing consumers continue to work unchanged.","properties":{"id":{"type":"string","description":"Internal Repull reservation ID"},"listingId":{"type":"string","description":"Internal Repull listing ID this reservation is on."},"guestId":{"type":"string","deprecated":true,"description":"DEPRECATED — use `primaryGuest.id`. Internal Repull guest ID. Kept populated for back-compat."},"checkIn":{"type":"string","format":"date","example":"2026-04-15"},"checkOut":{"type":"string","format":"date","example":"2026-04-20"},"status":{"type":"string","enum":["confirmed","pending","cancelled","completed"],"example":"confirmed","description":"Lifecycle status. The API normalises a multi-decade internal taxonomy down to these four buckets, so the value you receive is always one of the enum constants. `completed` is derived from `checkOut < today`."},"source":{"type":"string","nullable":true,"description":"Booking source / channel. Lowercase. May be null on legacy rows. Canonical name as of 2026-05; `platform` is kept as an alias.","enum":["airbnb","booking.com","vrbo","direct","website","owner","other",null],"example":"airbnb"},"platform":{"type":"string","nullable":true,"deprecated":true,"description":"DEPRECATED alias for `source`. Same value, kept for back-compat.","enum":["airbnb","booking.com","vrbo","direct","website","owner","other",null],"example":"airbnb"},"confirmationCode":{"type":"string","description":"Channel-side confirmation code (Airbnb HMxxx, Booking.com numeric, etc.).","example":"HMXYZ123"},"primaryGuest":{"allOf":[{"$ref":"#/components/schemas/ReservationPrimaryGuest"}],"description":"Inline guest summary. May be undefined for owner-blocks / pre-arrival rows."},"occupancy":{"allOf":[{"$ref":"#/components/schemas/ReservationOccupancy"}],"description":"Normalized guest counts. May be undefined when the source channel did not provide counts."},"financials":{"allOf":[{"$ref":"#/components/schemas/ReservationFinancials"}],"description":"Normalized money block. Always populated for paid reservations."},"totalPrice":{"type":"string","deprecated":true,"description":"DEPRECATED — use `financials.totalPrice` (a number). Decimal-as-string (precision 10, scale 2) kept for back-compat.","example":"1250.00"},"currency":{"type":"string","deprecated":true,"description":"DEPRECATED — use `financials.currency`. ISO 4217 currency code.","example":"USD"},"guestDetails":{"type":"object","deprecated":true,"description":"DEPRECATED — use `occupancy` for normalized counts and `primaryGuest` for guest identity. Raw guest details from the source channel; shape varies by platform.","additionalProperties":true},"createdAt":{"type":"string","format":"date-time","description":"When the reservation row was created in Repull (not the booking-on-channel timestamp)."},"updatedAt":{"type":"string","format":"date-time","description":"Last time this reservation was modified (dates, status, price, or guest details). Advances on every amendment or cancellation — poll or compare this value to reconcile changes instead of fingerprinting individual fields."},"bookedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the booking was made on the source channel (when reported by the channel)."},"guestName":{"type":"string","nullable":true,"description":"Pre-resolved display name (`firstName lastName`) from the joined guest row. Undefined when no first name is available."}},"required":["id","listingId","checkIn","checkOut","status","confirmationCode","createdAt","updatedAt"]},"Guest":{"type":"object","description":"Guest list-row shape returned by `GET /v1/guests`. Pre-resolved primary phone/email + display name + cumulative stay aggregates so list UIs can render without a per-row round-trip.","properties":{"id":{"type":"string"},"displayName":{"type":"string","example":"Jane","description":"Short display name (first name)."},"displayNameLong":{"type":"string","example":"Jane Doe","description":"Long display name (first + last). Falls back to displayName when last name is missing."},"avatarUrl":{"type":"string","format":"uri","nullable":true},"language":{"type":"string","nullable":true,"description":"Guest's preferred language (ISO 639-1)."},"country":{"type":"string","nullable":true,"description":"Guest country (from profile metadata or address)."},"phone":{"type":"string","nullable":true,"description":"Primary phone contact (or first non-primary if no primary set)."},"email":{"type":"string","format":"email","nullable":true,"description":"Primary email contact."},"totalReservations":{"type":"integer","description":"Lifetime reservation count."},"totalRevenue":{"type":"string","description":"Decimal-as-string to preserve precision across mixed-currency totals.","example":"14250.00"},"lastStayedAt":{"type":"string","format":"date-time","nullable":true},"firstStayedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"GuestContact":{"type":"object","description":"One contact channel attached to a guest profile (phone, email, etc.).","properties":{"type":{"type":"string","example":"phone","description":"Contact channel type (`phone`, `email`, etc.)."},"value":{"type":"string","example":"+15551234567"},"verified":{"type":"boolean"},"isPrimary":{"type":"boolean"},"lastUsed":{"type":"string","format":"date-time","nullable":true}}},"GuestFlag":{"type":"object","description":"A risk/operational flag attached to a guest profile (e.g. blacklist, do-not-host, VIP). Severity comes from main vanio's flag taxonomy.","properties":{"type":{"type":"string","description":"Severity / category (e.g. `info`, `warning`, `block`)."},"note":{"type":"string","nullable":true,"description":"Reason text when present."},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time","nullable":true}}},"GuestNote":{"type":"object","properties":{"id":{"type":"string"},"body":{"type":"string","nullable":true},"category":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time","nullable":true},"createdBy":{"type":"string","nullable":true}}},"GuestReservationsSummary":{"type":"object","description":"Aggregate counts of reservations attached to the guest. `future` is derived from `total - past - cancelled`.","properties":{"total":{"type":"integer"},"future":{"type":"integer"},"past":{"type":"integer"},"cancelled":{"type":"integer"}}},"GuestProfile":{"type":"object","description":"Full guest profile returned by `GET /v1/guests/{id}`. Aggregates the base list-row fields plus contacts, flags, notes, risk metadata, and a reservations-summary rollup.","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"displayNameLong":{"type":"string"},"avatarUrl":{"type":"string","format":"uri","nullable":true},"language":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"email":{"type":"string","format":"email","nullable":true},"totalReservations":{"type":"integer"},"totalRevenue":{"type":"string","description":"Decimal as string."},"currency":{"type":"string","nullable":true},"isBlacklisted":{"type":"boolean"},"blacklistedReason":{"type":"string","nullable":true},"riskLevel":{"type":"string","nullable":true,"description":"Main-vanio risk score (e.g. `low`, `medium`, `high`)."},"verificationLevel":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time","nullable":true},"contacts":{"type":"array","items":{"$ref":"#/components/schemas/GuestContact"}},"flags":{"type":"array","items":{"$ref":"#/components/schemas/GuestFlag"}},"notes":{"type":"array","items":{"$ref":"#/components/schemas/GuestNote"}},"reservationsSummary":{"$ref":"#/components/schemas/GuestReservationsSummary"}}},"CalendarDay":{"type":"object","properties":{"date":{"type":"string","format":"date","example":"2026-04-15"},"available":{"type":"boolean"},"price":{"type":"number","example":250},"minNights":{"type":"integer","example":2}}},"Conversation":{"type":"object","description":"Channel-agnostic message thread between the host workspace and a guest. Returned by `GET /v1/conversations`. The `id` is the internal Repull thread id (integer) — pass it back as the `{id}` path param on detail / messages calls.","properties":{"id":{"type":"string"},"platform":{"type":"string","nullable":true,"enum":["airbnb","booking","vrbo","website","email"],"example":"airbnb"},"externalThreadId":{"type":"string","nullable":true,"description":"The source channel's own thread id (Airbnb thread id, Booking conversation id, …). Pass this as the `{threadId}` path param on `POST /v1/channels/airbnb/messaging/{threadId}/messages` to reply — it is the bridge from a unified conversation straight to the provider-specific send call. `null` when the thread has no external id yet (e.g. a website/email thread)."},"guestId":{"type":"string","nullable":true},"listingId":{"type":"string","nullable":true},"reservationId":{"type":"string","nullable":true},"subject":{"type":"string","nullable":true,"description":"Thread subject (email/website channels) or null when not applicable."},"lastMessageAt":{"type":"string","format":"date-time","nullable":true},"lastMessagePreview":{"type":"string","nullable":true,"description":"Short preview of the most recent message body for list-UI rendering."},"unreadCount":{"type":"integer"},"status":{"type":"string","enum":["open","archived"],"description":"`archived` is reserved for a future bit on `message_threads` — currently always `open`."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"ConversationHost":{"type":"object","description":"Linked host metadata for a conversation thread. Currently populated for Airbnb threads (resolved through `airbnb_hosts`); null for other channels until per-channel host enrichment lands.","properties":{"id":{"type":"string"},"airbnbId":{"type":"string","description":"Airbnb-side host id."},"firstName":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string","format":"uri","nullable":true}}},"ConversationGuestContact":{"type":"object","properties":{"type":{"type":"string","example":"phone"},"value":{"type":"string"},"isPrimary":{"type":"boolean"},"isVerified":{"type":"boolean"}}},"ConversationGuest":{"type":"object","description":"Linked guest metadata for a conversation thread. Resolved through the thread's `reservation_id` → `reservations.guest_id`. Up to 50 contacts are returned.","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string","format":"uri","nullable":true},"contacts":{"type":"array","items":{"$ref":"#/components/schemas/ConversationGuestContact"}}}},"ConversationDetail":{"type":"object","description":"Returned by `GET /v1/conversations/{id}`. Extends the list-row `Conversation` shape with expanded `host` + `guest` blocks so SDK consumers can render thread headers without an extra round-trip.","allOf":[{"$ref":"#/components/schemas/Conversation"},{"type":"object","properties":{"host":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/ConversationHost"}]},"guest":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/ConversationGuest"}]}}}]},"ConversationMessageAttachment":{"type":"object","properties":{"id":{"type":"string"},"imageUrl":{"type":"string","format":"uri"},"contentType":{"type":"string","example":"image/jpeg"},"createdAt":{"type":"string","format":"date-time"}}},"Message":{"type":"object","description":"A single message inside a conversation thread. Returned by `GET /v1/conversations/{id}/messages`. `direction` is normalized to `inbound` (from the guest) / `outbound` (from the host or an automation).","properties":{"id":{"type":"string"},"externalMessageId":{"type":"string","nullable":true,"description":"ID assigned by the source channel (Airbnb message id, Booking message id, etc.). Stable across syncs."},"direction":{"type":"string","enum":["inbound","outbound"]},"senderType":{"type":"string","nullable":true,"description":"Free-form sender role from the channel (e.g. `guest`, `host`, `system`, `airbnb`). Use `direction` for binary inbound/outbound logic."},"senderName":{"type":"string"},"senderAvatar":{"type":"string","format":"uri","nullable":true},"channel":{"type":"string","nullable":true,"description":"Delivery channel — `airbnb`, `booking`, `sms`, `email`, etc."},"body":{"type":"string","description":"Message body in the original language."},"translatedBody":{"type":"string","nullable":true,"description":"English translation when the original language is non-English and a translation has been computed."},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/ConversationMessageAttachment"}},"isAutomated":{"type":"boolean","description":"`true` when the message was sent by a Vanio automation (template, schedule, etc.)."},"aiGenerated":{"type":"boolean","description":"`true` when the body was authored by Vanio AI (autopilot, draft)."},"sentAt":{"type":"string","format":"date-time"},"deliveredAt":{"type":"string","format":"date-time"},"readAt":{"type":"string","format":"date-time","nullable":true}}},"ReviewCategory":{"type":"object","description":"One scored sub-category of a multi-axis review (cleanliness, communication, accuracy, etc.). Categories vary by platform.","properties":{"category":{"type":"string","example":"cleanliness"},"rating":{"type":"number","nullable":true,"description":"Per-category rating on the platform's scale (typically 1..5)."},"comment":{"type":"string","nullable":true}}},"ReviewResponse":{"type":"object","description":"Host response to a review, when present.","properties":{"body":{"type":"string"},"submittedAt":{"type":"string","format":"date-time","nullable":true}}},"Review":{"type":"object","description":"A guest or host review unified across channels. Returned by `GET /v1/reviews` and `GET /v1/reviews/{id}`. Populated from main vanio's unified `reviews` table after the per-channel backfill cron has run.","properties":{"id":{"type":"string","description":"Internal Repull review id — pass back to `/v1/reviews/{id}`."},"externalId":{"type":"string","description":"ID in the source channel (Airbnb review id, Booking review id, etc.). Pass as `review_id` to the provider reply endpoint."},"platform":{"type":"string","nullable":true,"enum":["airbnb","booking","vrbo"]},"listingId":{"type":"string","nullable":true,"description":"Internal Repull listing id the review is attached to."},"providerPropertyId":{"type":"string","nullable":true,"description":"The source channel's own listing/property id for this review (Booking.com hotel/property id, Airbnb listing id, …). Pass this as `property_id` to `POST /v1/channels/booking/reviews` to post a host reply — it is the bridge from a unified review straight to the provider-specific reply call. `null` when the source listing id has not been mirrored yet."},"reservationId":{"type":"string","nullable":true},"reservationConfirmationCode":{"type":"string","nullable":true,"description":"Channel-side confirmation code for the reservation being reviewed."},"guestId":{"type":"string","nullable":true},"guestName":{"type":"string","nullable":true},"guestAvatar":{"type":"string","format":"uri","nullable":true},"reviewerRole":{"type":"string","enum":["guest","host"],"description":"Who wrote the review — `guest` (about the host/property) or `host` (about the guest)."},"rating":{"type":"number","nullable":true,"description":"Overall rating on the platform's scale (typically 1..5). May be `null` for review types that lack a numeric overall score."},"categories":{"type":"array","items":{"$ref":"#/components/schemas/ReviewCategory"}},"publicReview":{"type":"string","nullable":true,"description":"Public-facing review text shown on the listing page."},"privateFeedback":{"type":"string","nullable":true,"description":"Private feedback the reviewer sent only to the host."},"isRevieweeRecommended":{"type":"boolean","nullable":true,"description":"Did the reviewer recommend the reviewee? Used for guest-side reviews."},"response":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/ReviewResponse"}]},"submittedAt":{"type":"string","format":"date-time","nullable":true},"updatedAt":{"type":"string","format":"date-time","nullable":true},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"When the review window closes (Airbnb has a 14-day window after checkout)."},"hidden":{"type":"boolean"},"language":{"type":"string","nullable":true,"description":"Detected language (ISO 639-1) of the review body."}}},"Connection":{"type":"object","properties":{"id":{"type":"string"},"provider":{"type":"string","example":"hostaway"},"status":{"type":"string","enum":["active","inactive","error"],"example":"active"},"externalAccountId":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"host":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/ConnectHost"}],"description":"Host metadata for the linked account. Currently populated for Airbnb only; null for other providers."}}},"ConnectHost":{"type":"object","description":"Public-facing metadata about the host whose account is linked. Lets clients render an account-level card (avatar + name) instead of just an opaque ID. Email is intentionally NOT exposed for Airbnb — the partner API doesn't return host email.","properties":{"displayName":{"type":"string","nullable":true,"example":"Lidia","description":"Short display name (Airbnb first name)."},"displayNameLong":{"type":"string","nullable":true,"example":"Lidia","description":"Preferred long-form name. Falls back to displayName when the host hasn't set a preferred form."},"avatarUrl":{"type":"string","nullable":true,"format":"uri","description":"Profile picture URL (small)."},"avatarUrlLarge":{"type":"string","nullable":true,"format":"uri","description":"Profile picture URL (large)."},"activationStatus":{"type":"string","nullable":true,"example":"active","description":"Per-provider activation/onboarding status."}}},"ConnectProvider":{"type":"object","description":"A channel the multi-channel Connect picker can show. Returned by `GET /v1/connect/providers` and consumed by SDKs that render their own picker UI.","properties":{"id":{"type":"string","example":"airbnb","description":"Stable identifier passed back to /select-provider and used in /v1/connect/{provider} routes."},"displayName":{"type":"string","example":"Airbnb"},"category":{"type":"string","enum":["ota","pms"],"description":"Channel category — OTAs are listing marketplaces; PMSes are property management systems."},"connectPattern":{"type":"string","enum":["oauth","credentials","activation","claim"],"description":"How the host is connected. `oauth`: provider-side consent screen. `credentials`: hosted form collects API keys. `activation`: push-only handshake (Vrbo). `claim`: connectivity-provider designation in the channel's Extranet (Booking.com)."},"status":{"type":"string","enum":["live","beta","coming-soon"],"description":"Pickers should hide / disable `coming-soon` cards. `beta` cards are clickable but show a Beta pill."},"logoUrl":{"type":"string","format":"uri","description":"Logo URL — Clearbit stand-in until self-hosted SVGs land."},"description":{"type":"string","example":"OAuth consent — host approves access in one click."},"docsUrl":{"type":"string","format":"uri","example":"https://repull.dev/docs/channels/airbnb"},"aliases":{"type":"array","items":{"type":"string"},"nullable":true,"example":["airbnb","abnb"],"description":"Optional friendly aliases the picker's search box can match."}},"required":["id","displayName","category","connectPattern","status","logoUrl","description","docsUrl"]},"ConnectProviderListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ConnectProvider"}}}},"ConnectSession":{"type":"object","description":"A multi-channel Connect picker session. The `url` is the hosted picker page on connect.repull.dev — redirect the host to it, they pick a channel, and the picker takes them through the per-provider flow before redirecting back to the original `redirectUrl`.","properties":{"sessionId":{"type":"string","example":"cs_8gQrT2v9k3M4nLp7wJxYzAbCdEfGhIjKlMnOp"},"url":{"type":"string","format":"uri","example":"https://connect.repull.dev/cs_8gQrT2v9k3M4nLp7wJxYzAbCdEfGhIjKlMnOp"},"expiresAt":{"type":"string","format":"date-time"},"state":{"type":"string","nullable":true,"description":"Echoed back from the request body for SDK consumers that pass an opaque correlation token."}},"required":["sessionId","url","expiresAt"]},"SelectProviderResponse":{"type":"object","description":"Returned by `POST /v1/connect/sessions/{sessionId}/select-provider`. The picker UI navigates the user to `nextUrl` to begin the per-provider handoff.","properties":{"sessionId":{"type":"string"},"provider":{"type":"string","example":"airbnb"},"pattern":{"type":"string","enum":["oauth","credentials","activation","claim"]},"nextUrl":{"type":"string","format":"uri","description":"Where to send the user next — OAuth consent, credentials form, activation checklist, or claim form."}}},"ConnectStatus":{"type":"object","description":"Connection status response for a single provider. When `connected` is false, all other fields except `provider` and `host` may be omitted, and `host` is null.","properties":{"connected":{"type":"boolean","example":true},"provider":{"type":"string","example":"airbnb"},"id":{"type":"string","description":"Repull-side connection ID. Stable across token refreshes.","example":"3"},"status":{"type":"string","enum":["active","inactive","error"],"example":"active"},"externalAccountId":{"type":"string","nullable":true,"description":"Provider-side account ID (e.g. the Airbnb host ID).","example":"23998907"},"createdAt":{"type":"string","format":"date-time"},"host":{"nullable":true,"allOf":[{"$ref":"#/components/schemas/ConnectHost"}],"description":"Host metadata, populated for Airbnb when the host row exists. Null for other providers (per-provider enrichment is incremental)."}}},"WebhookSubscription":{"type":"object","description":"A registered webhook endpoint. The `secret` field is only present in the response of `POST /v1/webhooks` and `POST /v1/webhooks/{id}/rotate-secret` (Stripe pattern — capture it then; it is masked everywhere else).","properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"description":{"type":"string","nullable":true},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"},"example":["reservation.created","reservation.updated"]},"apiVersion":{"type":"string","example":"2026-04"},"status":{"type":"string","enum":["active","paused","disabled"]},"consecutiveFailures":{"type":"integer"},"lastDeliveredAt":{"type":"string","format":"date-time","nullable":true},"lastSuccessAt":{"type":"string","format":"date-time","nullable":true},"lastFailureAt":{"type":"string","format":"date-time","nullable":true},"lastDeliveryStatus":{"type":"integer","nullable":true},"disabledAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"secretMasked":{"type":"string","nullable":true,"example":"whsec_a1b…f9c2"},"secret":{"type":"string","nullable":true,"description":"Plaintext signing secret. Only returned by create + rotate. Capture and store securely."}}},"WebhookDelivery":{"type":"object","description":"A single delivery attempt for a webhook event. The actual `WebhookEvent` envelope POSTed to the subscription URL is captured on `WebhookDeliveryDetail.payload` (this list view omits the body for size).","properties":{"id":{"type":"string","format":"uuid"},"eventId":{"type":"string","format":"uuid","description":"Stable across retries of the same logical event."},"eventType":{"$ref":"#/components/schemas/WebhookEventType"},"statusCode":{"type":"integer","nullable":true},"responseTimeMs":{"type":"integer","nullable":true},"attempt":{"type":"integer"},"success":{"type":"boolean"},"errorMessage":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"succeededAt":{"type":"string","format":"date-time","nullable":true},"failedAt":{"type":"string","format":"date-time","nullable":true}}},"WebhookDeliveryDetail":{"type":"object","description":"Full request + response capture for one delivery attempt. `payload` is the exact `WebhookEvent` envelope that was (or would have been) POSTed to the subscription URL.","properties":{"id":{"type":"string"},"eventId":{"type":"string"},"eventType":{"$ref":"#/components/schemas/WebhookEventType"},"payload":{"$ref":"#/components/schemas/WebhookEvent"},"requestHeaders":{"type":"object","nullable":true},"statusCode":{"type":"integer","nullable":true},"responseHeaders":{"type":"object","nullable":true},"responseBody":{"type":"string","nullable":true},"responseTimeMs":{"type":"integer","nullable":true},"attempt":{"type":"integer"},"success":{"type":"boolean"},"errorMessage":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"WebhookDeliveryListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"WebhookEventCatalog":{"type":"object","description":"Canonical catalog of every event the API can deliver, grouped by domain. Each entry includes a realistic `samplePayload` matching the discriminated `WebhookEvent` union — so SDKs can render docs and dashboards from this single source of truth.","properties":{"domains":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventCatalogEntry"}}}}},"flat":{"type":"array","description":"All events in a flat list (same entries as `domains[].events`, ungrouped).","items":{"$ref":"#/components/schemas/WebhookEventCatalogEntry"}}}},"WebhookEventCatalogEntry":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/WebhookEventType"},"domain":{"type":"string","enum":["reservations","listings","calendar","accounts","reviews","ai","payments","system"]},"title":{"type":"string"},"description":{"type":"string"},"samplePayload":{"type":"object","description":"Realistic example of the `data` payload an event of this `type` will deliver. Shape matches the matching variant in the `WebhookEvent` discriminated union."}}},"WebhookEventType":{"type":"string","description":"Canonical event type identifier. Every webhook delivery declares one of these in its `type` field; SDKs key the discriminated `WebhookEvent` union on this value.","enum":["reservation.created","reservation.updated","reservation.cancelled","reservation.message.received","reservation.alteration.created","reservation.alteration.responded","listing.created","listing.updated","listing.deleted","calendar.updated","account.created","account.disconnected","review.created","review.responded","ai.operation.completed","ai.operation.failed","payment.completed","payment.refunded","repull.ping"]},"ReservationWebhookObject":{"type":"object","description":"Lightweight reservation snapshot delivered as `data.object` on every reservation webhook event. Stable across `reservation.created`, `reservation.updated`, and `reservation.cancelled`. Fetch the full reservation via `GET /v1/reservations/{id}` if you need pricing, guest contact info, or audit history — those are deliberately omitted to keep deliveries small.","required":["id","uid","channel","listingId","customerId","checkinDate","checkoutDate","status"],"properties":{"id":{"type":"integer","description":"Repull-internal reservation id. Pass to `GET /v1/reservations/{id}`.","example":212605},"uid":{"type":"string","description":"Channel-side confirmation code (Airbnb HM-prefixed, Booking.com numeric, etc.). Stable across the lifetime of the reservation.","example":"HMX4CMA2X9"},"channel":{"type":"string","description":"Source channel — `airbnb`, `booking`, `vrbo`, `direct`, `owner`, `mid_stay_clean`, etc.","example":"airbnb"},"listingId":{"type":"integer","description":"Repull listing id this reservation is on.","example":5668},"customerId":{"type":"integer","description":"Workspace (customer) id this reservation belongs to.","example":1},"checkinDate":{"type":"string","format":"date","description":"Check-in date (local property date, no timezone).","example":"2026-06-10"},"checkoutDate":{"type":"string","format":"date","description":"Check-out date (local property date, no timezone).","example":"2026-06-16"},"status":{"type":"string","description":"Lifecycle status — typically `confirmed`, `cancelled`, `pending`, `inquiry`.","example":"confirmed"}}},"ReservationCreatedPayload":{"type":"object","description":"Payload for `reservation.created`. A new reservation arrived from any connected channel or direct booking. Stripe-pattern envelope: `data.object` carries the reservation snapshot.","required":["object"],"properties":{"object":{"$ref":"#/components/schemas/ReservationWebhookObject"}}},"ReservationUpdatedPayload":{"type":"object","description":"Payload for `reservation.updated`. Dates, status, or any tracked field changed on an existing reservation. `data.object` is the post-change snapshot; `data.previousAttributes` lists ONLY the fields that actually moved, with their prior values. Fields not in `previousAttributes` did not change.","required":["object"],"properties":{"object":{"$ref":"#/components/schemas/ReservationWebhookObject"},"previousAttributes":{"type":"object","description":"Sparse map: every key here is a field on the reservation snapshot whose value changed in this event, mapped to its prior value. Mirrors the keys of `ReservationWebhookObject` (e.g. `checkinDate`, `checkoutDate`, `status`). Receivers can diff `object[k]` vs `previousAttributes[k]` to know what moved.","additionalProperties":true,"example":{"checkinDate":"2026-06-11","checkoutDate":"2026-06-16"}}}},"ReservationCancelledPayload":{"type":"object","description":"Payload for `reservation.cancelled`. A reservation was cancelled by the guest, host, or platform. `data.object` reflects the post-cancel snapshot (status will be `cancelled`); top-level fields capture cancellation metadata.","required":["object"],"properties":{"object":{"$ref":"#/components/schemas/ReservationWebhookObject"},"cancelledAt":{"type":"string","format":"date-time","description":"When the cancellation was recorded.","example":"2026-05-01T14:00:00.000Z"},"cancelledBy":{"type":"string","enum":["guest","host","platform"],"description":"Who initiated the cancellation.","example":"guest"},"reason":{"type":"string","nullable":true,"description":"Free-form cancellation reason from the source channel, if available.","example":"guest_requested"}}},"ReservationMessageReceivedPayload":{"type":"object","description":"Payload for `reservation.message.received`. A new inbound message arrived on a reservation thread.","properties":{"reservationId":{"type":"integer","example":215906},"threadId":{"type":"string","example":"thr_01HX5XPQ2K"},"from":{"type":"object","properties":{"type":{"type":"string","example":"guest","description":"Message author (guest, host, system)."},"name":{"type":"string","example":"Alex Morgan"}}},"body":{"type":"string","example":"Hi! What time can we check in?"},"sentAt":{"type":"string","format":"date-time","example":"2026-05-01T15:00:00.000Z"}}},"AlterationWebhookObject":{"type":"object","description":"Lightweight alteration snapshot delivered as `data.object` on `reservation.alteration.*` events. Currently Airbnb only. Fetch full state (original vs new dates/guests/price) via `GET /v1/channels/airbnb/alterations` filtered by `reservation_code`.","required":["id","channel","reservationId","customerId","status"],"properties":{"id":{"type":"integer","description":"Repull-internal alteration id (`reservation_alterations.id`).","example":4471},"alterationId":{"type":"string","nullable":true,"description":"Provider-side alteration/resolution id.","example":"RAX9K2M4C1"},"channel":{"type":"string","description":"Source channel. Currently always `airbnb`.","example":"airbnb"},"reservationId":{"type":"integer","description":"Repull reservation id the alteration targets. Pass to `GET /v1/reservations/{id}`.","example":215906},"customerId":{"type":"integer","description":"Workspace (customer) id.","example":1},"status":{"type":"string","description":"Alteration lifecycle status.","enum":["pending","accepted","declined","cancelled"],"example":"pending"},"initiator":{"type":"string","nullable":true,"description":"Who requested the alteration.","enum":["guest","host",null],"example":"guest"}}},"AlterationChange":{"type":"object","description":"A single requested field delta on an alteration: prior value (`from`) vs proposed value (`to`).","properties":{"from":{"description":"Prior value (string date, integer count, or decimal price string)."},"to":{"description":"Proposed value (string date, integer count, or decimal price string)."}}},"ReservationAlterationCreatedPayload":{"type":"object","description":"Payload for `reservation.alteration.created`. A new reservation alteration was requested (Airbnb). `data.object` carries the snapshot; `data.changes` lists the requested field deltas.","required":["object"],"properties":{"object":{"$ref":"#/components/schemas/AlterationWebhookObject"},"changes":{"type":"object","description":"Requested deltas keyed by field name (`checkIn`, `checkOut`, `guestCount`, `totalPrice`). Only changed fields appear.","additionalProperties":{"$ref":"#/components/schemas/AlterationChange"},"example":{"checkOut":{"from":"2026-06-16","to":"2026-06-18"},"totalPrice":{"from":"1320.00","to":"1760.00"}}}}},"ReservationAlterationRespondedPayload":{"type":"object","description":"Payload for `reservation.alteration.responded`. A pending alteration was accepted, declined, or cancelled. `data.object.status` reflects the new state.","required":["object"],"properties":{"object":{"$ref":"#/components/schemas/AlterationWebhookObject"},"respondedAt":{"type":"string","format":"date-time","description":"When the response was recorded.","example":"2026-05-01T16:30:00.000Z"}}},"ListingCreatedPayload":{"type":"object","description":"Payload for `listing.created`. A new property was synced into Repull from a connected PMS or channel.","properties":{"id":{"type":"integer","example":6250},"title":{"type":"string","example":"R-Sable 1302 — Radium Hot Springs"},"address":{"type":"object","properties":{"city":{"type":"string","example":"Radium Hot Springs"},"region":{"type":"string","example":"BC"},"country":{"type":"string","example":"CA"}}},"bedrooms":{"type":"integer","example":2},"bathrooms":{"type":"number","example":2},"maxGuests":{"type":"integer","example":6},"createdAt":{"type":"string","format":"date-time","example":"2026-05-01T12:00:00.000Z"}}},"ListingUpdatedPayload":{"type":"object","description":"Payload for `listing.updated`. Listing content, amenities, photos, or status changed.","properties":{"id":{"type":"integer","example":6250},"changes":{"type":"object","additionalProperties":true,"description":"Map of `field` → `{ from, to }` pairs describing what changed.","example":{"title":{"from":"R-Sable 1302","to":"R-Sable 1302 — Radium Hot Springs"}}},"updatedAt":{"type":"string","format":"date-time","example":"2026-05-01T12:30:00.000Z"}}},"ListingDeletedPayload":{"type":"object","description":"Payload for `listing.deleted`. A property was removed from Repull or the upstream PMS.","properties":{"id":{"type":"integer","example":6250},"deletedAt":{"type":"string","format":"date-time","example":"2026-05-01T16:00:00.000Z"},"reason":{"type":"string","nullable":true,"example":"deactivated_by_owner"}}},"CalendarUpdatedPayload":{"type":"object","description":"Payload for `calendar.updated`. Availability or pricing for a listing was updated.","properties":{"listingId":{"type":"integer","example":6250},"range":{"type":"object","properties":{"start":{"type":"string","format":"date","example":"2026-06-01"},"end":{"type":"string","format":"date","example":"2026-06-15"}}},"affectedDates":{"type":"integer","example":14},"pricingChanged":{"type":"boolean","example":true},"availabilityChanged":{"type":"boolean","example":false}}},"AccountCreatedPayload":{"type":"object","description":"Payload for `account.created`. An OAuth or API credential connection was completed by an end user.","properties":{"workspaceId":{"type":"string","format":"uuid","example":"47f8883d-28c2-4d2c-b020-c7cef1aff62c"},"accountId":{"type":"string","example":"acc_01HX5XPQ2K"},"provider":{"type":"string","example":"airbnb","description":"PMS or channel provider id (e.g. airbnb, booking, hostaway)."},"accessType":{"type":"string","example":"full_access"},"createdAt":{"type":"string","format":"date-time","example":"2026-05-01T12:00:00.000Z"}}},"AccountDisconnectedPayload":{"type":"object","description":"Payload for `account.disconnected`. A PMS or channel connection was revoked, expired, or rejected by the upstream provider.","properties":{"workspaceId":{"type":"string","format":"uuid","example":"47f8883d-28c2-4d2c-b020-c7cef1aff62c"},"accountId":{"type":"string","example":"acc_01HX5XPQ2K"},"connectionId":{"type":"string","nullable":true,"description":"Stable connection identifier — alias of accountId for this event variant."},"provider":{"type":"string","example":"airbnb"},"disconnectedAt":{"type":"string","format":"date-time","example":"2026-05-01T17:00:00.000Z"},"reason":{"type":"string","enum":["refresh_token_rejected","manual_disconnect","auth_expired","revoked_upstream"],"example":"refresh_token_rejected","description":"Why the connection was lost. `refresh_token_rejected` — upstream OAuth refresh endpoint returned a hard rejection. `manual_disconnect` — host/admin disconnected via the dashboard. `auth_expired` — credentials aged out without ever being used. `revoked_upstream` — provider notified us the user revoked access."}}},"ReviewWebhookObject":{"type":"object","description":"Lightweight review snapshot delivered as `data.object` on `review.*` events. Fetch the full review (category ratings, public text, private feedback, response body) via `GET /v1/reviews/{id}`.","required":["id","channel","customerId","reviewerRole"],"properties":{"id":{"type":"integer","description":"Repull-internal review id. Pass to `GET /v1/reviews/{id}`.","example":90210},"channel":{"type":"string","nullable":true,"description":"Source channel the review came from.","example":"airbnb"},"listingId":{"type":"integer","nullable":true,"description":"Repull listing id the review is about.","example":5668},"reservationId":{"type":"integer","nullable":true,"description":"Repull reservation id the review is attached to, if known.","example":215906},"customerId":{"type":"integer","description":"Workspace (customer) id.","example":1},"reviewerRole":{"type":"string","enum":["guest","host"],"description":"Who wrote the review — `guest` (about the host/property) or `host` (about the guest).","example":"guest"},"rating":{"type":"integer","nullable":true,"description":"Overall star rating, if present.","example":5},"submittedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the review was submitted on the source channel.","example":"2026-05-01T09:00:00.000Z"}}},"ReviewCreatedPayload":{"type":"object","description":"Payload for `review.created`. A new review was received on a reservation. `data.object.reviewerRole` disambiguates guest vs host authorship.","required":["object"],"properties":{"object":{"$ref":"#/components/schemas/ReviewWebhookObject"}}},"ReviewRespondedPayload":{"type":"object","description":"Payload for `review.responded`. A public host response to a review was recorded. Fetch the response body via `GET /v1/reviews/{id}`.","required":["object"],"properties":{"object":{"$ref":"#/components/schemas/ReviewWebhookObject"},"respondedAt":{"type":"string","format":"date-time","description":"When the host response was recorded.","example":"2026-05-02T11:00:00.000Z"}}},"AiOperationCompletedPayload":{"type":"object","description":"Payload for `ai.operation.completed`. An async AI run (review response, message draft, pricing suggestion) finished.","properties":{"operationId":{"type":"string","example":"aiop_01HX5XPQ2K"},"type":{"type":"string","example":"respond-to-guest","description":"AI operation kind — e.g. respond-to-guest, price-suggestion, review-response."},"inputSummary":{"type":"string","example":"Guest asked about parking"},"output":{"type":"object","additionalProperties":true,"description":"Operation-specific output object.","example":{"message":"Free underground parking is included with your stay."}},"tokensUsed":{"type":"integer","example":184},"completedAt":{"type":"string","format":"date-time","example":"2026-05-01T18:00:00.000Z"}}},"AiOperationFailedPayload":{"type":"object","description":"Payload for `ai.operation.failed`. An async AI run terminated with an error and will not be retried.","properties":{"operationId":{"type":"string","example":"aiop_01HX5XPQ2L"},"type":{"type":"string","example":"price-suggestion"},"error":{"type":"object","properties":{"code":{"type":"string","example":"no_market_data"},"message":{"type":"string","example":"Insufficient comparable listings."}}},"failedAt":{"type":"string","format":"date-time","example":"2026-05-01T18:01:00.000Z"}}},"PaymentCompletedPayload":{"type":"object","description":"Payload for `payment.completed`. A guest payment was successfully captured.","properties":{"id":{"type":"string","example":"pay_01HX5XPQ2K"},"reservationId":{"type":"integer","example":215906},"amount":{"type":"string","example":"1320.00"},"currency":{"type":"string","example":"USD"},"method":{"type":"string","example":"card"},"capturedAt":{"type":"string","format":"date-time","example":"2026-05-01T12:35:00.000Z"}}},"PaymentRefundedPayload":{"type":"object","description":"Payload for `payment.refunded`. A previous payment was refunded in part or in full.","properties":{"id":{"type":"string","example":"pay_01HX5XPQ2K"},"refundId":{"type":"string","example":"rfn_01HX5XPQ2K"},"reservationId":{"type":"integer","example":215906},"amount":{"type":"string","example":"1320.00"},"currency":{"type":"string","example":"USD"},"refundedAt":{"type":"string","format":"date-time","example":"2026-05-01T19:00:00.000Z"}}},"RepullPingPayload":{"type":"object","description":"Payload for `repull.ping`. A diagnostic delivery used by the dashboard to verify endpoint reachability.","properties":{"message":{"type":"string","example":"Ping from Repull. If you can read this, your endpoint is reachable."}}},"ReservationCreatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid","description":"Stable event id — same across delivery retries of the same logical event."},"type":{"type":"string","enum":["reservation.created"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string","example":"2026-04"},"data":{"$ref":"#/components/schemas/ReservationCreatedPayload"}}},"ReservationUpdatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["reservation.updated"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ReservationUpdatedPayload"}}},"ReservationCancelledEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["reservation.cancelled"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ReservationCancelledPayload"}}},"ReservationMessageReceivedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["reservation.message.received"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ReservationMessageReceivedPayload"}}},"ReservationAlterationCreatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["reservation.alteration.created"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ReservationAlterationCreatedPayload"}}},"ReservationAlterationRespondedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["reservation.alteration.responded"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ReservationAlterationRespondedPayload"}}},"ListingCreatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["listing.created"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ListingCreatedPayload"}}},"ListingUpdatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["listing.updated"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ListingUpdatedPayload"}}},"ListingDeletedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["listing.deleted"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ListingDeletedPayload"}}},"CalendarUpdatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["calendar.updated"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/CalendarUpdatedPayload"}}},"AccountCreatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["account.created"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/AccountCreatedPayload"}}},"AccountDisconnectedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["account.disconnected"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/AccountDisconnectedPayload"}}},"ReviewCreatedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["review.created"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ReviewCreatedPayload"}}},"ReviewRespondedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["review.responded"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/ReviewRespondedPayload"}}},"AiOperationCompletedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["ai.operation.completed"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/AiOperationCompletedPayload"}}},"AiOperationFailedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["ai.operation.failed"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/AiOperationFailedPayload"}}},"PaymentCompletedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["payment.completed"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/PaymentCompletedPayload"}}},"PaymentRefundedEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["payment.refunded"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/PaymentRefundedPayload"}}},"RepullPingEvent":{"type":"object","required":["type","data"],"properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["repull.ping"]},"createdAt":{"type":"string","format":"date-time"},"apiVersion":{"type":"string"},"data":{"$ref":"#/components/schemas/RepullPingPayload"}}},"WebhookEvent":{"oneOf":[{"$ref":"#/components/schemas/ReservationCreatedEvent"},{"$ref":"#/components/schemas/ReservationUpdatedEvent"},{"$ref":"#/components/schemas/ReservationCancelledEvent"},{"$ref":"#/components/schemas/ReservationMessageReceivedEvent"},{"$ref":"#/components/schemas/ReservationAlterationCreatedEvent"},{"$ref":"#/components/schemas/ReservationAlterationRespondedEvent"},{"$ref":"#/components/schemas/ListingCreatedEvent"},{"$ref":"#/components/schemas/ListingUpdatedEvent"},{"$ref":"#/components/schemas/ListingDeletedEvent"},{"$ref":"#/components/schemas/CalendarUpdatedEvent"},{"$ref":"#/components/schemas/AccountCreatedEvent"},{"$ref":"#/components/schemas/AccountDisconnectedEvent"},{"$ref":"#/components/schemas/ReviewCreatedEvent"},{"$ref":"#/components/schemas/ReviewRespondedEvent"},{"$ref":"#/components/schemas/AiOperationCompletedEvent"},{"$ref":"#/components/schemas/AiOperationFailedEvent"},{"$ref":"#/components/schemas/PaymentCompletedEvent"},{"$ref":"#/components/schemas/PaymentRefundedEvent"},{"$ref":"#/components/schemas/RepullPingEvent"}],"discriminator":{"propertyName":"type","mapping":{"reservation.created":"#/components/schemas/ReservationCreatedEvent","reservation.updated":"#/components/schemas/ReservationUpdatedEvent","reservation.cancelled":"#/components/schemas/ReservationCancelledEvent","reservation.message.received":"#/components/schemas/ReservationMessageReceivedEvent","reservation.alteration.created":"#/components/schemas/ReservationAlterationCreatedEvent","reservation.alteration.responded":"#/components/schemas/ReservationAlterationRespondedEvent","listing.created":"#/components/schemas/ListingCreatedEvent","listing.updated":"#/components/schemas/ListingUpdatedEvent","listing.deleted":"#/components/schemas/ListingDeletedEvent","calendar.updated":"#/components/schemas/CalendarUpdatedEvent","account.created":"#/components/schemas/AccountCreatedEvent","account.disconnected":"#/components/schemas/AccountDisconnectedEvent","review.created":"#/components/schemas/ReviewCreatedEvent","review.responded":"#/components/schemas/ReviewRespondedEvent","ai.operation.completed":"#/components/schemas/AiOperationCompletedEvent","ai.operation.failed":"#/components/schemas/AiOperationFailedEvent","payment.completed":"#/components/schemas/PaymentCompletedEvent","payment.refunded":"#/components/schemas/PaymentRefundedEvent","repull.ping":"#/components/schemas/RepullPingEvent"}},"description":"The full event envelope POSTed to your webhook URL. Discriminated on `type` — narrow `event.data` by switching on `event.type`. Use the matching `*Event` variant directly if your SDK lacks discriminator support."},"AirbnbListing":{"type":"object","description":"A Vanio listing paired with its Airbnb connection rows. The list endpoint groups every `listings_airbnb` row that points at the same Vanio `listingId` under a single `connections[]` array.","properties":{"listingId":{"type":"integer","description":"Vanio (Repull) listing id","example":6248},"name":{"type":"string","description":"Listing title","example":"Oceanview Villa"},"city":{"type":"string","nullable":true,"example":"Malibu"},"connections":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbConnection"}}}},"AirbnbConnection":{"type":"object","description":"An Airbnb-side connection record for a Vanio listing. The same property may appear under multiple connections if it has been linked from multiple Airbnb host accounts.","properties":{"id":{"type":"integer","description":"Connection row id"},"airbnbId":{"type":"string","description":"Airbnb-side listing id","example":"1116939745194659457"},"hostId":{"type":"string","description":"Airbnb host user id"},"active":{"type":"boolean"},"syncEnabled":{"type":"boolean"},"primary":{"type":"boolean"},"markup":{"type":"string","nullable":true,"description":"Decimal markup (e.g. \"1.10\" for +10%)."},"createdAt":{"type":"string","format":"date-time"},"amenities":{"type":"array","nullable":true,"description":"Present only when `?include=amenities` is passed. Sourced from the local `listings_airbnb_amenities` cache (populated by the Airbnb sync worker). Returns `null` when the cache is empty for this connection — see the top-level `data_freshness` envelope to disambiguate \"never synced\" vs \"host disconnected\" vs \"fresh and genuinely empty\".","items":{"type":"object","properties":{"id":{"type":"string","description":"Airbnb amenity id (e.g. `wifi`, `kitchen`)."},"is_present":{"type":"boolean"},"instruction":{"type":"string","nullable":true,"description":"Host-supplied instruction for the amenity (e.g. \"WiFi password is on the fridge\")."}}}},"accessibility_amenities":{"type":"array","nullable":true,"description":"Present only when `?include=amenities` is passed. Accessibility-tagged subset of the local amenity cache (step-free access, wide doorways, grab rails, disabled parking, wheelchair, accessible-height fixtures, hoists, etc). Returns an empty array when amenities synced but none qualify as accessibility; returns `null` when the cache is empty for this connection (use `data_freshness` to disambiguate \"never synced\" from \"fresh and genuinely empty\").","items":{"type":"object","properties":{"id":{"type":"string","description":"Airbnb amenity id (e.g. `wheelchair_accessible`, `home_step_free_access`)."},"is_present":{"type":"boolean"},"instruction":{"type":"string","nullable":true}}}}}},"BookingProperty":{"type":"object","description":"A property registered in the Booking.com extranet for the connected hotel ID.","properties":{"id":{"type":"string","description":"Booking.com hotel/property ID"},"name":{"type":"string"},"status":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"city":{"type":"string","nullable":true}}},"VrboListing":{"type":"object","description":"A VRBO listing.","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","nullable":true}}},"PlumguideListing":{"type":"object","description":"A Plumguide listing.","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","nullable":true}}},"AirbnbReservation":{"type":"object","description":"An Airbnb reservation as returned by the channel API. Use `confirmationCode` to address it in Airbnb operations.","properties":{"confirmationCode":{"type":"string","example":"HMABC12345"},"listingId":{"type":"string"},"status":{"type":"string","enum":["accepted","pending","cancelled","denied","inquiry"],"example":"accepted"},"checkIn":{"type":"string","format":"date"},"checkOut":{"type":"string","format":"date"},"guestName":{"type":"string","nullable":true},"guestCount":{"type":"integer","nullable":true},"totalPrice":{"type":"number","nullable":true},"currency":{"type":"string","nullable":true}}},"VrboReservation":{"type":"object","description":"A VRBO reservation.","properties":{"id":{"type":"string"},"listingId":{"type":"string"},"status":{"type":"string","nullable":true},"checkIn":{"type":"string","format":"date"},"checkOut":{"type":"string","format":"date"}}},"AirbnbThread":{"type":"object","description":"An Airbnb message thread.","properties":{"id":{"type":"string"},"listingId":{"type":"string","nullable":true},"guestName":{"type":"string","nullable":true},"lastMessageAt":{"type":"string","format":"date-time","nullable":true},"unreadCount":{"type":"integer","nullable":true}}},"AirbnbAlteration":{"type":"object","description":"An Airbnb reservation alteration request (date change, guest-count change, or price change), mirrored locally in `reservation_alterations`. Fields prefixed `original*` describe the reservation as it stands today; `new*` fields describe the proposed change. Compare them to render a diff and decide whether to accept (`POST .../{id}/accept`) or decline (`POST .../{id}/decline`).","properties":{"id":{"type":"integer","description":"Internal Repull mirror-row id (not the Airbnb alteration id — use `alterationId` for the `{id}` path param on the get / accept / decline routes)."},"alterationId":{"type":"string","nullable":true,"description":"Airbnb alteration id. This is the `{id}` you pass to `GET/POST /v1/channels/airbnb/alterations/{id}` and the accept / decline sub-routes."},"reservationId":{"type":"integer","nullable":true,"description":"Repull reservation id the alteration belongs to."},"platform":{"type":"string","example":"airbnb","description":"Always `airbnb` on this surface."},"status":{"type":"string","nullable":true,"description":"Alteration lifecycle status — e.g. `pending` (awaiting a decision), `accepted`, `declined`, `canceled`."},"initiator":{"type":"string","nullable":true,"description":"Who proposed the alteration — e.g. `host` or `guest`."},"reason":{"type":"string","nullable":true,"description":"Free-text reason supplied with the alteration request."},"notes":{"type":"string","nullable":true,"description":"Additional notes attached to the alteration."},"originalCheckIn":{"type":"string","format":"date-time","nullable":true,"description":"Check-in on the reservation BEFORE the proposed change."},"originalCheckOut":{"type":"string","format":"date-time","nullable":true,"description":"Check-out on the reservation BEFORE the proposed change."},"originalGuestCount":{"type":"integer","nullable":true,"description":"Guest count BEFORE the proposed change."},"originalTotalPrice":{"type":"string","nullable":true,"description":"Total price (decimal string) BEFORE the proposed change."},"newCheckIn":{"type":"string","format":"date-time","nullable":true,"description":"Proposed new check-in."},"newCheckOut":{"type":"string","format":"date-time","nullable":true,"description":"Proposed new check-out."},"newGuestCount":{"type":"integer","nullable":true,"description":"Proposed new guest count."},"newTotalPrice":{"type":"string","nullable":true,"description":"Proposed new total price (decimal string)."},"createdAt":{"type":"string","format":"date-time","nullable":true,"description":"When the alteration was first mirrored locally."},"updatedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the alteration mirror row was last updated."}},"additionalProperties":true},"AirbnbAmenity":{"type":"object","description":"A single Airbnb amenity from the local cache.","properties":{"id":{"type":"string","description":"Airbnb amenity id (e.g. `wifi`, `bathroom_step_free_access`)."},"is_present":{"type":"boolean","nullable":true,"description":"Whether the amenity is present on the listing."},"instruction":{"type":"string","nullable":true,"description":"Optional host-supplied instruction for the amenity."}}},"AirbnbReview":{"type":"object","description":"An Airbnb review (guest → host or host → guest).","properties":{"id":{"type":"string"},"reservationCode":{"type":"string","nullable":true},"rating":{"type":"integer","nullable":true,"minimum":1,"maximum":5},"comment":{"type":"string","nullable":true},"response":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time","nullable":true}}},"BookingConversation":{"type":"object","description":"A Booking.com guest conversation.","properties":{"id":{"type":"string"},"reservationId":{"type":"string","nullable":true},"guestName":{"type":"string","nullable":true},"lastMessageAt":{"type":"string","format":"date-time","nullable":true}}},"BookingReservationRoom":{"type":"object","description":"A single reserved room within a Booking.com reservation. Raw Booking OTA shape — passed through unmodified from the connector.","properties":{"id":{"type":"string","description":"Room id."},"roomreservation_id":{"type":"string","description":"Per-room reservation id."},"arrival_date":{"type":"string","description":"Room arrival date (YYYY-MM-DD)."},"departure_date":{"type":"string","description":"Room departure date (YYYY-MM-DD)."},"guest_name":{"type":"string","nullable":true},"numberofguests":{"type":"integer","nullable":true},"adults":{"type":"integer","nullable":true},"children":{"type":"integer","nullable":true},"meal_plan":{"type":"string","nullable":true},"totalprice":{"type":"number","nullable":true},"currencycode":{"type":"string","nullable":true},"status":{"type":"string","nullable":true,"enum":["new","modified","cancelled"]},"special_requests":{"type":"array","items":{"type":"string"},"nullable":true}},"additionalProperties":true},"BookingReservation":{"type":"object","description":"A Booking.com reservation as returned by `GET /v1/channels/booking/reservations`.\n\n**This is the RAW Booking.com OTA payload**, passed through from the connector unmodified — it is NOT renormalised to the unified Repull reservation shape (`GET /v1/reservations`). Field names, casing, and nesting follow Booking's OTA format (nested `room[]`, `customer`, snake_case keys). Additional Booking-side fields not enumerated here may be present, and any field may be absent depending on the reservation `type` (`new` / `modified` / `details`). For a channel-agnostic, stable reservation contract, use `GET /v1/reservations` instead.","properties":{"id":{"type":"string","description":"Booking.com reservation id."},"status":{"type":"string","nullable":true,"enum":["new","modified","cancelled"]},"date":{"type":"string","nullable":true,"description":"Booking date (YYYY-MM-DD)."},"time":{"type":"string","nullable":true,"description":"Booking time."},"customer":{"type":"object","additionalProperties":true,"description":"Guest / customer block (name, contact, address) in Booking's OTA shape."},"room":{"type":"array","items":{"$ref":"#/components/schemas/BookingReservationRoom"},"description":"Reserved rooms."},"total_price":{"type":"number","nullable":true},"currency_code":{"type":"string","nullable":true},"payment_status":{"type":"string","nullable":true,"enum":["pending","completed","failed"]},"commission_amount":{"type":"number","nullable":true},"cancellation_deadline":{"type":"string","nullable":true},"special_requests":{"type":"array","items":{"type":"string"},"nullable":true},"company_name":{"type":"string","nullable":true}},"additionalProperties":true},"AIOperation":{"type":"object","properties":{"operation":{"type":"string","enum":["respond-to-guest","classify-intent","generate-listing","review-response","price-suggestion"],"description":"AI operation to perform"},"input":{"type":"object","description":"Operation-specific input data"}}},"CustomSchemaMappings":{"type":"object","description":"Field-mapping table. Keys are the output field names emitted in the response payload; values are simple expressions referenced against the source `native` payload (dot paths, basic arithmetic, string concatenation). Min 1 entry, max 50 entries. Each key must be <= 100 chars; each expression must be <= 500 chars and pass the safety check (no `eval`, no `function`, no `process`, etc.).","additionalProperties":{"type":"string"},"minProperties":1,"maxProperties":50,"example":{"listing_id":"propertyId","arrival":"checkIn","departure":"checkOut","guest_name":"primaryGuest.firstName + ' ' + primaryGuest.lastName","nightly_rate":"financials.breakdown.basePrice / nights"}},"CustomSchema":{"type":"object","description":"A custom field-mapping schema owned by the workspace. Reshapes the `native` response into the workspace's preferred field names. Apply one per request via the `X-Schema: <name>` header on any read endpoint.","properties":{"id":{"type":"string","format":"uuid","description":"Stable workspace-scoped identifier."},"name":{"type":"string","example":"my-app-schema","description":"3-100 lowercase chars, hyphens allowed (`^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$`). Must be unique within the workspace. Cannot collide with reserved names (`calry`, `calry-v1`, `native`)."},"description":{"type":"string","nullable":true},"mappings":{"$ref":"#/components/schemas/CustomSchemaMappings"},"active":{"type":"boolean","description":"When `false`, requests carrying this schema name in `X-Schema` fall back to `native`."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","name","mappings","active","createdAt"]},"CustomSchemaSummary":{"type":"object","description":"List-row shape returned by `GET /v1/schema/custom`. Same fields as `CustomSchema` minus heavy nested data — kept identical today; reserved as a separate schema so the list shape can shrink without breaking the detail call.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"mappings":{"$ref":"#/components/schemas/CustomSchemaMappings"},"active":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","name","mappings","active","createdAt"]},"CustomSchemaCreate":{"type":"object","description":"Request body for `POST /v1/schema/custom`.","required":["name","mappings"],"properties":{"name":{"type":"string","minLength":3,"maxLength":100,"pattern":"^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$","example":"my-app-schema","description":"3-100 lowercase chars + hyphens. Must be unique within the workspace and cannot collide with reserved names (`calry`, `calry-v1`, `native`)."},"description":{"type":"string","nullable":true,"description":"Optional human-readable note shown in the dashboard."},"mappings":{"$ref":"#/components/schemas/CustomSchemaMappings"}}},"CustomSchemaCreateResponse":{"type":"object","description":"Returned by `POST /v1/schema/custom` (201). Includes a `usage` hint telling the caller exactly which header value to set on subsequent requests.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"mappings":{"$ref":"#/components/schemas/CustomSchemaMappings"},"usage":{"type":"string","example":"Set header: X-Schema: my-app-schema"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","name","mappings","usage","createdAt"]},"CustomSchemaUpdate":{"type":"object","description":"Request body for `PATCH /v1/schema/custom/{id}`. All fields optional; omitted fields are left unchanged. `name` is intentionally NOT patchable — create a new schema and migrate consumers if you need to rename.","properties":{"description":{"type":"string","nullable":true},"mappings":{"$ref":"#/components/schemas/CustomSchemaMappings"},"active":{"type":"boolean","description":"Toggle the schema on/off. When `false`, requests carrying this schema name in `X-Schema` fall back to `native`."}}},"CustomSchemaListResponse":{"type":"object","description":"Returned by `GET /v1/schema/custom`. Returns every custom schema owned by the workspace.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CustomSchemaSummary"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"CustomSchemaDeleteResponse":{"type":"object","properties":{"deleted":{"type":"boolean","example":true}},"required":["deleted"]},"Error":{"type":"object","description":"Standardized error envelope. Returned by EVERY 4xx/5xx response on this API. Required fields (`code`, `message`, `fix`, `docs_url`, `request_id`) are designed for LLM-driven self-recovery — an AI agent should be able to fix the underlying problem and retry without escalating to a human. Lead with `fix` and `docs_url` in your tooling; demote `support` (rare) to a last resort.","required":["error"],"properties":{"error":{"type":"object","required":["code","message","fix","docs_url","request_id"],"properties":{"code":{"type":"string","description":"Stable machine-parseable error identifier. Match on this for retry logic. Codes are namespaced and never change meaning.","example":"invalid_params"},"message":{"type":"string","description":"Human-readable cause. Echoes the offending value when relevant.","example":"The check_in_after parameter must be an ISO 8601 date (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ssZ). You sent: 'garbage'."},"fix":{"type":"string","description":"Exact recovery steps. Surface this verbatim in your UI / agent reasoning trace — it is written to be actionable without further reading.","example":"Pass check_in_after as a string in ISO 8601 format. Example: ?check_in_after=2026-01-15"},"docs_url":{"type":"string","format":"uri","description":"Canonical write-up for this error code. URL pattern: `https://repull.dev/docs/errors/{code}`.","example":"https://repull.dev/docs/errors/invalid_params"},"request_id":{"type":"string","description":"Opaque per-request id. Mirrors the `x-request-id` response header. Capture it before retrying so logs can be correlated.","example":"req_01J5X7Y8Z9ABCDEF12345678"},"field":{"type":"string","description":"Body field, query param, or path segment the error is about. Present when the error is parameter-specific.","example":"check_in_after"},"value_received":{"description":"Echo of the offending value (truncated to 200 chars). Useful for debugging — helps callers see what the server actually parsed.","example":"garbage"},"valid_values":{"type":"array","items":{"type":"string"},"description":"Allowed values when the error is enum-related (e.g. unknown `provider`, unknown `status`).","example":["airbnb","booking","vrbo","plumguide"]},"validParams":{"type":"array","items":{"type":"string"},"description":"Sorted list of every query param this endpoint accepts. Present on `code: \"unknown_params\"` (HTTP 422) so SDK consumers can self-correct without reading docs.","example":["cursor","has_reservation","include_total","limit","listingId","q"]},"endpoint":{"type":"string","description":"The endpoint path that produced the error. Present on `code: \"unknown_params\"` so consumers can match validation failures to the operation they invoked.","example":"/v1/guests"},"did_you_mean":{"type":"string","description":"Suggestion for typos and near-matches. Present when the server can guess the intent.","example":"check_in_after"},"retry_after":{"type":"integer","description":"Seconds the client should wait before retrying. Mirrors the `Retry-After` HTTP header. Present on rate-limit responses and on transient upstream failures that are safe to retry.","example":60},"support":{"type":"object","description":"LAST-RESORT contact handle. Only set on errors that genuinely cannot be self-fixed (billing dispute, account-state corruption, OAuth partner intervention). Never fall back to support before trying `fix` and `docs_url`.","properties":{"email":{"type":"string","format":"email"},"url":{"type":"string","format":"uri"},"reference":{"type":"string","description":"Internal reference to quote when contacting support."}}}}}}},"Pagination":{"type":"object","description":"Canonical cursor-based pagination envelope. Pass `nextCursor` back as `?cursor=` to fetch the next page; stop when `hasMore` is `false`. The cursor is opaque base64 — do not parse or construct it by hand.","properties":{"nextCursor":{"type":"string","nullable":true,"description":"Opaque base64 cursor — pass back as `?cursor=<value>`. `null` when there are no more pages."},"hasMore":{"type":"boolean"},"total":{"type":"integer","description":"Total rows matching the current filter (across all pages). Present when `?include_total=true` (the default on most endpoints). Omit `?include_total=false` to skip the COUNT(*) on very large workspaces."}},"required":["nextCursor","hasMore"]},"PropertyListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Property"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"ReservationListResponse":{"type":"object","description":"Cursor-paginated reservation list. Pass `pagination.nextCursor` back as `?cursor=` to fetch the next page; stop when `pagination.hasMore` is `false`. The `total` field is the count of rows matching the current filter (across all pages); pass `?include_total=false` to skip the COUNT(*) on very large workspaces.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Reservation"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"GuestListResponse":{"type":"object","description":"Cursor-paginated guest list. Pass `pagination.nextCursor` back as `?cursor=` to fetch the next page.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Guest"}},"pagination":{"$ref":"#/components/schemas/CursorPagination"}}},"ConversationListResponse":{"type":"object","description":"Cursor-paginated conversation list. Pass `pagination.nextCursor` back as `?cursor=` to fetch the next page.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Conversation"}},"pagination":{"$ref":"#/components/schemas/CursorPagination"}}},"MessageListResponse":{"type":"object","description":"Cursor-paginated message list. Pass `pagination.nextCursor` back as `?cursor=` to fetch the next page.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"pagination":{"$ref":"#/components/schemas/CursorPagination"}}},"ReviewListResponse":{"type":"object","description":"Cursor-paginated review list. Pass `pagination.nextCursor` back as `?cursor=` to fetch the next page.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Review"}},"pagination":{"$ref":"#/components/schemas/CursorPagination"}}},"ConnectionListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Connection"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"WebhookListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookSubscription"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"CalendarResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CalendarDay"}}}},"AirbnbDataFreshness":{"type":"object","description":"Top-level freshness indicator for any DB-backed Airbnb read. Tells consumers WHY a column may be `null` or stale without sprinkling per-row error envelopes through the response. The endpoint always returns 200 + DB data; this field is the single signal for \"should I prompt the user to reconnect / wait for sync?\".","required":["last_synced_at","stale"],"properties":{"last_synced_at":{"type":"string","format":"date-time","nullable":true,"description":"Most recent sync timestamp across the rows in the response. `null` when nothing has ever synced for this customer."},"stale":{"type":"boolean","description":"`true` when any host is disconnected, when the local cache is empty, or when the cache hasn't been refreshed in 24h+. `false` when hosts are healthy and sync is fresh."},"reason":{"type":"string","nullable":true,"description":"Why the data is stale. One of `host_disconnected_since_<iso>`, `sync_lag_>_24h`, `never_synced`. Omitted when `stale` is `false`."},"fix_url":{"type":"string","format":"uri","nullable":true,"description":"Dashboard URL the consumer can open to resolve the staleness (typically the Airbnb reconnect screen). Omitted when `stale` is `false`."}}},"AirbnbListingListResponse":{"type":"object","required":["data","pagination","data_freshness"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbListing"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}},"AirbnbTransaction":{"type":"object","description":"One Airbnb host transaction — a reservation earning, a settled payout, or a resolution adjustment — with the genuine host- and guest-side financial breakdown Airbnb exposes. All money is in host currency; fees and withholding are negative (deductions). Two owner-statement concepts are NOT available from Airbnb and are listed in `unavailable_fields` rather than fabricated: property-management fee and itemised nightly discounts (the latter are already netted into `host_breakdown.accommodation_subtotal`).","required":["transaction_id","host_breakdown","guest_breakdown","payout","unavailable_fields"],"properties":{"transaction_id":{"type":"string","description":"Upstream Airbnb transaction id."},"type":{"type":"string","nullable":true,"enum":["Reservation","Payout","Resolution_Adjustment"],"description":"Transaction kind."},"reference":{"type":"string","nullable":true},"date":{"type":"string","format":"date","nullable":true,"description":"Transaction date."},"confirmation_code":{"type":"string","nullable":true,"description":"Airbnb confirmation code — links this transaction to a reservation."},"reservation_id":{"type":"integer","nullable":true,"description":"Resolved Vanio reservation id when the confirmation code matched a reservation in this workspace; null otherwise."},"listing_id":{"type":"string","nullable":true,"description":"Airbnb listing id."},"thread_id":{"type":"string","nullable":true},"nights":{"type":"integer","nullable":true},"reservation_start_date":{"type":"string","format":"date","nullable":true},"booked_at":{"type":"string","format":"date-time","nullable":true},"check_in":{"type":"string","format":"date-time","nullable":true},"check_out":{"type":"string","format":"date-time","nullable":true},"time_zone":{"type":"string","nullable":true},"guest_name":{"type":"string","nullable":true},"status":{"type":"string","nullable":true,"enum":["COMPLETED","UPCOMING"],"description":"Payout status signal: COMPLETED (settled) vs UPCOMING (expected)."},"status_type":{"type":"string","nullable":true},"payout":{"type":"object","required":["payout_id","payout_date","paid_out_amount"],"properties":{"payout_id":{"type":"string","nullable":true},"payout_date":{"type":"string","format":"date","nullable":true,"description":"Settlement date (populated on Payout-type rows)."},"paid_out_amount":{"type":"number","nullable":true}}},"currency":{"type":"string","nullable":true},"host_currency":{"type":"string","nullable":true},"amount":{"type":"number","nullable":true,"description":"Top-level transaction amount."},"host_breakdown":{"type":"object","description":"Host-side breakdown (all host currency).","required":["host_payout"],"properties":{"accommodation_subtotal":{"type":"number","nullable":true,"description":"Nightly rate × nights, net of stay-level discounts already applied by Airbnb."},"cleaning_fee":{"type":"number","nullable":true},"host_service_fee_base":{"type":"number","nullable":true,"description":"Airbnb host service fee, base component (negative = deduction)."},"host_service_fee_vat":{"type":"number","nullable":true,"description":"Airbnb host service fee, VAT component (negative = deduction)."},"host_service_fee_total":{"type":"number","nullable":true,"description":"Convenience sum of base + VAT."},"airbnb_collected_tax":{"type":"number","nullable":true},"pass_through_tax":{"type":"number","nullable":true},"occupancy_tax":{"type":"number","nullable":true},"tax_withholding":{"type":"number","nullable":true},"host_payout":{"type":"number","nullable":true,"description":"Expected/actual net payout to the host."}}},"guest_breakdown":{"type":"object","description":"Guest-side breakdown (what the guest paid).","required":["total_paid"],"properties":{"total_paid":{"type":"number","nullable":true},"service_fee_base":{"type":"number","nullable":true},"service_fee_vat":{"type":"number","nullable":true}}},"unavailable_fields":{"type":"array","items":{"type":"string"},"description":"Fields Airbnb does not expose (never fabricated), e.g. `management_fee`, `itemized_discounts`."},"standard_fees":{"nullable":true,"description":"Raw Airbnb standard-fees array."},"tax_details":{"nullable":true,"description":"Raw Airbnb tax-details object."},"synced_at":{"type":"string","format":"date-time","nullable":true}}},"AirbnbConnectionHost":{"type":"object","description":"One Airbnb host record under the workspace, decorated with its most recent disconnect reason from `airbnb_host_events` (backfill events excluded).","properties":{"airbnbUserId":{"type":"string","example":"719854265","description":"Upstream Airbnb user id."},"name":{"type":"string","nullable":true,"example":"STR Assistance","description":"Display name (preferred form, falling back to legal first name). Null when both fields are empty."},"isConnected":{"type":"boolean","example":false},"lastSyncedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the host record was last touched (token refresh / activation / restriction). Closest available proxy for \"last successful sync\"."},"deactivatedAt":{"type":"string","format":"date-time","nullable":true,"description":"When the host was last marked inactive. Null on currently-connected hosts."},"lastDisconnectReason":{"type":"string","nullable":true,"example":"token_refresh_rejected","description":"Reason of the most recent non-backfill disconnect event. Common values: `token_refresh_rejected`, `auth_expired`, `user_revoked`. Null when the host has no recorded disconnects."}},"required":["airbnbUserId","name","isConnected","lastSyncedAt","deactivatedAt","lastDisconnectReason"]},"AirbnbConnectionSummary":{"type":"object","description":"Workspace-level Airbnb connection state. The dedicated answer to \"is my Airbnb still connected?\" — emit one summary instead of inferring from per-listing 401s.","properties":{"status":{"type":"string","enum":["connected","disconnected","reconnect_required","never_connected"],"description":"`connected` — every host is currently connected. `reconnect_required` — at least one host is connected, at least one is not. `disconnected` — every host has been disconnected. `never_connected` — the workspace has never linked an Airbnb account."},"hostCount":{"type":"integer","example":2},"hosts":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbConnectionHost"}},"fixUrl":{"type":"string","format":"uri","nullable":true,"description":"Self-serve recovery URL. Set whenever `status` is anything other than `connected`. Points at the dashboard surface where the host re-authorizes (or initiates the first OAuth flow for `never_connected` workspaces).","example":"https://repull.dev/dashboard/connections/airbnb"}},"required":["status","hostCount","hosts"]},"AirbnbConnectionResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AirbnbConnectionSummary"}},"required":["data"]},"AirbnbReservationListResponse":{"type":"object","description":"Cursor-paginated Airbnb reservation list. Pass `pagination.next_cursor` back as `?cursor=` to fetch the next page; stop when `pagination.has_more` is `false`.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbReservation"}},"pagination":{"$ref":"#/components/schemas/CursorPagination"}}},"AirbnbThreadListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbThread"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"AirbnbReviewListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbReview"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"BookingPropertyListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BookingProperty"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"BookingConversationListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BookingConversation"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"VrboListingListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/VrboListing"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"VrboReservationListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/VrboReservation"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"PlumguideListingListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PlumguideListing"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"ListingCreateRequest":{"type":"object","required":["name"],"description":"Inputs for `POST /v1/listings`. Provide enough address detail (street + city + lat/lng) for downstream Airbnb publish to work.","properties":{"name":{"type":"string","example":"Sunset Loft #2","description":"Public guest-facing title"},"propertyType":{"type":"string","example":"apartment"},"street":{"type":"string","example":"123 Main St"},"city":{"type":"string","example":"Miami Beach"},"state":{"type":"string","example":"FL"},"countryCode":{"type":"string","example":"US"},"lat":{"type":"number","example":25.7617},"lng":{"type":"number","example":-80.1918},"bedrooms":{"type":"integer","example":2},"bathrooms":{"type":"number","example":1.5},"beds":{"type":"integer","example":2},"personCapacity":{"type":"integer","example":4},"summary":{"type":"string"},"description":{"type":"string"},"defaultDailyPrice":{"type":"number"},"cleaningFee":{"type":"number"},"cancellationPolicy":{"type":"string","enum":["flexible","moderate","strict","super_strict"]},"checkInTimeStart":{"type":"string","example":"15:00"},"checkOutTime":{"type":"string","example":"11:00"},"allowsPets":{"type":"boolean"},"allowsSmoking":{"type":"boolean"},"allowsChildren":{"type":"boolean"},"allowsEvents":{"type":"boolean"}}},"ListingCreateResponse":{"type":"object","properties":{"id":{"type":"string","description":"New listing ID — use for follow-up generate-content / publish calls"}}},"ListingActiveRequest":{"type":"object","required":["active"],"properties":{"active":{"type":"boolean","description":"Target active state. `false` deactivates the listing and removes it from your billable listing count; `true` reactivates it. Free-tier workspaces are still subject to their listing cap."}}},"ListingActiveResponse":{"type":"object","properties":{"id":{"type":"string","description":"The listing id (id fields are serialized as strings to preserve precision)."},"active":{"type":"boolean","description":"The resulting active state after the toggle."}}},"ListingContentUpdateRequest":{"type":"object","description":"Canonical PMS-owned listing content. Every field is optional — this is a partial update, only the fields you send are written; absent fields are left untouched. This is a LOCAL write only: it does NOT push to Airbnb/Booking.com. Distribution is a separate explicit publish step. `photos` are ingested by URL and attached to the listing in order (full-replace by default, or append via `photosMode`).","properties":{"title":{"type":"string","nullable":true,"description":"Guest-facing title. Written to the listing name and the `en` description."},"name":{"type":"string","nullable":true,"description":"Alias for `title`."},"description":{"type":"string","nullable":true,"description":"Long-form listing description."},"summary":{"type":"string","nullable":true,"description":"Short summary / tagline."},"amenities":{"description":"FULL replacement of the amenity set. Accepts canonical keys as a string[] or structured rows. Omit to leave amenities untouched; send `[]` to clear them.","oneOf":[{"type":"array","items":{"type":"string"},"example":["wifi","kitchen","free_parking"]},{"type":"array","items":{"type":"object","required":["amenityKey"],"properties":{"amenityKey":{"type":"string"},"category":{"type":"string","nullable":true},"isPresent":{"type":"boolean","nullable":true,"default":true},"instruction":{"type":"string","nullable":true}}}}]},"address":{"type":"object","description":"Partial address. Only provided sub-fields are written.","properties":{"street":{"type":"string","nullable":true},"city":{"type":"string","nullable":true},"countryCode":{"type":"string","nullable":true,"description":"ISO-3166 alpha-2 country code."},"lat":{"type":"number","nullable":true},"lng":{"type":"number","nullable":true}}},"occupancy":{"type":"object","properties":{"maxGuests":{"type":"integer","nullable":true},"bedrooms":{"type":"integer","nullable":true},"beds":{"type":"integer","nullable":true},"bathrooms":{"type":"number","nullable":true,"description":"Decimal, e.g. 1.5."}}},"policies":{"type":"object","properties":{"checkInTimeStart":{"type":"string","nullable":true,"description":"e.g. \"15\" (3pm)."},"checkInTimeEnd":{"type":"string","nullable":true},"checkOutTime":{"type":"string","nullable":true,"description":"e.g. \"11\" (11am)."},"houseRules":{"type":"string","nullable":true,"description":"Free-text house rules."},"cancellationPolicy":{"type":"string","nullable":true,"description":"Cancellation policy slug/label."},"cancellation":{"type":"string","nullable":true,"description":"Alias for `cancellationPolicy`."},"allowsChildren":{"type":"boolean","nullable":true},"allowsInfants":{"type":"boolean","nullable":true},"allowsPets":{"type":"boolean","nullable":true},"allowsSmoking":{"type":"boolean","nullable":true},"allowsEvents":{"type":"boolean","nullable":true}}},"photos":{"type":"array","description":"Photo set — full replacement by default (pass `photosMode: \"append\"` to add after existing photos, or `[]` to clear; omit to leave untouched). Each entry is a hosted image URL (string) or a structured ref. URL-ingest only: the URL is persisted and attached to the listing in order — the OTA push downloads it at publish time. Binary/multipart upload is a follow-up. A non-empty array with no valid http(s) URL is reported in `deferred` (existing photos left untouched).","items":{"oneOf":[{"type":"string","format":"uri","description":"Hosted image URL (http/https).","example":"https://cdn.example.com/photos/living-room.jpg"},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Hosted image URL (http/https)."},"thumbnailUrl":{"type":"string","format":"uri","nullable":true},"caption":{"type":"string","nullable":true},"category":{"type":"string","nullable":true,"default":"property"},"width":{"type":"integer","nullable":true},"height":{"type":"integer","nullable":true},"sortOrder":{"type":"integer","nullable":true,"description":"Explicit position; defaults to the array index."},"sourceUrl":{"type":"string","format":"uri","nullable":true,"description":"Provenance URL; defaults to `url`."}}}]}},"photosMode":{"type":"string","enum":["replace","append"],"default":"replace","description":"How `photos` is applied: `replace` (full replacement of the photo set) or `append` (add after the existing photos). Ignored when `photos` is absent."}}},"ListingContentUpdateResponse":{"type":"object","properties":{"id":{"type":"string","description":"The listing id (serialized as a string to preserve precision)."},"changed":{"type":"array","items":{"type":"string"},"description":"Content slabs that were actually written, e.g. [\"title\",\"occupancy\",\"amenities\"]."},"deferred":{"type":"array","items":{"type":"string"},"description":"Provided-but-not-applied fields — e.g. \"photos\" when a non-empty photos array carried no valid http(s) URL."}}},"ListingGenerateContentRequest":{"type":"object","properties":{"photos":{"type":"array","items":{"type":"string","format":"uri"},"description":"Up to 8 reference photos. When present, Repull AI vision is used for grounded copy.","maxItems":8},"style":{"type":"string","enum":["warm","professional","concise"],"default":"warm"},"persist":{"type":"boolean","default":true,"description":"Save the generated content to the listing (so subsequent publishes pick it up)."}}},"ListingContent":{"type":"object","description":"Rich multilingual content slab for a listing — guest-facing copy sourced from `listings_descriptions` (the `en` row when surfaced via `?include=content`). Also returned as the AI-generated payload from `POST /v1/listings/{id}/generate-content` (where `title` and `amenities` are populated). All fields are individually nullable.","properties":{"title":{"type":"string","maxLength":50,"nullable":true,"description":"Public listing title. Populated only by `generate-content`; not stored on `listings_descriptions`."},"summary":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"space":{"type":"string","nullable":true},"guestAccess":{"type":"string","nullable":true},"neighborhoodOverview":{"type":"string","nullable":true},"gettingAround":{"type":"string","nullable":true,"description":"Free-text directions for getting to + around the property (e.g. \"Take Highway 95 north for 12 miles\")."},"transit":{"type":"string","nullable":true},"houseRules":{"type":"string","nullable":true},"additionalRules":{"nullable":true,"description":"Structured supplementary rules (JSON; shape evolves with the listings_descriptions schema)."},"notes":{"type":"string","nullable":true},"interactionWithGuests":{"type":"string","nullable":true,"description":"Host’s description of how they engage with guests (e.g. \"Self check-in, available via message\")."},"amenities":{"type":"array","items":{"type":"string"},"description":"Free-text amenity strings. Populated only by `generate-content`; the `?include=amenities` expansion returns the structured `ListingAmenity[]` instead."}}},"ListingDetails":{"type":"object","description":"Structural detail slab for a listing — bedrooms/bathrooms/beds, person capacity, check-in window, wifi credentials, house manual, directions. Sourced from `listings_details` (one row per listing). Surfaced on `GET /v1/listings/{id}` and `GET /v1/listings` only when the caller passes `?include=details`. All fields are individually nullable.","properties":{"propertyType":{"type":"string","nullable":true,"description":"Specific property type (e.g. `apartment`, `townhouse`, `cabin`)."},"propertyTypeCategory":{"type":"string","nullable":true,"description":"Coarser grouping above propertyType (e.g. `house`, `apartment`)."},"roomTypeCategory":{"type":"string","nullable":true,"description":"Sleeping arrangement (e.g. `entire_home`, `private_room`, `shared_room`)."},"bedrooms":{"type":"integer","nullable":true},"bathrooms":{"type":"string","nullable":true,"description":"Numeric value carried as a string to preserve fractional bathrooms (e.g. `\"1.5\"`)."},"beds":{"type":"integer","nullable":true},"personCapacity":{"type":"integer","nullable":true,"description":"Maximum guest capacity."},"checkInTimeStart":{"type":"string","nullable":true,"description":"Earliest check-in time, free-form (e.g. `\"15:00\"`, `\"3 PM\"`, `\"flexible\"`)."},"checkInTimeEnd":{"type":"string","nullable":true,"description":"Latest check-in time."},"checkOutTime":{"type":"string","nullable":true,"description":"Check-out time."},"minNights":{"type":"integer","nullable":true},"maxNights":{"type":"integer","nullable":true},"advanceBookingDays":{"type":"integer","nullable":true,"description":"How far in advance bookings are allowed."},"turnoverDays":{"type":"integer","nullable":true,"description":"Required gap (in days) between consecutive bookings."},"wifiNetwork":{"type":"string","nullable":true},"wifiPassword":{"type":"string","nullable":true},"houseManual":{"type":"string","nullable":true,"description":"Long-form house manual / welcome guide."},"directions":{"type":"string","nullable":true,"description":"Long-form arrival directions."},"propertySize":{"nullable":true,"description":"Structured size info (JSON; e.g. `{ \"value\": 65, \"unit\": \"sqm\" }`). Shape evolves with the listings_details schema."},"yearBuilt":{"type":"integer","nullable":true},"numberOfFloors":{"type":"integer","nullable":true,"description":"Total floors in the building."},"listingFloor":{"type":"integer","nullable":true,"description":"Which floor the listing is on."}}},"ListingGenerateContentResponse":{"type":"object","properties":{"listingId":{"type":"string"},"persisted":{"type":"boolean"},"content":{"$ref":"#/components/schemas/ListingContent"}}},"ListingPhotoUploadUrlRequest":{"type":"object","required":["fileName","fileType"],"properties":{"fileName":{"type":"string","description":"Original file name, e.g. \"living-room.jpg\".","example":"living-room.jpg"},"fileType":{"type":"string","description":"Image MIME type. Must start with \"image/\".","example":"image/jpeg"},"fileSize":{"type":"integer","nullable":true,"description":"File size in bytes, when known. Must be positive if provided."}}},"ListingPhotoUploadUrlResponse":{"type":"object","description":"A short-lived signed upload target. PUT the raw file bytes to `uploadUrl` — the bytes never pass through the Repull API.","properties":{"listingId":{"type":"string"},"uploadUrl":{"type":"string","format":"uri","description":"PUT the raw file bytes here directly from the client. Not a Repull or vanio API endpoint — a signed storage URL."},"token":{"type":"string","description":"Opaque upload token bound to this signed URL."},"path":{"type":"string","description":"Storage path the photo will live at once uploaded. Pass this to `DELETE /v1/listings/{id}/photos` to remove it later."},"publicUrl":{"type":"string","format":"uri","description":"Durable public URL for the photo once the upload completes. Attach it to the listing via `PUT /v1/listings/{id}/content` (`photos` field)."},"expiresIn":{"type":"integer","description":"Seconds until `uploadUrl` expires. Mint a new one via a fresh POST if the upload did not happen in time."}}},"ListingPhoto":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Public URL for the photo."},"path":{"type":"string","description":"Storage path — pass to `DELETE /v1/listings/{id}/photos` to remove this photo."},"size":{"type":"integer","description":"File size in bytes."},"createdAt":{"type":"string","format":"date-time"}}},"ListingPhotosResponse":{"type":"object","properties":{"listingId":{"type":"string"},"photos":{"type":"array","items":{"$ref":"#/components/schemas/ListingPhoto"}}}},"ListingPhotoDeleteRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Storage path of the photo to delete, as returned by `GET /v1/listings/{id}/photos` or the `upload-url` response."}}},"ListingPhotoDeleteResponse":{"type":"object","properties":{"listingId":{"type":"string"},"deleted":{"type":"boolean"}}},"ListingPublishAirbnbRequest":{"type":"object","description":"Pass either `airbnbConnectionId` (update an already-mapped listing) or `hostId` (create a brand-new Airbnb listing under that host).","properties":{"airbnbConnectionId":{"type":"string","description":"Existing Airbnb connection row id"},"hostId":{"type":"string","description":"Airbnb host id (required for first-time creates)"},"force":{"type":"boolean","default":false,"description":"Re-push every section, ignoring dirty-fields tracking"}}},"ListingPublishResponse":{"type":"object","properties":{"listingId":{"type":"string"},"channel":{"type":"string","enum":["airbnb","booking"]},"result":{"type":"object","description":"Channel-specific push result (sections pushed, errors, etc.)"}}},"ListingPublishStatusChannel":{"type":"object","properties":{"platform":{"type":"string","example":"airbnb"},"pushStatus":{"type":"string","enum":["idle","pushing","success","error"],"nullable":true},"lastPushedAt":{"type":"string","format":"date-time","nullable":true},"lastPulledAt":{"type":"string","format":"date-time","nullable":true},"dirtyFields":{"type":"array","items":{"type":"string"}},"platformHasChanges":{"type":"boolean"}}},"ListingPublishStatusConnection":{"type":"object","description":"Per-channel connection state. Distinct from `channels` (sync activity) — a listing can be connected here yet have empty `channels` if it has never been pushed.","properties":{"channel":{"type":"string","example":"airbnb","description":"Channel name: airbnb, booking, vrbo, etc."},"connected":{"type":"boolean","description":"True when the link is active (not disconnected/suspended)."},"syncEnabled":{"type":"boolean","description":"True when sync writes are enabled for this channel."},"since":{"type":"string","format":"date-time","nullable":true,"description":"ISO timestamp the connection was first established."}}},"ListingPublishStatusResponse":{"type":"object","properties":{"listingId":{"type":"string"},"channels":{"type":"array","description":"Sync activity per channel — empty if the listing has never been pushed/pulled. Empty does NOT mean \"not connected\"; check `connections` for that.","items":{"$ref":"#/components/schemas/ListingPublishStatusChannel"}},"connections":{"type":"array","description":"Connection state per channel. Populated even when `channels` is empty so callers can distinguish \"owned, never pushed\" from \"owned, never connected\".","items":{"$ref":"#/components/schemas/ListingPublishStatusConnection"}}}},"ListingChannel":{"type":"object","description":"Per-platform connection for a listing — one row per channel the listing is published to.","properties":{"platform":{"type":"string","example":"airbnb"},"externalId":{"type":"string","description":"ID in the platform (Airbnb listing id, Booking room id, etc.)"},"active":{"type":"boolean"},"syncEnabled":{"type":"boolean"}}},"ListingAmenity":{"type":"object","description":"A single amenity row from the unified `listings_amenities` table. Surfaced on `GET /v1/listings/{id}` and `GET /v1/properties/{id}` only when the caller passes `?include=amenities`.","properties":{"amenityKey":{"type":"string","description":"Canonical amenity key (e.g. `wifi`, `pool`, `parking`).","example":"wifi"},"category":{"type":"string","nullable":true,"description":"Optional grouping (e.g. `essentials`, `safety`)."},"isPresent":{"type":"boolean","description":"`true` when the listing has this amenity, `false` when it has been explicitly opted out."},"instruction":{"type":"string","nullable":true,"description":"Optional free-form instruction for the guest (e.g. WiFi password, parking notes)."}},"required":["amenityKey","isPresent"]},"Listing":{"type":"object","description":"A vacation rental listing in your Repull workspace.","properties":{"id":{"type":"string","description":"Repull listing id"},"name":{"type":"string","example":"I - Stafford Apartment"},"address":{"type":"object","properties":{"street":{"type":"string","nullable":true},"city":{"type":"string","nullable":true}}},"thumbnailUrl":{"type":"string","format":"uri","nullable":true},"status":{"type":"string","enum":["active","inactive","archived"]},"channels":{"type":"array","items":{"$ref":"#/components/schemas/ListingChannel"},"description":"Channels (Airbnb, Booking, VRBO, etc.) the listing is connected to."},"amenities":{"type":"array","items":{"$ref":"#/components/schemas/ListingAmenity"},"description":"Amenity rows for the listing. **Only present when the caller passes `?include=amenities`.** Empty array (`[]`) when the listing has no amenity rows."},"content":{"allOf":[{"$ref":"#/components/schemas/ListingContent"}],"nullable":true,"description":"**Only present when the caller passes `?include=content`.** Sourced from `listings_descriptions` for the `en` locale. `null` when the listing has no description row stored (vs the field being absent — that signals the caller did not opt into the expansion)."},"details":{"allOf":[{"$ref":"#/components/schemas/ListingDetails"}],"nullable":true,"description":"**Only present when the caller passes `?include=details`.** Sourced from `listings_details`. `null` when the listing has no details row stored (vs the field being absent — that signals the caller did not opt into the expansion)."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"CursorPagination":{"$ref":"#/components/schemas/Pagination"},"ListingListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Listing"}},"pagination":{"$ref":"#/components/schemas/CursorPagination"}}},"ListingPricingRecommendation":{"type":"object","description":"A pricing recommendation for one date in the listing's calendar window.","properties":{"date":{"type":"string","format":"date","example":"2026-05-14"},"currentPrice":{"type":"number","nullable":true,"description":"Current calendar price (from Vanio listings_calendar_days) before applying the recommendation."},"recommendedPrice":{"type":"number","description":"Atlas model's recommended price."},"minPrice":{"type":"number","nullable":true},"maxPrice":{"type":"number","nullable":true},"currency":{"type":"string","example":"USD"},"confidence":{"type":"number","description":"Model confidence in [0, 1]."},"bookingProbability":{"type":"number","nullable":true,"description":"Expected booking probability for the date at the recommended price."},"expectedRevenue":{"type":"number","nullable":true},"factors":{"type":"object","additionalProperties":true,"description":"Free-form JSON of model factors (comp distance, event boost, weekend, demand, etc.)."},"status":{"type":"string","enum":["pending","applied","declined"],"description":"Lifecycle state."},"modelVersion":{"type":"string"},"generatedAt":{"type":"string","format":"date-time"}}},"ListingPricingResponse":{"type":"object","properties":{"listingId":{"type":"string"},"dateRange":{"type":"object","properties":{"start":{"type":"string","format":"date"},"end":{"type":"string","format":"date"}}},"recommendations":{"type":"array","items":{"$ref":"#/components/schemas/ListingPricingRecommendation"}},"listing":{"type":"object","nullable":true,"description":"AI-derived base-price context for the listing.","properties":{"aiBasePrice":{"type":"number","nullable":true},"aiBasePriceFactors":{"type":"object","additionalProperties":true,"nullable":true},"qualityTier":{"type":"string","nullable":true},"segment":{"type":"string","nullable":true},"currency":{"type":"string","nullable":true}}},"compSummary":{"type":"object","nullable":true,"description":"5km comp aggregate (Atlas comp_listings).","properties":{"count":{"type":"integer"},"avgPrice":{"type":"number","nullable":true},"minPrice":{"type":"number","nullable":true},"maxPrice":{"type":"number","nullable":true}}}}},"ListingPricingApplyRequest":{"type":"object","required":["dates","action"],"properties":{"dates":{"type":"array","items":{"type":"string","format":"date"},"description":"Dates the action applies to. Must match dates that have a `pending` recommendation; others are silently skipped."},"action":{"type":"string","enum":["apply","decline"]}}},"ListingPricingApplyResponse":{"type":"object","properties":{"ok":{"type":"boolean"},"applied":{"type":"integer","nullable":true,"description":"Number of recommendations applied (apply action only)."},"declined":{"type":"integer","nullable":true,"description":"Number of dates declined (decline action only)."}}},"ListingPricingStrategy":{"type":"object","description":"Strategy that constrains the Atlas pricing model for one listing.","properties":{"id":{"type":"string","nullable":true},"listingId":{"type":"string"},"customerId":{"type":"string"},"mode":{"type":"string","enum":["recommend","auto"],"description":"`recommend` surfaces suggestions; `auto` applies them on the next sync."},"minPrice":{"type":"number","nullable":true},"maxPrice":{"type":"number","nullable":true},"maxDailyChangePct":{"type":"number","description":"Max day-over-day swing in %.","default":15},"weekendMarkupPct":{"type":"number","nullable":true,"description":"% bump applied on Fri/Sat."},"dayOfWeekMultipliers":{"type":"object","additionalProperties":{"type":"number"},"description":"Multiplier per ISO weekday key (0..6)."},"targetOccupancyPct":{"type":"number","nullable":true},"targetMonthlyRevenue":{"type":"number","nullable":true},"ownerMinMonthlyPayout":{"type":"number","nullable":true},"compPositionTarget":{"type":"string","enum":["below","match","above"],"default":"match"},"compAdjustPct":{"type":"number","description":"Extra adjustment vs comp median (-30..+30).","default":0},"eventBoostEnabled":{"type":"boolean","default":true},"eventBoostMaxPct":{"type":"number","default":30},"isDefault":{"type":"boolean","description":"true when no row exists yet and the response is server-side defaults."}}},"ListingPricingStrategyInput":{"type":"object","description":"Same shape as `ListingPricingStrategy` minus the read-only fields. Send only fields you want to change.","properties":{"mode":{"type":"string","enum":["recommend","auto"]},"minPrice":{"type":"number","nullable":true},"maxPrice":{"type":"number","nullable":true},"maxDailyChangePct":{"type":"number"},"weekendMarkupPct":{"type":"number","nullable":true},"dayOfWeekMultipliers":{"type":"object","additionalProperties":{"type":"number"}},"targetOccupancyPct":{"type":"number","nullable":true},"targetMonthlyRevenue":{"type":"number","nullable":true},"ownerMinMonthlyPayout":{"type":"number","nullable":true},"compPositionTarget":{"type":"string","enum":["below","match","above"]},"compAdjustPct":{"type":"number"},"eventBoostEnabled":{"type":"boolean"},"eventBoostMaxPct":{"type":"number"}}},"BulkPricingItem":{"type":"object","required":["listingId","dates"],"description":"A single (listingId, dates) pair in a bulk pricing request. The action in the parent request body applies to every date in `dates` for this listing.","properties":{"listingId":{"type":"string","example":"4118"},"dates":{"type":"array","items":{"type":"string","format":"date"},"minItems":1,"example":["2026-05-14","2026-05-15"]}}},"BulkPricingRequest":{"type":"object","required":["action","items"],"description":"Body for `POST /v1/listings/pricing/bulk`. Apply or decline pending Atlas pricing recommendations across many listings in one call. Capped at 500 items per request — exceeding returns 422.","properties":{"action":{"type":"string","enum":["apply","decline"],"description":"`apply` writes the recommended price to each listing's calendar and fans out to channels (Airbnb/Booking/VRBO). `decline` marks the recommendations as `declined` so they stop surfacing."},"items":{"type":"array","minItems":1,"maxItems":500,"items":{"$ref":"#/components/schemas/BulkPricingItem"}}}},"BulkPricingFailure":{"type":"object","description":"Per-item failure entry. Per-item failures DO NOT fail the whole batch — partial-success is the norm at this scale.","properties":{"listingId":{"type":"string"},"dates":{"type":"array","items":{"type":"string","format":"date"}},"errorCode":{"type":"string","enum":["not_owned","no_pending_recommendations","calendar_update_failed","internal_error"],"example":"not_owned"},"error":{"type":"string"}}},"BulkPricingResponse":{"type":"object","description":"Response for `POST /v1/listings/pricing/bulk`. Per-item failures are returned granularly so the SDK consumer can retry just the bad entries.","properties":{"processed":{"type":"integer","description":"Total dates attempted across every item."},"succeeded":{"type":"integer","description":"Total dates that were successfully applied (or declined)."},"failed":{"type":"array","items":{"$ref":"#/components/schemas/BulkPricingFailure"}}}},"ListingPricingHistoryEntry":{"type":"object","description":"One date in the recommendation-vs-applied audit trail.","properties":{"date":{"type":"string","format":"date"},"recommendedRate":{"type":"number","description":"The Atlas model's recommended price for the date."},"appliedRate":{"type":"number","nullable":true,"description":"Price actually written to the calendar. `null` when status is `pending` or `declined`. For now, when `status=applied` this equals `recommended_rate` because the apply path writes the recommendation verbatim."},"status":{"type":"string","enum":["pending","applied","declined","overridden"],"description":"`overridden` is reserved for a future signal — it never appears today."},"recommendationFactors":{"type":"object","additionalProperties":true,"description":"Raw model factors (comp distance, event boost, weekend, demand, etc.)."},"appliedAt":{"type":"string","format":"date-time","nullable":true},"appliedBy":{"type":"string","nullable":true,"description":"Who applied it (e.g. `auto`, `api`, `user`)."}}},"ListingPricingHistoryResponse":{"type":"object","description":"Cursor-paginated audit trail. Pagination is keyset on `date ASC` — stable across concurrent writes.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ListingPricingHistoryEntry"}},"pagination":{"$ref":"#/components/schemas/CursorPagination"}}},"ListingCompNightly":{"type":"object","properties":{"date":{"type":"string","format":"date"},"rate":{"type":"number","nullable":true},"available":{"type":"boolean"}}},"ListingComp":{"type":"object","description":"A single competitor listing in a comp set, sorted closest-first. Includes per-day rate/availability for the requested calendar window.","properties":{"compId":{"type":"string"},"listingName":{"type":"string","nullable":true},"distanceKm":{"type":"number","nullable":true,"description":"Haversine distance from the source listing in km, rounded to 3 decimals."},"bedrooms":{"type":"integer","nullable":true},"maxGuests":{"type":"integer","nullable":true},"ratings":{"type":"object","properties":{"avg":{"type":"number","nullable":true},"count":{"type":"integer"}}},"currency":{"type":"string","nullable":true},"currentNightlyRate":{"type":"number","nullable":true,"description":"Latest snapshot ADR — fallback to render when the calendar window is empty."},"nightly":{"type":"array","items":{"$ref":"#/components/schemas/ListingCompNightly"},"description":"Per-day rate + availability for the requested window. May be empty if Atlas hasn't snapshotted the comp recently."},"lat":{"type":"number","nullable":true},"lng":{"type":"number","nullable":true},"platform":{"type":"string","nullable":true,"example":"airbnb"},"externalUrl":{"type":"string","format":"uri","nullable":true,"description":"Link to the listing on its source platform when one is available."}}},"ListingCompsResponse":{"type":"object","description":"Returned by `GET /v1/listings/{id}/comps`. Comps without coordinates are excluded — there's no way to rank them by distance. May include a `warning` field when the source listing itself has no coordinates.","properties":{"listingId":{"type":"string"},"dateRange":{"type":"object","properties":{"start":{"type":"string","format":"date"},"end":{"type":"string","format":"date"}}},"radiusKm":{"type":"number"},"total":{"type":"integer"},"data":{"type":"array","items":{"$ref":"#/components/schemas/ListingComp"}},"warning":{"type":"string","nullable":true,"description":"Present (and `data` empty) when the source listing has no coordinates."}}},"ListingSegment":{"type":"object","description":"One Atlas DNA segment (e.g. `upscale-modern-2br`) with share + ADR aggregates across the scoped comp set or market.","properties":{"name":{"type":"string","example":"upscale-modern-2br"},"sharePct":{"type":"number","description":"Percent of analyzed comps in the scope that fall in this segment."},"sampleSize":{"type":"integer"},"avgAdrInSegment":{"type":"number","nullable":true},"currency":{"type":"string","nullable":true},"qualityTier":{"type":"string","nullable":true,"enum":["budget","standard","upscale","luxury",null]},"designStyle":{"type":"string","nullable":true,"description":"Decomposed style token (e.g. `modern`, `mid-century`)."},"bedrooms":{"type":"integer","nullable":true,"description":"Decomposed bedroom count. `0` indicates studio."},"myListingMatch":{"type":"boolean","description":"True when the source listing's `ai_segment` matches this segment."}}},"ListingQualityTier":{"type":"object","properties":{"tier":{"type":"string","enum":["budget","standard","upscale","luxury"]},"sharePct":{"type":"number"},"avgAdr":{"type":"number","nullable":true},"sampleSize":{"type":"integer"}}},"ListingSegmentRecommendation":{"type":"object","description":"Structural observation about the segment landscape — not LLM-generated.","properties":{"kind":{"type":"string","enum":["low_dna_coverage","self_not_scored","my_segment","tier_uplift","segment_not_found","no_market_signal","no_geo"],"description":"Stable identifier for the recommendation kind. SDKs can switch on this safely."},"message":{"type":"string"},"evidence":{"type":"object","additionalProperties":true,"nullable":true}}},"ListingSegmentsResponse":{"type":"object","description":"Returned by `GET /v1/listings/{id}/segments`. Honest about DNA coverage — when no comps in the scope have been DNA-scored, returns `totalCompsAnalyzed: 0` plus a `low_dna_coverage` recommendation rather than fabricated data.","properties":{"listingId":{"type":"string"},"level":{"type":"string","enum":["comp_set","market"]},"scope":{"type":"object","description":"When `level=comp_set` carries `radiusKm`; when `level=market` carries `city`. May be empty when neither could be resolved.","properties":{"radiusKm":{"type":"number","nullable":true},"city":{"type":"string","nullable":true}}},"mySegment":{"type":"string","nullable":true,"description":"The source listing's own `ai_segment` (or null if not yet scored)."},"myQualityTier":{"type":"string","nullable":true,"enum":["budget","standard","upscale","luxury",null]},"totalCompsAnalyzed":{"type":"integer","description":"Number of comps in scope that have a DNA score. `0` is a coverage signal, not an error."},"segments":{"type":"array","items":{"$ref":"#/components/schemas/ListingSegment"}},"qualityTiers":{"type":"array","items":{"$ref":"#/components/schemas/ListingQualityTier"}},"recommendations":{"type":"array","items":{"$ref":"#/components/schemas/ListingSegmentRecommendation"}}}},"BookingPricingRateUpdateRestrictions":{"type":"object","description":"Optional length-of-stay / availability restrictions for one rate update. Every field here is forwarded verbatim into Booking.com's rates XML (`minimumstay`, `maximumstay`, `closedonarrival`, `closedondeparture`, …) — omit a field to leave that restriction untouched.","properties":{"minStay":{"type":"integer","nullable":true,"description":"Minimum length of stay (`minimumstay`)."},"maxStay":{"type":"integer","nullable":true,"description":"Maximum length of stay (`maximumstay`)."},"closedToArrival":{"type":"boolean","nullable":true,"description":"Closed-to-arrival — guests may not check in on the affected dates (`closedonarrival`)."},"closedToDeparture":{"type":"boolean","nullable":true,"description":"Closed-to-departure — guests may not check out on the affected dates (`closedondeparture`)."},"minStayArrival":{"type":"integer","nullable":true,"description":"Arrival-based minimum length of stay (`minimumstay_arrival`)."},"maxStayArrival":{"type":"integer","nullable":true,"description":"Arrival-based maximum length of stay (`maximumstay_arrival`)."},"exactStayArrival":{"type":"integer","nullable":true,"description":"Arrival-based exact length of stay (`exactstay_arrival`)."},"minAdvanceRes":{"type":"string","nullable":true,"description":"Minimum advance-reservation window, format `XDY` (X days Y hours) — `min_advance_res`."},"maxAdvanceRes":{"type":"string","nullable":true,"description":"Maximum advance-reservation window, format `XDY` (X days Y hours) — `max_advance_res`."}}},"BookingPricingRateUpdate":{"type":"object","required":["roomId","rateId","dateRange","price","currency"],"description":"A single (room, rate-plan, date-range) update pushed to Booking.com via the rates API.","properties":{"roomId":{"type":"string","description":"Booking.com room ID for the rate plan. Comes from `listings_booking_rooms` mapping."},"rateId":{"type":"string","description":"Booking.com rate-plan ID."},"dateRange":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","format":"date"},"end":{"type":"string","format":"date"}}},"price":{"type":"number"},"currency":{"type":"string","example":"USD"},"singlePrice":{"type":"number","nullable":true},"occupancy":{"type":"integer","nullable":true},"roomsToSell":{"type":"integer","nullable":true,"description":"Rooms to sell for the date range. Set to `0` to stop-sell this room/rate on the rates endpoint (Booking's dedicated `<closed>` stop-sell flag lives on the availability endpoint — see `BookingAvailabilityUpdate.closed`)."},"restrictions":{"$ref":"#/components/schemas/BookingPricingRateUpdateRestrictions"}}},"BookingPricingUpdateRequest":{"type":"object","required":["updates"],"description":"Body for `PUT /v1/channels/booking/listings/{id}/pricing`. Pricing on Booking is per-room/per-rate-plan, so `room_id` + `rate_id` are required on every update.","properties":{"updates":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/BookingPricingRateUpdate"}}}},"BookingPricingUpdateResponse":{"type":"object","properties":{"hotelId":{"type":"string"},"listingId":{"type":"string"},"pushed":{"type":"integer","description":"Number of updates Booking.com accepted as `success`. Falls back to total update count when Booking omits per-update status on full success."},"requested":{"type":"integer"},"errors":{"type":"array","items":{"type":"object","additionalProperties":true},"description":"Per-update failure rows from Booking — shape mirrors the Booking rates API response."},"raw":{"type":"object","additionalProperties":true,"description":"Verbatim Booking response envelope for debugging."}}},"BookingPricingResponse":{"type":"object","description":"Returned by `GET /v1/channels/booking/listings/{id}/pricing`. Mirrors Booking's `getRoomRateAvailability` response with `hotelId` and `listingId` echoed back for SDK consumers.","properties":{"hotelId":{"type":"string"},"listingId":{"type":"string"}},"additionalProperties":true},"BookingRoomsRatesResponse":{"type":"object","description":"Returned by `GET /v1/channels/booking/properties/{id}/rooms`. Exposes the Booking.com room + rate-plan mapping ids for a listing so a caller can assemble a `PUT /v1/channels/booking/availability` restriction write (which requires `roomId` + `rateId` on every update). Sourced from Booking's B.XML roomrates feed.","properties":{"hotel_id":{"type":"string","description":"Booking.com hotel/property id the rooms belong to."},"listing_id":{"type":"integer","description":"Vanio listing id echoed back."},"rooms":{"type":"array","items":{"type":"object","properties":{"roomId":{"type":"string","nullable":true,"description":"Booking.com room id — use as `roomId` in an ARI update."},"roomName":{"type":"string","nullable":true},"rates":{"type":"array","items":{"type":"object","properties":{"rateId":{"type":"string","nullable":true,"description":"Booking.com rate-plan id — use as `rateId` in an ARI update."},"rateName":{"type":"string","nullable":true},"policy":{"type":"string","nullable":true,"description":"Cancellation policy name."},"policyId":{"type":"string","nullable":true},"maxPersons":{"type":"integer","nullable":true},"pricingType":{"type":"string","nullable":true,"description":"Pricing model: `Standard`, `RLO`, `OBP`, or `LOS`."},"isChildRate":{"type":"boolean","nullable":true,"description":"Whether this rate plan is a derived child rate."}}}}}}}}},"BookingAvailabilityStateResponse":{"type":"object","description":"Returned by `GET /v1/channels/booking/availability`. Proxies Booking's `getRoomRateAvailability` — current rate / availability / restriction state per room + rate plan for the requested window, keyed by `property_id`. Field set is whatever Booking.com emits (parsed from their XML), so the object is open-ended.","properties":{"property_id":{"type":"string","description":"Booking.com hotel/property id echoed back."}},"additionalProperties":true},"BookingAvailabilityUpdate":{"type":"object","required":["roomId","rateId","dateRange","availableRooms"],"description":"One (room, rate-plan, date-range) availability update. Carries inventory (`availableRooms`), the dedicated stop-sell flag (`closed`), and the same length-of-stay / arrival restrictions as a rate update.","properties":{"roomId":{"type":"string","description":"Booking.com room id."},"rateId":{"type":"string","description":"Booking.com rate-plan id."},"dateRange":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","format":"date"},"end":{"type":"string","format":"date"}}},"availableRooms":{"type":"integer","description":"Rooms to sell (`roomstosell`). `0` blocks the room for the range."},"status":{"type":"string","enum":["available","unavailable","on_request"],"nullable":true},"closed":{"type":"boolean","nullable":true,"description":"Dedicated stop-sell flag (`<closed>` in Booking's XML). `true` fully stops sale for the room/date-range regardless of `availableRooms`."},"restrictions":{"$ref":"#/components/schemas/BookingPricingRateUpdateRestrictions"}}},"BookingAvailabilityUpdateRequest":{"type":"object","required":["type","property_id","updates"],"description":"Body for `PUT /v1/channels/booking/availability`. Selects one of Booking's three ARI write paths via `type` and forwards `updates` verbatim to the connector.","properties":{"type":{"type":"string","enum":["rates","availability","derived-pricing"],"description":"`rates` → price + restrictions (`updateRates`); `availability` → inventory + stop-sell + restrictions (`updateAvailability`); `derived-pricing` → occupancy-derived pricing rules (`updateDerivedPricing`)."},"property_id":{"oneOf":[{"type":"integer"},{"type":"string"}],"description":"Booking.com hotel/property id (numeric; accepted as int or numeric string)."},"updates":{"type":"array","minItems":1,"description":"For `type: \"rates\"` each item is a `BookingPricingRateUpdate`; for `type: \"availability\"` a `BookingAvailabilityUpdate`; for `type: \"derived-pricing\"` a derived-price rule set.","items":{"oneOf":[{"$ref":"#/components/schemas/BookingPricingRateUpdate"},{"$ref":"#/components/schemas/BookingAvailabilityUpdate"}]}}}},"AirbnbCalendarOperation":{"type":"object","description":"One calendar operation. Supply either `start_date` + `end_date` OR a `dates` array. Every restriction here is forwarded verbatim to Airbnb's batch calendar API.","properties":{"start_date":{"type":"string","format":"date","nullable":true,"description":"Inclusive range start (pair with `end_date`)."},"end_date":{"type":"string","format":"date","nullable":true,"description":"Inclusive range end (pair with `start_date`)."},"dates":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Explicit date or `start:end` range strings, as an alternative to `start_date`/`end_date`."},"daily_price":{"type":"number","nullable":true,"description":"Nightly price override."},"availability":{"type":"string","enum":["available","unavailable","default"],"nullable":true,"description":"Stop-sell is expressed here: `unavailable` blocks the date(s); `available` re-opens; `default` reverts to rule-based availability."},"min_nights":{"type":"integer","nullable":true,"description":"Minimum length of stay for the date(s)."},"max_nights":{"type":"integer","nullable":true,"description":"Maximum length of stay for the date(s)."},"closed_to_arrival":{"type":"boolean","nullable":true,"description":"Closed-to-arrival — no check-ins on the affected date(s)."},"closed_to_departure":{"type":"boolean","nullable":true,"description":"Closed-to-departure — no check-outs on the affected date(s)."},"notes":{"type":"string","nullable":true}}},"AirbnbAvailabilityWriteRequest":{"type":"object","required":["type"],"description":"Body for `PUT /v1/channels/airbnb/listings/{id}/availability`. `type: \"calendar\"` carries per-date restrictions (min/max nights, closed-to-arrival/departure, stop-sell); `type: \"rules\"` carries listing-level availability rules (default min/max nights, booking lead time, turnover days).","properties":{"type":{"type":"string","enum":["rules","calendar"]},"operations":{"type":"array","description":"Required when `type: \"calendar\"`. Batch of per-date restriction operations.","items":{"$ref":"#/components/schemas/AirbnbCalendarOperation"}},"rules":{"type":"object","additionalProperties":true,"description":"Required when `type: \"rules\"`. Airbnb availability-rules object — `default_min_nights`, `default_max_nights`, `booking_lead_time`, `turnover_days`, `day_of_week_min_nights`, `seasonal_min_nights`, etc."}}},"AirbnbPricingWriteRequest":{"type":"object","required":["type"],"description":"Body for `PUT /v1/channels/airbnb/listings/{id}/pricing`. The `type` discriminator selects the pricing sub-resource. `type: \"calendar\"` shares the same per-date restriction shape as the availability endpoint (min/max nights, closed-to-arrival/departure, stop-sell via `availability: \"unavailable\"`).","properties":{"type":{"type":"string","enum":["model","standard","los","rate-plan","fees","currency","rule","calendar"]},"operations":{"type":"array","description":"Required when `type: \"calendar\"`. Batch of per-date price + restriction operations.","items":{"$ref":"#/components/schemas/AirbnbCalendarOperation"}},"modelType":{"type":"string","nullable":true,"description":"Required when `type: \"model\"` — the pricing-availability model to switch the listing to."},"settings":{"type":"object","additionalProperties":true,"nullable":true,"description":"Required for `type: \"standard\" | \"rate-plan\" | \"fees\"` — the pricing-settings object to PUT."},"records":{"type":"array","items":{"type":"object","additionalProperties":true},"nullable":true,"description":"Required for `type: \"los\"` — length-of-stay records."},"currency":{"type":"string","nullable":true,"description":"Required for `type: \"currency\"` — ISO 4217 code."},"rule":{"type":"object","additionalProperties":true,"nullable":true,"description":"Required for `type: \"rule\"` — a single pricing rule appended to the listing."}}},"MarketSummary":{"type":"object","description":"Per-city KPIs combining the customer's own listings with Atlas comp aggregates.","properties":{"city":{"type":"string","example":"Radium Hot Springs"},"myListings":{"type":"integer"},"totalListings":{"type":"integer","description":"Atlas-tracked active comps in this city."},"marketSharePct":{"type":"integer","nullable":true},"myAvgAdr":{"type":"number","nullable":true},"marketAvgAdr":{"type":"number","nullable":true},"priceDiffPct":{"type":"integer","nullable":true,"description":"(myAvgAdr - marketAvgAdr) / marketAvgAdr * 100, rounded."},"myAvgRating":{"type":"number","nullable":true},"marketAvgRating":{"type":"number","nullable":true},"myOccupancyPct":{"type":"number","nullable":true,"description":"Customer's 30-day forward occupancy %."},"marketOccupancyPct":{"type":"number","nullable":true},"propertyTypes":{"type":"integer","description":"Distinct property types Atlas has seen in this city."}}},"MarketMyListing":{"type":"object","description":"Lightweight customer listing entry for map rendering.","properties":{"id":{"type":"string"},"name":{"type":"string"},"city":{"type":"string","nullable":true},"lat":{"type":"number"},"lng":{"type":"number"},"thumbnail":{"type":"string","format":"uri","nullable":true},"todayPrice":{"type":"integer","nullable":true,"description":"Pre-computed ADR rounded to integer."},"blocked":{"type":"boolean"},"bookedNights":{"type":"integer"},"availableNights":{"type":"integer"},"type":{"type":"string","enum":["mine"],"example":"mine"}}},"MarketBrowseFeatured":{"type":"object","description":"Curated featured market entry on the discovery summary. Enriched with `subscribed` so the dashboard can render \"Already unlocked\" pills without an extra round-trip.","properties":{"city":{"type":"string"},"country":{"type":"string","description":"ISO 3166-1 alpha-2."},"listings":{"type":"integer","description":"Atlas-tracked active comps in this city."},"avgAdr":{"type":"number","nullable":true,"description":"Atlas-aggregated avg nightly rate (mixed currency, dominated by the country base)."},"subscribed":{"type":"boolean"}}},"MarketBrowseCategory":{"type":"object","properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2."},"count":{"type":"integer","description":"Number of Atlas-tracked cities in this country."}}},"MarketBrowseEntry":{"type":"object","description":"Discovery catalog entry returned by `/v1/markets/browse`.","properties":{"city":{"type":"string"},"country":{"type":"string","description":"ISO 3166-1 alpha-2."},"listings":{"type":"integer","description":"Atlas-tracked active comps in this city."},"avgAdr":{"type":"number","nullable":true},"currency":{"type":"string","description":"ISO 4217 currency derived from the country code."},"subscribed":{"type":"boolean"}}},"MarketBrowseResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MarketBrowseEntry"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}},"MarketsOverviewResponse":{"type":"object","description":"Overview of every market the customer operates in, plus auxiliary discovery slices. Wraps the canonical `{ data, pagination }` envelope around the per-city KPI list (`data`) so SDK consumers see the same shape they get from every other list endpoint. Auxiliary fields (`totals`, `myListings`, `browse`, `freeMarket`, `subscriptions`, `tier`) are returned as siblings because they are NOT paginated. The overview returns every market in one shot — `nextCursor` is always `null` and `hasMore` is always `false`.","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MarketSummary"},"description":"Per-city KPIs for every market the customer operates in."},"pagination":{"$ref":"#/components/schemas/Pagination"},"totals":{"type":"object","properties":{"myListings":{"type":"integer"},"markets":{"type":"integer"},"totalCompetitors":{"type":"integer"}}},"myListings":{"type":"array","items":{"$ref":"#/components/schemas/MarketMyListing"}},"freeMarket":{"type":"string","nullable":true,"description":"City auto-assigned as the customer's free market (largest by listing count). Null for customers with no listings."},"subscriptions":{"type":"object","description":"Active per-market unlocks vs the tier quota.","properties":{"active":{"type":"integer"},"limit":{"type":"integer"}}},"tier":{"type":"string","description":"Resolved Repull tier (free / starter / custom)."},"browse":{"type":"object","description":"Lightweight discovery summary. Use `/v1/markets/browse` for the full paginated catalog.","properties":{"featured":{"type":"array","items":{"$ref":"#/components/schemas/MarketBrowseFeatured"},"description":"Top ~50 markets by listing volume the customer doesn't already operate in."},"categories":{"type":"array","items":{"$ref":"#/components/schemas/MarketBrowseCategory"},"description":"Top 50 countries by tracked-city count."},"totalAvailable":{"type":"integer","description":"Total Atlas-tracked cities in the catalog."}}}}},"MarketEvent":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"category":{"type":"string","nullable":true},"startDate":{"type":"string","format":"date"},"endDate":{"type":"string","format":"date","nullable":true},"lat":{"type":"number","nullable":true},"lng":{"type":"number","nullable":true},"rank":{"type":"number","nullable":true},"localRank":{"type":"number","nullable":true},"attendance":{"type":"integer","nullable":true},"demandImpact":{"type":"string","nullable":true},"labels":{"type":"array","items":{"type":"string"},"nullable":true}}},"MarketTopComp":{"type":"object","properties":{"id":{"type":"string"},"platformListingId":{"type":"string"},"title":{"type":"string","nullable":true},"propertyType":{"type":"string","nullable":true},"bedrooms":{"type":"integer","nullable":true},"maxGuests":{"type":"integer","nullable":true},"rating":{"type":"number","nullable":true},"reviewCount":{"type":"integer","nullable":true},"currentNightlyRate":{"type":"number","nullable":true},"thumbnailUrl":{"type":"string","format":"uri","nullable":true},"lat":{"type":"number","nullable":true},"lng":{"type":"number","nullable":true},"url":{"type":"string","format":"uri"},"distanceKm":{"type":"number","nullable":true}}},"MarketDetailResponse":{"type":"object","description":"Detailed view for a single city. Several sub-objects are passed through verbatim from upstream — keys mirror the underlying SQL aggregations.","properties":{"city":{"type":"string"},"priceDistribution":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"count":{"type":"integer"},"avg_rate":{"type":"integer","nullable":true},"min_rate":{"type":"integer","nullable":true},"max_rate":{"type":"integer","nullable":true}}}},"bedroomBreakdown":{"type":"array","items":{"type":"object","additionalProperties":true}},"propertyTypeMix":{"type":"array","items":{"type":"object","properties":{"property_type":{"type":"string"},"count":{"type":"integer"},"avg_rate":{"type":"integer","nullable":true}}}},"events":{"type":"array","items":{"$ref":"#/components/schemas/MarketEvent"}},"wheelhouseTrends":{"type":"array","items":{"type":"object","additionalProperties":true}},"benchmarks":{"type":"array","items":{"type":"object","additionalProperties":true}},"healthSummary":{"type":"object","additionalProperties":true,"nullable":true},"topComps":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/MarketTopComp"}},"mapItems":{"type":"array","items":{"$ref":"#/components/schemas/MarketTopComp"}},"total":{"type":"integer"},"page":{"type":"integer"},"pageSize":{"type":"integer"}}},"marketPosition":{"type":"object","nullable":true,"additionalProperties":true},"capacityGap":{"type":"object","additionalProperties":true},"supplyTrend":{"type":"array","items":{"type":"object","properties":{"month":{"type":"string","example":"2026-04"},"new_listings":{"type":"integer"}}}}}},"MarketCalendarDay":{"type":"object","properties":{"date":{"type":"string","format":"date"},"marketAvgRate":{"type":"number","nullable":true},"marketMinRate":{"type":"number","nullable":true},"marketMaxRate":{"type":"number","nullable":true},"pricedListings":{"type":"integer"},"occupancyPct":{"type":"number","nullable":true},"totalListings":{"type":"integer"},"wheelhouseOccupancy":{"type":"number","nullable":true},"wheelhouseAdr":{"type":"number","nullable":true},"events":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"category":{"type":"string","nullable":true},"rank":{"type":"number","nullable":true},"attendance":{"type":"integer","nullable":true}}}},"myPrice":{"type":"number","nullable":true,"description":"Only present when `listingId` is supplied."},"myAvailable":{"type":"boolean","description":"Only meaningful when `listingId` is supplied."}}},"MarketCalendarResponse":{"type":"object","properties":{"city":{"type":"string"},"dateRange":{"type":"object","properties":{"start":{"type":"string","format":"date"},"end":{"type":"string","format":"date"}}},"days":{"type":"array","items":{"$ref":"#/components/schemas/MarketCalendarDay"}},"events":{"type":"array","items":{"$ref":"#/components/schemas/MarketEvent"}}}},"BookingVerifyHotelRequest":{"type":"object","required":["sessionId","hotelId"],"description":"Body for `POST /v1/connect/booking/verify`. Manual-paste fallback that closes a Booking.com Connect session after the customer completes Stage 1 designation in the Extranet.","properties":{"sessionId":{"type":"string","description":"The Connect session ID returned by `createConnectSession`. Acts as the capability token — no API key required.","example":"cs_8gQrT2v9k3M4nLp7wJxYzAbCdEfGhIjKlMnOp"},"hotelId":{"type":"string","description":"Booking.com hotel ID the customer pasted. 6+ digits.","example":"12345678"}}},"BookingVerifyHotelResponse":{"type":"object","description":"Successful verify response. The session is transitioned to `awaiting_room_mapping` and a `pms_connections` row is upserted.","properties":{"valid":{"type":"boolean","example":true},"sessionId":{"type":"string"},"connectionId":{"type":"string","description":"Repull-side `pms_connections.id` for the linked Booking account."},"hotelId":{"type":"string"},"hotelName":{"type":"string","nullable":true},"hotelType":{"type":"string","nullable":true,"description":"Booking.com hotel/property type code (e.g. `apartment`, `hotel`)."},"country":{"type":"string","nullable":true},"city":{"type":"string","nullable":true}},"required":["valid","sessionId","connectionId","hotelId"]},"BookingConnectRoom":{"type":"object","description":"A Booking.com room imported from the claimed hotel. The customer maps each room to one of their Repull listings via `mapConnectBookingRooms`.","properties":{"roomId":{"type":"string","description":"Repull-side `listings_booking_rooms.id`. Pass this back in the mapping submission."},"roomName":{"type":"string","example":"Deluxe King"},"maxGuests":{"type":"integer","nullable":true},"numberOfRooms":{"type":"integer","description":"Number of inventory units of this room type at the hotel."},"currentListingId":{"type":"string","nullable":true,"description":"Currently mapped Repull listing ID, or null if not yet mapped."},"roomBookingId":{"type":"string","nullable":true,"description":"Booking.com-side room ID (used internally for `listing_platform_links`)."}},"required":["roomId","roomName","numberOfRooms"]},"BookingConnectListingOption":{"type":"object","description":"A Repull listing the customer can map a Booking room to. Mirrors the minimal shape needed for a select dropdown.","properties":{"id":{"type":"string"},"name":{"type":"string"},"city":{"type":"string","nullable":true}},"required":["id","name"]},"BookingConnectRoomsResponse":{"type":"object","description":"Returned by `GET /v1/connect/booking/rooms`. The hosted picker page polls this every ~2s while the room import runs server-side; once `status` is `ready` it renders the mapping UI.","properties":{"status":{"type":"string","enum":["importing","ready","completed"],"description":"`importing` — listings_booking row exists but rooms not yet imported. `ready` — rooms imported, awaiting mapping. `completed` — session already finished."},"sessionId":{"type":"string"},"hotelId":{"type":"string"},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/BookingConnectRoom"}},"listingOptions":{"type":"array","items":{"$ref":"#/components/schemas/BookingConnectListingOption"}}},"required":["status","sessionId","hotelId","rooms","listingOptions"]},"BookingRoomMapping":{"type":"object","required":["roomId"],"description":"A single room→listing assignment. Pass `listingId: null` to explicitly UNMAP a room (e.g. \"skip this room for now\") — this also removes the corresponding `listing_platform_links` row.","properties":{"roomId":{"type":"string","description":"Repull-side `listings_booking_rooms.id` from `listConnectBookingRooms`."},"listingId":{"type":"string","nullable":true,"description":"Repull listing to bind to this room. `null` to unmap."}}},"MapConnectBookingRoomsRequest":{"type":"object","required":["sessionId","mappings"],"description":"Body for `POST /v1/connect/booking/map-rooms`. Submits all room→listing assignments in one transaction; on success the Connect session is marked `completed`.","properties":{"sessionId":{"type":"string"},"mappings":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/BookingRoomMapping"}}}},"MapConnectBookingRoomsResponse":{"type":"object","properties":{"success":{"type":"boolean","example":true},"mapped":{"type":"integer","description":"Number of rooms processed (mapped + unmapped)."},"sessionId":{"type":"string"},"connectionId":{"type":"string"}},"required":["success","mapped","sessionId","connectionId"]},"MapAirbnbListingRequest":{"type":"object","required":["airbnbId","listingId"],"description":"Body for `POST /v1/channels/airbnb/listings/map`.","properties":{"airbnbId":{"type":"string","description":"The Airbnb listing id to map. Discover it via `GET /v1/channels/airbnb/listings`."},"listingId":{"type":"integer","description":"Canonical Repull listing id to link the Airbnb listing to. Must belong to your workspace. A numeric string is also accepted."},"hostId":{"type":"string","description":"Optional. When present, must match the Airbnb listing's host id — guards against mapping the wrong host's listing."},"syncEnabled":{"type":"boolean","default":true,"description":"Whether the resulting platform link has sync enabled."}}},"MapAirbnbListingResponse":{"type":"object","required":["success","alreadyMapped","airbnbId","listingId","hostId","listingAirbnbId","platformLinkId"],"description":"Id fields are strings (API-wide convention — bigint ids are stringified to avoid 53-bit JS-number precision loss).","properties":{"success":{"type":"boolean","example":true},"alreadyMapped":{"type":"boolean","description":"True when the Airbnb listing was already mapped to this listing (no-op)."},"airbnbId":{"type":"string"},"listingId":{"type":"string"},"previousListingId":{"type":"string","description":"The listing the Airbnb record pointed at before this call. Omitted on a no-op."},"hostId":{"type":"string"},"listingAirbnbId":{"type":"string","description":"Internal id of the `listings_airbnb` record."},"platformLinkId":{"type":"string","description":"Internal id of the resulting `listing_platform_links` row."}}},"AirbnbListingActionRequest":{"type":"object","required":["action"],"description":"Body for `POST /v1/channels/airbnb/listings/{id}`.","properties":{"action":{"type":"string","enum":["delete","push","publish"],"description":"`delete` deactivates the Repull record. `push`/`publish` push content to Airbnb."},"airbnbConnectionId":{"type":"string","description":"For `push`/`publish`: the Airbnb connection to update (from `GET /v1/channels/airbnb/listings/{id}`). Pass this OR `hostId`."},"hostId":{"type":"string","description":"For `push`/`publish`: create + publish a new Airbnb listing under this host. Pass this OR `airbnbConnectionId`."},"force":{"type":"boolean","default":false,"description":"For `push`/`publish`: re-push every field, ignoring dirty-field tracking."}}},"StudioError":{"type":"object","description":"Repull-style structured error envelope. Surface the `fix` and `docs_url` to your end users so they can self-serve.","properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Stable machine-readable error code (e.g. `bad_request`, `not_found`, `rate_limited`)."},"message":{"type":"string","description":"Human-readable description of what went wrong."},"fix":{"type":"string","description":"Suggested next action for the caller (optional)."},"docs_url":{"type":"string","format":"uri","description":"Link to the docs page that explains this error."}}}}},"StudioProject":{"type":"object","description":"A single Repull Studio project — a vibe-coded app generated from a prompt. Each project has its own files, generations, and deployments.","properties":{"id":{"type":"string","format":"uuid","description":"Project UUID."},"slug":{"type":"string","description":"URL-safe slug (unique within your account). Used for the deployment subdomain."},"name":{"type":"string","description":"Human-readable project name."},"prompt":{"type":"string","nullable":true,"description":"Initial prompt that seeded the project."},"template_id":{"type":"string","nullable":true,"description":"Template the project was scaffolded from, if any."},"status":{"type":"string","enum":["draft","building","live","archived"],"description":"Current project lifecycle status."},"customer_id":{"type":"integer","description":"Owning Repull account ID."},"created_at":{"type":"string","format":"date-time"},"last_active_at":{"type":"string","format":"date-time","description":"Updated whenever a file, generation, or deployment is touched."},"deleted_at":{"type":"string","format":"date-time","nullable":true,"description":"Soft-delete timestamp. `null` for live projects."}}},"StudioFile":{"type":"object","description":"A single source file inside a Studio project. Files are addressed by their relative `path`.","properties":{"path":{"type":"string","description":"Project-relative path, e.g. `src/app/page.tsx`."},"content":{"type":"string","description":"UTF-8 file contents."},"sha256":{"type":"string","description":"SHA-256 hex digest of the content — use it to detect drift before writing."},"size":{"type":"integer","description":"Byte length of the content."},"updated_at":{"type":"string","format":"date-time"}}},"StudioGeneration":{"type":"object","description":"A single Repull AI generation run — captures the prompt, the model output, and token accounting.","properties":{"generation_id":{"type":"string","format":"uuid"},"project_id":{"type":"string","format":"uuid"},"prompt":{"type":"string"},"response":{"type":"string","description":"Generated text output."},"tokens_in":{"type":"integer","description":"Prompt tokens consumed."},"tokens_out":{"type":"integer","description":"Completion tokens produced."},"model":{"type":"string","description":"Model identifier used to produce the response."},"created_at":{"type":"string","format":"date-time"}}},"StudioDeployment":{"type":"object","description":"A deployed instance of a Studio project, served from a `*.studio.repull.dev` subdomain.","properties":{"deployment_id":{"type":"string","format":"uuid"},"project_id":{"type":"string","format":"uuid"},"subdomain":{"type":"string","description":"Subdomain assigned to this deployment (e.g. `my-app-a1b2c3`)."},"status":{"type":"string","enum":["provisioning","building","live","suspended","failed"],"description":"Current deployment lifecycle status."},"url":{"type":"string","format":"uri","description":"Fully-qualified URL where the deployment is reachable when `status` is `live`."},"created_at":{"type":"string","format":"date-time"},"suspended_at":{"type":"string","format":"date-time","nullable":true,"description":"Set when the deployment is paused via `/suspend`."}}}},"parameters":{"limit":{"name":"limit","in":"query","schema":{"type":"integer","default":25,"maximum":100},"description":"Max items per page (cap is 100; over-cap returns 422)."},"cursor":{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque base64 cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."},"provider":{"name":"provider","in":"path","required":true,"schema":{"type":"string"},"description":"PMS provider slug (e.g., hostaway, guesty, ownerrez)"},"XSchemaHeader":{"name":"X-Schema","in":"header","required":false,"description":"Apply a custom or built-in schema to transform the response. Built-in: `native` (default), `calry`, `calry-v1`. Custom: any schema name created via `POST /v1/schema/custom`. Unknown / inactive schema names fall back to `native`.","schema":{"type":"string","example":"my-app-schema"}},"IncludeTotal":{"name":"include_total","in":"query","required":false,"description":"When `true` (default), the response's `pagination.total` carries the count of rows matching the current filter, across all pages. Pass `false` to skip the count for very large workspaces where the per-page COUNT(*) cost matters.","schema":{"type":"boolean","default":true}},"Offset":{"name":"offset","in":"query","required":false,"description":"First-class alias for cursor-based pagination. Mutually exclusive with `cursor` — passing both returns 422. Accepts integers in `[0, 10000]`; deeper walks must use `cursor` (constant per-page cost). The response always includes `pagination.next_cursor` so consumers can switch from offset → cursor mid-walk for deep pagination without re-keying.","schema":{"type":"integer","minimum":0,"maximum":10000,"default":0}}},"responses":{"BadRequest":{"description":"Malformed request — invalid JSON, missing required field, wrong content-type, or a parameter that fundamentally cannot be parsed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalidJson":{"summary":"Body was not valid JSON","value":{"error":{"code":"invalid_request","message":"Request body is not valid JSON.","fix":"Send the body with `Content-Type: application/json` and a JSON document. The first parse error was at character 17 (unexpected `,`).","docs_url":"https://repull.dev/docs/errors/invalid_request","request_id":"req_01J5X7Y8Z9ABCDEF12345678"}}}}}}},"Unauthorized":{"description":"No API key on the request, or the key is unknown / revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missingKey":{"summary":"No Authorization or x-api-key header","value":{"error":{"code":"unauthorized","message":"No API key on the request. Pass it via the `Authorization: Bearer <key>` header (or the `x-api-key` header).","fix":"Add `Authorization: Bearer sk_test_<your-key>` to the request. Sandbox keys start with `sk_test_`, production with `sk_live_`. Get a key at https://repull.dev/dashboard/keys.","docs_url":"https://repull.dev/docs/errors/unauthorized","request_id":"req_01J5X7Y8Z9ABCDEF12345678"}}},"invalidKey":{"summary":"Key not found or revoked","value":{"error":{"code":"invalid_api_key","message":"The API key on this request is unknown or has been revoked.","fix":"Confirm the key is copied exactly from https://repull.dev/dashboard/keys (no leading/trailing whitespace) and that the prefix matches the environment you intend to hit (`sk_test_` for sandbox, `sk_live_` for production). If the key was rotated, update the consumer with the new value.","docs_url":"https://repull.dev/docs/errors/invalid_api_key","request_id":"req_01J5X7Y8Z9ABCDEF12345678"}}}}}}},"Forbidden":{"description":"The API key is valid but lacks access to this resource or product.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"missingEntitlement":{"summary":"Workspace not entitled to the requested product","value":{"error":{"code":"forbidden","message":"Markets/Atlas access requires the `markets` product.","fix":"Subscribe to the markets product at https://repull.dev/dashboard/billing — it activates immediately and unlocks every `/v1/markets/*` and `/v1/atlas/*` endpoint.","docs_url":"https://repull.dev/docs/errors/forbidden","request_id":"req_01J5X7Y8Z9ABCDEF12345678"}}}}}}},"NotFound":{"description":"The resource id is well-formed but no matching row exists in this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"notFound":{"summary":"Resource id not found in this workspace","value":{"error":{"code":"notFound","message":"Reservation 999999 was not found in this workspace.","fix":"Verify the id exists in this workspace via `GET /v1/reservations`. Ids from one workspace are not valid in another — switch API keys if you intended a different workspace.","docs_url":"https://repull.dev/docs/errors/not_found","request_id":"req_01J5X7Y8Z9ABCDEF12345678","field":"id","value_received":999999}}}}}}},"Conflict":{"description":"The request conflicts with the current resource state (already-exists, terminal, mid-flight).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"terminalSession":{"summary":"Session already in a terminal state","value":{"error":{"code":"session_terminal","message":"Session is in terminal state: succeeded.","fix":"Terminal sessions cannot be reused. Start a new session via `POST /v1/connect/{provider}` to retry.","docs_url":"https://repull.dev/docs/errors/session_terminal","request_id":"req_01J5X7Y8Z9ABCDEF12345678","session_status":"succeeded"}}}}}}},"UnprocessableEntity":{"description":"Request is well-formed but a parameter value is out of range or fails validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"badIsoDate":{"summary":"Parameter is not a valid ISO 8601 date","value":{"error":{"code":"invalid_params","message":"The check_in_after parameter must be an ISO 8601 date (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ssZ). You sent: 'garbage'.","fix":"Pass check_in_after as a string in ISO 8601 format. Example: ?check_in_after=2026-01-15","docs_url":"https://repull.dev/docs/errors/invalid_params","request_id":"req_01J5X7Y8Z9ABCDEF12345678","field":"check_in_after","value_received":"garbage"}}},"limitTooLarge":{"summary":"limit exceeds the per-endpoint cap","value":{"error":{"code":"invalid_params","message":"limit=500 exceeds the per-page cap of 100 for this endpoint.","fix":"Lower `limit` to 100 or below and walk pages with `?cursor=<pagination.nextCursor>`.","docs_url":"https://repull.dev/docs/errors/invalid_params","request_id":"req_01J5X7Y8Z9ABCDEF12345678","field":"limit","value_received":500}}},"unknownParams":{"summary":"Query parameter is not in the endpoint's allowlist","value":{"error":{"code":"unknownParams","message":"Unknown query parameters: search.","fix":"Did you mean 'q'? Valid params: cursor, has_reservation, include_total, limit, listing_id, q.","valid_params":["cursor","has_reservation","include_total","limit","listingId","q"],"docs_url":"https://repull.dev/docs/errors/unknown_params","endpoint":"/v1/guests"}}}}}}},"PaymentRequired":{"description":"Plan-listings cap exceeded. The customer's active listing count is above the cap of their resolved Repull tier (`free` = 3, `starter` = 50, `custom` = unlimited). Returned on every route except `/v1/health`, `/v1/usage/*`, and any `DELETE` — these stay served so the customer can read their state, render the over-cap UI, or trim listings to get back under the cap.\n\nDistinct from 429 / `rate_limit_exceeded` — this is NOT a \"wait and retry\" condition. The only paths back to a 200 are:\n  1. `DELETE` enough listings via `DELETE /v1/listings/{id}` (or the channel-specific listings DELETE) until `active_listings <= limit`.\n  2. Upgrade to a tier with a higher cap at `https://repull.dev/dashboard/billing`. The very next request after the upgrade lands will see the new cap (server-side usage cache is 60s, so allow up to 1 minute for propagation).\n\nNo `Retry-After` header — backoff doesn't fix this.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"freeTierOverCap":{"summary":"Free tier customer over the 3-listings cap","value":{"error":{"code":"listings_limit_exceeded","message":"Your account has 24 active listings but the 'free' tier is capped at 3. The Repull API only serves accounts whose listing count is within their plan.","fix":"Either reduce your active listings to 3 or fewer (via DELETE endpoints — these are still served), or upgrade your plan at https://repull.dev/dashboard/billing to lift the cap immediately.","docs_url":"https://repull.dev/docs/errors/listings_limit_exceeded","request_id":"req_01J5X7Y8Z9ABCDEF12345678","tier":"free","limit":3,"active_listings":24,"upgrade_url":"https://repull.dev/dashboard/billing"}}}}}}},"TooManyRequests":{"description":"Quota exceeded. Wait `retry_after` seconds (or until `X-RateLimit-Reset`) before retrying.","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying."},"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Total requests allowed in the current window."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests remaining in the current window."},"X-RateLimit-Reset":{"schema":{"type":"string","format":"date-time"},"description":"When the window resets (ISO 8601)."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"monthlyQuota":{"summary":"Monthly request quota exhausted","value":{"error":{"code":"rate_limit_exceeded","message":"You have exceeded your monthly request quota for the 'free' tier (10000 / 10000). Quota resets 2026-06-01T00:00:00.000Z.","fix":"Wait 2592000s for the new billing month, or upgrade your plan at https://repull.dev/dashboard/billing to lift the cap immediately.","docs_url":"https://repull.dev/docs/errors/rate_limit_exceeded","request_id":"req_01J5X7Y8Z9ABCDEF12345678","retry_after":2592000,"tier":"free","limit":10000,"used":10000,"scope":"monthly","resets_at":"2026-06-01T00:00:00.000Z"}}}}}}},"InternalError":{"description":"Unexpected server-side failure. Always retryable with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"transient":{"summary":"Transient internal failure","value":{"error":{"code":"internal_error","message":"handler failed: connection timeout","fix":"Retry the request with exponential backoff. If the same request_id keeps failing, capture it (and the timestamp) for diagnosis.","docs_url":"https://repull.dev/docs/errors/internal_error","request_id":"req_01J5X7Y8Z9ABCDEF12345678"}}}}}}}}},"paths":{"/v1/health":{"get":{"operationId":"get_health","description":"Liveness probe. Returns `{ status: \"ok\", version }` when the API process is running. No auth required. Suitable for uptime monitors, load-balancer health checks, and SDK self-tests.","summary":"Health check","tags":["System"],"security":[],"responses":{"200":{"description":"API is healthy","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"ok"},"version":{"type":"string","example":"1.0.0"}}}}}}}}},"/v1/sandbox/seed":{"post":{"operationId":"seed_sandbox","summary":"Seed sandbox fixtures","description":"Provision a deterministic set of test fixtures for contract testing WITHOUT live provider accounts. **Requires a test key (`sk_test_*`)** — live or legacy keys are rejected with 403.\n\nSeeds, scoped to the authenticated test customer: 3 sample listings, 5 reservations across the lifecycle (created / modified / cancelled / date-changed / pending), and 2 fake connected provider accounts (Airbnb + Booking.com) so the pairing + connection-status flows are testable without real OAuth.\n\nIdempotent — re-seeding upserts the same rows and returns the same ids. The seeded rows are visible ONLY under a test key, via the normal read endpoints (`GET /v1/listings`, `/v1/reservations`, `/v1/connect`, `/v1/channels/airbnb/listings`, `/v1/channels/airbnb/connection`). They live in a data space fully isolated from live inventory.","tags":["Sandbox"],"responses":{"200":{"description":"Fixtures seeded — returns the ref→id map","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SandboxSeedResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Not a test key — the sandbox is only reachable with `sk_test_*`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/sandbox/reset":{"post":{"operationId":"reset_sandbox","summary":"Reset sandbox fixtures","description":"Clear the authenticated test customer's sandbox fixtures. **Requires a test key (`sk_test_*`)** — live or legacy keys are rejected with 403.\n\nDeletes ONLY the customer's rows in the isolated sandbox data space — it can never touch real inventory (`listings`, `reservations`, connections). Idempotent: resetting an empty sandbox returns zero counts (still 200). Returns per-resource deletion counts.","tags":["Sandbox"],"responses":{"200":{"description":"Fixtures cleared — returns per-resource deletion counts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SandboxResetResult"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Not a test key — the sandbox is only reachable with `sk_test_*`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/properties":{"get":{"operationId":"list_properties","summary":"List properties","description":"Cursor-paginated list of properties for the authenticated workspace. Walk pages with `?cursor=<pagination.nextCursor>`; stop when `pagination.hasMore` is `false`. Cursor is opaque base64 — do not parse it.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`.\n\nFilters: `q` (substring on name/street/city), `status` (active|inactive|all), `lifecycle_status` (exact match on the listing's lifecycle state). Other unknown params (e.g. `?search=` or `?propertyId=`) are rejected with 422 — no silent unfiltered results.","tags":["Properties"],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":100},"description":"Page size (max 100). Requests over the cap return 422."},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."},{"$ref":"#/components/parameters/Offset"},{"name":"q","in":"query","schema":{"type":"string"},"description":"Case-insensitive substring search on name, street, or city."},{"name":"status","in":"query","schema":{"type":"string","enum":["active","inactive","all"],"default":"active"},"description":"Filter by status. Default returns active only; pass `inactive` to invert or `all` to include both."},{"name":"lifecycle_status","in":"query","schema":{"type":"string","example":"live"},"description":"Filter by lifecycle status (e.g. `live`, `draft`, `archived`). Pass `all` to disable the filter."},{"name":"channel","in":"query","schema":{"type":"string","enum":["airbnb","booking","vrbo"],"example":"airbnb"},"description":"Filter to properties with an active link on the given OTA/channel (airbnb, booking, vrbo). Omit to include every channel. Each property also returns a `channels` array listing the OTAs it is published on."},{"$ref":"#/components/parameters/IncludeTotal"}],"responses":{"200":{"description":"Properties list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertyListResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/properties/{id}":{"get":{"operationId":"get_property","description":"Fetch a single property by Repull id. Property ids are workspace-scoped — an id from one workspace is not valid in another. 404 means the id does not exist OR belongs to a different workspace.\n\n**Optional expansions:** Pass `?include=amenities` to enrich the response with the property's amenities (sourced from the unified `listings_amenities` table). Returns `[]` when the property has no amenity rows. The default response stays lean; consumers must opt in.","summary":"Get property details","tags":["Properties"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"include","in":"query","required":false,"schema":{"type":"string","enum":["amenities"]},"description":"Comma-separated optional expansions. Currently supported: `amenities`. Unknown values return 422."}],"responses":{"200":{"description":"Property details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Property"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/availability/{propertyId}":{"get":{"operationId":"get_availability","summary":"Get property availability","description":"Channel-agnostic day-by-day availability calendar for a property over a date window. Returns a thin per-date shape — `{ date, available, price, minNights }` — projected from the connected channel calendar (currently Airbnb).\n\nThe `from` and `to` query params are **required** (ISO `YYYY-MM-DD`, inclusive) — omitting or malforming either returns 422. The window is capped at 366 days; longer ranges are truncated to the first 366 days.\n\nEvery date in the window is present in `days`: dates with no explicit calendar row fall back to `available: true` at the property's default nightly price. A property with no channel calendar still returns a real 200 (a fully-default calendar), never a 404 — 404 means the property id does not exist or belongs to a different workspace.\n\nThis endpoint is read-only, and the projected per-date shape carries **availability, price, and min-nights only** — it does NOT expose max-stay, closed-to-arrival (CTA), closed-to-departure (CTD), or the dedicated stop-sell flag. To read or write that full restriction set on Booking.com use the channel routes: `GET`/`PUT /v1/channels/booking/availability` (with the room + rate ids from `GET /v1/channels/booking/properties/{id}/rooms`). Availability **writes** always stay per-channel: `PUT /v1/channels/airbnb/listings/{id}/availability` (Airbnb) or `PUT /v1/channels/booking/availability` (Booking.com).","tags":["Availability"],"parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull property id (equal to `listings.id`; the same integer used as `propertyId` on availability and `listingId` on reservations)."},{"name":"from","in":"query","required":true,"schema":{"type":"string","format":"date"},"description":"Start of the window (inclusive), ISO `YYYY-MM-DD`. Required — missing/malformed returns 422. `startDate` is accepted as an alias."},{"name":"to","in":"query","required":true,"schema":{"type":"string","format":"date"},"description":"End of the window (inclusive), ISO `YYYY-MM-DD`. Required — missing/malformed returns 422. `endDate` is accepted as an alias."}],"responses":{"200":{"description":"Availability calendar","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertyAvailability"}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/reservations":{"get":{"operationId":"list_reservations","summary":"List reservations","description":"Cursor-paginated list of reservations across all connected PMS platforms. Filter by platform, status, listing, or check-in date range.\n\n**Pagination:** Walk pages with `?cursor=` — pass `pagination.nextCursor` from one response back as `?cursor=` on the next request. Stop when `pagination.hasMore` is `false`. `limit` defaults to 50, max 100; requesting more returns 422 (no silent truncation).\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`. For deep pagination cursor remains O(1) per page; offset > 10000 returns 422 with a docs link.","tags":["Reservations"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"minimum":1,"maximum":100},"description":"Page size (max 100). Requests over the cap return 422."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."},{"$ref":"#/components/parameters/Offset"},{"name":"platform","in":"query","schema":{"type":"string"},"description":"Filter by booking platform"},{"name":"status","in":"query","schema":{"type":"string","enum":["confirmed","pending","cancelled","completed"]},"description":"Filter by lifecycle status. **Case-insensitive** — `confirmed`, `Confirmed`, and `CONFIRMED` all match. Each public value expands to the full set of internal sub-states server-side: `confirmed` matches `accept`/`confirmed`/`modified`, `cancelled` matches every cancellation sub-state (`cancelled_by_host`, `declined`, `expired`, etc.), `pending` includes `inquiry`/`awaiting_payment`. `completed` is a derived state — combine `status=confirmed` with `check_out_before=<today>` to filter for past stays."},{"name":"listingId","in":"query","schema":{"type":"integer"},"description":"Filter to a single listing"},{"name":"check_in_after","in":"query","schema":{"type":"string","format":"date","example":"2026-05-31"},"description":"Check-in date >= this value"},{"name":"check_in_before","in":"query","schema":{"type":"string","format":"date","example":"2026-05-31"},"description":"Check-in date <= this value"},{"name":"check_out_after","in":"query","schema":{"type":"string","format":"date","example":"2026-05-31"},"description":"Check-out date >= this value"},{"name":"check_out_before","in":"query","schema":{"type":"string","format":"date","example":"2026-05-31"},"description":"Check-out date <= this value"},{"name":"checkInFrom","in":"query","schema":{"type":"string","format":"date"},"deprecated":true,"description":"Deprecated alias for `check_in_after`."},{"name":"checkInTo","in":"query","schema":{"type":"string","format":"date"},"deprecated":true,"description":"Deprecated alias for `check_in_before`."},{"name":"checkInAfter","in":"query","schema":{"type":"string","format":"date"},"deprecated":true,"description":"Use `check_in_after` (snake_case) instead.","x-go-name":"CheckInAfterCamel"},{"name":"checkInBefore","in":"query","schema":{"type":"string","format":"date"},"deprecated":true,"description":"Use `check_in_before` (snake_case) instead.","x-go-name":"CheckInBeforeCamel"},{"name":"checkOutAfter","in":"query","schema":{"type":"string","format":"date"},"deprecated":true,"description":"Use `check_out_after` (snake_case) instead.","x-go-name":"CheckOutAfterCamel"},{"name":"checkOutBefore","in":"query","schema":{"type":"string","format":"date"},"deprecated":true,"description":"Use `check_out_before` (snake_case) instead.","x-go-name":"CheckOutBeforeCamel"},{"$ref":"#/components/parameters/IncludeTotal"}],"responses":{"200":{"description":"Reservations list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReservationListResponse"}}}},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/reservations/{id}":{"get":{"operationId":"get_reservation","summary":"Get reservation details","description":"Returns the full record for a single reservation, scoped to the authenticated workspace. Response shape is identical to a single row in `GET /v1/reservations` so SDK consumers can use the same type for both. Returns **404** if the id does not exist OR belongs to a different workspace — the API never differentiates the two so caller can't enumerate other workspaces' ids.","tags":["Reservations"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Internal Repull reservation ID."}],"responses":{"200":{"description":"Reservation details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Reservation"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/guests":{"get":{"operationId":"listGuests","summary":"List guests","description":"Cursor-paginated list of guests in the workspace. Walks `guests.id ASC` keyset for constant per-page cost regardless of how many guests the customer has. Use `pagination.nextCursor` from one response as the `cursor` query param of the next request.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`.\n\nFilters: `q` (substring on name/email/phone), `has_reservation` (`true`|`false`), `listing_id` (restrict to guests with at least one reservation on that listing).","tags":["Guests"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100},"description":"Max items per page. Hard cap is 100."},{"name":"q","in":"query","schema":{"type":"string"},"description":"Case-insensitive substring search on name, email, or phone."},{"name":"has_reservation","in":"query","schema":{"type":"boolean"},"description":"Restrict to guests that do (`true`) or do not (`false`) have any reservation on file."},{"name":"listingId","in":"query","schema":{"type":"integer"},"description":"Restrict to guests with at least one reservation on the given internal Repull listing id."}],"responses":{"200":{"description":"Guests page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestListResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/guests/{id}":{"get":{"operationId":"getGuest","summary":"Get guest profile","description":"Returns the full guest profile — base list-row fields plus contacts, flags, notes, risk metadata, and reservation aggregates. Aggregates main vanio's `GuestService.getGuestProfile()` into the public Repull shape so SDK consumers don't have to learn the internal schema.","tags":["Guests"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Guest profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GuestProfile"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/conversations":{"get":{"operationId":"listConversations","summary":"List conversations","description":"Cursor-paginated list of message threads owned by the workspace. Backed by main vanio's `/api/threads/list` which keyset-paginates against `(last_message_at, id)` for constant per-page cost. Use `pagination.nextCursor` from one response as the `cursor` query param of the next request.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`.\n\nFilters: `platform` (`airbnb`|`booking`|`vrbo`|`website`|`email`), `status` (`open`|`archived` — `archived` is a stable no-op until the bit lands on `message_threads`).","tags":["Conversations"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100},"description":"Max items per page. Hard cap is 100."},{"name":"platform","in":"query","schema":{"type":"string","enum":["airbnb","booking","vrbo","website","email"]},"description":"Restrict to threads on a single channel."},{"name":"status","in":"query","schema":{"type":"string","enum":["open","archived"]},"description":"Filter by archive status. `archived` currently always returns an empty page — kept for forward-compat."}],"responses":{"200":{"description":"Conversations page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationListResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/conversations/{id}":{"get":{"operationId":"getConversation","summary":"Get conversation detail","description":"Returns one thread (the same shape as the list-row `Conversation`) plus expanded `host` (from `airbnb_hosts` for the thread's `host_id`) and `guest` (resolved via the thread's `reservation_id`, with up to 50 contacts) blocks.","tags":["Conversations"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Internal Repull thread id."}],"responses":{"200":{"description":"Conversation detail","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationDetail"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/conversations/{id}/messages":{"get":{"operationId":"listConversationMessages","summary":"List messages in a conversation","description":"Cursor-paginated messages within one thread. Defaults to newest-first (`?order=desc`); pass `?order=asc` for chronological replay. Use `pagination.nextCursor` from one response as the `cursor` query param of the next request.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`.","tags":["Conversations"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Internal Repull thread id."},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},{"name":"order","in":"query","schema":{"type":"string","enum":["asc","desc"],"default":"desc"},"description":"`desc` (default) returns newest first. `asc` returns chronological replay."}],"responses":{"200":{"description":"Messages page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageListResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/reviews":{"get":{"operationId":"listReviews","summary":"List reviews","description":"Cursor-paginated guest + host review stream for the workspace. Backed by main vanio's unified `reviews` table (populated by per-channel backfill crons), so this surface returns the complete cross-channel history — separate from `/v1/channels/airbnb/reviews` which hits Airbnb live.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`.\n\nFilters: `platform` (`airbnb`|`booking`|`vrbo`), `listing_id` (internal Repull listing id), `rating_min` / `rating_max` (inclusive bounds, 0..5), `status` (`responded`|`unanswered`|`all`), `reviewer_role` (`guest` (default) | `host` | `all`).","tags":["Reviews"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},{"name":"platform","in":"query","schema":{"type":"string","enum":["airbnb","booking","vrbo"]}},{"name":"listingId","in":"query","schema":{"type":"integer"},"description":"Restrict to one internal Repull listing."},{"name":"rating_min","in":"query","schema":{"type":"number","minimum":0,"maximum":5}},{"name":"rating_max","in":"query","schema":{"type":"number","minimum":0,"maximum":5}},{"name":"status","in":"query","schema":{"type":"string","enum":["responded","unanswered","all"]},"description":"`responded` — host has replied. `unanswered` — host has not replied. `all` — no filter."},{"name":"reviewerRole","in":"query","schema":{"type":"string","enum":["guest","host","all"],"default":"guest"},"description":"`guest` (default) — reviews written by guests about the host/property. `host` — reviews written by the host about guests. `all` — both."}],"responses":{"200":{"description":"Reviews page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewListResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/reviews/{id}":{"get":{"operationId":"getReview","summary":"Get review","description":"Returns one review (the bare `Review` object — NOT wrapped in `{ data: ... }`). Scoped to the authenticated workspace via the listings join — reviews that don't belong to the workspace return 404 (we don't differentiate to avoid leaking other customers' ids).","tags":["Reviews"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Internal Repull review id."}],"responses":{"200":{"description":"Review","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Review"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/connect":{"get":{"operationId":"list_connections","summary":"List PMS/OTA connections","description":"Returns all active connections to PMS and OTA platforms.","tags":["Connect"],"responses":{"200":{"description":"Connections","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectionListResponse"}}}}}},"post":{"operationId":"createConnectSession","summary":"Create a multi-channel Connect picker session","description":"Mints a session that lands the user on the channel picker at `connect.repull.dev/{sessionId}` instead of jumping straight to a single provider. The user picks a channel from the registry, the picker page POSTs `selectConnectProvider` to bind the choice, and the per-provider flow takes over.\n\nUse this when you want one entry point for all 13 channels. Use `POST /v1/connect/{provider}` instead when your UI already knows which channel to connect.","tags":["Connect"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["redirectUrl"],"properties":{"redirectUrl":{"type":"string","format":"uri","description":"Where to send the user after they finish (or cancel). Status query params are appended."},"state":{"type":"string","nullable":true,"description":"Opaque pass-through correlation token. Echoed back in the response."},"allowedProviders":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Optional whitelist of provider IDs the picker should expose. Omit to show every channel in the registry."},"locale":{"type":"string","nullable":true,"description":"Optional UI language for the hosted Connect pages. Accepts any supported locale code (currently `en`, `fr`). When set it pins the language for the whole flow, overriding the workspace `default_language`. Unknown codes are ignored and the page falls back to the workspace default, then `Accept-Language`, then `en`. The end user can still override per-visit with a `?locale=` query param on the hosted page.","example":"fr"}}}}}},"responses":{"201":{"description":"Picker session created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectSession"}}}}}}},"/v1/connect/providers":{"get":{"operationId":"listConnectProviders","summary":"List Connect channels","description":"Returns the public registry of every channel the picker supports. No customer-specific data — display metadata only. Cached for 5 minutes at the edge.","tags":["Connect"],"security":[],"responses":{"200":{"description":"Channel registry","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectProviderListResponse"}}}}}}},"/v1/connect/sessions/{sessionId}/select-provider":{"post":{"operationId":"selectConnectProvider","summary":"Bind a picker session to a provider","description":"Called by the hosted picker page once the user clicks a channel card. Validates the provider exists and is permitted by the session's `allowed_providers` whitelist (if any), then returns the next-step URL the picker should navigate to.\n\nNo API key required — the session ID is the capability token. The session must still be pending and unexpired.","tags":["Connect"],"security":[],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"},"description":"The picker session ID returned by `createConnectSession`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["provider"],"properties":{"provider":{"type":"string","example":"airbnb","description":"Provider ID from the registry."}}}}}},"responses":{"200":{"description":"Provider bound; next URL returned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SelectProviderResponse"}}}}}}},"/v1/connect/{provider}":{"get":{"operationId":"get_connect_status","summary":"Get connection status","description":"Returns the current connection status for a provider, including host metadata (display name + avatar) for Airbnb so clients can render an account-level confirmation UI.","tags":["Connect"],"parameters":[{"$ref":"#/components/parameters/provider"}],"responses":{"200":{"description":"Connection status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConnectStatus"}}}}}},"post":{"operationId":"create_connection","summary":"Connect to PMS/OTA provider","description":"Establish a connection to a PMS or OTA platform. Credentials vary by provider — see docs for each provider.\n\nAirbnb-specific: pass `redirectUrl` (where to send the user after consent) and optionally `accessType`. Three tiers: `read_only` grants read-only scopes; `messaging` grants read scopes plus message read/send but NOT property management, so it can coexist with another app (e.g. an existing PMS) that already holds property management on the same Airbnb account; `full_access` — the default — grants full host scopes including the exclusive property management (only one app per Airbnb account can hold it). The response returns a hosted `url` to redirect the user to.\n\nBooking.com: pass `redirectUrl` (no `accessType`). The response returns a hosted `url` — send the user there to designate FantasticStay in their Booking.com Extranet and paste their Hotel ID. Same response shape as Airbnb (`url`, `sessionId`, `expiresAt`).\n\nPMS providers (api-key based) pass `apiKey` instead; Plumguide passes `clientId`/`clientSecret`.","tags":["Connect"],"parameters":[{"$ref":"#/components/parameters/provider"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","description":"Provider-specific credentials (apiKey, clientId/clientSecret, etc.) or OAuth init params for Airbnb.","properties":{"redirectUrl":{"type":"string","format":"uri","description":"Airbnb + Booking.com — where to redirect the user after they finish the hosted connect flow."},"accessType":{"type":"string","enum":["read_only","full_access","messaging"],"default":"full_access","description":"Airbnb only — selects the OAuth scope set. 'read_only' grants read-only scopes; 'messaging' grants read scopes plus message read/send but NOT property management, so it can coexist with another app (e.g. an existing PMS) that already holds property management on the same Airbnb account; 'full_access' (default) grants full host scopes including the exclusive property management (only one app per Airbnb account can hold it)."},"apiKey":{"type":"string","description":"PMS providers — API key."},"clientId":{"type":"string","description":"Plumguide — client ID."},"clientSecret":{"type":"string","description":"Plumguide — client secret."},"locale":{"type":"string","nullable":true,"description":"Airbnb only — optional UI language for the hosted Connect pages. Accepts any supported locale code (currently `en`, `fr`); unknown codes are ignored and resolution falls back to the workspace `default_language`, then `Accept-Language`, then `en`.","example":"fr"}}}}}},"responses":{"200":{"description":"Connected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Connection"}}}}}},"delete":{"operationId":"delete_connection","summary":"Disconnect provider","description":"Disconnect a PMS or OTA from this workspace. Revokes the OAuth token (where applicable), purges credentials, and stops all sync jobs. Resources synced from the provider remain queryable but become read-only and stop receiving updates.","tags":["Connect"],"parameters":[{"$ref":"#/components/parameters/provider"}],"responses":{"200":{"description":"Disconnected"}}}},"/v1/connect/booking/verify":{"post":{"operationId":"verifyBookingHotel","summary":"Verify a Booking.com hotel ID for a Connect session","description":"Manual-paste fallback that closes the Booking.com claim flow. Call this after the customer completes Stage 1 designation in their Booking Extranet (ticking FantasticStay/Repull as their connectivity provider) and pastes their Hotel ID into the hosted picker.\n\nValidates the hotel against Booking's property API, persists the `pms_connections` row, kicks off the room import, and transitions the Connect session to `awaiting_room_mapping`.\n\nNo API key required — the `sessionId` is the capability token. Sessions in any terminal state are rejected.","tags":["Connect"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingVerifyHotelRequest"}}}},"responses":{"200":{"description":"Hotel verified, session bumped to `awaiting_room_mapping`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingVerifyHotelResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"410":{"description":"Session has expired — start a new one","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"412":{"description":"Stage 1 designation not yet complete in the Extranet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Booking.com verification call failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Booking.com client credentials not configured on this deployment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/connect/booking/rooms":{"get":{"operationId":"listConnectBookingRooms","summary":"List Booking.com rooms imported for a Connect session","description":"Returns the rooms imported from the Booking.com hotel claimed in this Connect session, plus the customer's listing options for the mapping dropdowns. Hosted-picker pages poll this endpoint every ~2s after `verifyBookingHotel` succeeds; once rooms appear the page transitions to the mapping UI.\n\nNo API key required — the `sessionId` query param is the capability token.","tags":["Connect"],"security":[],"parameters":[{"name":"sessionId","in":"query","required":true,"schema":{"type":"string"},"description":"The Connect session ID returned by `createConnectSession`."}],"responses":{"200":{"description":"Rooms + listing options","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingConnectRoomsResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"410":{"description":"Session has expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"425":{"description":"Hotel claim not yet persisted — retry shortly","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/connect/booking/map-rooms":{"post":{"operationId":"mapConnectBookingRooms","summary":"Submit room→listing mappings for a Booking.com Connect session","description":"Submits the customer's room→listing mapping choices in one transaction. For each mapping, updates `listings_booking_rooms.listing_id` and replaces the corresponding `listing_platform_links` row. Pass `listingId: null` to explicitly unmap a room.\n\nOn success the Connect session is marked `completed` and the hosted picker page emits a `repull:connect:completed` postMessage to the embedding window.\n\nNo API key required — the `sessionId` in the body is the capability token. Each mapping's `roomId` must belong to the customer's claimed hotel; mismatched IDs are rejected with 403.","tags":["Connect"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MapConnectBookingRoomsRequest"}}}},"responses":{"200":{"description":"All mappings applied; session marked completed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MapConnectBookingRoomsResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"description":"A room ID does not belong to this customer's claimed hotel","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"410":{"description":"Session has expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"425":{"description":"No Booking.com connection found for this customer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/webhooks":{"get":{"operationId":"list_webhooks","summary":"List webhook subscriptions","description":"List every webhook subscription registered for this workspace. Each row includes the destination URL, subscribed event types, and the most recent delivery summary.","tags":["Webhooks"],"responses":{"200":{"description":"Webhooks","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookListResponse"}}}}}},"post":{"operationId":"create_webhook","summary":"Create webhook subscription","description":"Register a new endpoint. Returns the plaintext signing secret ONCE — capture it from the response and store it securely. After this call the secret is masked everywhere; mint a new one with `POST /v1/webhooks/{id}/rotate-secret` if you lose it. See `GET /v1/webhooks/event-types` for the full list of subscribable events.","tags":["Webhooks"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"}},"description":{"type":"string","nullable":true},"apiVersion":{"type":"string","example":"2026-04"}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscription"}}}}}}},"/v1/webhooks/event-types":{"get":{"operationId":"list_webhook_event_types","summary":"List webhook event types","description":"The canonical catalog of every event the API can deliver, grouped by domain, with realistic sample payloads.","tags":["Webhooks"],"responses":{"200":{"description":"Catalog","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEventCatalog"}}}}}}},"/v1/webhooks/{id}":{"get":{"operationId":"get_webhook","summary":"Get webhook subscription","description":"Fetch a single webhook subscription by id. Use the `deliveries` sub-resource to list recent attempts, and `ping` to send a test event.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Subscription","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscription"}}}}}},"patch":{"operationId":"update_webhook","summary":"Update webhook subscription","description":"Update url, description, events, or status (active|paused). Re-enabling clears `consecutive_failures` and `disabled_at`.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"description":{"type":"string","nullable":true},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"}},"status":{"type":"string","enum":["active","paused"]}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscription"}}}}}},"delete":{"operationId":"delete_webhook","summary":"Delete webhook subscription","description":"Delete a webhook subscription. In-flight deliveries already on the queue are still attempted; new events stop firing immediately.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted"}}}},"/v1/webhooks/{id}/rotate-secret":{"post":{"operationId":"rotate_webhook_secret","summary":"Rotate signing secret","description":"Mints a fresh signing secret and returns the plaintext ONCE. After this response the secret is masked everywhere — capture and store it now.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Rotated","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"secret":{"type":"string"},"rotatedAt":{"type":"string","format":"date-time"}}}}}}}}},"/v1/webhooks/{id}/ping":{"post":{"operationId":"ping_webhook","summary":"Send ping event","description":"Fires a synthetic `repull.ping` at the subscription URL and returns the full delivery cycle inline. Used by dashboards and health-check probes.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Ping delivered (success or failure — check the response fields)"}}}},"/v1/webhooks/{id}/test/{event_type}":{"post":{"operationId":"test_fire_webhook","summary":"Send test event of a specific type","description":"Delivers a realistic fixture payload of the requested event type to the subscription URL.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"event_type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/WebhookEventType"},"example":"reservation.created"}],"responses":{"200":{"description":"Test delivered"}}}},"/v1/webhooks/{id}/deliveries":{"get":{"operationId":"list_webhook_deliveries","summary":"List webhook deliveries","description":"Cursor-paginated history of every delivery attempt for this subscription. Walk pages with `?cursor=<pagination.nextCursor>`; stop when `pagination.hasMore` is `false`. The cursor is opaque base64 — do not parse it. `?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — mutually exclusive with `cursor`.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":25,"maximum":100}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","description":"Opaque cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."}},{"$ref":"#/components/parameters/Offset"},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["success","failure","all"],"default":"all"}}],"responses":{"200":{"description":"Deliveries","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryListResponse"}}}}}}},"/v1/webhooks/{id}/deliveries/{delivery_id}":{"get":{"operationId":"get_webhook_delivery","summary":"Get webhook delivery","description":"Full request + response capture for one delivery attempt.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Delivery","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookDeliveryDetail"}}}}}}},"/v1/webhooks/{id}/deliveries/{delivery_id}/replay":{"post":{"operationId":"replay_webhook_delivery","summary":"Replay webhook delivery","description":"Re-sends the original payload (same eventId, fresh deliveryId, attempt + 1).","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Replayed"}}}},"/v1/webhooks/test":{"post":{"operationId":"test_webhook","summary":"[Legacy] Send test webhook to a URL","description":"Deprecated: prefer creating a subscription then calling `POST /v1/webhooks/{id}/ping`. Kept for back-compat.","tags":["Webhooks"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"event_type":{"$ref":"#/components/schemas/WebhookEventType"},"signing_secret":{"type":"string"}}}}}},"responses":{"200":{"description":"Test delivered"}}}},"/v1/channels/airbnb/connection":{"get":{"operationId":"get_airbnb_connection","summary":"Get Airbnb connection state","description":"Returns the workspace's Airbnb host connection state in one envelope. Use this instead of inferring connection health from per-listing 401s on `GET /v1/channels/airbnb/listings` — that's noisy (every per-listing call has to fail before you know) and ambiguous (a single 5xx looks identical to a deauth).\n\nPure DB read — does NOT touch Airbnb's API, so it's cheap to poll from a status-page surface.\n\nThe response includes one row per Airbnb host the workspace has linked. Each row carries `isConnected`, `lastSyncedAt`, `deactivatedAt`, and `lastDisconnectReason` (most recent non-backfill row in `airbnb_host_events`).\n\nA self-serve `fixUrl` is included whenever `status` is anything other than `connected` — points at the dashboard where the host re-authorizes (or initiates the first OAuth flow for `never_connected` workspaces).","tags":["Airbnb"],"responses":{"200":{"description":"Airbnb connection summary","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbConnectionResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings":{"get":{"operationId":"list_airbnb_listings","summary":"List Airbnb listings","description":"List every Airbnb listing this workspace has access to via the connected Airbnb account. **Pure DB read — never calls Airbnb upstream.** The connect flow is what populates the local cache; the API serves what's already there. Customers with a disconnected host still see their last-synced data, with the top-level `data_freshness` envelope flagging the staleness and pointing at the reconnect URL.\n\nPass `?include=amenities` to enrich each connection with its locally-cached amenity set. Returns `null` per connection when the cache is empty.","tags":["Airbnb"],"parameters":[{"name":"include","in":"query","schema":{"type":"string","example":"amenities"},"description":"Comma-separated expansions. Currently supported: `amenities` (adds `amenities` and `accessibility_amenities` arrays to each connection, sourced from the local `listings_airbnb_amenities` cache)."}],"responses":{"200":{"description":"Listings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbListingListResponse"}}}}}}},"/v1/channels/airbnb/listings/{id}":{"get":{"operationId":"get_airbnb_listing","summary":"Get Airbnb listing","description":"Fetch all Airbnb connection rows for a single Vanio listing id. A property may be linked from multiple Airbnb hosts — every match is returned. Pass `?include=amenities` to enrich each row with its current Airbnb amenities.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"include","in":"query","schema":{"type":"string","example":"amenities"},"description":"Comma-separated expansions. Currently supported: `amenities`."}],"responses":{"200":{"description":"Listing details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbListing"}}}}}},"post":{"operationId":"airbnb_listing_action","summary":"Listing action (delete/push/publish)","description":"Apply a state action to a listing by id. The path `id` is the canonical Repull listing id.\n\n`delete` is a **deactivate of the Repull record only** — it sets the listing inactive and KEEPS the row; it does NOT touch the upstream Airbnb listing (Repull never deletes or deactivates on Airbnb's side). Use it to exclude a listing / trim back under the plan-listings cap; reactivate via `PATCH /v1/listings/{id}` with `{ \"active\": true }`. Idempotent.\n\n`push` / `publish` push the listing's content to Airbnb via the same host-side sync orchestrator as `POST /v1/listings/{id}/publish/airbnb` — pass `airbnbConnectionId` to update an already-mapped Airbnb listing, or `hostId` to create + publish a new one under that host. `force` re-pushes every field, ignoring dirty-field tracking.\n\nAny other action (e.g. `pull`, `unlist`) returns a structured 422 naming the supported actions.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbListingActionRequest"}}}},"responses":{"200":{"description":"Action completed"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/channels/airbnb/listings/map":{"post":{"operationId":"map_airbnb_listing","summary":"Map an Airbnb listing to a Repull listing","description":"Link an existing Airbnb listing to a canonical Repull listing/property. **API-key-scoped** (unlike the Booking room mapping, which is Connect-session-scoped).\n\nDiscover the `airbnbId` (+ `hostId`) via `GET /v1/channels/airbnb/listings`, then re-point it at the `listingId` of your choice — the dedup / consolidation case where the Airbnb sync auto-created its own listing but you want the inventory under an existing property.\n\nRepoints both the Airbnb record and its platform link to the target listing in one transaction. Idempotent — re-mapping to the same listing is a 200 no-op (`alreadyMapped: true`). Scope is enforced against your workspace on both the target listing and the existing Airbnb record; a listing that already links a different Airbnb listing returns 409.","tags":["Airbnb"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MapAirbnbListingRequest"}}}},"responses":{"200":{"description":"Mapped","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MapAirbnbListingResponse"}}}},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/channels/airbnb/listings/{id}/pricing":{"get":{"operationId":"get_airbnb_listing_pricing","summary":"Get Airbnb pricing","description":"Read the current pricing config (base price, weekend uplift, length-of-stay discounts, smart-pricing bounds) for an Airbnb listing.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pricing data"}}},"put":{"operationId":"update_airbnb_listing_pricing","summary":"Update Airbnb pricing","description":"Push pricing changes to Airbnb. The `type` discriminator selects the sub-resource (model, standard settings, LOS, rate-plan, fees, currency, rule, or per-date `calendar`). `type: \"calendar\"` carries the full per-date restriction set — nightly price, min/max nights, closed-to-arrival, closed-to-departure, and stop-sell (`availability: \"unavailable\"`). For settings sub-resources the full object is replaced — GET first, mutate locally, then PUT the whole object.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbPricingWriteRequest"}}}},"responses":{"200":{"description":"Updated"}}}},"/v1/channels/airbnb/listings/{id}/availability":{"get":{"operationId":"get_airbnb_listing_availability","summary":"Get Airbnb availability","description":"Read the per-day availability calendar for an Airbnb listing. Returns one row per day including price overrides, min-stay, and blocked status.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Availability data"}}},"put":{"operationId":"update_airbnb_listing_availability","summary":"Update Airbnb availability","description":"Push availability + restrictions to Airbnb. `type: \"calendar\"` writes per-date restrictions — min/max nights, closed-to-arrival, closed-to-departure, and stop-sell (`availability: \"unavailable\"`) — via a batch of operations that each target either a date range or an explicit date list. `type: \"rules\"` writes listing-level availability rules (default min/max nights, booking lead time, turnover days, seasonal/day-of-week min nights). Restrictions never leak across channels — this endpoint writes only to Airbnb.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbAvailabilityWriteRequest"}}}},"responses":{"200":{"description":"Updated"}}}},"/v1/channels/airbnb/listings/{id}/photos":{"get":{"operationId":"list_airbnb_listing_photos","summary":"List Airbnb photos","description":"List photos attached to an Airbnb listing in display order. Returns the public CDN URL plus Airbnb-side metadata (id, caption, room).","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Photos"}}},"post":{"operationId":"upload_airbnb_listing_photos","summary":"Upload photos to Airbnb","description":"Upload one or more photos to an Airbnb listing. Accepts public image URLs (Airbnb fetches them) — direct binary upload is not supported on this endpoint.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"201":{"description":"Uploaded"}}},"delete":{"operationId":"delete_airbnb_listing_photo","summary":"Delete an Airbnb photo","description":"Remove a single photo from an Airbnb listing. Pass the Airbnb-side photo id as `?photoId=`. Write-side — calls Airbnb upstream; the local photo cache is reconciled by the sync worker afterwards.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"photoId","in":"query","required":true,"schema":{"type":"string"},"description":"Airbnb-side photo id to delete."}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean","example":true}}}}}},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/messaging":{"get":{"operationId":"list_airbnb_threads","summary":"List Airbnb message threads","description":"List Airbnb message threads (one per guest conversation). Cursor-paginated. Each thread includes a preview of the latest message.","tags":["Airbnb"],"responses":{"200":{"description":"Message threads","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbThreadListResponse"}}}}}}},"/v1/channels/airbnb/messaging/{threadId}/messages":{"get":{"operationId":"list_airbnb_thread_messages","summary":"Get Airbnb messages","description":"Fetch the full message log for an Airbnb thread, ordered oldest-to-newest. Walk pages with `?cursor=` until `pagination.hasMore` is `false`.","tags":["Airbnb"],"parameters":[{"name":"threadId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Messages","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageListResponse"}}}}}},"post":{"operationId":"send_airbnb_message","summary":"Send Airbnb message","description":"Send a message in an Airbnb thread as the host. Airbnb enforces content rules (no off-platform contact info, no external URLs) — violating messages are rejected upstream and surface as `airbnb_error`.\n\nThe `{threadId}` is the Airbnb thread id — the `externalThreadId` field on a unified `Conversation` (`GET /v1/conversations`).","tags":["Airbnb"],"parameters":[{"name":"threadId","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb thread id (the `externalThreadId` on a unified `Conversation`)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"string","description":"Message body to send to the guest."},"mediaUrl":{"type":"string","format":"uri","nullable":true,"description":"Optional URL of an image/media attachment to send with the message."},"mediaType":{"type":"string","nullable":true,"description":"Optional MIME/media type hint for `mediaUrl` (e.g. `image/jpeg`)."}}}}}},"responses":{"201":{"description":"Sent"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/reservations":{"get":{"operationId":"list_airbnb_reservations","summary":"List Airbnb reservations","description":"Cursor-paginated list of reservations sourced directly from Airbnb. Use this when you need Airbnb-specific fields (guest payout split, cancellation policy snapshot) that the unified `/v1/reservations` endpoint flattens away.\n\nWalk pages with `?cursor=<pagination.next_cursor>` until `pagination.has_more` is `false`. The cursor is opaque — never construct or parse it client-side.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`. Internally this walks upstream Airbnb cursor pages to skip rows, so deep offsets cost N/limit upstream round-trips; cursor remains the better choice for deep pagination.\n\nWhen `status` is omitted, all statuses are returned (Airbnb defaults to `accepted` only on its own surface, but this endpoint normalises to \"all\"). Pass `?status=accepted` to scope.","tags":["Airbnb"],"parameters":[{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned by the previous response's `pagination.next_cursor`. Omit to fetch the first page."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":100},"description":"Max items per page. Hard cap is 100."},{"name":"listing_id","in":"query","schema":{"type":"string"},"description":"Filter to one Airbnb listing id (numeric string)."},{"name":"status","in":"query","schema":{"type":"string","enum":["pending","accepted","denied","cancelled","completed","failed_verification","request_voided"]},"description":"Filter by reservation status. Omit to receive all statuses."},{"name":"start_date","in":"query","schema":{"type":"string","format":"date"},"description":"ISO 8601 (YYYY-MM-DD) lower bound on Airbnb's date range filter."},{"name":"end_date","in":"query","schema":{"type":"string","format":"date"},"description":"ISO 8601 (YYYY-MM-DD) upper bound on Airbnb's date range filter."},{"name":"include_total","in":"query","schema":{"type":"boolean","default":true},"description":"Whether to include `pagination.total`. Always populated when Airbnb returns a total count (effectively always); accepted for shape symmetry with the rest of the API."}],"responses":{"200":{"description":"Reservations","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbReservationListResponse"}}}}}}},"/v1/channels/airbnb/reservations/{code}":{"get":{"operationId":"get_airbnb_reservation","summary":"Get Airbnb reservation","description":"Fetch a single Airbnb reservation by Airbnb confirmation code (e.g. `HMABCDEF12`).","tags":["Airbnb"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Reservation details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbReservation"}}}}}},"post":{"operationId":"airbnb_reservation_action","summary":"Accept/decline/cancel Airbnb reservation","description":"Apply a state action to an Airbnb reservation — `accept` / `decline` (for inquiries and reservation requests), `cancel` (host cancellation, carries penalties), `pre-approve` (for inquiries).","tags":["Airbnb"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Action completed"}}}},"/v1/channels/airbnb/reviews":{"get":{"operationId":"list_airbnb_reviews","summary":"List Airbnb reviews","description":"List reviews left by guests on Airbnb listings in this workspace. Includes both reviews of the host and reviews of the guest (where the host has not yet submitted theirs).","tags":["Airbnb"],"responses":{"200":{"description":"Reviews","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbReviewListResponse"}}}}}},"post":{"operationId":"respond_airbnb_review_legacy","summary":"Respond to / submit Airbnb review (legacy)","description":"Legacy action-based shape. Body `{ action: \"respond\"|\"submit\", reviewId, response?, review? }`. Kept for backwards compatibility — prefer `PUT /v1/channels/airbnb/reviews/{id}` (edit) and `POST /v1/channels/airbnb/reviews/{id}/respond` (reply) for new integrations.","tags":["Airbnb"],"deprecated":true,"responses":{"200":{"description":"Response posted"},"201":{"description":"Review submitted"}}}},"/v1/channels/airbnb/reviews/{id}":{"put":{"operationId":"edit_airbnb_review","summary":"Edit Airbnb host review","description":"Edit a host-side review for an Airbnb stay. Airbnb collapses POST + PUT into the same upstream call (`PUT /v2/listing_reviews/{id}`), so this endpoint covers both initial submit and subsequent edits while the review window is open.\n\nBody is a partial `AirbnbReview` — pass the fields you want to change (rating, public review, private feedback, category ratings).","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb review id (`HRabc123` style)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbReview"}}}},"responses":{"200":{"description":"Updated review","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbReview"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/reviews/{id}/respond":{"post":{"operationId":"respond_airbnb_review","summary":"Respond to Airbnb review","description":"Post a public host response to a guest review. Airbnb allows one response per review — repeated POSTs return 409. Response text is capped at 1000 characters.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb review id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["response"],"properties":{"response":{"type":"string","maxLength":1000,"description":"Public response text. Capped at 1000 characters."}}}}}},"responses":{"200":{"description":"Response posted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbReview"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/alterations":{"get":{"operationId":"list_airbnb_alterations","summary":"List Airbnb alterations","description":"List reservation alteration requests for Airbnb reservations in this workspace. **Pure DB read** from the local `reservation_alterations` mirror — never calls Airbnb upstream — scoped to your workspace via the reservations join.\n\nDefault returns only pending alterations; pass `?type=all` for the full history. Filter to a single reservation with `?reservation_code=<confirmation code>`. Every response carries the `data_freshness` envelope.","tags":["Airbnb"],"parameters":[{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["pending","all"],"default":"pending"},"description":"Scope: `pending` (default) returns only alterations awaiting a decision; `all` returns every alteration."},{"name":"reservation_code","in":"query","required":false,"schema":{"type":"string"},"description":"Airbnb confirmation code — restricts results to a single reservation. Returns an empty array when no reservation matches within your workspace."}],"responses":{"200":{"description":"Alterations","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbAlteration"}},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"create_airbnb_alteration","summary":"Create Airbnb alteration","description":"Create a reservation alteration request (change dates, guest count, or price) on Airbnb. **Write-side** — calls Airbnb upstream. Requires a connected Airbnb host for the workspace, else `404 no_connection`.","tags":["Airbnb"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["confirmation_code"],"properties":{"confirmation_code":{"type":"string","description":"Airbnb confirmation code of the reservation to alter."},"check_in":{"type":"string","format":"date","description":"New check-in date (YYYY-MM-DD)."},"check_out":{"type":"string","format":"date","description":"New check-out date (YYYY-MM-DD)."},"number_of_guests":{"type":"integer","description":"New guest count."}},"additionalProperties":true}}}},"responses":{"201":{"description":"Alteration created"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/alterations/{id}":{"get":{"operationId":"get_airbnb_alteration","summary":"Get Airbnb alteration","description":"Fetch a single Airbnb reservation alteration by its Airbnb alteration id. **Pure DB read**, workspace-scoped via the reservations join. Returns `404 not_found` when no alteration matches the id in your workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb alteration id."}],"responses":{"200":{"description":"Alteration","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"$ref":"#/components/schemas/AirbnbAlteration"},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/alterations/{id}/accept":{"post":{"operationId":"accept_airbnb_alteration","summary":"Accept Airbnb alteration","description":"Accept a pending Airbnb reservation alteration. **Write-side** — calls Airbnb upstream (`respondToAlteration`) to approve the proposed date / guest-count / price change. Requires a connected Airbnb host for the workspace (else `404 no_connection`) and that the alteration id belongs to a reservation in your workspace (else `404 not_found`). No request body is required.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb alteration id (the `alterationId` from a `GET /v1/channels/airbnb/alterations` row)."}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","description":"No fields required. An empty body is accepted.","additionalProperties":false}}}},"responses":{"200":{"description":"Alteration accepted"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/alterations/{id}/decline":{"post":{"operationId":"decline_airbnb_alteration","summary":"Decline Airbnb alteration","description":"Decline a pending Airbnb reservation alteration. **Write-side** — calls Airbnb upstream (`respondToAlteration`) to reject the proposed change. Requires a connected Airbnb host for the workspace (else `404 no_connection`) and that the alteration id belongs to a reservation in your workspace (else `404 not_found`). No request body is required.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb alteration id (the `alterationId` from a `GET /v1/channels/airbnb/alterations` row)."}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","description":"No fields required. An empty body is accepted.","additionalProperties":false}}}},"responses":{"200":{"description":"Alteration declined"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/transactions":{"get":{"operationId":"list_airbnb_transactions","summary":"List Airbnb transactions","description":"List Airbnb host transactions (reservation earnings, payouts, resolution adjustments) for this workspace, newest first. **Pure DB read** — customer-facing reads never call Airbnb upstream; they serve the `airbnb_transactions` mirror. Each row carries the genuine host- and guest-side financial breakdown (accommodation subtotal, cleaning fee, host + guest service fees split base/VAT, tax buckets, expected/actual host payout with settlement status). Trigger a refresh with `POST` on this path. When the mirror is empty or the host disconnected, `data_freshness.stale = true` with a `reason` (`never_synced`, `host_disconnected_<iso>`, `sync_lag_>_24h`).","tags":["Airbnb"],"responses":{"200":{"description":"Transactions","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbTransaction"}},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"sync_airbnb_transactions","summary":"Sync Airbnb transactions","description":"Refresh the Airbnb transactions mirror for this workspace by pulling from Airbnb upstream and upserting the breakdown that `GET` serves. Optional JSON body `{ start_date, end_date, transaction_type }` (`transaction_type` is `COMPLETED` or `UPCOMING`; both are synced when omitted). Returns `{ synced, count }`.","tags":["Airbnb"],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"start_date":{"type":"string","format":"date","description":"Inclusive lower bound on transaction date."},"end_date":{"type":"string","format":"date","description":"Inclusive upper bound on transaction date."},"transaction_type":{"type":"string","enum":["COMPLETED","UPCOMING"]}}}}}},"responses":{"200":{"description":"Sync result","content":{"application/json":{"schema":{"type":"object","required":["synced","count"],"properties":{"synced":{"type":"boolean"},"count":{"type":"integer","description":"Number of transactions upserted."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"No connected Airbnb account for this workspace.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/messaging/{threadId}":{"get":{"operationId":"get_airbnb_thread","summary":"Get Airbnb thread","description":"Fetch a single Airbnb message thread by its Airbnb thread id. **Pure DB read** from the local `message_threads` mirror, workspace-scoped. Returns `404 not_found` when no thread matches. For the messages within a thread use `GET /v1/channels/airbnb/messaging/{threadId}/messages`.","tags":["Airbnb"],"parameters":[{"name":"threadId","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb thread id (matches the external thread id)."}],"responses":{"200":{"description":"Thread","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"$ref":"#/components/schemas/AirbnbThread"},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/messaging/{threadId}/messages/{messageId}":{"patch":{"operationId":"update_airbnb_message","summary":"Edit / react to / mark an Airbnb message","description":"Act on a single message in an Airbnb thread. **Write-side** — calls Airbnb upstream. The `action` discriminator selects the operation:\n\n- `edit` — replace message text (requires `message`).\n- `unsend` — retract the message.\n- `read` — mark the message as read.\n- `react` — add a reaction (requires `reaction`).\n\nRequires a connected Airbnb host, else `404 no_connection`.","tags":["Airbnb"],"parameters":[{"name":"threadId","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb thread id."},{"name":"messageId","in":"path","required":true,"schema":{"type":"string"},"description":"Airbnb message id within the thread."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["edit","unsend","read","react"],"description":"Operation to perform on the message."},"message":{"type":"string","description":"New message text. Required when `action` is `edit`."},"reaction":{"type":"string","description":"Reaction to add. Required when `action` is `react`."}}}}}},"responses":{"200":{"description":"Message updated"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings/{id}/amenities":{"get":{"operationId":"list_airbnb_listing_amenities","summary":"List Airbnb amenities","description":"List an Airbnb listing's amenities. **Pure DB read** from the local `listings_airbnb_amenities` cache — never calls Airbnb upstream. The response splits amenities into `amenities` (regular) and `accessibility_amenities` (step-free access, wide doorways, grab rails, disabled parking, wheelchair, accessible-height fixtures, hoists, etc). Both are arrays (`[]` when none). Consult `data_freshness` to disambiguate \"never synced\" from \"fresh and genuinely empty\". Returns `404` when the listing has no Airbnb connection in this workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."}],"responses":{"200":{"description":"Amenities","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"type":"object","properties":{"amenities":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbAmenity"}},"accessibility_amenities":{"type":"array","items":{"$ref":"#/components/schemas/AirbnbAmenity"}}}},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings/{id}/checkin-guide":{"get":{"operationId":"get_airbnb_checkin_guide","summary":"Get Airbnb check-in guide","description":"Return every published locale variant of an Airbnb listing's check-in guide. **Pure DB read** from `listings_airbnb_check_in_guides`. Pass `?locale=en` to filter to one locale (prefix match). Returns `404` when the listing has no Airbnb connection in this workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."},{"name":"locale","in":"query","required":false,"schema":{"type":"string","example":"en"},"description":"Filter to a single locale (prefix match, case-insensitive)."}],"responses":{"200":{"description":"Check-in guides","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"operationId":"update_airbnb_checkin_guide","summary":"Upsert Airbnb check-in guide","description":"Upsert the check-in guide for one locale on an Airbnb listing. **Write-side** — calls Airbnb upstream; the DB mirror is reconciled by the sync worker once the upstream call returns. Target the locale with `?locale=en` (defaults to `en`). Requires a connected Airbnb host, else `404 no_connection`.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."},{"name":"locale","in":"query","required":false,"schema":{"type":"string","default":"en"},"description":"Locale to upsert. Defaults to `en`."}],"responses":{"200":{"description":"Check-in guide upserted"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings/{id}/checkout-guide":{"get":{"operationId":"get_airbnb_checkout_guide","summary":"Get Airbnb checkout guide","description":"Return the checkout tasks an Airbnb listing shows guests at departure. **Pure DB read** from `listings_airbnb_checkout_tasks`. Returns `404` when the listing has no Airbnb connection in this workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."}],"responses":{"200":{"description":"Checkout tasks","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings/{id}/descriptions":{"get":{"operationId":"list_airbnb_listing_descriptions","summary":"List Airbnb descriptions","description":"List an Airbnb listing's per-locale content (name, summary, house rules, etc). **Pure DB read** from `listings_airbnb_descriptions`. Filter to one locale with `?locale=en` (the legacy `?country=` param is accepted as a soft alias). Returns `404` when the listing has no Airbnb connection in this workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."},{"name":"locale","in":"query","required":false,"schema":{"type":"string","example":"en"},"description":"Filter to a single locale (prefix match, case-insensitive)."},{"name":"country","in":"query","required":false,"schema":{"type":"string"},"description":"Legacy alias for `locale`. Prefer `locale`."}],"responses":{"200":{"description":"Descriptions","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings/{id}/quality":{"get":{"operationId":"get_airbnb_listing_quality","summary":"Get Airbnb listing quality","description":"Return an Airbnb listing's quality signals — standards, reservation issues, and monthly quality stats. **Pure DB read** from the local quality mirrors. Scope the response with `?type=all|standards|issues|stats` (default `all`, which returns `{ standards, issues }`). Returns `404` when the listing has no Airbnb connection in this workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."},{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["all","standards","issues","stats"],"default":"all"},"description":"Which quality slice to return. `all` returns `{ standards, issues }`."}],"responses":{"200":{"description":"Quality data","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"description":"Shape depends on `type`: an object `{ standards, issues }` for `all`/`standards`, or an array for `issues`/`stats`."},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings/{id}/rooms":{"get":{"operationId":"list_airbnb_listing_rooms","summary":"List Airbnb rooms","description":"List the rooms configured on an Airbnb listing, ordered by room number. **Pure DB read** from `listings_airbnb_rooms`. Returns `404` when the listing has no Airbnb connection in this workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."}],"responses":{"200":{"description":"Rooms","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"create_airbnb_listing_room","summary":"Create an Airbnb room","description":"Create a new room on an Airbnb listing. **Write-side** — calls Airbnb upstream. Body is the full room object minus `room_id`. Requires a connected Airbnb host, else `404 no_connection`.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"description":"Full Airbnb room object minus `room_id`."}}}},"responses":{"201":{"description":"Room created"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"delete_airbnb_listing_room","summary":"Delete an Airbnb room","description":"Delete a room from an Airbnb listing. **Write-side** — calls Airbnb upstream. Pass the Airbnb-side room id as `?roomId=`. Requires a connected Airbnb host, else `404 no_connection`.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."},{"name":"roomId","in":"query","required":true,"schema":{"type":"string"},"description":"Airbnb-side room id to delete."}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean","example":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/listings/{id}/settings":{"get":{"operationId":"get_airbnb_listing_settings","summary":"Get Airbnb listing settings","description":"Return an Airbnb listing's host roles, published locales, and regulatory permits. **Pure DB read** — host roles from `listings_airbnb_details.host_roles`, locales from distinct `listings_airbnb_descriptions.locale`, permits from `listings_airbnb_permits`. Scope with `?type=all|hosts|permits|locales` (default `all`, which returns `{ hosts, locales }`). Returns `404` when the listing has no Airbnb connection in this workspace.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Repull listing id (numeric string)."},{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["all","hosts","permits","locales"],"default":"all"},"description":"Which settings slice to return. `all` returns `{ hosts, locales }`."}],"responses":{"200":{"description":"Settings","content":{"application/json":{"schema":{"type":"object","required":["data","data_freshness"],"properties":{"data":{"description":"Shape depends on `type`: `{ hosts, locales }` for `all`, or an array for `hosts`/`permits`/`locales`."},"data_freshness":{"$ref":"#/components/schemas/AirbnbDataFreshness"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/airbnb/offers":{"post":{"operationId":"create_airbnb_offer","summary":"Create Airbnb special offer or pre-approval","description":"Create a special offer or a pre-approval on Airbnb. **Write-side** — calls Airbnb upstream. The `type` discriminator selects the flavour:\n\n- `offer` — a special offer with custom terms (the remaining body fields are the offer params).\n- `preapproval` — pre-approve an inquiry thread (requires `threadId`; optional `blockInstantBooking`).\n\nRequires a connected Airbnb host, else `404 no_connection`.","tags":["Airbnb"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["type"],"properties":{"type":{"type":"string","enum":["offer","preapproval"],"description":"Which kind of offer to create."},"threadId":{"type":"string","description":"Airbnb thread id. Required when `type` is `preapproval`."},"blockInstantBooking":{"type":"boolean","default":false,"description":"For `preapproval` — whether to block instant booking."}},"additionalProperties":true}}}},"responses":{"201":{"description":"Offer created"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"withdraw_airbnb_offer","summary":"Withdraw Airbnb special offer","description":"Withdraw a previously-created Airbnb special offer. **Write-side** — calls Airbnb upstream. Pass the offer id as `?offerId=`. Requires a connected Airbnb host, else `404 no_connection`.","tags":["Airbnb"],"parameters":[{"name":"offerId","in":"query","required":true,"schema":{"type":"string"},"description":"Airbnb special-offer id to withdraw."}],"responses":{"200":{"description":"Offer withdrawn"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/listings":{"get":{"operationId":"listListings","summary":"List listings","description":"Cursor-paginated list of listings owned by the authenticated workspace. Use `pagination.nextCursor` from one response as the `cursor` query param of the next request to walk the full set. `?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`. Filters: `q` (substring on name/street/city), `status`, `channel`.\n\n**Optional expansions:** Pass `?include=content` to enrich each row with the rich content slab (summary, description, space, house rules, etc. — sourced from `listings_descriptions` for the `en` locale). Pass `?include=details` for the structural slab (bedrooms, bathrooms, person capacity, check-in window, wifi, house manual, etc.). Both default to `null` per row when the underlying `listings_descriptions` / `listings_details` row is missing — distinct from the field being absent (which signals the expansion was not requested). Combine comma-separated, e.g. `?include=content,details`. The default response stays lean; consumers must opt in.","tags":["Listings"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100},"description":"Max items per page. Hard cap is 100."},{"name":"q","in":"query","schema":{"type":"string"},"description":"Case-insensitive substring search on name, street, or city."},{"name":"status","in":"query","schema":{"type":"string","enum":["active","inactive","archived"]},"description":"Filter by listing status."},{"name":"channel","in":"query","schema":{"type":"string","example":"airbnb"},"description":"Restrict to listings published on the given channel (`airbnb`, `booking`, `vrbo`, etc.). Joins through `listing_platform_links` and matches active links only."},{"name":"include","in":"query","required":false,"schema":{"type":"string","example":"content,details"},"description":"Comma-separated optional expansions. Currently supported: `content`, `details`. Unknown values return 422 with a `valid_values` envelope. (Note: `amenities` is not yet supported on the list endpoint — use the detail endpoint to fetch amenity rows for a single listing.)"}],"responses":{"200":{"description":"Listings page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingListResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"post":{"operationId":"createListing","summary":"Create a Repull listing","description":"Create a new vacation-rental listing under the authenticated workspace. The listing is stored in the canonical Vanio listings tables and can be published to multiple channels (Airbnb, Booking.com) via the publish endpoints.","tags":["Listings"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingCreateRequest"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingCreateResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"402":{"$ref":"#/components/responses/PaymentRequired"}}}},"/v1/listings/{id}":{"get":{"operationId":"getListing","summary":"Get a listing","description":"Fetch a single listing by id. Returns the same shape as one element of the `GET /v1/listings` response, so you can bind the result to the same model. Cross-tenant access (a listing that belongs to a different workspace) returns 404 — never 403, never reveals the listing's existence.\n\n**Optional expansions:** Pass `?include=amenities` to enrich the response with the listing's amenity rows (`[]` when the listing has none). Pass `?include=content` for the rich content slab (summary, description, space, house rules, etc. — sourced from `listings_descriptions` for the `en` locale; `null` when no row is stored). Pass `?include=details` for the structural slab (bedrooms, bathrooms, person capacity, check-in window, wifi, house manual, etc.; `null` when no row is stored). Combine comma-separated, e.g. `?include=amenities,content,details`. The default response stays lean; consumers must opt in.","tags":["Listings"],"parameters":[{"$ref":"#/components/parameters/XSchemaHeader"},{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull listing id"},{"name":"include","in":"query","required":false,"schema":{"type":"string","example":"content,details"},"description":"Comma-separated optional expansions. Currently supported: `amenities`, `content`, `details`. Unknown values return 422 with a `valid_values` envelope."}],"responses":{"200":{"description":"The listing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Listing"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"patch":{"operationId":"updateListingActive","summary":"Deactivate or reactivate a listing","description":"Toggle a listing's active state. Send `{ \"active\": false }` to **deactivate** (exclude the listing from Repull) or `{ \"active\": true }` to **reactivate** it.\n\n\"Deactivate\" keeps the listing row — it is NOT a hard delete, and it NEVER touches the upstream channel (Airbnb / Hospitable / Booking.com). Repull only mutates its own copy of the inventory. Deactivating is the self-serve way to get back under the plan-listings cap without paying.\n\nReactivation respects the plan-listings cap: if activating this listing would push you over the cap for your tier, the call returns `402 listings_limit_exceeded` and the listing stays inactive. Deactivate another listing or upgrade first.\n\nIdempotent: setting a listing to the state it's already in returns 200.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull listing id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingActiveRequest"}}}},"responses":{"200":{"description":"Updated active state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingActiveResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"operationId":"deactivateListing","summary":"Deactivate (exclude) a listing","description":"Deactivate a listing — sets it inactive and excludes it from Repull. This is a **soft** operation: the listing row is KEPT (never hard-deleted) and the upstream channel (Airbnb / Hospitable / Booking.com) is NEVER touched. Repull only mutates its own copy.\n\nEquivalent to `PATCH /v1/listings/{id}` with `{ \"active\": false }`. This is the primary self-serve way for a free-tier customer to trim back under the plan-listings cap — `DELETE` is served even when the account is over the cap (a 402-locked account can still call it). To bring a listing back, use `PATCH` with `{ \"active\": true }`.\n\nIdempotent: deactivating an already-inactive listing returns 200.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull listing id"}],"responses":{"200":{"description":"Listing deactivated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingActiveResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/listings/{id}/content":{"put":{"operationId":"updateListingContent","summary":"Update canonical listing content","description":"Write your PMS's canonical listing content — title, description, amenities, address, occupancy, and policies — into a Repull listing, making it the source of truth. This is the flagship \"the PMS owns listing content, Repull distributes it\" enabler.\n\n**Partial update:** every field is optional. Only the fields you send are written; absent fields are left untouched. `amenities` is a FULL replacement of the amenity set (omit to leave untouched, send `[]` to clear).\n\n**Local write only — NOT a channel publish.** This mutates Repull's own copy of the content. It does NOT push to Airbnb / Booking.com; it marks the channels dirty so a later publish knows what changed. Distribution stays a separate explicit step.\n\n**Photos are deferred:** a provided `photos` array is echoed back in the `deferred` field and NOT persisted (media ingestion is a follow-up).\n\nCross-tenant access (a listing that belongs to a different workspace) returns 404 — never 403. This endpoint is served even when the account is over the plan-listings cap, since editing content on a listing you already own never grows the portfolio.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull listing id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingContentUpdateRequest"}}}},"responses":{"200":{"description":"Content updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingContentUpdateResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/listings/{id}/generate-content":{"post":{"operationId":"generateListingContent","summary":"AI-generate listing content","description":"Generate guest-facing copy (title, summary, description, amenities, etc.) for a listing using Repull AI. When `photos` are provided the vision model is used for photo-grounded copy. Persists into the listing by default.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingGenerateContentRequest"}}}},"responses":{"200":{"description":"Generated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingGenerateContentResponse"}}}},"404":{"$ref":"#/components/responses/NotFound"},"502":{"description":"AI provider failed or returned non-JSON"}}}},"/v1/listings/{id}/publish/airbnb":{"post":{"operationId":"publishListingToAirbnb","summary":"Publish a listing to Airbnb","description":"Push a Repull listing to Airbnb. Pass `airbnbConnectionId` to update an already-mapped Airbnb listing, or `hostId` to create a brand-new Airbnb listing under that host.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPublishAirbnbRequest"}}}},"responses":{"200":{"description":"Pushed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPublishResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/listings/{id}/publish/booking":{"post":{"operationId":"publishListingToBooking","summary":"Publish a listing to Booking.com","description":"Push a Repull listing to Booking.com. The listing must already be mapped to a Booking property + room (created via the Booking-claim Connect flow).","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Pushed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPublishResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/listings/{id}/publish-status":{"get":{"operationId":"getListingPublishStatus","summary":"Per-channel publish status","description":"Returns connection state and sync activity per channel. `channels` is sync activity (empty until first push). `connections` is connection state (populated as soon as a channel is linked). Recommended polling cadence: at most once per 30s per listing — for bulk views, prefer `GET /v1/listings` and filter client-side.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPublishStatusResponse"}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/listings/{id}/photos/upload-url":{"post":{"operationId":"createListingPhotoUploadUrl","summary":"Mint a direct-to-storage photo upload URL","description":"Mints a short-lived signed upload URL + token for a listing photo. **The client PUTs the raw file bytes directly to the returned `uploadUrl` — the file bytes never pass through the Repull API or main vanio.** This endpoint only mints the URL; do not POST the file itself here, it will not be accepted.\n\nFlow: (1) POST here with `fileName`/`fileType`/optional `fileSize` to get `{ uploadUrl, token, path, publicUrl, expiresIn }`; (2) PUT the raw file bytes to `uploadUrl` from the client; (3) `publicUrl` is the durable URL for the uploaded photo — attach it to the listing via `PUT /v1/listings/{id}/content` (`photos` field) or list it back via `GET /v1/listings/{id}/photos`.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull listing id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPhotoUploadUrlRequest"}}}},"responses":{"200":{"description":"Signed upload URL minted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPhotoUploadUrlResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/listings/{id}/photos":{"get":{"operationId":"listListingPhotos","summary":"List a listing's stored photos","description":"Returns the photo set currently stored for this listing.","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull listing id"}],"responses":{"200":{"description":"Photos","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPhotosResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"delete":{"operationId":"deleteListingPhoto","summary":"Delete a stored listing photo","description":"Deletes a single stored photo by its storage `path` (as returned by `GET /v1/listings/{id}/photos` or `POST /v1/listings/{id}/photos/upload-url`).","tags":["Listings"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Repull listing id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPhotoDeleteRequest"}}}},"responses":{"200":{"description":"Photo deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPhotoDeleteResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/channels/booking/properties":{"get":{"operationId":"list_booking_properties","summary":"List Booking.com properties","description":"List Booking.com hotels claimed by this workspace. Each row includes the Booking-side hotel id and the connected room types.","tags":["Booking.com"],"responses":{"200":{"description":"Properties","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingPropertyListResponse"}}}}}}},"/v1/channels/booking/availability":{"get":{"operationId":"get_booking_availability","summary":"Read current Booking.com rates/availability/restrictions","description":"Read the current rate, availability, and restriction state for a Booking.com property so you can reconcile before writing with the PUT on this path. Keyed by `property_id` (the Booking hotel id), symmetric with the PUT.\n\nProxies Booking's `getRoomRateAvailability` — the returned fields (price, rooms-to-sell, min/max stay, closed-to-arrival/departure, stop-sell) are whatever Booking.com emits for the window. A listing-id-keyed equivalent is available at `GET /v1/channels/booking/listings/{id}/pricing`.","tags":["Booking.com"],"parameters":[{"name":"property_id","in":"query","required":true,"schema":{"type":"string"},"description":"Booking.com hotel/property id."},{"name":"start_date","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Window start (ISO YYYY-MM-DD)."},{"name":"number_of_days","in":"query","required":false,"schema":{"type":"integer"},"description":"Window length in days."},{"name":"room_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to a single Booking.com room id."},{"name":"room_level","in":"query","required":false,"schema":{"type":"boolean"},"description":"When true, returns room-level (vs rate-plan-level) state."}],"responses":{"200":{"description":"Current ARI state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingAvailabilityStateResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"operationId":"update_booking_availability","summary":"Update Booking.com rates/availability","description":"Push availability, rates, and the full restriction set to Booking.com. `type` selects the write path:\n\n- `rates` — nightly price + length-of-stay / arrival restrictions (min/max stay, closed-to-arrival, closed-to-departure, advance-reservation window).\n- `availability` — inventory (`availableRooms`), the dedicated stop-sell flag (`closed`), and the same restriction set.\n- `derived-pricing` — occupancy-derived pricing rules.\n\nRestrictions never leak across channels — this endpoint writes only to Booking.com. Errors from upstream surface as `booking_error`.","tags":["Booking.com"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingAvailabilityUpdateRequest"}}}},"responses":{"200":{"description":"Updated"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/content":{"get":{"operationId":"get_booking_content","summary":"Get Booking.com content","description":"Fetch the current content (descriptions, amenities, photos) for a Booking.com property. Used to round-trip edits through Repull.","tags":["Booking.com"],"responses":{"200":{"description":"Content"}}},"post":{"operationId":"update_booking_content","summary":"Update Booking.com content","description":"Push content changes (descriptions, amenities, photos) to Booking.com. Booking enforces editorial review on text fields — changes appear after their content moderation queue clears.","tags":["Booking.com"],"responses":{"200":{"description":"Updated"}}}},"/v1/channels/booking/messaging":{"get":{"operationId":"list_booking_conversations","summary":"List Booking.com conversations","description":"List Booking.com guest conversations. Cursor-paginated. Use the messaging POST to send a reply.","tags":["Booking.com"],"responses":{"200":{"description":"Conversations","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingConversationListResponse"}}}}}},"post":{"operationId":"send_booking_message","summary":"Send Booking.com message","description":"Send a message in a Booking.com conversation as the host. Booking enforces content rules similar to Airbnb.","tags":["Booking.com"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["property_id","conversation_id","message"],"properties":{"property_id":{"type":"integer","description":"Booking.com property (hotel) id the conversation belongs to."},"conversation_id":{"type":"string","description":"Booking.com conversation id to reply in."},"message":{"type":"string","description":"Message body to send to the guest."}}}}}},"responses":{"201":{"description":"Sent"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/reviews":{"get":{"operationId":"list_booking_reviews","summary":"List Booking.com reviews","description":"List guest reviews for a Booking.com property. Pass `property_id` (the Booking.com hotel id) as a query param — required.","tags":["Booking.com"],"parameters":[{"name":"property_id","in":"query","required":true,"schema":{"type":"string"},"description":"Booking.com hotel/property id."}],"responses":{"200":{"description":"Reviews"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"reply_booking_review","summary":"Reply to Booking.com review","description":"Post a public host reply to a guest review on Booking.com. Booking allows one host reply per review — repeated POSTs are rejected by upstream.\n\nBooking.com does NOT support host-authored reviews of guests via the API (platform-level limitation), so this endpoint is reply-only.","tags":["Booking.com"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["property_id","review_id","response"],"properties":{"property_id":{"type":"string","description":"Booking.com hotel/property id."},"review_id":{"type":"string","description":"Booking.com review id (from `GET /v1/channels/booking/reviews`)."},"response":{"type":"string","description":"Public host reply text."}}}}}},"responses":{"200":{"description":"Reply posted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"502":{"description":"Booking.com upstream rejected the reply.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/channels/booking/listings/{id}/pricing":{"get":{"operationId":"getBookingListingPricing","summary":"Get Booking.com pricing for a listing","description":"Resolves the Vanio listing ID to its Booking.com `hotel_id` (via the `listings_booking` mapping owned by the authenticated workspace), then proxies Booking's `getRoomRateAvailability` for the requested window. Pricing on Booking is per-room/per-rate-plan, so `room_id` and `room_level` flow through query params unchanged.\n\nMirrors the per-channel `/listings/{id}/pricing` shape used by Airbnb so SDK consumers can carry a Vanio listing ID across channels.","tags":["Booking.com"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Vanio listing ID — resolved to a Booking.com hotel ID via the workspace mapping."},{"name":"startDate","in":"query","required":false,"schema":{"type":"string","format":"date"}},{"name":"number_of_days","in":"query","required":false,"schema":{"type":"integer"}},{"name":"room_id","in":"query","required":false,"schema":{"type":"string"}},{"name":"room_level","in":"query","required":false,"schema":{"type":"boolean"},"description":"When true, returns room-level (vs rate-plan-level) availability."}],"responses":{"200":{"description":"Pricing pulled from Booking.com","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingPricingResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"operationId":"updateBookingListingPricing","summary":"Update Booking.com pricing for a listing","description":"Pushes one or more rate updates to Booking.com via `updateRates`. Each update needs `roomId` + `rateId` + `dateRange` + `price` + `currency`. Field-level validation runs up front so callers don't have to parse Booking's XML error envelope to discover a missing `roomId`.","tags":["Booking.com"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingPricingUpdateRequest"}}}},"responses":{"200":{"description":"Updates pushed (per-update success/failure breakdown in `errors[]`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingPricingUpdateResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/charges":{"get":{"operationId":"get_booking_charges","summary":"Get Booking.com charges","description":"Fetch the extra-charge set (cleaning fee, resort fee, city tax, etc.) configured for a Booking.com property. Pass the Booking.com `property_id` as a query param — required.","tags":["Booking.com"],"parameters":[{"name":"property_id","in":"query","required":true,"schema":{"type":"string"},"description":"Booking.com hotel/property id."}],"responses":{"200":{"description":"Charges"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"operationId":"update_booking_charges","summary":"Set Booking.com charges","description":"Replace the extra-charge set for a Booking.com property. The body carries the target `property_id` and the full `charges` array — Booking treats the write as a full replacement, so include every charge you want to keep.","tags":["Booking.com"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["property_id","charges"],"properties":{"property_id":{"type":"string","description":"Booking.com hotel/property id."},"charges":{"type":"array","items":{"type":"object","additionalProperties":true},"description":"Full charge set to apply."}}}}}},"responses":{"200":{"description":"Charges updated"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/properties/{id}/rooms":{"get":{"operationId":"list_booking_property_rooms","summary":"List Booking.com rooms + rate-plan ids for a listing","description":"Return every Booking.com room and its rate plans for a listing, each with the `roomId` / `rateId` needed to assemble a restriction write via `PUT /v1/channels/booking/availability`.\n\n`id` is a Vanio listing id — resolved to the Booking `hotel_id` via the workspace mapping (a listing with no active Booking.com mapping returns 404). Sourced from Booking's B.XML roomrates feed, which returns rooms and rate plans together (the rooms-unit feed alone omits rate-plan ids). This is the API-key surface for the room/rate ids that were previously only reachable inside the hosted Connect room-mapping flow.","tags":["Booking.com"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Vanio listing id — resolved to a Booking.com hotel id via the workspace mapping."}],"responses":{"200":{"description":"Rooms with their rate-plan ids","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingRoomsRatesResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/properties/{id}":{"get":{"operationId":"get_booking_property","summary":"Get Booking.com connection for a listing","description":"Return the Booking.com connection record(s) for a Vanio listing — the linked Booking hotel id, sync flags, markup, sync category, and suspension state. Scoped to the authenticated workspace; a listing with no Booking.com connection returns 404.","tags":["Booking.com"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Vanio listing ID."}],"responses":{"200":{"description":"Booking.com connection rows"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/reservations":{"get":{"operationId":"list_booking_reservations","summary":"List Booking.com reservations","description":"Pull reservations from Booking.com. `type=new` (default) returns un-acknowledged bookings; `type=modified` returns changed bookings. Pass both `reservation_id` and `hotel_id` to fetch a single reservation's full details. Acknowledge processed reservations with the POST so Booking stops re-serving them in the `new` queue.","tags":["Booking.com"],"parameters":[{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["new","modified","details"],"default":"new"},"description":"Which set to pull. `details` requires `reservation_id` + `hotel_id`."},{"name":"hotel_id","in":"query","required":false,"schema":{"type":"string"},"description":"Booking.com hotel id — filters `new`/`modified`, and is required with `reservation_id` for details."},{"name":"reservation_id","in":"query","required":false,"schema":{"type":"string"},"description":"Booking.com reservation id — with `hotel_id`, returns that reservation's details."}],"responses":{"200":{"description":"Raw Booking.com reservation payload — passed through from the connector unmodified (NOT the unified `/v1/reservations` shape). With `type=details` (plus `reservation_id` + `hotel_id`) the body is a single `BookingReservation`; with `type=new`/`type=modified` it is the raw Booking OTA collection wrapper whose reservations are `BookingReservation` objects. See the `BookingReservation` schema for the per-reservation field set and caveats.","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/BookingReservation"},{"type":"object","additionalProperties":true,"description":"Raw Booking.com OTA collection wrapper (`new`/`modified` list types) — reservations are nested under the connector's XML-derived collection shape as `BookingReservation` objects."}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"acknowledge_booking_reservations","summary":"Acknowledge Booking.com reservations","description":"Acknowledge one or more reservations so Booking.com removes them from the `new` queue. The body carries `reservation_ids` (non-empty array). Acknowledge only after you have durably persisted each reservation.","tags":["Booking.com"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reservation_ids"],"properties":{"reservation_ids":{"type":"array","items":{"type":"string"},"minItems":1,"description":"Booking.com reservation ids to acknowledge."}}}}}},"responses":{"200":{"description":"Acknowledged"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/setup":{"post":{"operationId":"booking_setup","summary":"Booking.com property setup actions","description":"Action-router for onboarding a property onto Booking.com. Select the step with `action`:\n\n- `create-legal-entity` — register the legal entity (returns 201).\n- `check-legal-status` — poll legal-entity status by `leid`.\n- `check-readiness` — check whether a property is ready to open (`property_id`).\n- `open-property` — open the property for sale (`property_id`).\n- `set-contacts` — set property contacts (`property_id`, `contacts`).\n- `set-policies` — set property policies (`property_id`, plus policy fields).\n\nMissing required fields per action return a validation error; upstream failures surface as `booking_error`.","tags":["Booking.com"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["create-legal-entity","check-legal-status","check-readiness","open-property","set-contacts","set-policies"]},"property_id":{"type":"string","description":"Booking.com property id — required for readiness/open/contacts/policies actions."},"leid":{"type":"integer","description":"Legal entity id — required for `check-legal-status`."},"contacts":{"type":"array","items":{"type":"object","additionalProperties":true},"description":"Contacts payload for `set-contacts`."}},"additionalProperties":true}}}},"responses":{"200":{"description":"Action result"},"201":{"description":"Legal entity created (for `create-legal-entity`)"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/booking/webhooks":{"get":{"operationId":"list_booking_webhooks","summary":"List Booking.com webhook subscriptions","description":"List the workspace's Booking.com Content Notification Service (CNS) subscriptions — the notification types Booking pushes to your callback URLs.","tags":["Booking.com"],"responses":{"200":{"description":"Subscriptions"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"operationId":"create_booking_webhook","summary":"Subscribe to a Booking.com notification","description":"Subscribe to a Booking.com CNS notification type, delivered to `callback_url`. Returns 201 on success.","tags":["Booking.com"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["notification_type","callback_url"],"properties":{"notification_type":{"type":"string","description":"Booking.com CNS notification type."},"callback_url":{"type":"string","format":"uri","description":"HTTPS endpoint Booking.com pushes notifications to."}}}}}},"responses":{"201":{"description":"Subscribed"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"delete_booking_webhook","summary":"Unsubscribe from a Booking.com notification","description":"Remove a Booking.com CNS subscription. Pass the `notification_type` to unsubscribe as a query param — required.","tags":["Booking.com"],"parameters":[{"name":"notification_type","in":"query","required":true,"schema":{"type":"string"},"description":"Booking.com CNS notification type to unsubscribe."}],"responses":{"200":{"description":"Unsubscribed"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/vrbo/listings":{"get":{"operationId":"list_vrbo_listings","summary":"List VRBO listings","description":"List VRBO listings this workspace owns. VRBO is agency-model — Repull reads listings via the public iCal/HTTP feeds.","tags":["VRBO"],"responses":{"200":{"description":"Listings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VrboListingListResponse"}}}}}}},"/v1/channels/vrbo/reservations":{"get":{"operationId":"list_vrbo_reservations","summary":"List VRBO reservations","description":"Cursor-paginated list of VRBO reservations sourced from the public booking feed. Lag is typically 5-10 minutes vs. Airbnb / Booking.com. `?offset=` is accepted as a first-class alias for `?cursor=` (mutually exclusive; offset capped at 10000).","tags":["VRBO"],"parameters":[{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"minimum":1,"maximum":100}},{"$ref":"#/components/parameters/IncludeTotal"}],"responses":{"200":{"description":"Reservations","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VrboReservationListResponse"}}}}}}},"/v1/channels/plumguide/listings":{"get":{"operationId":"list_plumguide_listings","summary":"List Plumguide listings","description":"List Plumguide listings this workspace has access to. Plumguide is approval-based — listings appear once Plumguide has accepted them.","tags":["Plumguide"],"responses":{"200":{"description":"Listings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlumguideListingListResponse"}}}}}}},"/v1/channels/plumguide/availability":{"get":{"operationId":"get_plumguide_availability","summary":"Get Plumguide availability","description":"Read the per-day availability calendar for a Plumguide listing. Returns the same row shape as Airbnb availability for SDK convenience.","tags":["Plumguide"],"responses":{"200":{"description":"Availability"}}},"put":{"operationId":"update_plumguide_availability","summary":"Push availability to Plumguide","description":"Push per-day availability changes to Plumguide. Plumguide accepts only the next 24 months — dates beyond that are silently ignored.","tags":["Plumguide"],"responses":{"200":{"description":"Pushed"}}}},"/v1/channels/plumguide/pricing":{"get":{"operationId":"get_plumguide_pricing","summary":"Get Plumguide pricing","description":"Read the current pricing for a Plumguide listing (base price, currency, weekend uplift).","tags":["Plumguide"],"responses":{"200":{"description":"Pricing"}}},"put":{"operationId":"update_plumguide_pricing","summary":"Push pricing to Plumguide","description":"Push pricing changes to Plumguide. Plumguide rounds all prices to whole units of the listing currency — sub-unit precision is silently truncated.","tags":["Plumguide"],"responses":{"200":{"description":"Pushed"}}}},"/v1/channels/plumguide/bookings":{"get":{"operationId":"list_plumguide_bookings","summary":"List Plumguide bookings","description":"List Plumguide bookings. Default returns all bookings; pass `listing_id` to filter to one listing, or `booking_code` to fetch a single booking.","tags":["Plumguide"],"parameters":[{"name":"listing_id","in":"query","required":false,"schema":{"type":"integer"},"description":"Filter to a single Plumguide listing."},{"name":"booking_code","in":"query","required":false,"schema":{"type":"string"},"description":"Fetch a single booking by its Plumguide booking code."}],"responses":{"200":{"description":"Bookings"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/channels/plumguide/webhooks":{"get":{"operationId":"get_plumguide_webhooks","summary":"Get Plumguide webhook config","description":"Read the current Plumguide webhook configuration for this workspace.","tags":["Plumguide"],"responses":{"200":{"description":"Webhook config"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"put":{"operationId":"update_plumguide_webhooks","summary":"Replace Plumguide webhook config","description":"Replace the Plumguide webhook configuration. The body carries the full webhook config payload — this is a full replacement, not a patch.","tags":["Plumguide"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"description":"Full Plumguide webhook config payload."}}}},"responses":{"200":{"description":"Updated"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"operationId":"delete_plumguide_webhooks","summary":"Remove Plumguide webhook config","description":"Delete the Plumguide webhook configuration for this workspace. Plumguide stops delivering webhooks until a new config is set.","tags":["Plumguide"],"responses":{"200":{"description":"Deleted"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/listings/{id}/pricing":{"get":{"operationId":"get_listing_pricing","summary":"Get pricing recommendations","description":"Returns date-by-date pricing recommendations for a listing's upcoming calendar window, plus the listing's base-price context and a 5km comp summary. Recommendations come from the Atlas pricing model — pre-computed nightly and stored in `pricing_recommendations`. Use POST to apply or decline pending recommendations.","tags":["Pricing"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Listing ID"},{"name":"startDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Inclusive start of the calendar window. Defaults to today."},{"name":"endDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Inclusive end of the calendar window. Defaults to today + 90 days."}],"responses":{"200":{"description":"Pricing recommendations + factors","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPricingResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"502":{"description":"Upstream Atlas/main vanio failure"}}},"post":{"operationId":"apply_listing_pricing","summary":"Apply or decline pricing recommendations","description":"Apply: writes the recommended price to the listing's calendar for the given dates and triggers the platform fan-out (Airbnb / Booking.com / VRBO). Decline: marks the recommendation as `declined` so it stops surfacing — the model can re-recommend on the next training cycle.","tags":["Pricing"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPricingApplyRequest"}}}},"responses":{"200":{"description":"Action completed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPricingApplyResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"}}}},"/v1/listings/{id}/pricing/strategy":{"get":{"operationId":"get_listing_pricing_strategy","summary":"Get pricing strategy","description":"Returns the strategy that constrains how the Atlas pricing model behaves for this listing. If no strategy row exists yet, returns sane defaults flagged with `isDefault: true`.","tags":["Pricing"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Pricing strategy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPricingStrategy"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"put":{"operationId":"update_listing_pricing_strategy","summary":"Update pricing strategy","description":"Upserts the strategy on `(listing_id, customer_id)` — repeated PUTs are idempotent. Send only the fields you want to change; omitted fields take server-side defaults.","tags":["Pricing"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPricingStrategyInput"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}}}}},"/v1/listings/pricing/bulk":{"post":{"operationId":"bulkApplyPricing","summary":"Bulk apply or decline pricing recommendations","description":"Apply or decline pending Atlas pricing recommendations across many listings in one call. Built for power users with hundreds of listings who would otherwise need 500 sequential single-listing POSTs.\n\n- `items` is capped at 500 entries per request — exceeding returns 422.\n- Per-item failures (stale listing IDs, no pending recs, channel auth blips) DO NOT fail the whole batch — partial success is the norm at this scale and the granular `failed[]` array lets the SDK retry just the bad entries.\n- Tier-limit accounting: this endpoint counts as **1 API call** regardless of how many items the body contains.\n\nApply path writes the recommended price to each listing's calendar via the calendar service (which fans out to Airbnb/Booking/VRBO) then marks the Atlas recommendation `applied`. Decline path is Atlas-only — fast.","tags":["Pricing"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPricingRequest"}}}},"responses":{"200":{"description":"Bulk action processed (check `failed[]` for partial failures)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPricingResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"502":{"description":"Upstream Atlas/main vanio failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/listings/{id}/pricing/history":{"get":{"operationId":"getListingPricingHistory","summary":"Pricing recommendation audit trail","description":"Cursor-paginated audit trail of pricing recommendations vs applied prices for a listing across a date window. Use `pagination.nextCursor` from one response as the `cursor` query param of the next request.\n\nDefaults to ±90 days from today. Cursor is a keyset on `date ASC` — stable even if rows are added during a partner's pagination walk. `limit` is capped at 500 — exceeding returns 422.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`.","tags":["Pricing"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"startDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Inclusive. Defaults to today - 90 days."},{"name":"endDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Inclusive. Defaults to today + 90 days."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":100,"minimum":1,"maximum":500}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor returned in the previous response's `pagination.nextCursor`. Omit to fetch the first page."},{"$ref":"#/components/parameters/Offset"}],"responses":{"200":{"description":"Pricing history page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingPricingHistoryResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"502":{"description":"Upstream Atlas/main vanio failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/listings/{id}/comps":{"get":{"operationId":"listListingComps","summary":"Comp set for a listing (with daily nightly pricing)","description":"Returns the actual comp set for a listing — the underlying competitor listings (with daily nightly pricing), not just the aggregated `compSummary` from `/pricing`. Each comp comes back with distance, bedrooms, ratings, lat/lng, platform link, and a per-day rate/availability series for the requested window.\n\nPowered by Atlas. Comps with no coordinates are excluded — there's no way to rank them by distance. Listings without coordinates return `data: []` and a `warning` field.","tags":["Atlas"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"radius_km","in":"query","required":false,"schema":{"type":"number","default":5,"minimum":0.5,"maximum":50},"description":"Bbox + haversine on lat/lng. Default 5, max 50."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"minimum":1,"maximum":100},"description":"Closest-first. Max 100."},{"name":"startDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Defaults to today."},{"name":"endDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Defaults to today + 30 days."}],"responses":{"200":{"description":"Comp set with daily pricing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingCompsResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"502":{"description":"Upstream Atlas/main vanio failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/listings/{id}/segments":{"get":{"operationId":"getListingSegments","summary":"Atlas DNA segment intelligence for a listing","description":"Aggregates Atlas DNA segment signal (quality tier, design style, bedrooms) across the listing's geographic neighborhood (default: 5km radius) or the whole city, so consumers can answer:\n- What segments dominate my market?\n- Which segment does my listing match best?\n- What's the ADR uplift for moving up a tier?\n\nDNA coverage is still ramping — segments are scored asynchronously. Cities and radii without scored comps return `totalCompsAnalyzed: 0` plus a `low_dna_coverage` recommendation rather than fabricated data.","tags":["Atlas"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"level","in":"query","required":false,"schema":{"type":"string","enum":["comp_set","market"],"default":"comp_set"},"description":"`comp_set` (default) restricts to a `radius_km` bbox. `market` aggregates across the whole city."},{"name":"radius_km","in":"query","required":false,"schema":{"type":"number","default":5,"minimum":0.5,"maximum":50},"description":"Only used when `level=comp_set`."}],"responses":{"200":{"description":"Segment intelligence (may carry a `low_dna_coverage` recommendation when scope is unscored)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListingSegmentsResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"502":{"description":"Upstream Atlas/main vanio failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/markets":{"get":{"operationId":"list_markets","summary":"List markets the customer operates in","description":"Returns per-city KPIs across every market the authenticated customer has listings in (market share, ADR vs market, occupancy, ratings) plus a lightweight `browse` discovery summary (top-50 featured markets, country categories, total catalog size). For the full paginated discovery catalog with search, call `GET /v1/markets/browse`. Each `markets[]` entry is enriched with `subscribed` + `source` from the customer's market subscriptions.","tags":["Markets"],"responses":{"200":{"description":"Markets overview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketsOverviewResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"502":{"description":"Upstream Atlas/main vanio failure"}}}},"/v1/markets/browse":{"get":{"operationId":"list_market_browse","summary":"Paginated discovery catalog","description":"Cursor-paginated, search-filterable catalog of every Atlas-tracked market the customer could expand into. Backed by the precomputed `market_summaries` table (>=5 active comps per city). Supports fuzzy `q` substring search (trigram-indexed), `country` (ISO 3166-1 alpha-2) filter, and `sort` (`listings_desc` | `name_asc`). Use the `nextCursor` from `pagination` to walk pages — the cursor is an opaque base64 token; do not parse it.\n\n`?offset=` is also accepted as a first-class alias for shallow paging (0..10000) — see the `offset` parameter below. Mutually exclusive with `cursor`.\n\n`pagination.total` is the count of markets matching the current `q`/`country`/`min_listings` filter (across all pages) — same shape as every other list endpoint.","tags":["Markets"],"parameters":[{"name":"q","in":"query","required":false,"schema":{"type":"string"},"description":"Substring match on city name (case-insensitive)."},{"name":"country","in":"query","required":false,"schema":{"type":"string","minLength":2,"maxLength":2},"description":"ISO 3166-1 alpha-2 (e.g. `US`, `ES`)."},{"name":"min_listings","in":"query","required":false,"schema":{"type":"integer","default":5,"minimum":0},"description":"Minimum comp-set size — cities with fewer active comps are excluded."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor returned by the previous page's `pagination.nextCursor`."},{"$ref":"#/components/parameters/Offset"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":30,"minimum":1,"maximum":100}},{"name":"sort","in":"query","required":false,"schema":{"type":"string","enum":["listings_desc","name_asc"],"default":"listings_desc"}}],"responses":{"200":{"description":"Paginated discovery catalog","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketBrowseResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"502":{"description":"Upstream Atlas/main vanio failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/markets/{city}":{"get":{"operationId":"get_market","summary":"Deep-dive on a single market","description":"Detailed market view for one city — price distribution, bedroom mix, property types, upcoming events, Wheelhouse demand, monthly benchmarks, customer health rollup, top comps (proximity-sorted, paginated), customer's percentile position, capacity-mix gap, and a 6-month supply trend.","tags":["Markets"],"parameters":[{"name":"city","in":"path","required":true,"schema":{"type":"string"},"description":"URL-encoded city name (e.g. `Radium%20Hot%20Springs`)."},{"name":"compsPage","in":"query","required":false,"schema":{"type":"integer","default":1,"minimum":1},"description":"1-indexed page number for the `topComps` slice."}],"responses":{"200":{"description":"Market deep-dive","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketDetailResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"502":{"description":"Upstream failure"}}}},"/v1/markets/{city}/calendar":{"get":{"operationId":"get_market_calendar","summary":"Calendar-level market view","description":"Date-by-date market view for a city — market avg / min / max nightly rate, occupancy %, Wheelhouse demand, events touching the date, and (when `listingId` is supplied) an overlay of the customer's own pricing + availability.","tags":["Markets"],"parameters":[{"name":"city","in":"path","required":true,"schema":{"type":"string"}},{"name":"startDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Defaults to today."},{"name":"endDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Defaults to today + 365 days."},{"name":"listingId","in":"query","required":false,"schema":{"type":"integer"},"description":"Optional — overlays the customer's own pricing/availability for direct comparison. Bypasses the upstream cache."}],"responses":{"200":{"description":"Per-date market calendar","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketCalendarResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"502":{"description":"Upstream failure"}}}},"/v1/billing":{"post":{"operationId":"create_billing_checkout","summary":"Create checkout session","description":"Redirect user to Stripe checkout.","tags":["Billing"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"plan":{"type":"string","enum":["starter","growth","scale"]}}}}}},"responses":{"200":{"description":"Checkout URL"}}}},"/v1/usage/logs":{"get":{"operationId":"get_usage_logs","summary":"List API request logs","description":"Cursor-paginated raw API request log for the authenticated workspace, newest first. Filter by time `range`, `operation` id(s), status class, or free-text `q`. Walk pages with `cursor` from `pagination.next_cursor` until `pagination.has_more` is `false`; `offset` is accepted as a shallow alias (deep walks must use `cursor`).","tags":["Billing"],"parameters":[{"name":"range","in":"query","required":false,"schema":{"type":"string","enum":["1h","24h","7d","30d"],"default":"24h"},"description":"Time window ending now."},{"name":"operation","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated `operation_id` filter."},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["2xx","3xx","4xx","5xx"]},"description":"Status-class filter."},{"name":"q","in":"query","required":false,"schema":{"type":"string"},"description":"Free-text match on path / operation / request id."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"minimum":1,"maximum":200},"description":"Page size (max 200)."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from the previous response's `pagination.next_cursor`."},{"$ref":"#/components/parameters/Offset"},{"$ref":"#/components/parameters/IncludeTotal"}],"responses":{"200":{"description":"Log rows + pagination envelope","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"requestId":{"type":"string","nullable":true},"method":{"type":"string"},"path":{"type":"string"},"operationId":{"type":"string","nullable":true},"statusCode":{"type":"integer","nullable":true},"latencyMs":{"type":"integer","nullable":true},"requestBytes":{"type":"integer","nullable":true},"responseBytes":{"type":"integer","nullable":true},"ipAddress":{"type":"string","nullable":true},"userAgent":{"type":"string","nullable":true},"errorCode":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"next_cursor":{"type":"string","nullable":true},"has_more":{"type":"boolean"},"total":{"type":"integer"}}},"range":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/usage/summary":{"get":{"operationId":"get_usage_summary","summary":"Get usage summary","description":"Aggregated usage over the requested `range` — tier + plan limits, quota used/remaining, next reset, a per-operation breakdown (request/error counts, error rate, avg latency), a daily timeline, status-class distribution, and range totals.","tags":["Billing"],"parameters":[{"name":"range","in":"query","required":false,"schema":{"type":"string","enum":["7d","30d","90d"],"default":"30d"},"description":"Aggregation window ending now."}],"responses":{"200":{"description":"Usage summary","content":{"application/json":{"schema":{"type":"object","properties":{"tier":{"type":"string"},"limits":{"type":"object","properties":{"monthly_requests":{"type":"integer","nullable":true},"daily_ai_requests":{"type":"integer","nullable":true}}},"used":{"type":"object","properties":{"monthly":{"type":"integer"},"daily_ai":{"type":"integer"}}},"remaining":{"type":"object","properties":{"monthly":{"type":"integer","nullable":true},"daily_ai":{"type":"integer","nullable":true}}},"resets_at":{"type":"string","format":"date-time"},"breakdown":{"type":"array","items":{"type":"object","properties":{"operationId":{"type":"string"},"requestCount":{"type":"integer"},"errorCount":{"type":"integer"},"errorRate":{"type":"number"},"avgLatencyMs":{"type":"integer"}}}},"timeline":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string"},"requestCount":{"type":"integer"},"errorCount":{"type":"integer"}}}},"statusDistribution":{"type":"object","properties":{"2xx":{"type":"integer"},"3xx":{"type":"integer"},"4xx":{"type":"integer"},"5xx":{"type":"integer"}}},"totals":{"type":"object","properties":{"requests":{"type":"integer"},"errors":{"type":"integer"},"avgLatencyMs":{"type":"integer"}}},"range":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/usage/tier":{"get":{"operationId":"get_usage_tier","summary":"Get tier and quota","description":"Lightweight current-tier snapshot for status badges and quota meters — plan limits (monthly requests, daily AI requests, dynamic-pricing listings), the amount used, the amount remaining, and the next reset. `null` limits mean unlimited on that dimension.","tags":["Billing"],"responses":{"200":{"description":"Tier + quota","content":{"application/json":{"schema":{"type":"object","properties":{"tier":{"type":"string"},"limits":{"type":"object","properties":{"monthly_requests":{"type":"integer","nullable":true},"daily_ai_requests":{"type":"integer","nullable":true},"dynamic_pricing_listings":{"type":"integer","nullable":true}}},"used":{"type":"object","properties":{"monthly":{"type":"integer"},"daily_ai":{"type":"integer"},"dynamic_pricing_listings":{"type":"integer"}}},"remaining":{"type":"object","properties":{"monthly":{"type":"integer","nullable":true},"daily_ai":{"type":"integer","nullable":true},"dynamic_pricing_listings":{"type":"integer","nullable":true}}},"resets_at":{"type":"string","format":"date-time"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/v1/schema/custom":{"post":{"operationId":"createCustomSchema","summary":"Create a custom schema","description":"Create a workspace-scoped field-mapping schema. The schema reshapes the `native` response payload into your app's preferred field names. After creation, set `X-Schema: <name>` on any read endpoint to apply it.\n\n**Reserved names:** `calry`, `calry-v1`, `native` are built-in schemas and cannot be used as a custom name.\n\n**Mapping safety:** Each mapping value is parsed by an internal expression engine — `eval`, `Function`, `process`, and other unsafe keywords are rejected up front. Field names are capped at 100 chars and expressions at 500 chars.","tags":["schema"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomSchemaCreate"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomSchemaCreateResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"get":{"operationId":"listCustomSchemas","summary":"List custom schemas","description":"Returns every custom schema owned by the workspace, including inactive ones.","tags":["schema"],"responses":{"200":{"description":"Custom schemas list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomSchemaListResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}}},"/v1/schema/custom/{id}":{"get":{"operationId":"getCustomSchema","summary":"Get a custom schema","description":"Fetch a single custom schema by id. Scoped to the authenticated workspace — schemas that belong to other workspaces return 404.","tags":["schema"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Custom schema id."}],"responses":{"200":{"description":"Custom schema","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomSchema"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}},"patch":{"operationId":"updateCustomSchema","summary":"Update a custom schema","description":"Patch the description, mappings, or active flag of a custom schema. The schema `name` is immutable — create a new schema and migrate consumers if you need to rename. Mapping updates are revalidated for safety.","tags":["schema"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomSchemaUpdate"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomSchema"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"operationId":"deleteCustomSchema","summary":"Delete a custom schema","description":"Hard-delete a custom schema. Subsequent requests carrying its name in `X-Schema` fall back to `native`. There is no undelete.","tags":["schema"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomSchemaDeleteResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/kv":{"get":{"operationId":"list_kv","summary":"List KV entries","description":"Returns every non-expired key-value row in the given project, sorted ascending by key. Use `prefix` to scope to a key namespace (e.g. `prefix=user:42:` to fetch all entries for one user). Hard cap of 1,000 rows per response — for projects approaching that, paginate by walking prefix buckets.","tags":["KV"],"parameters":[{"name":"project_id","in":"query","schema":{"type":"string","default":"default"},"description":"Project namespace. Defaults to `default`. Free-form string the customer chooses (typically the Studio project id)."},{"name":"prefix","in":"query","schema":{"type":"string"},"description":"Restrict to keys starting with this string. `LIKE` wildcards (`%`, `_`) are escaped — pass them literally."}],"responses":{"200":{"description":"KV entries","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"value":{},"ttl_at":{"type":"string","format":"date-time","nullable":true},"updated_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"total":{"type":"integer"},"has_more":{"type":"boolean"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"delete":{"operationId":"clear_kv","summary":"Clear KV entries by prefix","description":"Bulk-deletes every key in the project whose name starts with `prefix`. The `prefix` parameter is required — there is no \"delete every key in this project\" shortcut; pass an empty `prefix` is rejected with 422 to prevent accidental wipes. Returns the number of rows removed.","tags":["KV"],"parameters":[{"name":"project_id","in":"query","schema":{"type":"string","default":"default"}},{"name":"prefix","in":"query","required":true,"schema":{"type":"string"},"description":"Required. Keys starting with this string are deleted."}],"responses":{"200":{"description":"Bulk delete result","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"integer"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}}},"/v1/kv/{key}":{"get":{"operationId":"get_kv","summary":"Get a KV entry","description":"Fetches a single key. Returns 404 when the key does not exist OR has expired (rows past `ttl_at` are filtered from reads). Cross-tenant lookups also return 404 — the API never reveals existence of another customer's keys.","tags":["KV"],"parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"},"description":"KV key. URL-encode `/`, `:`, etc. so they survive routing."},{"name":"project_id","in":"query","schema":{"type":"string","default":"default"}}],"responses":{"200":{"description":"KV entry","content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string"},"value":{},"ttl_at":{"type":"string","format":"date-time","nullable":true},"updated_at":{"type":"string","format":"date-time"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}},"put":{"operationId":"set_kv","summary":"Set a KV entry","description":"Upserts a key. The full row is replaced — there is no partial update. Pass `ttl_seconds` (positive integer) to auto-expire the row; omit for no expiry. **Caps:** 64 KiB per row (key bytes + value JSON bytes), 1 MiB per customer (sum across ALL projects/keys). Over either cap returns 413.","tags":["KV"],"parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"}},{"name":"project_id","in":"query","schema":{"type":"string","default":"default"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["value"],"properties":{"value":{"description":"Any JSON-serializable value. Stored verbatim."},"ttl_seconds":{"type":"integer","minimum":1,"description":"Optional TTL in seconds. The row's `ttl_at` is set to `now() + ttl_seconds`. Past-`ttl_at` rows are filtered from reads. Pass a positive integer; `0` is rejected."}}},"examples":{"default":{"value":{"value":{"theme":"dark","density":"compact"}}},"withTtl":{"value":{"value":{"token":"abc"},"ttl_seconds":3600}}}}}},"responses":{"200":{"description":"Upserted entry","content":{"application/json":{"schema":{"type":"object","properties":{"key":{"type":"string"},"value":{},"ttl_at":{"type":"string","format":"date-time","nullable":true},"updated_at":{"type":"string","format":"date-time"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"413":{"description":"Payload too large (per-key 64 KiB or per-customer 1 MiB cap exceeded)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"$ref":"#/components/responses/UnprocessableEntity"}}},"delete":{"operationId":"delete_kv","summary":"Delete a KV entry","description":"Removes a single key. Returns `{ deleted: true }` if the row was present, `{ deleted: false }` if it was already absent — both are 200 (idempotent).","tags":["KV"],"parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"}},{"name":"project_id","in":"query","schema":{"type":"string","default":"default"}}],"responses":{"200":{"description":"Delete result","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}}}}