# Landlord Rental API v1

- API host: https://gremlin-go-api.fly.dev
- Base path: https://gremlin-go-api.fly.dev/api/v1/rental
- Authentication: `x-api-key: <landlord API key>` header on every request
- API keys page (landlord signs in): https://landlord.kokweng.net/landlord/api-keys

## Who this is for

This guide is written for an AI agent that manages rental listings on behalf of one landlord. The landlord gives you an API key and plain-language instructions ("list my condo in Mont Kiara for RM2,800", "take the Cheras house offline"). You turn those instructions into the API calls below.

You act for exactly one landlord: the owner of the API key. You can only see and change that landlord's properties and listings.

## Rules for agents

1. **Get explicit confirmation before you submit or withdraw.** Submitting a listing makes a legal ownership declaration in the landlord's name. Withdrawing takes a live listing offline for good. Ask the landlord first, every time.
2. **Never invent facts.** Address, unit number, rent, deposits, and photos must come from the landlord or from a source the landlord pointed you to. If a required value is missing, ask.
3. **Read before you replace.** `PUT /properties/{id}` and `PUT /listings/{id}` replace the whole record. Fields you leave out are reset to empty or zero. Always `GET` the record, change only what the landlord asked for, and send the full object back.
4. **Report the outcome in plain words.** After each change, tell the landlord what changed and the listing status, for example "Submitted. Status is now pending review."
5. **Keep the key secret.** Never print the full API key back to the landlord or anyone else.
6. **Upload only photos the landlord took for this handover; never reuse listing photos.**
7. **To correct a landlord's item, add a corrected one and ask the landlord to delete the original.**
8. **When `readiness.ready` is true, tell the landlord to submit in the portal before `submit_window.closes_on`.**
9. **Report every open thread where `awaiting` is `landlord`, with `review_due_on`.**

## Quick start

All paths below are relative to the API host shown above this guide. In the examples, `$RENTAL_API` is that host and `$RENTAL_API_KEY` is the landlord's key.

```bash
export RENTAL_API="https://api.example.com"   # the API host shown above
export RENTAL_API_KEY="grm_live_..."          # the landlord's key

curl -s "$RENTAL_API/api/v1/rental/properties" \
  -H "x-api-key: $RENTAL_API_KEY"
```

A `200` with `{"items": [...], "pagination": {...}}` means the key works.

## Getting an API key

The landlord creates the key, not you.

1. The landlord signs in to the landlord portal and opens the API keys page (link above this guide).
2. They choose a mode, permissions, an expiry (30 days to 1 year, 90 days recommended), and optionally an hourly request allowance (1 to 10,000, default 1,000).
3. The full key is shown once. The landlord copies it and gives it to you.

Key modes:

| Prefix | Mode | Behaviour |
| --- | --- | --- |
| `grm_live_` | Live | Real data. Submitted listings go to an admin for review before they go public. |
| `grm_test_` | Test | Sandbox. Records are marked `is_test: true` and are invisible to live keys (and vice versa). A submitted test listing goes straight to `live` with no review and never appears on the public site. |

Use a test key to rehearse a workflow. Test and live records cannot see each other: a live key gets `404` for a test record.

## Permissions

The landlord picks permission bundles when creating the key. Each endpoint needs one scope.

| Bundle in the portal | Scopes granted | What it unlocks |
| --- | --- | --- |
| Manage my listings | `properties:read`, `properties:write`, `listings:read`, `listings:write` | Properties, listings, submit, withdraw, reading photos |
| Upload photos | `media:read`, `media:write` | Uploading and syncing photos |
| Upload documents | `proofs:read`, `proofs:write` | Uploading and listing ownership proofs |
| Money, agreements, tenants | `handover:read`, `handover:write` | Move-in handover reports, areas, entries, and photos (no contacts yet) |

"Manage my listings" and "Upload photos" are ticked by default. "Upload documents" is opt-in: the landlord ticks it when creating the key if they want you to upload ownership proofs. Without it, the landlord uploads the proof themselves in the landlord portal (on the property), and you submit the listing afterwards.

A call without the needed scope returns `403 insufficient_permission`. Tell the landlord which bundle to add; you cannot change a key's permissions.

## Core concepts

