# LandyDandy Listings API — guide for AI agents

> **For the land seller:** give this file (or its link,
> `https://landydandy.com/docs/landydandy-api-guide-for-ai-agents.md`) and an API key to your AI agent
> (Claude, ChatGPT, or any agent that can make web requests). It can then list your properties on
> LandyDandy, update prices and terms, add photos, publish listings and mark them sold.
>
> **Set up in 3 steps**
>
> 1. On LandyDandy go to **Settings → API** (`https://landydandy.com/admin/settings?tab=api`) and click
>    **Create API Key**.
> 2. Tick the permissions the agent needs. The default key can create and edit drafts only. Also tick
>    `photos:write` to add photos and `status:write` to publish or mark sold.
>    If your agent cannot run code (for example a plain chat assistant), turn **off**
>    "Require signed write requests". Agents that run code (Claude Code, scripts) can keep it on.
>    The full key is shown **once**; copy it.
> 3. Give the agent this guide and the key, and tell it what to do, for example:
>    *"Here is my LandyDandy API key and the API guide. List the 12 lots in this spreadsheet as drafts,
>    then show me what is missing before you publish anything."*
>
> Treat the key like a password. You can revoke it any time in Settings → API.
> Prefer no code at all? **Listings → Bulk upload** takes a CSV or Excel sheet and a folder of photos.

---

## Rules for the agent

1. **Drafts first. Ask before going live.** Create listings as `draft`. Publish (`available`) only when the
   seller has said so for those listings.
2. **Never invent property facts.** Send only what the seller gave you or what you read from a source the
   seller pointed to. Leave a field out rather than guess (utilities, access, zoning, flood zone, etc.).
3. **One listing per lot, never twice.** Always send `external_source` + `external_id` on create (for example
   `"external_source": "my-agent"`, `"external_id": "<the seller's own lot ID or the APN>"`). Sending the same
   pair again returns the existing listing instead of a duplicate.
4. **Update, don't recreate.** To change a listing, `PATCH` it. Do not delete and re-create.
5. **Respect the plan and the rate limit.** On `429` wait for `Retry-After` seconds. When publishing fails with a
   `details[]` item whose `field` is `listing_limit`, tell the seller their plan's active-listing limit is reached.
6. **Report back.** After a batch, tell the seller what was created, what was updated, what failed and why
   (the API returns field-level reasons), and what each draft still needs before it can be published.

---

## Basics

| | |
|---|---|
| Base URL | `https://landydandy.com/api/v1` |
| Auth header | `Authorization: Bearer ldy_live_…` (41-character key) |
| Body | JSON object, `Content-Type: application/json`, max 1 MB |
| Rate limit | per key, set by the seller (default 60 requests/minute); headers `X-RateLimit-Remaining`, `Retry-After` |

Every response is an envelope:

```json
{ "success": true, "data": { … }, "meta": { "page": 1, "per_page": 20, "total": 57, "total_pages": 3 } }
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Invalid listing data",
  "details": [ { "field": "properties[0].county", "value": "Wallr", "message": "County not found in Texas", "suggestion": "Waller" } ] } }
```

Error codes: `UNAUTHORIZED` (401: missing, wrong, revoked or expired key; bad signature), `FORBIDDEN` (403: missing
permission, IP not allowed), `VALIDATION_ERROR` / `INVALID_REQUEST` (400/413), `NOT_FOUND` (404),
`CONFLICT` (409: slug taken, nonce reused), `RATE_LIMITED` (429), `INTERNAL_ERROR` (500), `SERVICE_UNAVAILABLE` (503).
When a `details[]` item has a `suggestion`, it is the value we expected; ask the seller or use it if it is clearly a typo.

### Signed write requests (on by default)

If the key has "Require signed write requests" on, every `POST`, `PUT`, `PATCH` and `DELETE` needs four extra
headers. `GET` is never signed.

```
x-ldy-timestamp:       unix seconds (must be within 5 minutes of server time)
x-ldy-nonce:           random string, 16–128 characters, never reused
x-ldy-content-sha256:  lowercase hex SHA-256 of the exact raw body ("" for DELETE)
x-ldy-signature:       lowercase hex HMAC-SHA256(key = the full API key, message = canonical)

canonical = METHOD + "\n" + PATH_AND_QUERY + "\n" + timestamp + "\n" + nonce + "\n" + content_sha256
            (PATH_AND_QUERY is like /api/v1/listings/abc — not the full URL)
```

