# Flats.ge Listing API — v1

Create real-estate listings using a normal HTTPS API. Any independent client that can make authenticated HTTP calls can use this contract. Flats.ge does not depend on any client's runtime or database.

Base: `https://flats.ge/api/v1/listings`
Specification: `/developers/listings.openapi.json`
Schema: `flats.listing.v1`

**AI agent acting for an owner?** You don't need a key: see [agents.md](https://flats.ge/developers/agents.md). The owner taps Approve in a WhatsApp message (no code passes through you) and you list one apartment for them.

## Access

Use an operator-issued client token as `Authorization: Bearer <flats_lsk_token>` on every listing API call. Each token can access only that client's drafts. `listings:write` allows drafts/uploads, `listings:submit` allows a validated revision to enter moderation, `listings:withdraw` allows the client to take its own published listing offline (DELETE), and `listings:publish` is reserved for explicitly trusted direct-publish clients. Withdrawing never grants publication. Tokens are revocable. Initial access is provisioned by the operator; automated registration, OAuth and an MCP server are not available yet. Never ask for or use a Supabase service-role key. Store client tokens on your server, not in browser JavaScript.

## Workflow

1. POST a JSON draft to the base URL. Keep `external_id` stable per property within your client. Identical content retries return the same draft and Flats-owned property ID. Changed content returns 409 with your draft ID and revision.
2. Save `draft_id`, `property_id` and `revision`. GET `/{draft_id}` to resume an interrupted upload.
3. To replace draft data, PATCH `/{draft_id}` with the whole input and `If-Match: "<revision>"`. This is a replacement, not a field merge. A stale revision returns 412; reconcile it rather than overwriting another writer. Identical replacement retries are safe. An existing published version remains live until the new revision is published.
4. First try `/complete` for an unverified file: it recovers an upload whose response was lost. Only `422 media_not_uploaded` requires a new ticket. For each missing file, POST `/{draft_id}/media/{media_id}/upload` with `If-Match`. Use the returned signed PUT URL for a raw file upload, or the returned TUS configuration for resumable uploads. Do not send your listing token to storage. Do not follow redirects with credentials. Upload permission is for one immutable object and expires in two hours. Never upsert or overwrite objects.
5. POST the matching `/complete` endpoint with `If-Match`. Flats verifies actual bytes, SHA-256, dimensions and codecs. Already-verified media returns `verified: true` and needs no upload. An existing upload after a lost response can be completed directly.
6. POST `/{draft_id}/validate` with `If-Match`. It reports missing information and unverified media. It does not submit, publish or grant permission to publish. POST `/{draft_id}/preflight` runs the same checks without the media-verification step, so a client can fix data and location before uploading files.
7. For moderated clients, POST `/{draft_id}/submit` with `If-Match`. The exact revision enters Admin review and returns `review_status: pending`. A returned revision exposes `review_status: changes_requested` and its reason on GET; change the draft to create a new revision, validate it and submit again. A rejection is final for that listing: GET shows `review_status: rejected` with `review_final: true`, and any later submit returns `409 review_rejected` with `final: true` and the reason. Do not resubmit the same property under a new `external_id`; Admin uses "changes requested" when a listing can be fixed. Client changes after submission create a new revision and invalidate the pending approval.
8. Admin approval re-runs the same listing validation against the exact submitted revision and then uses the existing atomic publication transaction. The previous public revision, if any, remains live until the new revision succeeds. Clients with the exceptional `listings:publish` scope may instead POST `/{draft_id}/publish` directly.
9. For an already-published property with a newer private revision, POST `/{draft_id}/cancel` with `If-Match` to discard only that private revision. Flats restores the private draft to the last published snapshot, advances the draft revision, and leaves the live public property untouched. This requires only the listing owner's write scope and never grants publication/unpublication rights. DELETE `/{draft_id}` remains the separate whole-listing cancel/withdraw operation; `listings:withdraw` (or `listings:publish`) is required if the draft was ever published, because DELETE archives the public property. Without it, DELETE of a published listing returns `403 publish_forbidden`. Local files are retained by the sender.

A submit receipt means pending review, not publication. Never announce publication before GET reports the reviewed revision as approved/published or a direct-publish client receives a successful publication receipt. Preserve local files and state if a request fails. Retry transient 429/503 responses with backoff; respect `Retry-After` with a bounded wait (for example at most 60 seconds and three attempts). Storage/draft limits and the daily verification budget require capacity or lifecycle correction. Authentication, validation and revision conflicts need correction, not blind retries.

## Input