- **Property**: the physical unit (address, type, rooms, size, amenities, photos, ownership proofs). One landlord can have many.
- **Listing**: the rental offer for one property (rent, deposits, tenancy length, description, house rules) and its review status. A property has at most one active listing at a time.
- **Ownership proof**: a document (PDF, JPEG, or PNG) that shows the landlord owns the property, such as a quit rent or assessment bill. At least one is required before a listing can be submitted.
- **Declaration**: the landlord's statement that they own the property and the listing is accurate. Sent as `declaration_accepted: true` when submitting. Only send it after the landlord has confirmed.

### Listing status

```text
draft ──submit──▶ pending_review ──admin approves──▶ live ──withdraw──▶ withdrawn
                        │                              │
                        └──admin rejects──▶ rejected   └──rent change──▶ pending_review
                                              │
                                              └──edit, then submit──▶ pending_review
```

| Status | Meaning | What you can do |
| --- | --- | --- |
| `draft` | Created, not yet submitted. | Edit, upload photos, submit. |
| `pending_review` | Waiting for an admin. | Edit. Wait. |
| `live` | Public on the marketplace. | Edit, withdraw. |
| `rejected` | Admin rejected it. The reason is in `review_reason` on `GET /listings/{id}`. | Fix the issue with `PUT /listings/{id}` or `PUT /properties/{id}`, then submit the same listing again. |
| `suspended` | Held by an admin. | Nothing that needs a new review. Tell the landlord to contact support. |
| `withdrawn` | Taken offline by the landlord. Final. | Nothing. |
| `rented` | Let to a tenant. Final. | Nothing. |

Approving, rejecting, suspending, and marking as rented happen outside this API.

Changes that send a `live` listing back to `pending_review`:

- changing `monthly_rent` on the listing,
- changing the property's `address_line`,
- uploading a new ownership proof.

Other listing edits (deposits, tenancy length, dates, `description`, `house_rules`) keep the listing live.

## Workflow: list a new property

1. **Find or create the property.** `GET /api/v1/rental/properties` and look for a match first. If there is none, `POST /api/v1/rental/properties`. Note the `id`.
2. **Add photos.** Upload files with `POST /properties/{id}/photos`, or copy a gallery from another site with `PUT /properties/{id}/photos`. Up to 10 photos per property.
3. **Check for an ownership proof.** `GET /properties/{id}/proofs` (needs "Upload documents"). If the list is empty, upload one with `POST /properties/{id}/proofs`, or ask the landlord to upload it in the portal.
4. **Create the listing.** `POST /api/v1/rental/listings` with the property id and the rental terms. It starts as `draft`.
5. **Confirm with the landlord.** Show the property, terms, and photo count. Ask them to confirm the ownership declaration.
6. **Submit.** `POST /listings/{id}/submit` with `{"declaration_accepted": true}`. Status becomes `pending_review` (or `live` with a test key).
7. **Check back later.** `GET /listings/{id}` shows `live` once approved, with a `slug`, or `rejected` with a `review_reason`.

## Workflow: change the rent

1. `GET /api/v1/rental/properties/{propertyId}` and read `current_listing.id`.
2. `GET /api/v1/rental/listings/{listingId}`.
3. Change `monthly_rent` in that object and `PUT /api/v1/rental/listings/{listingId}` with every listing field.
4. Tell the landlord that a live listing goes back to review after a rent change.

## Workflow: take a listing offline

1. Confirm with the landlord. Withdrawal is final: the property cannot get a new listing through the API afterwards.
2. `POST /api/v1/rental/listings/{listingId}/withdraw`. Only works when the status is `live`.

## Conventions

- **Format.** JSON request and response bodies, `snake_case` field names. Photo and proof uploads use `multipart/form-data`.
- **IDs.** Opaque strings. Store them; do not build them.
- **Money.** `monthly_rent` is whole Malaysian ringgit (MYR): `2800` means RM2,800. Not sen.
- **Deposits.** `deposit_months` is the security deposit in months of rent, decimals allowed (`2`, `1.5`). `utility_deposit` is a fixed amount in MYR (`300` means RM300), shown as-is on the public listing.
- **Dates.** RFC 3339 timestamps, for example `2026-11-01T00:00:00Z`. A bare `2026-11-01` is rejected.
- **Not yours = not found.** A property or listing that belongs to someone else returns `404`, the same as one that does not exist.