Node.js:

```js
import { createHash, createHmac, randomUUID } from 'node:crypto';

async function ldy(method, path, body) {           // path like '/listings' or '/listings/<id>'
  const key = process.env.LANDYDANDY_API_KEY;
  const raw = body === undefined ? '' : JSON.stringify(body);
  const fullPath = `/api/v1${path}`;
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = randomUUID();
  const hash = createHash('sha256').update(raw, 'utf8').digest('hex');
  const sig = createHmac('sha256', key).update([method, fullPath, ts, nonce, hash].join('\n'), 'utf8').digest('hex');
  const base = process.env.LANDYDANDY_BASE_URL ?? 'https://landydandy.com';
  const res = await fetch(`${base}${fullPath}`, {
    method,
    headers: {
      Authorization: `Bearer ${key}`,
      ...(method === 'GET' ? {} : {
        'Content-Type': 'application/json',
        'x-ldy-timestamp': ts, 'x-ldy-nonce': nonce, 'x-ldy-content-sha256': hash, 'x-ldy-signature': sig,
      }),
    },
    body: method === 'GET' ? undefined : raw,
  });
  return { status: res.status, body: await res.json().catch(() => null) };
}
```

Python:

```python
import hashlib, hmac, json, os, time, uuid, urllib.request

def ldy(method, path, body=None):
    key = os.environ["LANDYDANDY_API_KEY"]
    raw = "" if body is None else json.dumps(body)
    full_path = "/api/v1" + path
    ts, nonce = str(int(time.time())), uuid.uuid4().hex
    digest = hashlib.sha256(raw.encode()).hexdigest()
    sig = hmac.new(key.encode(), "\n".join([method, full_path, ts, nonce, digest]).encode(), hashlib.sha256).hexdigest()
    headers = {"Authorization": f"Bearer {key}"}
    if method != "GET":
        headers.update({"Content-Type": "application/json", "x-ldy-timestamp": ts, "x-ldy-nonce": nonce,
                        "x-ldy-content-sha256": digest, "x-ldy-signature": sig})
    base = os.environ.get("LANDYDANDY_BASE_URL", "https://landydandy.com")
    req = urllib.request.Request(base + full_path, method=method, headers=headers,
                                 data=None if method == "GET" else raw.encode())
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, json.loads(r.read() or b"null")
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read() or b"null")
```

The signature uses the exact bytes you send: build the JSON string once, hash it, and send that same string.

### Permissions (scopes)

| Scope | Needed for |
|---|---|
| `listings:read` | `GET /listings`, `GET /listings/{id}` |
| `listings:write` + `properties:write` | `POST /listings`; `listings:write` alone for `PATCH /listings/{id}` |
| `photos:write` | sending `photos` on create; `POST`/`PATCH /properties/{id}/photos` |
| `photos:delete` | `DELETE /properties/{id}/photos/{photoId}` |
| `status:write` | any status other than `draft` (publish, off market, sold) |
| `listings:delete` | `DELETE /listings/{id}` (archive) |
| `properties:read` / `properties:write` / `properties:delete` | the `/properties` endpoints |

New keys get `listings:read`, `listings:write`, `properties:read`, `properties:write` unless the seller ticks more.
A missing permission returns `403 FORBIDDEN` naming the scope.

---

## How LandyDandy models a listing

- A **listing** is what buyers see: title, price, description, owner-financing terms, photos, status.
- A listing has **1–25 properties** (parcels). The first (`primary_property_index`, default 0) is the primary
  parcel; the listing's state, county, city and acres come from its parcels.
- Photos belong to the listing. The photo endpoints are addressed by the listing's **primary property id**
  (`listing.primary_property_id`, also `property_ids[0]` in the create response).
- Status values: `draft`, `available` (live), `reserved`, `pending`, `off_market`, `sold`.

**A listing can be published (`available`) only when it has:** a title, a price above $0, a non-empty
description, a state and a county, at least one parcel, at least one photo, and the seller's plan still has room
for another active listing. If one is missing the API answers `400` with one `details[]` item per missing thing.

---

## Endpoints