```json
{
  "schema_version": "flats.listing.v1",
  "external_id": "my-property-123",
  "property": {
    "property_type": "apartment", "currency": "USD",
    "title": {"en": "Apartment in Tbilisi"},
    "district": {"en": "Vake"},
    "description": {"en": "Property description confirmed by the owner."},
    "deal_type": "sale", "price": 120000, "area": 82,
    "beds": 2, "baths": 1, "floor": 7, "floors": 12,
    "building": "new", "furnished": true,
    "lat": 41.7, "lng": 44.7,
    "city_id": "<active catalog city ID>", "street_id": "<active catalog street ID>"
  },
  "features": {"parking": false, "ac": true},
  "media": [],
  "private": {"contact_name": "Owner", "phones": ["<verified contact>"], "source": {}}
}
```

The example has no media and cannot be published yet. Add media descriptors with `id` (UUID), `kind` (`gallery`, `video`, `poster`, `render_gallery`, `original_gallery`), `sort` (0–19), `content_type`, `byte_size` and the actual lowercase 64-character `sha256`.

Drafts may be partial. Publication supports every `property_type` (`apartment`, `house`, `cottage`, `office`, `commercial`, `hotel`, `land`), USD prices and sale/rent/daily deals. Land needs only price, area and deal type; every other kind also needs beds, baths, floor, building and furnished. Request matching (buyer alerts) uses sales only. Price and area accept at most two decimal places. Optional `rooms` (1–100), `renovation` (`frame`, `renovated`, `unrenovated`, `in_progress`) and canonical `is_frame` can be supplied when known; bedrooms are never changed based on area. `is_frame` is independent from renovation. Frame listings may provide any short non-empty `frame_type` label (for example `black`, `white`, `green`); any subtype automatically normalizes `is_frame` to `true`, so clients do not need to send both fields. This subtype is informational/renovation metadata and does not change Render/Original gallery behavior. A Frame property must provide both `render_gallery` and `original_gallery`; it does not need a duplicate normal `gallery`. To explicitly make a property non-Frame, send `is_frame: false` without a Black/White subtype. No exchange rate, address, furniture or missing amenity is inferred. Omit unknown fields; omit a feature or use null for unknown, true for yes and false for no. Title, district and description may be written in one language. Either send English (`en`), or send the owner's text in one app language (`ka`, `ru`, `he`, `ja` or `en`) and set `private.owner_lang` to that code: Admin approval translates it into the other app languages, including English, before publishing. Direct publication (`/publish`) has no translation step and still requires English. Supply additional language keys when known. Location may be supplied as verified coordinates, or as an active catalog street when source coordinates are unavailable. If both are supplied, coordinates are authoritative. A street is no longer required when verified coordinates are present. Use actual property/project coordinates, not a city center. Every public translation of title, district, description and display address, and building number, must omit recognizable URLs, bare domains, email addresses and phone numbers; keep provenance, contact details and raw input under `private`.

Catalog city/street IDs are those used by the regular Flats app. The current read-only catalog lives in Supabase public `location_cities`, `location_districts`, `location_subdistricts`, `location_streets`, with `active=true`; existing catalog clients can keep using the published catalog. District/subdistrict IDs must be active and belong to the same city. When a street is supplied, district/subdistrict must also match that street membership. Validation and publication share these checks. Coordinates over 50 km from the catalog city center are rejected; this coarse city check does not establish an exact property location, which the owner must confirm. Listing credentials do not grant arbitrary Supabase access. A first-party catalog discovery endpoint can be added alongside future self-service access.

Media: up to 20 normal gallery images, 20 Render comparison images and 20 Original comparison images (JPEG or WebP, 10 MiB each), one H.264/yuv420p MP4 (100 MiB, up to 10 minutes, AAC audio if present), and one JPEG/WebP video poster; 150 MiB total and 20 megapixels per visual stream. Normalize locally before declaring hashes. A video requires a poster. Frame comparison images use `render_gallery` for the turnkey visualization and `original_gallery` for the real frame-condition photos. Uploads go to private Flats-controlled buckets. Public media signing remains restricted to published properties.

Default client limits: 240 backend operations/minute, 100 active drafts and 2 GiB of active declared media / outstanding upload reservations. Published and cancelled drafts do not occupy draft slots; creating a pending revision does. Tickets reserve the real bucket maximum (10 MiB image / 100 MiB video), then verified immutable objects reserve their actual bytes. The backend checks bucket caps before signing. Replacing media does not reset an outstanding reservation. Cached verification is bound to the full media descriptor, including the complete SHA-256 and byte size. Changing metadata for an already reserved immutable path returns `409 media_changed`; use a new media UUID for replacement bytes.