## Properties

### GET /api/v1/rental/properties

Lists the landlord's properties. Scope `properties:read`.

| Query | Default | Notes |
| --- | --- | --- |
| `page` | `1` | 1-based. |
| `limit` | `20` | 1 to 100. Values outside that range fall back to 20. |

Response `200`:

```json
{
  "items": [
    {
      "id": "8f1c…",
      "property_type": "condo",
      "building_name": "Arcoris",
      "address_line": "10 Jalan Kiara",
      "unit_number": "A-12-3",
      "area": "Mont Kiara",
      "city": "Kuala Lumpur",
      "state": "Kuala Lumpur",
      "postcode": "50480",
      "bedrooms": 2,
      "bathrooms": 2,
      "furnishing": "fully_furnished",
      "has_parking": true,
      "size_sqft": 950,
      "amenities": ["air_conditioning", "pool", "gym"],
      "is_test": false,
      "origin_channel": "api",
      "external_refs": [],
      "current_listing": { "id": "c41d…", "status": "live" },
      "created_at": "2026-09-30T08:00:00Z",
      "updated_at": "2026-09-30T08:00:00Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "totalRecords": 1, "totalPages": 1, "hasNext": false, "hasPrev": false, "mode": "offset" }
}
```

`current_listing` is `null` when the property has no listing.

### POST /api/v1/rental/properties

Creates a property, or updates the matching one if it already exists. Scope `properties:write`. Supports `Idempotency-Key`.

Request body:

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `property_type` | string | yes | `condo`, `landed`, or `room`. |
| `address_line` | string | yes | Street address. |
| `area` | string | yes | Neighbourhood, e.g. `Mont Kiara`. |
| `city` | string | yes | |
| `state` | string | yes | Malaysian state or federal territory, e.g. `Selangor`, `Kuala Lumpur`, `Penang`. |
| `postcode` | string | yes | 5 digits. |
| `unit_number` | string | for `condo` and `room` | Needed to tell units in the same building apart. |
| `building_name` | string | no | Condo or apartment name. |
| `bedrooms` | integer | no | |
| `bathrooms` | integer | no | |
| `furnishing` | string | no | Use `fully_furnished`, `partially_furnished`, or `unfurnished`. |
| `has_parking` | boolean | no | |
| `size_sqft` | integer | no | `0` (unknown) or 50 to 100,000. |
| `amenities` | string[] | no | Only values from the amenity list below. |
| `source` | string | with `external_ref` | Where the property came from, e.g. `propertyguru`. Lowercase letters, digits, `_`, `-`; up to 32 characters. |
| `external_ref` | string | with `source` | The property's id on that source. |

Amenities: `air_conditioning`, `water_heater`, `water_tank`, `washing_machine`, `refrigerator`, `kitchen`, `wifi`, `parking`, `ev_charging`, `security`, `gated_guarded`, `pool`, `gym`, `lift`, `balcony`.

How a match is found:

1. Same `source` and `external_ref` as an existing property: that property is updated with only the fields you sent. Response `200`.
2. Same address (building or street, unit number, and postcode) as one existing property: that property is updated and the `source`/`external_ref` is linked to it. Response `200`.
3. No match: a new property is created. Response `201`.

So `POST` is safe to repeat. Sending `source` and `external_ref` makes repeat imports from another site land on the same property.

Example:

```bash
curl -s -X POST "$RENTAL_API/api/v1/rental/properties" \
  -H "x-api-key: $RENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6c1b3f0e-1a2b-4c5d-8e9f-001122334455" \
  -d '{
    "property_type": "condo",
    "building_name": "Arcoris",
    "address_line": "10 Jalan Kiara",
    "unit_number": "A-12-3",
    "area": "Mont Kiara",
    "city": "Kuala Lumpur",
    "state": "Kuala Lumpur",
    "postcode": "50480",
    "bedrooms": 2,
    "bathrooms": 2,
    "furnishing": "fully_furnished",
    "has_parking": true,
    "size_sqft": 950,
    "amenities": ["air_conditioning", "pool", "gym"]
  }'
```

Conflicts specific to this endpoint:

