Writing listings

A key with properties:write creates, edits and archives your listings, and adds their photos. It also reads them, as properties:read does. Give it to the system your listings live in — a property CRM, a back office, a feed — so letsrealty follows it without anyone retyping them.

Writes follow the same rules as the console: while your subscription is read-only they answer 402, and a suspended account answers 503.

Creating, editing and archiving

  • POST /v1/properties — create a listing. Only title is required; the answer is 201 with the listing.
  • PATCH /v1/properties/{property_id} — change a listing. Only the fields you send change; the answer is 200 with the listing.
  • DELETE /v1/properties/{property_id} — archive a listing: it leaves your website and the default lists, keeps its slug and price history, and answers 204. Nothing is ever deleted for good.
curl https://api.letsrealty.io/v1/properties \
  -H "Authorization: Bearer $LETSREALTY_API_KEY" \
  -H "Idempotency-Key: crm-listing-4412" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Casa Tamarindo",
    "description": "Three bedrooms, a pool and a view of the bay.",
    "internal_ref": "CASA-TAM-07",
    "listing_type": "sale",
    "price": "385000",
    "currency": "USD",
    "property_type": "house",
    "bedrooms": 3,
    "bathrooms": 2,
    "place_code": "CR-50309",
    "agent_email": "maria@agency.example"
  }'

The body takes the listing's fields as Listings describes them — title, description, listing_type, price, currency, rent_price, property_type, bedrooms, bathrooms, area, lot_area, parking_spaces, year_built, floor, hoa_fee, amenities, address, latitude, longitude, location_precision, is_featured, video_url, tour_url and internal_ref — plus place_code, agent_id or agent_email, highlights and ai_enrichment, below. Send prices as strings, such as "385000".

An internal_ref another listing already has is a 409. Every write accepts an Idempotency-Key, so a retry after a timeout never creates a listing twice; see Rate limits & idempotency.

Syncing by your own reference

PUT /v1/properties/by-ref/{internal_ref} is the one call a sync job needs. It finds the listing by your reference:

  • None has it yet: the listing is created with that internal_ref, and the answer is 201.
  • One has it: that listing is updated with the fields the body sends, following PATCH's rules, and the answer is 200.

The body takes the same fields as a create, so title is always required. An internal_ref in the body that differs from the one in the path is a 422.

A nightly sync sends every listing in your system, one line per listing with its reference and its JSON:

while read -r ref body; do
  curl -X PUT "https://api.letsrealty.io/v1/properties/by-ref/$ref" \
    -H "Authorization: Bearer $LETSREALTY_API_KEY" \
    -H "Idempotency-Key: nightly-2026-10-04-$ref" \
    -H "Content-Type: application/json" \
    -d "$body"
done < listings.tsv

Keep the id each answer returns. To take down a listing you no longer sell, archive it by that id with DELETE /v1/properties/{property_id}. Putting the date in the Idempotency-Key makes a rerun of the same night replay its answers, while the next night writes again.

A listing your key created has origin: "api", and every listing a key writes shows the key's name in the console. A key's edit to an imported listing counts as a manual edit, so a later import leaves it alone.

Place and Agent

  • place_code — the listing's Place, one of the codes GET /v1/places lists. A listing with a Place goes on your website; one without stays off it, with needs_location: true.
  • agent_id or agent_email — the Agent the listing is attributed to. Send one or neither: both, or an Agent you don't have, is a 422.

Photos from a URL

POST /v1/properties/{property_id}/images:import adds a photo your system already hosts:

curl https://api.letsrealty.io/v1/properties/6f1c…/images:import \
  -H "Authorization: Bearer $LETSREALTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://media.example.com/casa-tamarindo/pool.jpg", "is_primary": true }'

The answer is 202 with an import whose status is pending. letsrealty fetches the photo in the background, and the import becomes ready, with the gallery image, or failed, with a failure_reason. Poll it with GET /v1/properties/{property_id}/images:import/{import_id}, or wait for the listing's property.updated event.

  • The URL must be https on a public host. Redirects are followed up to three times, each to a public host.
  • The file must be a JPEG, PNG, WebP, AVIF or GIF image of at most 20 MB.
  • The same URL on the same listing again returns the existing import with 200, so resending your whole gallery adds nothing twice. A failed import is tried again.
  • is_primary makes it the cover; sort_order places it in the gallery.

To upload files instead, mint an upload with POST /v1/properties/{property_id}/images/uploads (the content_type and size_bytes), PUT the file to the returned url with the returned headers, then confirm it with POST /v1/properties/{property_id}/images and the object_key. PATCH /v1/properties/{property_id}/images/{image_id} changes a photo's sort_order or is_primary, and DELETE /v1/properties/{property_id}/images/{image_id} removes it.

AI and your text

A listing on your website gets AI Enrichment — translations, highlights and an SEO description — the way a listing made in the console does. A create, or an edit that puts the listing on your website or changes its title or description there, queues it, within your AI allowance. Send "ai_enrichment": false to skip it.

  • Without ?lang=, title and description are the listing's own text, and highlights (up to 6) are its highlights in that language.
  • With ?lang= and an enabled Site Language, title, description and highlights are that language's text.
  • Text you send is yours: AI runs leave it alone, as they do an Agent's edit in the console.

The answer comes back in the language you wrote in.

Events

Webhook endpoints and GET /v1/events tell your other systems about every listing change, whether it came from the console, an import or a key — so a sync can run both ways:

  • property.created and property.updated (with changed_fields; images for a gallery change).
  • property.went_live — the listing came onto your website.
  • property.price_changed — with previous_price and previous_currency.
  • property.archived.

They need properties:read. See Webhooks.