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. Onlytitleis 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 codesGET /v1/placeslists. A listing with a Place goes on your website; one without stays off it, withneeds_location: true.agent_idoragent_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
httpson 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_primarymakes it the cover;sort_orderplaces 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=,titleanddescriptionare the listing's own text, andhighlights(up to 6) are its highlights in that language. - With
?lang=and an enabled Site Language,title,descriptionandhighlightsare 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.createdandproperty.updated(withchanged_fields;imagesfor a gallery change).property.went_live— the listing came onto your website.property.price_changed— withprevious_priceandprevious_currency.property.archived.
They need properties:read. See Webhooks.