| Code | Meaning | What to do |
| --- | --- | --- |
| `unit_number_required` | The landlord already has a property in this building and you sent no `unit_number`. `fields` lists the existing property ids. | Ask for the unit number, or use one of the listed properties if it is the same unit. |
| `ambiguous_match` | The address matches more than one existing property. `fields` lists their ids. | Ask the landlord which one is meant, then use `PUT /properties/{id}`. |
| `duplicate_address` | Another landlord already registered this address. | Stop and tell the landlord; do not retry with a changed address. |
| `duplicate_external_ref` | This `source` and `external_ref` are already linked to another property. | Use that property instead. |

### GET /api/v1/rental/properties/{id}

Returns one property, with `current_listing` (`{id, status}` or `null`). Scope `properties:read`.

### PUT /api/v1/rental/properties/{id}

Replaces the property. Scope `properties:write`. Same body as `POST`; only `property_type` and `address_line` are checked as required, but **every field you omit is cleared**. Send the full object you got from `GET`, with your changes applied.

Changing `address_line` sends a live listing back to review.

## Photos

Each property holds up to 10 photos. Photos are shown in `position` order.

### GET /api/v1/rental/properties/{id}/photos

Scope `properties:read`. Response `200`:

```json
{ "photos": [ { "id": "…", "position": 0, "url": "https://…", "source": null, "created_at": "…" } ] }
```

### POST /api/v1/rental/properties/{id}/photos

Uploads one photo. Scope `media:write`. Supports `Idempotency-Key`.

- Multipart form field `photo`.
- JPEG or PNG, up to 5 MB. The part's content type must be `image/jpeg` or `image/png`.
- The photo is added after the existing ones. Response `201` with the photo.

```bash
curl -s -X POST "$RENTAL_API/api/v1/rental/properties/$PROPERTY_ID/photos" \
  -H "x-api-key: $RENTAL_API_KEY" \
  -F "photo=@living-room.jpg;type=image/jpeg"
```

### PUT /api/v1/rental/properties/{id}/photos

Copies a gallery from public image URLs, for example from the landlord's listing on another site. Scope `media:write`.

```json
{
  "source": "propertyguru",
  "photos": [
    { "url": "https://cdn.example.com/1.jpg" },
    { "url": "https://cdn.example.com/2.jpg" }
  ]
}
```

- The list is the complete set for that `source`. Photos from that source that are not in the list are removed. Photos uploaded directly or from other sources are never touched. An empty list removes all photos from that source.
- The order of `photos` sets the display order.
- URLs must be `https`, publicly reachable, at most 2,048 characters, JPEG or PNG, up to 5 MB each. No duplicate URLs.
- One bad URL does not fail the request. Check `results`:

```json
{
  "photos": [ … ],
  "results": [
    { "url": "https://cdn.example.com/1.jpg", "status": "created", "photo_id": "…" },
    { "url": "https://cdn.example.com/2.jpg", "status": "fetch_timeout", "photo_id": null }
  ]
}
```

| Status | Meaning |
| --- | --- |
| `created` | New photo stored. |
| `unchanged` | Already stored, same image. |
| `replaced` | The image at that URL changed and was replaced. |
| `duplicate_image` | The same image is already in the gallery from another upload. |
| `url_not_https` | The URL is not `https`. |
| `url_blocked` | The URL points to a private or blocked address. |
| `too_many_redirects` | More than 3 redirects. |
| `fetch_timeout` | The image took too long to download. |
| `fetch_failed` | The download failed. |
| `unsupported_media_type` | Not a JPEG or PNG. |
| `too_large` | Over 5 MB. |

Repeating the same `PUT` is safe and retries failed URLs.

## Ownership proofs

### GET /api/v1/rental/properties/{id}/proofs

Scope `proofs:read`. Returns a JSON array, newest first, with metadata only:

```json
[ { "id": "…", "created_at": "2026-09-30T08:00:00Z" } ]
```

An empty array means the listing cannot be submitted yet.

### POST /api/v1/rental/properties/{id}/proofs

Uploads one ownership proof. Scope `proofs:write`. Supports `Idempotency-Key`.

- Multipart form field `proof`.
- PDF, JPEG, or PNG, up to 5 MB. Up to 10 proofs per property.
- Response `201` with `{ "id", "created_at" }`.
- Uploading a proof sends a live listing back to review.