### Create a listing — `POST /listings`

```json
{
  "external_source": "my-agent",
  "external_id": "RF101",
  "title": "5 Wooded Acres Near Hempstead, Owner Financing",
  "price": 20000,
  "description": "Five wooded acres on a county road...",
  "owner_financing_available": true,
  "down_payment": 999,
  "interest_rate": 9.9,
  "financing_max_term": 5,
  "document_fee": 299,
  "status": "draft",
  "properties": [
    {
      "state": "TX",
      "county": "Waller",
      "parcel_number": "R12345",
      "acres": 5,
      "property_street_address": "0 FM 1488",
      "zip_code": "77445",
      "gps_coordinates": "30.0957, -95.9378",
      "primary_property_class": "Recreational",
      "road_access_legal": "Public Road Frontage",
      "utilities_power": "Available at Lot Line"
    }
  ],
  "photos": [
    { "url": "https://example.com/lot-front.jpg", "is_primary": true },
    { "url": "https://example.com/lot-road.jpg" }
  ]
}
```

- Required: `title` (≤ 200 chars), `price` (whole dollars ≥ 1), `properties` (1–25 objects).
- `201` → `data.listing` (with `id`, `primary_property_id`, `properties`, `photos`, `financing_terms`),
  `data.property_ids`, `data.photos.uploaded[]` / `data.photos.failed[{original_url, error}]`, `data.warnings[]`.
- `200` with `data.duplicate: true` → this `external_source` + `external_id` already exists; nothing was created.
  `PATCH` the returned listing instead.
- Asking for `"status": "available"` creates a draft, adds the photos, then publishes only if every publish
  requirement is met; otherwise it stays a draft and `warnings[]` says why. Needs `status:write`.
- Photos: up to 20 per request (50 per listing in total), public `http(s)` image links only, max 20 MB each,
  30-second download limit each; they are converted to WebP. Links to private pages, folders or login-protected
  files fail. A Dropbox file link works with `?raw=1` instead of `?dl=0`. Google Drive files must be shared
  "Anyone with the link" and sent as `https://drive.google.com/uc?export=download&id=<file id>`; if they still
  fail, ask the seller for direct image links.

### Read listings — `GET /listings`, `GET /listings/{id}`

- `GET /listings?page=1&per_page=100&status=draft` → `data[]` with `id`, `title`, `status`, `price`, `slug`,
  `primary_property_id`, `property_count`, `photo_count`, `market_data.api_import.external_id`, …; `meta` for paging
  (`per_page` 1–100).
- `GET /listings/{id}` → `data.listing` with its `properties`, `photos` and `financing_terms`.
- Public page of a live listing: `https://landydandy.com/property/{slug}`.

### Update a listing — `PATCH /listings/{id}`

Send only the fields to change. Listing fields: `title`, `price`, `description`, `video_tour_url`, `rf_number`,
`owner_financing_available`, `owner_financing_price`, `down_payment`, `interest_rate`, `financing_max_term`,
`financing_max_term_months`, `financing_term_unit`,
`document_fee`, `late_fee_amount`, deposits, `faqs`, `use_custom_property_contact`, `property_contact_name`,
`property_contact_email`, `property_contact_phone`, `status`.

```json
{ "price": 18500, "down_payment": 799 }
```

- Parcel facts (acres, address, utilities, zoning, GPS…) are **not** changed here: use `PATCH /properties/{property id}`.
- `"status": "available"` runs the publish check and needs `status:write`.
- `"status": "sold"` is refused here — use the status endpoint below.

### Update parcel facts — `PATCH /properties/{property id}`

Partial update of one parcel, same field names as inside `properties[]` above. Send only documented fields:
any other field is refused with `400 VALIDATION_ERROR` naming it. Sending a `status` other than `draft` needs
`status:write`; photos go through the photos endpoints below.

```json
{ "acres": 5.2, "utilities_water": "Needs Well", "zoning": "AG", "zoning_type": "agricultural" }
```

### Photos — `/properties/{primary property id}/photos`

- `GET` → photos in order.
- `POST {"photos":[{"url":"https://…","is_primary":false}]}` → adds up to 20 (50 per listing). `201` with
  `uploaded[]` and `failed[]` (check `failed`; `201` is returned even if every photo failed).
