{"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` | 5 |\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":"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":{"Property":{"type":"object","description":"A vacation rental property from a connected PMS","properties":{"id":{"type":"string","description":"Internal Repull property ID"},"externalId":{"type":"string","description":"ID in the source PMS"},"name":{"type":"string","description":"Property name","example":"Oceanview Suite #3"},"address":{"type":"string","description":"Full address"},"city":{"type":"string","example":"Miami Beach"},"state":{"type":"string","example":"FL"},"country":{"type":"string","example":"US"},"latitude":{"type":"number","example":25.7617},"longitude":{"type":"number","example":-80.1918},"bedrooms":{"type":"integer","example":2},"bathrooms":{"type":"number","example":1.5},"maxGuests":{"type":"integer","example":6},"thumbnail":{"type":"string","format":"uri","description":"Primary photo URL"},"provider":{"type":"string","description":"Source PMS","example":"hostaway"},"amenities":{"type":"array","items":{"$ref":"#/components/schemas/ListingAmenity"},"description":"Amenity rows for the property. **Only present when the caller passes `?include=amenities`.** Empty array (`[]`) when the property has no amenity rows."}}},"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.","properties":{"totalPrice":{"type":"number","nullable":true,"description":"Stay total in `currency`. Number, not string.","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)."},"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"]},"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"},"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.)."},"platform":{"type":"string","nullable":true,"enum":["airbnb","booking","vrbo"]},"listingId":{"type":"string","nullable":true,"description":"Internal Repull listing id the review is attached to."},"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","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","listing.created","listing.updated","listing.deleted","calendar.updated","account.created","account.disconnected","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"}}},"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."}}},"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"}}},"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"}}},"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/ListingCreatedEvent"},{"$ref":"#/components/schemas/ListingUpdatedEvent"},{"$ref":"#/components/schemas/ListingDeletedEvent"},{"$ref":"#/components/schemas/CalendarUpdatedEvent"},{"$ref":"#/components/schemas/AccountCreatedEvent"},{"$ref":"#/components/schemas/AccountDisconnectedEvent"},{"$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","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","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}}},"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}}},"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"}}},"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"}}},"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"}}},"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.","properties":{"minStay":{"type":"integer","nullable":true},"maxStay":{"type":"integer","nullable":true},"closedToArrival":{"type":"boolean","nullable":true},"closedToDeparture":{"type":"boolean","nullable":true}}},"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},"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},"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"]},"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/api-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/api-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` = 5, `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 5-listings cap","value":{"error":{"code":"listings_limit_exceeded","message":"Your account has 24 active listings but the 'free' tier is capped at 5. The Repull API only serves accounts whose listing count is within their plan.","fix":"Either reduce your active listings to 5 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":5,"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/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."},{"$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/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"}}},"post":{"operationId":"create_reservation","description":"Create a reservation in the source PMS. Required fields depend on the connected provider (e.g. Airbnb requires guest email; Booking.com requires hotel id). Validation errors return 422 with the offending `field` populated.","summary":"Create a reservation","tags":["Reservations"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["propertyId","checkIn","checkOut","guestFirstName","guestLastName"],"properties":{"propertyId":{"type":"string"},"checkIn":{"type":"string","format":"date"},"checkOut":{"type":"string","format":"date"},"guestFirstName":{"type":"string"},"guestLastName":{"type":"string"},"guestEmail":{"type":"string"},"guestPhone":{"type":"string"},"guestCount":{"type":"integer"},"totalPrice":{"type":"number"},"currency":{"type":"string"}}}}}},"responses":{"201":{"description":"Reservation created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Reservation"}}}}}}},"/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"}}},"patch":{"operationId":"update_reservation","summary":"Update reservation","description":"Patch reservation fields (dates, status, special requests). Only fields included in the body are modified. Use the cancel endpoint for cancellations — DELETE handles cancellation but not partial updates.","tags":["Reservations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"checkIn":{"type":"string","format":"date"},"checkOut":{"type":"string","format":"date"},"status":{"type":"string"},"totalPrice":{"type":"number"}}}}}},"responses":{"200":{"description":"Updated"}}},"delete":{"operationId":"cancel_reservation","summary":"Cancel reservation","description":"Cancel an existing reservation. Cancellation rules vary by provider — Airbnb host-cancellations carry penalties; Booking.com cancellations apply the per-rate-plan policy. Once 200 is returned, the upstream PMS state is committed.","tags":["Reservations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Cancelled"}}}},"/v1/availability/{propertyId}":{"get":{"operationId":"get_availability","summary":"Get availability calendar","description":"Returns day-by-day availability, pricing, and minimum stay for a property.","tags":["Availability"],"parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"integer"}},{"name":"startDate","in":"query","required":true,"schema":{"type":"string","format":"date"}},{"name":"endDate","in":"query","required":true,"schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"Calendar days","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarResponse"}}}}}},"put":{"operationId":"update_availability","summary":"Update availability","description":"Update pricing, availability, and minimum stay for specific dates.","tags":["Availability"],"parameters":[{"name":"propertyId","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"dates":{"type":"array","items":{"$ref":"#/components/schemas/CalendarDay"}}}}}}},"responses":{"200":{"description":"Updated"}}}},"/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."}}}}}},"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` (`read_only` for calendar-only OAuth scopes, or `full_access` — the default — for full host scopes). The response returns a hosted `url` to redirect the user to.","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 only — where to redirect the user after the OAuth flow completes."},"accessType":{"type":"string","enum":["read_only","full_access"],"default":"full_access","description":"Airbnb only — selects the OAuth scope set. 'read_only' grants calendar-only access; 'full_access' grants full host scopes (default)."},"apiKey":{"type":"string","description":"PMS providers — API key."},"clientId":{"type":"string","description":"Plumguide — client ID."},"clientSecret":{"type":"string","description":"Plumguide — client secret."}}}}}},"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"}}}}}},"post":{"operationId":"create_airbnb_listing","summary":"Create/push Airbnb listing","description":"Create a new Airbnb listing or push an existing Repull listing to Airbnb. Requires a connected Airbnb account. Returns the created listing id; publishing happens via the listing-action endpoint.","tags":["Airbnb"],"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AirbnbListing"}}}}}}},"/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 (push/publish/unlist/delete)","description":"Apply a state action to an Airbnb listing — `push` (sync local changes upstream), `publish` (make publicly bookable), `unlist` (hide), or `delete` (permanent). Each action has different reversibility — see docs.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Action completed"}}}},"/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 full pricing object is replaced — to patch a single field, GET first, mutate locally, then PUT the whole object.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"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 per-day availability + pricing overrides to Airbnb. Accepts a sparse map (date → fields) — only included dates are updated.","tags":["Airbnb"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"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"}}}},"/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`.","tags":["Airbnb"],"parameters":[{"name":"threadId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Sent"}}}},"/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/sync":{"post":{"operationId":"sync_airbnb","summary":"Bulk sync to Airbnb","description":"Push all property data to Airbnb in one call.","tags":["Airbnb"],"responses":{"200":{"description":"Sync started"}}}},"/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"}}}},"/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"}}}},"/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/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"}}}}}},"post":{"operationId":"create_booking_property","summary":"Create Booking.com property","description":"Onboard a new Booking.com hotel via the OAuth Connect flow. Returns the hotel id once Stage-1 designation completes in the Extranet.","tags":["Booking.com"],"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookingProperty"}}}}}}},"/v1/channels/booking/availability":{"put":{"operationId":"update_booking_availability","summary":"Update Booking.com rates/availability","description":"Push availability + rate changes to Booking.com's OTA system. Accepts the standard OTA rate message — see Booking's OTA docs for the field shape. Errors from upstream surface as `booking_error`.","tags":["Booking.com"],"responses":{"200":{"description":"Updated"}}}},"/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"],"responses":{"200":{"description":"Sent"}}}},"/v1/channels/booking/sync":{"post":{"operationId":"sync_booking","summary":"Bulk sync to Booking.com","description":"Trigger a full bulk sync of properties + availability + rates to Booking.com. Runs async — returns 202 with a job id; poll `/v1/sync/jobs/{id}` for status.","tags":["Booking.com"],"responses":{"200":{"description":"Sync started"}}}},"/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/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/listings/{id}/pricing":{"get":{"operationId":"getVrboListingPricing","summary":"Get VRBO pricing (501 — agency model)","description":"VRBO uses the agency model — VRBO PULLS rates from `/api/webhooks/vrbo/listings-xml/rates/{listing}/{unit}` rather than accepting a push API. This endpoint is declared for symmetry with the other channel-pricing routes but currently returns **501 Not Implemented** with a pointer at the public rate URL VRBO consumes. Use `GET /v1/listings/{id}/calendar` (once wired) to inspect the underlying source-of-truth.\n\nWhen the listings-XML rate-builder is ported into this repo, this endpoint will return the parsed rates VRBO sees.","tags":["VRBO"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"},"description":"Vanio listing ID — resolved to a VRBO listing/unit via the workspace mapping."}],"responses":{"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"501":{"description":"Not implemented — VRBO is agency-model. Response includes the public rate URL VRBO fetches.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"operationId":"updateVrboListingPricing","summary":"Update VRBO pricing (501 — no push API exists)","description":"VRBO has no rate-push API. To change what VRBO sees, update the underlying Vanio calendar/pricing-settings (e.g. `PUT /v1/listings/{id}/calendar` once wired) — VRBO will pick up the change on its next pull. This endpoint always returns **501** rather than fake-stubbing a successful push the SDK would silently swallow.","tags":["VRBO"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"501":{"description":"Not implemented — VRBO accepts no rate pushes. Update the calendar instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/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/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/ai":{"post":{"operationId":"create_ai_operation","summary":"AI operation","description":"Perform an AI-powered operation.\n\nOperations:\n- `respond-to-guest` — Generate a contextual guest response\n- `classify-intent` — Classify the intent of a guest message\n- `generate-listing` — Generate optimized listing description\n- `review-response` — Generate a review response\n- `price-suggestion` — Get AI pricing suggestions","tags":["AI"],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AIOperation"}}}},"responses":{"200":{"description":"AI response (may be streaming)","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"string"},"confidence":{"type":"number"}}}}}}}}},"/v1/billing":{"get":{"operationId":"get_billing","summary":"Get plan and usage","description":"Fetch the current plan, usage counters, and billing-cycle reset date for this workspace. Use this to surface a \"you have used X / Y\" indicator in your dashboard.","tags":["Billing"],"responses":{"200":{"description":"Plan info and current usage"}}},"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/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"}}}},"/api/studio/projects":{"get":{"summary":"List Studio projects","description":"Returns every Studio project owned by the authenticated account, excluding soft-deleted ones. Use this to populate a project picker or dashboard.","operationId":"listStudioProjects","tags":["Studio"],"responses":{"200":{"description":"Project list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/StudioProject"}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}},"post":{"summary":"Create a Studio project","description":"Spins up a new Studio project from a name + prompt. Repull AI uses the prompt to materialize the initial template; the returned project starts in `draft` status.","operationId":"createStudioProject","tags":["Studio"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","prompt"],"properties":{"name":{"type":"string","description":"Human-readable project name. Used to derive the slug."},"prompt":{"type":"string","description":"Initial prompt that seeds the project. Repull AI scaffolds the first generation from this."},"template_id":{"type":"string","nullable":true,"description":"Optional template to start from (e.g. `next-saas`). Omit to generate from prompt only."}}},"examples":{"default":{"value":{"name":"Booking Pulse","prompt":"A dashboard that surfaces booking velocity by source.","template_id":"next-saas"}}}}}},"responses":{"201":{"description":"Project created (draft state)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"status":{"type":"string","enum":["draft","building","live","archived"]}}}}}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/projects/{id}":{"get":{"summary":"Get a Studio project","description":"Fetches a single Studio project by ID, including its current status and timestamps.","operationId":"getStudioProject","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Project UUID."}],"responses":{"200":{"description":"Project","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/StudioProject"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}},"patch":{"summary":"Update a Studio project","description":"Updates project metadata. Only the included fields are touched; omit a field to leave it unchanged.","operationId":"updateStudioProject","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"status":{"type":"string","enum":["draft","building","live","archived"]}}},"examples":{"rename":{"value":{"name":"Booking Pulse v2"}}}}}},"responses":{"200":{"description":"Updated project","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/StudioProject"}}}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}},"delete":{"summary":"Delete a Studio project","description":"Soft-deletes a project. The project is archived and removed from the listing endpoint, but its files and deployments are retained for recovery.","operationId":"deleteStudioProject","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Project deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"deleted":{"type":"boolean"}}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/projects/{id}/files":{"get":{"summary":"List Studio project files","description":"Returns every file in the project tree with its content, sha256, and size. Use the digests to detect drift before writing.","operationId":"listStudioProjectFiles","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"File list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/StudioFile"}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/projects/{id}/files/{path}":{"put":{"summary":"Upsert a Studio project file","description":"Creates or replaces a file at the given path. Returns the new sha256 so subsequent writes can use optimistic concurrency.","operationId":"upsertStudioProjectFile","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"URL-encoded project-relative path, e.g. `src%2Fapp%2Fpage.tsx`."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content"],"properties":{"content":{"type":"string","description":"Full UTF-8 file contents — partial updates are not supported."}}},"examples":{"default":{"value":{"content":"export default function Page() { return <div>Hello</div> }"}}}}}},"responses":{"200":{"description":"File upserted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"sha256":{"type":"string"}}}}}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}},"delete":{"summary":"Delete a Studio project file","description":"Removes a single file from the project tree. The deployment is not redeployed automatically — trigger a new deployment to apply the change.","operationId":"deleteStudioProjectFile","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"URL-encoded project-relative path."}],"responses":{"200":{"description":"File deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"path":{"type":"string"},"deleted":{"type":"boolean"}}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project or file not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/projects/{id}/generations":{"post":{"summary":"Run a Studio generation","description":"Records a generation run scoped to a single project — Repull AI takes the prompt, generates the response, and stores it on the project timeline. Use this when you want generation history; for one-shot completions without persistence use `POST /api/studio/generate`.","operationId":"createStudioProjectGeneration","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["prompt"],"properties":{"prompt":{"type":"string","description":"Prompt to send to Repull AI."}}},"examples":{"default":{"value":{"prompt":"Add a dark-mode toggle to the header."}}}}}},"responses":{"201":{"description":"Generation recorded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"generation_id":{"type":"string","format":"uuid"},"response":{"type":"string"},"tokens_out":{"type":"integer"}}}}}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}},"headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying."}}}}}},"/api/studio/generate":{"post":{"summary":"Generate text with Repull AI","description":"Sends a prompt to Repull AI and returns the completion synchronously. This is the single LLM endpoint used by the Studio UI; programmatic clients can use it to drive their own vibe-coding flows. Responses include token accounting, cost-in-micros, and cache/fallback flags. 429s include a `Retry-After` header.","operationId":"generateStudioCompletion","tags":["Studio"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["project_id","prompt"],"properties":{"project_id":{"oneOf":[{"type":"string"},{"type":"integer"}],"description":"Project the generation belongs to (used for billing + rate limits)."},"prompt":{"type":"string","maxLength":32000,"description":"User prompt. Up to 32,000 characters."},"system_prompt":{"type":"string","maxLength":32000,"description":"Optional system prompt to steer the response."},"temperature":{"type":"number","minimum":0,"maximum":2,"description":"Sampling temperature. Defaults to model preset."},"max_tokens":{"type":"integer","minimum":1,"maximum":16384,"description":"Maximum completion tokens."}}},"examples":{"default":{"value":{"project_id":"a1b2c3d4-e5f6-7890-abcd-ef0123456789","prompt":"Refactor the header to use a sticky position."}}}}}},"responses":{"200":{"description":"Generation result","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"text":{"type":"string","description":"Generated completion text."},"generation_id":{"type":"string","format":"uuid"},"model":{"type":"string","description":"Model identifier that produced the response."},"tokens_in":{"type":"integer"},"tokens_out":{"type":"integer"},"latency_ms":{"type":"integer"},"cost_usd_micro":{"type":"integer","description":"Cost in millionths of a USD."},"cached":{"type":"boolean","description":"True if the response was served from cache."},"fallback":{"type":"boolean","description":"True if the primary model failed and Repull AI fell back to the secondary."}}}}}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"429":{"description":"Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}},"headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying."},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix timestamp when the quota resets."}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/deployments":{"get":{"summary":"List Studio deployments","description":"Returns every deployment across all projects in your account, newest first. Filter by project with `project_id`.","operationId":"listStudioDeployments","tags":["Studio"],"parameters":[{"name":"project_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"Optional — restrict the list to a single project."},{"name":"status","in":"query","schema":{"type":"string","enum":["provisioning","building","live","suspended","failed"]}},{"name":"limit","in":"query","schema":{"type":"integer","default":50}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Deployment list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/StudioDeployment"}},"pagination":{"$ref":"#/components/schemas/Pagination"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}},"post":{"summary":"Trigger a Studio deployment","description":"Kicks off a new deployment for a project — Repull provisions a Fly.io machine, writes the subdomain DNS record, and builds the project. The response returns immediately with `provisioning` status; poll `GET /api/studio/deployments/{id}` until `live`.","operationId":"createStudioDeployment","tags":["Studio"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["project_id"],"properties":{"project_id":{"type":"string","format":"uuid","description":"Project to deploy."}}},"examples":{"default":{"value":{"project_id":"a1b2c3d4-e5f6-7890-abcd-ef0123456789"}}}}}},"responses":{"201":{"description":"Deployment queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deployment_id":{"type":"string","format":"uuid"},"subdomain":{"type":"string"},"status":{"type":"string","enum":["provisioning"]}}}}}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Project not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/deployments/{id}":{"get":{"summary":"Get a Studio deployment","description":"Fetches a single deployment, including its current status and live URL. Poll this endpoint after `POST /api/studio/deployments` until `status` is `live`.","operationId":"getStudioDeployment","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/StudioDeployment"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Deployment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}},"delete":{"summary":"Delete a Studio deployment","description":"Tears down a deployment — releases the Fly.io machine, removes the DNS record, and marks the deployment as deleted. The underlying project is unaffected.","operationId":"deleteStudioDeployment","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deployment_id":{"type":"string","format":"uuid"},"deleted":{"type":"boolean"}}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Deployment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/deployments/{id}/suspend":{"post":{"summary":"Suspend a Studio deployment","description":"Pauses a deployment without deleting it — the Fly.io machine is stopped and the URL returns 503 until the deployment is woken. Suspended deployments do not accrue runtime charges.","operationId":"suspendStudioDeployment","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment suspended","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/StudioDeployment"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Deployment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"409":{"description":"Deployment is already suspended or in a non-suspendable state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/api/studio/deployments/{id}/wake":{"post":{"summary":"Wake a suspended Studio deployment","description":"Resumes a previously suspended deployment — Repull restarts the Fly.io machine and the URL becomes reachable again once `status` returns to `live`.","operationId":"wakeStudioDeployment","tags":["Studio"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deployment waking","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/StudioDeployment"}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"404":{"description":"Deployment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}},"409":{"description":"Deployment is not in a suspended state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudioError"}}}}}}},"/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"}}}}}}