```bash
curl -s -X POST "$RENTAL_API/api/v1/rental/properties/$PROPERTY_ID/proofs" \
  -H "x-api-key: $RENTAL_API_KEY" \
  -F "proof=@assessment-bill.pdf;type=application/pdf"
```

## Listings

### POST /api/v1/rental/listings

Creates the property's listing as `draft`, or updates its active listing. Scope `listings:write`. Supports `Idempotency-Key`.

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `property_id` | string | yes | |
| `monthly_rent` | integer | yes | Whole MYR, greater than 0. |
| `deposit_months` | number | yes | Security deposit in months of rent, 0 or more. Typical: `2`. |
| `utility_deposit` | number | yes | Utility deposit in MYR, 0 to 999. Typical: `300`. |
| `tenancy_months` | integer | yes | Tenancy length in months, 0 or more. Typical: `12`. |
| `available_from` | string | no | RFC 3339 timestamp. |
| `description` | string | no | What a renter sees. Write it from the landlord's facts. |
| `house_rules` | string | no | E.g. no smoking, no pets. |

What happens:

- The property has never had a listing: a `draft` is created. Response `201`.
- The property has a `draft`, `pending_review`, or `live` listing: that listing is updated. Response `200`.
- The property only has finished listings (`withdrawn`, `rented`, `rejected`, `suspended`): `409 listing_not_reopenable`. For a `rejected` listing, edit it with `PUT /listings/{id}` and submit it again instead.

```bash
curl -s -X POST "$RENTAL_API/api/v1/rental/listings" \
  -H "x-api-key: $RENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "property_id": "'"$PROPERTY_ID"'",
    "monthly_rent": 2800,
    "deposit_months": 2,
    "utility_deposit": 300,
    "tenancy_months": 12,
    "available_from": "2026-11-01T00:00:00Z",
    "description": "Fully furnished 2-bedroom unit with a city view, 5 minutes walk to Publika.",
    "house_rules": "No smoking. No pets."
  }'
```

Response:

```json
{
  "id": "c41d…",
  "property_id": "8f1c…",
  "monthly_rent": 2800,
  "deposit_months": 2,
  "utility_deposit": 300,
  "tenancy_months": 12,
  "available_from": "2026-11-01T00:00:00Z",
  "description": "Fully furnished 2-bedroom unit with a city view, 5 minutes walk to Publika.",
  "house_rules": "No smoking. No pets.",
  "status": "draft",
  "origin_channel": "api",
  "created_at": "2026-10-01T08:00:00Z",
  "updated_at": "2026-10-01T08:00:00Z"
}
```

### GET /api/v1/rental/listings/{id}

Scope `listings:read`. Returns the listing plus review fields: `review_reason` (why it was rejected), `reviewed_at`, `published_at`, and `slug` (set once live).

### PUT /api/v1/rental/listings/{id}

Replaces the listing terms. Scope `listings:write`. Same fields as `POST` without `property_id`. **Every field you omit is reset** (`available_from` to empty, `description` to empty, numbers to 0). Send the full object.

A rent change on a `live` listing sends it back to `pending_review`. A listing that is `suspended` refuses changes that need review (`409 listing_status_conflict`).

### POST /api/v1/rental/listings/{id}/submit

Sends a `draft` or `rejected` listing for review. Scope `listings:write`.

```json
{ "declaration_accepted": true }
```

Only send `true` after the landlord has confirmed that they own the property and the listing is accurate.

| Result | Meaning |
| --- | --- |
| `200`, status `pending_review` | Submitted. An admin will review it. |
| `200`, status `live` | Test key: published to the sandbox right away. |
| `400 declaration_not_accepted` | `declaration_accepted` was missing or `false`. |
| `400 no_ownership_proof` | Upload an ownership proof first. |
| `409 duplicate_under_review` | Another verified owner has a matching property. The listing is held for an admin. Tell the landlord. |
| `409 listing_status_conflict` | The listing is not `draft` or `rejected`. |

### POST /api/v1/rental/listings/{id}/withdraw

Takes a `live` listing offline. Final. Scope `listings:write`. No body. Returns `409 listing_status_conflict` if the listing is not `live`.
## Move-in handover