- `PATCH {"order":[{"id":"<photo id>","display_order":1,"is_primary":true}, …]}` → reorder / change the cover.
- `DELETE /properties/{id}/photos/{photoId}` → `204`. The last photo of a live listing cannot be deleted.

### Change status, mark sold — `PATCH /properties/{primary property id}/status`

```json
{ "status": "sold", "sold_price": 19000, "sold_date": "2026-10-09" }
```

- The only way to set `sold` (`sold_price` > 0 and `sold_date` `YYYY-MM-DD` are optional; date defaults to today).
- Also accepts `available` (publish check applies), `pending`, `reserved`, `off_market`, `draft`.
- Needs `status:write`. Returns `{id, previous_status, status}`.

### Archive — `DELETE /listings/{id}`

Takes the listing off the site (archived, `off_market`). Needs `listings:delete`. Prefer `off_market` via the
status endpoint if the seller may relist later.

---

## Field reference

**Listing:** `title` (≤ 200), `price` (integer ≥ 1), `description` (≤ 10 000 chars, plain text or simple HTML),
`video_tour_url` (URL), `rf_number` (seller's reference, ≤ 100), `owner_financing_available` (bool),
`owner_financing_price` (total price on terms; at least the cash price and the down payment — filled in for you
if left out), `down_payment`, `interest_rate` (0–100, ≤ 2 decimals), `financing_max_term` (whole years ≥ 1;
buyers see a monthly payment for each term) or `financing_max_term_months` (whole months 1–480, for a term
like 39 months; the listing then offers every term from 1 month up to it and `financing_term_unit` becomes
`"months"`), `document_fee`, `late_fee_amount`, `use_custom_property_contact` +
`property_contact_name` / `_email` / `_phone`.

**Parcel (inside `properties[]` or on `PATCH /properties/{id}`):** `state` (2-letter code or full name),
`county` (checked against US counties; "County" suffix optional), `city`, `property_street_address`, `zip_code`,
`subdivision`, `parcel_number` (APN), `acres` (≥ 0.01, ≤ 2 decimals), `gps_coordinates` (`"lat, lng"`),
`zoning`, `legal_description`, `annual_taxes` (yearly dollars; `annual_property_taxes` also accepted), `hoa_in_community` (true, false or "not sure"), `has_restrictions` (deed restrictions: true, false or "not sure"), `hoa_name`, `hoa_amount`,
`hoa_frequency`, `road_frontage` (feet), `access_notes`, `water_notes`, `sewer_notes`, `power_notes`,
`internet_notes`, `directions_to_property`, `closest_towns_cities`, the land-use fields `rv_use`, `rv_stay_limit`,
`build_deadline_months`, `land_use_notes`, `zoning_type`, `nearest_town_miles` (see below), and the choice fields below. (`cash_only` is
retired: it is ignored. For a cash-only listing send `owner_financing_available: false`.)

**Choice fields** (case-insensitive; send exactly one of these values). The plain words the seller sees in
LandyDandy are accepted too and stored as the value below, for example "City water at the road" →
`Municipal Water Available (At Street)`, "Easement" → `Deeded Easement`, "Not included" → `retained`,
"Not sure" → `unknown`/`Unknown`, "Wooded" → `Forest`. `primary_property_class` (land type) is optional. When the seller does not know, send "Not sure" (buyers never see it) or leave the field out; never guess.

| Field | Allowed values |
|---|---|
| `primary_property_class` | Residential · Recreational · Farm & Ranch · Timberland · Commercial · Investment |
| `current_condition` | Raw / Virgin Land · Partially Improved · Fully Developed |
| `flood_zone` | Outside Flood Zone (Zone X) · Partial Flood Zone · Inside Flood Zone (High Risk) · Unknown (FEMA codes also work: X → Outside, A/AE/AH/AO/V/VE → Inside, D → Unknown) |
| `utilities_water` | Municipal Water Connected · Municipal Water Available (At Street) · Private Well Installed · Needs Well (the old "Needs Well (Aquifer Verified)" is accepted and stored as Needs Well) · Water Haul / Cistern Required · Unknown (= Not sure) |
| `utilities_sewer` | Municipal Sewer Connected · Municipal Sewer Available (At Street) · Septic System Installed · Needs Septic (Perc Test Passed) · Needs Septic (No Test / Unknown) · Unknown (= Not sure) |
| `utilities_power` | Connected (Meter On-Site) · Available at Lot Line · Nearby (Within 0.25 miles) · Remote (Off-Grid Required) · Unknown (= Not sure) |
| `utilities_internet` | High-Speed Cable/Fiber Available · Starlink / Satellite Only · Cellular 5G/LTE · Unknown (= Not sure) |
| `road_access_legal` | Public Road Frontage · Deeded Easement · Private Road (HOA/Subdivision) · Unverified / Prescriptive · Landlocked (no legal access) · Unknown (= Not sure) |
| `road_access_surface` | Paved (Asphalt/Concrete) · Gravel / All-Weather · Dirt / Earthen · Two-Track / Trail · Unknown (= Not sure) |
| `road_access_vehicle` | Any Vehicle (Sedan) · High Clearance (SUV) · 4WD Required · ATV / Hike-In Only · Unknown (= Not sure) |
| `title_conveyance` | Warranty Deed · Special Warranty Deed · Quitclaim Deed · Tax Deed · Contract for Deed |
| `mineral_rights`, `timber_rights`, `oil_gas_rights` | included · retained · severed · unknown |
| `water_rights` | deeded_surface · well_permit_eligible · no_rights · unknown |
| `tax_status` | Standard Residential Tax · Agricultural Exemption · Timber Exemption · Wildlife Management · Unknown |
| `hoa_frequency` | Monthly · Quarterly · Yearly (only with `hoa_in_community: true`) |
| `is_subdividable` | yes · no · unknown |
| `zoning_type` | residential · agricultural · rural_mixed · commercial · none · unknown (`zoning` stays the county's code) |
| `rv_use` | not_allowed · short_stays · while_building · full_time · allowed_terms_not_stated · unknown. Send `full_time` ONLY when the rules say an RV may be lived in full time; `allowed_terms_not_stated` when they only say "RVs allowed". Also `rv_stay_limit` (text, e.g. "a few weeks") |
| `build_deadline_months` | whole months (1-600) when a house must be built within a set time; `land_use_notes` = other rules (text) |
| `nearest_town_miles` | driving miles to the nearest town (number) |

**Tag lists** (arrays, any of): `geography` (Mountain, Desert, Island, Forest, Open Plain, Wetlands, and one shape: Flat, Sloped, Steep or Mixed) ·
`water_features` (Riverfront, Lakefront, Creek / Stream, Pond, Oceanfront, Water View, None) ·
`structures_on_land` (House, Cabin, Mobile Home, Barn, Shed, None) · `fencing` (Fully Fenced, Partly Fenced or No Fence, plus a type if known: Barbed Wire, Game Fence,
Wood Fence, Cross Fenced) · `permitted_activities` (Camping, RV Living, Hunting (On-Site),
ATV / Off-Road, Boating, Farming, Horseback Riding) · `allowed_structures` (Site-Built Home, Mobile Home,
Tiny Home, Barndominium, RV (Long Term)).

Featured spots (Featured / Gold / Platinum) cannot be set through the API; the seller buys or moves them on
https://landydandy.com/admin/featured-spots. Setting `upgrade_tier` is refused.

---

## Recipes

**List a spreadsheet of lots.** For each row: `POST /listings` as `draft` with `external_source` + `external_id`
(the row's ID or APN), the parcel(s) and photo links. Collect `warnings`, `photos.failed` and validation errors.
Then `GET /listings?status=draft` and tell the seller, per draft, what is missing to publish. Publish only the ones
the seller approves: `PATCH /listings/{id}` `{"status":"available"}`.

**Change prices or terms on many listings.** `GET /listings` (page through all), match by `external_id`,
`rf_number` or title as the seller describes, show the seller the before → after list, then `PATCH` each.

**A lot sold.** `PATCH /properties/{primary_property_id}/status` `{"status":"sold","sold_price":…,"sold_date":"YYYY-MM-DD"}`.

**Take a lot off the market for now.** Same endpoint with `{"status":"off_market"}`; later `{"status":"available"}`.

**Add or replace photos.** `POST /properties/{primary_property_id}/photos`; then `PATCH` the same path with
`order` to set the cover; `DELETE` old ones (not the last photo of a live listing).