Before database authentication, a bounded process-local guard allows 360 requests/minute overall, 180 per Fly client IP and 20 failed authentications per IP. Shared verification limits allow four leased probes, at most 8 GiB / 2000 attempts globally and 2 GiB / 500 attempts per client per UTC day. Each process runs at most two probes. Verified successes and permanent immutable failures are cached; transient network/missing-object failures remain retryable.

Never-published drafts with no activity for 30 days expire, except drafts waiting for Admin review (`review_status: pending`), which never expire. Moderation state and audit events are private to Flats Admin; the listing client only receives its own review state and correction/rejection reason. Scheduled housekeeping deletes unreferenced storage through the Storage API and releases bytes only after deletion is confirmed. Live public media and active drafts are protected. Retired paths remain tombstoned and cannot be reattached. Deletion waits at least 27 hours after the latest ticket to cover two-hour signatures and outstanding TUS sessions. Pending revisions of existing published properties require explicit cancellation. Sale Float fee is 3%; monthly-rent commission is 50%; daily rental has no inferred commission. These presentation rules are controlled by Flats, not supplied by clients.

Errors follow RFC 9457 (`application/problem+json`): `status`, `code`, optional `errors`. Each validation issue (in `validate`/`preflight` responses and in `422 listing_incomplete`) has `field`, an English `message`, a stable machine `code` (for example `value_required`, `text_required`, `location_needs_review`, `location_required`, `media_required`, `media_unverified`, `contact_in_public_text`; catalog relationship checks use `catalog_location`) and a `category` (`metadata`, `location`, `public_text`, `media`, `media_upload`). A `location_needs_review` issue also lists `reasons` from server-side location checks (for example `address_coordinate_conflict`). Show the owner your own wording for each `code`; the message is English only. Important statuses: 401 invalid/revoked token; 403 insufficient scope; 404 absent/not-owned draft; 409 conflicting content; 412 stale revision; 422 invalid/incomplete data or media (including invalid Frame comparison media); 428 missing If-Match; 429 limit/busy; 503 temporarily unavailable. No browser-facing error exposes private credentials or upstream URLs.


## Private keys read by Flats

`private` is never public. Besides your own provenance, Flats reads these keys:

| Key | Meaning |
| --- | --- |
| `contact_name`, `phones` | Owner contact, visible to Flats Admin only |
| `source` | Your provenance object (portal, source URL, consent record), visible to Flats Admin only |
| `owner_lang` | Language of the owner's text (`en`, `ka`, `ru`, `he`, `ja`); approval translates the rest |
| `for_request_id` | 10-digit buyer request this listing answers. The buyer is alerted only when the published listing passes request matching (same kind and building, inside the request radius, price within 93–107 % of the budget); a direct answer then goes first and skips the per-request daily cap |

Flats writes `private._location_resolution` (its location check, with `reasons`); values sent for it are replaced.

## Original and renovation prices

After renovation pricing is explicitly activated by the operator, for newly
published regular properties, `property.price` is the original asking
amount in USD. Frame sales (the Frame flag, subtype or condition) automatically
include the same renovation estimate as developer projects. For another condition,
set `property.include_renovation: true` explicitly; comparison photos alone never
apply an allowance. Monthly and daily rental prices are not marked up.

The optional `property.original_price` is an explicitly verified original USD amount
and takes precedence over `price`. The public feed price is calculated from that
amount: original + $5,000 + area × $350/m² (original price/m² ≤ $1,000), $400/m²
(≤ $1,500), or $500/m² (> $1,500). Revisions recompute once from the original, never
from a previously calculated feed price. Original-photo mode displays the stored
original amount; Render mode displays the feed amount.

Existing listings with an unknown original price retain their current feed price.
Supply `original_price` only after confirming the actual source amount. Do not
send an already marked-up feed price as a new listing's original price or bulk
backfill existing amounts without verification.

Deployment compatibility: installing the pricing migration leaves the feature OFF.
Until activation, existing pricing semantics remain and no renovation allowance
is added. The app can read either database schema. Do not treat migration or app
deployment as activation. Operator rollout/rollback steps are documented in
`docs/REGULAR_PROPERTY_PRICING.md` in the repository.

Manual sale renovation opt-in requires both `render_gallery` and
`original_gallery` images. Publication rejects a missing group, and later
removals cannot leave an enabled manual listing without both groups. Replace
comparison media atomically. Zero/unknown prices remain zero rather than showing
only renovation costs. Metadata-only updates do not recalculate prices.