A move-in Handover Report records the condition, inventory, access devices, and meter readings of a property at the start of a tenancy. It provides an objective baseline against which move-out condition and deposit returns are evaluated.

### Lifecycle

```text
draft ──landlord submits in portal──▶ tenant_review ──tenant accepts or 7 days elapse──▶ finalized
```

1. **`draft`**: The agent and landlord author areas, entries, and attach photo evidence. The agent can create, update, and delete items they created (`created_via: "api"`). The report can only be created during the submit window (`starts_on − 7` through `starts_on + 3` Malaysian calendar dates).
2. **`tenant_review`**: The landlord submits the Baseline in the portal. The Baseline content is frozen. The tenant has a 7-day review window (`review_due_on`) to review the Baseline and open dispute threads on areas or entries. The landlord replies to threads in the portal.
3. **`finalized`**: All open threads are resolved and the tenant accepts the report, or the 7-day window closes with no tenant response. The report is permanent.

### Rules and Roles

- **Agent drafts; Landlord submits**: The API key allows agents to build and edit the draft. Only the landlord can submit the Baseline in the portal. The agent never submits, signs, or approves.
- **Own-items rule**: Agents can only update (`PATCH`) or delete (`DELETE`) areas, entries, and photos that have `created_via = 'api'`. Items created by the landlord in the portal cannot be modified or removed by the agent (`409 handover_item_not_editable`). To correct a landlord item, add a corrected item and ask the landlord to remove the original in the portal.
- **Submit window**: Reports can only be created between `starts_on − 7` days and `starts_on + 3` days (`opens_on` to `closes_on`). Outside this window, report creation returns `409 handover_window_not_open` or `409 handover_window_closed`.
- **Readiness check**: `GET /tenancies/{id}/handover-report` includes a `readiness` object indicating whether the draft is ready for submission (`ready: true`). Problems include `no_areas`, `area_without_photo`, `meter_without_photo`, `photo_not_ready`, and `outside_submit_window`. Warnings include `staged_photo_unattached`. When `ready` is true, tell the landlord to submit in the portal before `submit_window.closes_on`.
- **Threads and awaiting**: During tenant review, dispute threads indicate who must respond via `awaiting: "landlord"` or `awaiting: "tenant"`. Report every open thread where `awaiting` is `landlord`, with `review_due_on`.

### Entry types and fields

Each entry belongs to an area and has an `entry_type`:

| `entry_type` | Description | Specific Fields | Rules |
| --- | --- | --- | --- |
| `condition` | Room element condition (walls, flooring, doors) | `condition_note` (string, optional) | Does not require photo |
| `inventory` | Furnishings and appliances (sofa, fridge, bed) | `quantity` (integer, optional) | Does not require photo |
| `access_device` | Keys, access cards, remotes | `quantity` (integer, optional) | Does not require photo |
| `meter_reading` | Utility meter readings (electricity, water) | `meter_value` (decimal string), `meter_unit` (`kwh` or `m3`) | **Requires at least one attached photo**. `meter_value` must be a decimal string with up to 3 decimal places (e.g. `"10234.500"`). |

### Photo evidence, staging, and budgets

- **Evidence requirement**: Every area must have at least one directly attached photo. Every `meter_reading` entry must have at least one attached photo.
- **Staging vs Attaching**: Photos uploaded with `area_id` or `entry_id` are attached immediately. Photos uploaded without a target are staged. Staged photos do not satisfy area or meter requirements until attached with `PATCH /handover-media/{id}`. Staged photos with uploading or failed status do not block submission.
- **Photo budgets**: Up to 10 photos per area, up to 100 photos per baseline report. Exceeding budgets returns `409 handover_image_limit_reached`.
- **Idempotency**: `POST /handover-reports/{id}/media` **requires** an `Idempotency-Key` header (UUID).

### GET /api/v1/rental/tenancies/{id}/handover-report

Scope `handover:read`. Returns the tenancy's move-in handover report including status, submit window, readiness evaluation, areas, entries, media metadata, and dispute threads. Returns `404 handover_report_not_found` if no report exists or the tenancy is not visible.

### POST /api/v1/rental/tenancies/{id}/handover-report

Scope `handover:write`. Creates the move-in handover report draft for a visible tenancy, or returns `200` with the existing report. Must be called within the submit window (`starts_on - 7` through `starts_on + 3` Malaysian calendar dates). Returns `409 handover_window_not_open` before the window or `409 handover_window_closed` after the window.

### GET /api/v1/rental/handover-reports/{id}

Scope `handover:read`. Returns the move-in handover report by ID. Returns `404 handover_report_not_found` if not found or not visible.

### POST /api/v1/rental/handover-reports/{id}/areas

Scope `handover:write`. Adds an area to a draft handover report. Supports `Idempotency-Key`. Items created by the agent are marked `created_via: "api"`.

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `name` | string | yes | Name of the area (e.g. "Living Room", "Kitchen"). Max 100 characters. |
| `sort_order` | integer | no | Display order among areas (default: appended to end). |

Returns `409 handover_item_not_editable` if the report is not `draft`.

### PATCH /api/v1/rental/handover-areas/{id}

Scope `handover:write`. Updates an area's `name` or `sort_order`. Only allowed on areas created by the agent (`created_via = 'api'`) and while the report is `draft`. Returns `409 handover_item_not_editable` for landlord-created areas or submitted reports.

### DELETE /api/v1/rental/handover-areas/{id}

Scope `handover:write`. Deletes an area and all its entries. Only allowed on areas created by the agent while the report is `draft`. Returns `204`. Returns `409 handover_item_not_editable` for landlord-created areas or submitted reports.

### POST /api/v1/rental/handover-areas/{id}/entries

Scope `handover:write`. Adds an entry to an area in a draft report. Supports `Idempotency-Key`. Items created by the agent are marked `created_via: "api"`.

| Field | Type | Required | Rules |
| --- | --- | --- | --- |
| `entry_type` | string | yes | One of `condition`, `inventory`, `meter_reading`, `access_device`. |
| `label` | string | yes | Entry label (e.g. "Air Conditioner", "Electric Meter"). |
| `sort_order` | integer | no | Display order within the area. |
| `quantity` | integer | no | Used for `inventory` and `access_device`. Minimum 1. |
| `condition_note` | string | no | Used for `condition`. Description of current state. |
| `meter_value` | string | yes for meter | Decimal string with up to 3 decimal places (e.g. `"10423.500"`). |
| `meter_unit` | string | yes for meter | `kwh` or `m3`. |

Returns `409 handover_item_not_editable` if the report is not `draft`.

### PATCH /api/v1/rental/handover-entries/{id}

Scope `handover:write`. Updates an entry's fields. Only allowed on entries created by the agent (`created_via = 'api'`) and while the report is `draft`. Returns `409 handover_item_not_editable` on landlord items or submitted reports.

### DELETE /api/v1/rental/handover-entries/{id}

Scope `handover:write`. Deletes an entry. Only allowed on entries created by the agent while the report is `draft`. Returns `204`. Returns `409 handover_item_not_editable` on landlord items or submitted reports.

### POST /api/v1/rental/handover-reports/{id}/media

Scope `handover:write`. Uploads a photo to the draft handover report using multipart form data. **`Idempotency-Key` header is required**.

| Part | Type | Required | Description |
| --- | --- | --- | --- |
| `file` | binary | yes | JPEG or PNG file, up to 5MB. HEIC is not supported. |
| `area_id` | string | no | Directly attaches the photo to this area. |
| `entry_id` | string | no | Directly attaches the photo to this entry. |

If neither `area_id` nor `entry_id` is provided, the photo is uploaded as staged. Only allowed while the report is `draft`. Returns `409 handover_image_limit_reached` if area (10) or report (100) budget is reached.

### PATCH /api/v1/rental/handover-media/{id}

Scope `handover:write`. Attaches a staged photo to an area or entry. Send `{"area_id": "..."}` or `{"entry_id": "..."}`. Only allowed on photos created by the agent while the report is `draft`. Returns `409 handover_item_not_editable` otherwise.

### DELETE /api/v1/rental/handover-media/{id}

Scope `handover:write`. Deletes a photo from the draft. Only allowed on photos created by the agent while the report is `draft`. Returns `204`. Returns `409 handover_item_not_editable` otherwise.

### GET /api/v1/rental/handover-media/{id}/content

Scope `handover:read`. Streams photo content: original image bytes by default, or thumbnail with `?variant=thumbnail`. Available for all photos in a visible report, including tenant dispute photos. Invalid variant returns `400 validation_failed` with `fields: ["variant"]`.


## Errors

Every error has the same shape:

```json
{
  "error": {
    "code": "validation_failed",
    "message": "Validation failed",
    "fields": ["property_type"]
  },
  "request_id": "req-12345"
}
```

Act on `code`, not on `message`. `fields` appears for `validation_failed` (the fields to fix), `ambiguous_match`, and `unit_number_required` (candidate property ids). Quote `request_id` when the landlord contacts support.

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `validation_failed` | Fix the fields in `fields` and retry. |
| 400 | `declaration_not_accepted` | Ask the landlord to confirm, then send `declaration_accepted: true`. |
| 400 | `no_ownership_proof` | Upload a proof, or ask the landlord to. |
| 401 | `invalid_api_key` | The key is missing or wrong. Ask the landlord for the key. |
| 401 | `key_expired` | Ask the landlord to create a new key. |
| 401 | `key_disabled` | The key was revoked. Ask the landlord for a new key. |
| 403 | `insufficient_permission` | The key lacks the bundle for this endpoint. Tell the landlord which bundle to add. |
| 403 | `insufficient_role` | The account is not a landlord account. |
| 403 | `account_unavailable` | The account is suspended. The landlord must contact support. |
| 404 | `property_not_found`, `listing_not_found` | Wrong id, wrong key mode (test vs live), or not this landlord's. Re-list properties to find the right id. |
| 404 | `handover_report_not_found` | Handover report does not exist or tenancy is not visible. |
| 404 | `handover_item_not_found` | Handover area, entry, or photo does not exist or is not visible. |
| 404 | `route_not_found` | Check the path. |
| 405 | `method_not_allowed` | Check the HTTP method. |
| 409 | `listing_status_conflict` | The listing's status does not allow this. `GET` it and re-plan. |
| 409 | `listing_not_reopenable` | The property cannot get a new listing through the API. |
| 409 | `idempotency_conflict` | You reused an `Idempotency-Key` with a different body. Use a new key. |
| 409 | `duplicate_under_review` | Held for admin review. Tell the landlord. |
| 409 | `ambiguous_match`, `unit_number_required`, `duplicate_address`, `duplicate_external_ref` | See `POST /properties` above. |
| 409 | `handover_item_not_editable` | Handover area, entry, or photo cannot be edited or deleted (created via portal or report submitted/finalized). |
| 409 | `handover_window_not_open` | Move-in handover draft cannot be created before `starts_on - 7` days. |
| 409 | `handover_window_closed` | Move-in handover draft cannot be created after `starts_on + 3` days. |
| 409 | `handover_image_limit_reached` | Area (10 images) or report baseline (100 images) photo budget reached. |
| 410 | `upload_expired` | Photo upload session has expired. Upload a new photo. |
| 429 | `rate_limited` | Wait for `Retry-After` seconds. |
| 500 | `internal_error` | Retry once later. If it repeats, report the `request_id`. |
| 503 | `auth_unavailable`, `media_unavailable`, `rate_limit_unavailable` | Temporary. Retry after a short wait. |

## Rate limits

Each key has an hourly allowance, 1,000 requests by default. The counter resets at the top of every UTC hour. Every request with a valid key counts, including ones refused for validation or permissions.

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | The hourly allowance. |
| `X-RateLimit-Remaining` | Requests left this hour. |
| `X-RateLimit-Reset` | Unix time (seconds) of the next reset. |
| `Retry-After` | On `429` only: seconds to wait. |

## Retries and Idempotency-Key

`POST /properties`, `POST /properties/{id}/photos`, `POST /properties/{id}/proofs`, and `POST /listings` accept an optional `Idempotency-Key` header. Generate one random value (a UUID) per intended action and reuse it only when retrying that same action, for example after a timeout.

- Same key, same body: you get `200` with the current resource and the header `Idempotent-Replay: true`. Nothing is created twice.
- Same key, different body: `409 idempotency_conflict`.

The `PUT` endpoints are already safe to repeat. Do not blindly retry `submit` or `withdraw`: `GET` the listing first and check its status.
