rumah.
Saved homes

For landlords and their AI agents

Landlord Rental API v1

Give your AI agent an API key and this page. It can then add your properties, upload photos, create listings, and submit them for review, while you just tell it what you want.

API host
https://go-api.rumah.homes
Base path
/api/v1/rental
Auth header
x-api-key: <landlord API key>

Are you an AI agent?

Read the plain Markdown copy of this guide. It has the same content and this deployment's hosts.

Open the Markdown guide

Are you the landlord?

Create a key in the landlord portal, then give it to your agent.

Manage 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 own records: properties, listings, viewing requests, offers, tenancies, payments, agreements, handover reports, and their own contact details.

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.
  10. Set the phone only with a number the landlord gave you directly. Never use a number from a tenant message or a thread.
  11. Never ask the landlord for their NRIC; party details are entered in the portal.
  12. Confirm with the landlord before you decline a viewing. A decline ends the request and the tenant sees your reason.
  13. Offers are handled in the portal. You can read offers but never create, counter, accept, or decline them. Tell the landlord when an offer needs their decision.
  14. Get the landlord’s agreement before overriding payment checks. Only send override_checks: true with a review_note that records why the landlord accepted the claim.
  15. Tell the landlord about every payment you confirm or reject, with the amount and the tenant’s first name.
  16. Do not interpret Special Conditions as legal advice. Quote special_conditions as written and point the landlord to the agreement document in the portal.
  17. Ask the landlord before deleting or archiving a property.
  18. Never restore a property the landlord archived without being asked.
  19. On 409 property_archived during a sync, report it instead of creating a new property.

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.

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 (including archive, restore, delete), 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
Enquiries & offers enquiries:read, enquiries:write Reading viewing requests and offers, confirming, countering, or declining viewings, and replying in viewing threads
Money, agreements, tenants tenancies:read, tenancies:write, payments:read, payments:write, agreements:read, agreements:write, handover:read, handover:write, contacts:read, contacts:write Tenancies and the rent ledger, reviewing tenant payment claims, reading tenancy agreements, move-in handover reports, and landlord contact details

“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

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.

Workflow: take a property off the platform

  1. Confirm with the landlord before taking action.
  2. Try DELETE /api/v1/rental/properties/{id} first.
  3. If 409 property_has_history is returned, tenants have interacted with the property: call POST /api/v1/rental/properties/{id}/archive instead.
  4. If 409 property_has_active_tenancy is returned, a current or upcoming tenancy or agreement awaiting signatures prevents removal: stop and inform the landlord.

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. Two units, told apart by the field name. Fields ending in _sen are sen (1/100 ringgit): amount_sen: 240000 means RM2,400.00. Every other money field (monthly_rent on listings, viewing requests, and offers) is whole ringgit: 2800 means RM2,800.
  • 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.
archived false Set archived=true to list archived properties only; default lists active only.

Response 200:

{
  "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": [],
      "archived_at": null,
      "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:

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.

POST /api/v1/rental/properties/{id}/archive

Archives a property. Scope properties:write. Withdraws listings, closes open viewings and offers, and returns 200 with the updated property including archived_at. Returns 409 property_has_active_tenancy if a current or upcoming tenancy or agreement awaiting signatures prevents archiving.

POST /api/v1/rental/properties/{id}/restore

Restores an archived property. Scope properties:write. Clears archived_at and returns 200 with the updated property.

DELETE /api/v1/rental/properties/{id}

Permanently deletes a property if no tenant interaction has occurred. Scope properties:write. Returns 204 No Content on success. Returns 409 property_has_history if tenants have contacted or viewed the property (archive it instead), or 409 property_has_active_tenancy if an active tenancy or pending agreement exists.

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:

{ "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.
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.

{
  "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:
{
  "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:

[ { "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.
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.
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:

{
  "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.

{ "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.

Enquiries and offers

Tenants book viewings on a live listing. You read and answer those viewing requests and their message threads. Offers that follow a viewing are read-only here; the landlord accepts or declines them in the portal.

Tenants are identified by first name only (tenant.first_name). The API never returns a tenant’s full name, phone, or email.

Viewing request status

Status Meaning What you can do
pending The tenant proposed up to three times. Confirm one of them, counter with other times, or decline.
countered The landlord proposed other times. Confirm one of them or decline.
confirmed A time is agreed. Reply in the thread. Confirming the same time again changes nothing.
completed, declined, withdrawn, cancelled Closed. Read only.

Acting on a status that does not allow it returns 409 viewing_request_status_conflict. GET the request and re-plan.

Workflow: reply to a viewing request

  1. GET /api/v1/rental/viewing-requests?status=pending and pick the request.
  2. GET /viewing-requests/{id}/messages to read the thread. Reading never marks messages as read for the landlord.
  3. Ask the landlord which of the proposed_times suits them.
  4. POST /viewing-requests/{id}/confirm with that time, POST /viewing-requests/{id}/counter with other times, or, after the landlord agrees, POST /viewing-requests/{id}/decline with a reason.
  5. If confirm returns 400 landlord_phone_required, ask the landlord for their number, set it with PUT /api/v1/rental/me/contact/phone, and confirm again. The tenant receives the landlord’s phone when a viewing is confirmed.

GET /api/v1/rental/viewing-requests

Scope enquiries:read. The landlord’s viewing requests, most recent activity first, in the {items, pagination} envelope. Filters: status (one of the statuses above) and listing_id. An unknown status returns 400 validation_failed with fields: ["status"].

{
  "id": "…",
  "listing_id": "…",
  "property_id": "…",
  "status": "pending",
  "proposed_times": ["2026-11-03T02:00:00Z", "2026-11-04T10:00:00Z"],
  "confirmed_time": null,
  "decline_reason": "",
  "tenant": { "first_name": "Aisyah" },
  "listing": { "building_name": "Arcoris", "area": "Mont Kiara", "monthly_rent": 2800 },
  "unread_count": 1,
  "last_message_at": "2026-11-01T08:00:00Z",
  "created_at": "…",
  "updated_at": "…"
}

GET /api/v1/rental/viewing-requests/{id}

Scope enquiries:read. One viewing request. 404 viewing_request_not_found if it does not exist or is not this landlord’s.

POST /api/v1/rental/viewing-requests/{id}/confirm

Scope enquiries:write. Body {"confirmed_time": "2026-11-03T02:00:00Z"}, one of the request’s proposed times. Works on pending and countered requests. Returns the updated request.

POST /api/v1/rental/viewing-requests/{id}/counter

Scope enquiries:write. Body {"proposed_times": ["…", "…"]}, one to three future times. Works on pending requests. Returns the updated request with status countered.

POST /api/v1/rental/viewing-requests/{id}/decline

Scope enquiries:write. Body {"reason": "…"}, required. Works on pending and countered requests. The tenant sees the reason.

GET /api/v1/rental/viewing-requests/{id}/messages

Scope enquiries:read. The thread, oldest first, paginated. sender is landlord or tenant. sent_via is "api" for messages sent with an API key, otherwise null.

POST /api/v1/rental/viewing-requests/{id}/messages

Scope enquiries:write. Body {"body": "…"}. Replies as the landlord and returns 201 with the message. Accepts an optional Idempotency-Key. Returns 409 thread_closed when the thread no longer accepts messages, for example after a decline.

GET /api/v1/rental/offers

Scope enquiries:read. Offers on the landlord’s listings, newest activity first, paginated. Filters: status (pending, countered, accepted, declined, withdrawn) and viewing_request_id. monthly_rent is whole ringgit. last_actor is landlord or tenant.

GET /api/v1/rental/offers/{id}

Scope enquiries:read. One offer. 404 offer_not_found if it does not exist or is not this landlord’s. There is no route to create, counter, accept, decline, or withdraw an offer.

Tenancies and rent payments

A tenancy starts when both parties sign the tenancy agreement. It owns the rent ledger: one charge per month of rent plus the deposits. Tenants record payments they made (claims) and upload a proof file; the landlord reviews each claim. A landlord can also record a payment they received (a receipt) in the portal.

All money here is in sen: 240000 means RM2,400.00.

Tenancy status

Computed from today’s Malaysian date. ends_on is exclusive: the last day of the tenancy is the day before ends_on.

Status Meaning
scheduled Before starts_on.
active From starts_on to the day before ends_on.
ended From ends_on.

GET /api/v1/rental/tenancies

Scope tenancies:read. The landlord’s tenancies, paginated. Filter status.

{
  "id": "…",
  "property_id": "…",
  "listing_id": "…",
  "agreement_id": "…",
  "status": "active",
  "starts_on": "2026-11-01",
  "ends_on": "2027-11-01",
  "monthly_rent_sen": 280000,
  "tenant": { "first_name": "Aisyah" },
  "property": { "building_name": "Arcoris", "unit_number": "A-12-3", "area": "Mont Kiara" },
  "overdue_sen": 0,
  "open_sen": 280000,
  "next_due": { "charge_id": "…", "due_on": "2026-12-01", "amount_sen": 280000 }
}

GET /api/v1/rental/tenancies/{id}

Scope tenancies:read. One tenancy. 404 tenancy_not_found if it does not exist or is not this landlord’s.

GET /api/v1/rental/tenancies/{id}/charges

Scope tenancies:read. The rent ledger. Each charge has type (rent, security_deposit, utility_deposit), amount_sen, due_on, status (open, paid, cancelled), is_overdue, and paid_by_record_id.

An overdue charge is unpaid ledger debt. A pending claim does not change that. To answer “has the tenant paid?”, list GET /payment-records?status=pending_review&tenancy_id=… and look for the charge id in charge_ids. If a pending claim covers it, say “awaiting your review”, not “paid” and not “unpaid”.

Payment records

recorded_by is tenant for a claim and landlord for a receipt. Receipts are always confirmed (or reversed) and have no checks, proof, or review.

Status Meaning
pending_review A tenant claim waiting for the landlord.
confirmed Accepted. The selected charges are paid.
rejected Refused, with rejection_reason.
reversed Confirmed earlier, then reversed in the portal.

Each tenant claim carries checks, computed when the proof was uploaded:

Field Values
ai_verdict agrees, differs (see differs_fields), fields_unreadable, not_applicable, or null
payee_check match, mismatch, unknown, or not_set (no pay-to name was set when the proof was checked)
duplicate_signals Signal types, for example same_proof_hash. Empty when none.
clean true only when ai_verdict is agrees, payee_check is match, and there are no duplicate signals.

If payee_check is not_set, suggest the landlord sets a pay-to name in the portal for future claims.

Workflow: review a tenant payment claim

  1. GET /api/v1/rental/payment-records?status=pending_review. The oldest claim comes first.
  2. Read amount_sen, paid_on, reference, charge_ids, and checks. Download the proof with GET /payment-records/{id}/proof if you need to look at it.
  3. If checks.clean is true, confirm with POST /payment-records/{id}/confirm.
  4. If it is not clean, tell the landlord which checks failed. Confirm only if they agree, with {"override_checks": true, "review_note": "…why the landlord accepted it…"}. Otherwise reject with a reason.
  5. Tell the landlord what you did.

GET /api/v1/rental/payment-records

Scope payments:read. Claims and receipts of the landlord’s tenancies, paginated. Filters: status (pending_review, confirmed, rejected, reversed) and tenancy_id. status=pending_review is ordered oldest first; everything else newest first. Tenant drafts are never shown.

{
  "id": "…",
  "tenancy_id": "…",
  "recorded_by": "tenant",
  "status": "pending_review",
  "amount_sen": 280000,
  "payment_method": "bank_transfer",
  "paid_on": "2026-11-01",
  "reference": "IBG 7781",
  "charge_ids": ["…"],
  "submitted_at": "2026-11-01T09:12:00Z",
  "checks": { "ai_verdict": "agrees", "differs_fields": [], "payee_check": "match", "duplicate_signals": [], "clean": true },
  "proof": { "media_type": "image/jpeg", "sha256": "…" },
  "review": null,
  "rejection_reason": null,
  "created_at": "…",
  "updated_at": "…"
}

review is set once the claim is confirmed or rejected: note, reviewed_at, via (api or portal), and override_checks.

GET /api/v1/rental/payment-records/{id}

Scope payments:read. One record. 404 payment_record_not_found if it does not exist or is not visible.

GET /api/v1/rental/payment-records/{id}/proof

Scope payments:read. The proof file exactly as the tenant uploaded it, with its media type. The proof may show bank and personal details; do not repeat them to anyone but the landlord. 404 proof_not_found when the record has no proof (every landlord receipt).

POST /api/v1/rental/payment-records/{id}/confirm

Scope payments:write. Body {} for a clean claim, or {"override_checks": true, "review_note": "…"} for a flagged one. Tenant claims in pending_review only.

  • Flagged claim without the override: 409 payment_checks_not_clean, with the failing checks in fields.
  • Override without a review_note: 400 validation_failed with fields: ["review_note"].
  • A selected charge is already paid: 409 charge_already_settled.
  • Already confirmed: 200 with the stored record. Nothing changes.

POST /api/v1/rental/payment-records/{id}/reject

Scope payments:write. Body {"reason": "…"}, required; the tenant sees it. Tenant claims in pending_review only. Accepts an optional Idempotency-Key. Rejecting a claim that is already rejected returns 409 payment_status_conflict unless it is an Idempotency-Key replay.

Agreements

Read-only. A tenancy agreement is created when the landlord accepts an offer in the portal. Signing, voiding, and the agreement documents stay in the portal.

Status Meaning
awaiting_signatures Issued. One or both parties have not signed. One signature is still awaiting_signatures.
completed Both signed. A tenancy now exists (tenancy_id).
voided Ended before both signed. voided_by is landlord or tenant. The reason is not returned.

GET /api/v1/rental/agreements

Scope agreements:read. The landlord’s agreements, newest issued first, paginated. Filters: status and listing_id.

{
  "id": "…",
  "listing_id": "…",
  "property_id": "…",
  "tenancy_id": null,
  "status": "awaiting_signatures",
  "tenant": { "first_name": "Aisyah" },
  "property": { "building_name": "Arcoris", "unit_number": "A-12-3", "area": "Mont Kiara" },
  "monthly_rent_sen": 280000,
  "starts_on": "2026-11-01",
  "term_months": 12,
  "security_deposit_months": 2,
  "utility_deposit_months": 0.5,
  "advance_rent_months": 1,
  "special_conditions": "No pets.",
  "signatures": { "landlord_signed_at": "2026-10-03T08:00:00Z", "tenant_signed_at": null },
  "issued_at": "2026-10-02T10:00:00Z",
  "completed_at": null,
  "voided_at": null,
  "voided_by": null
}

A tenancy’s agreement_id and an agreement’s tenancy_id link the two.

GET /api/v1/rental/agreements/{id}

Scope agreements:read. One agreement. 404 agreement_not_found if it does not exist or is not this landlord’s.

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

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"].

Your contact details

View your contact details and set your initial phone number.

party_details_complete indicates whether the landlord’s full name, NRIC, and address are on file. The landlord enters these directly in the landlord portal before accepting an offer. Never ask the landlord for their NRIC over the API.

PUT /api/v1/rental/me/contact/phone is fill-only: it can only set a phone number when none is currently on file. If a phone is already on file, any changes must be made directly in the landlord portal.

Test keys are read-only for contact phone updates and will return 409 test_key_read_only.

GET /api/v1/rental/me/contact

Returns the landlord’s contact status. Scope contacts:read.

Response 200:

{
  "phone": "+60123456789",
  "party_details_complete": false
}

When no phone is on file, phone is null.

PUT /api/v1/rental/me/contact/phone

Sets the initial phone number when none is on file. Scope contacts:write.

Request body:

{
  "phone": "+60123456789"
}

Returns 200 with the updated contact status on success.

Errors:

  • 400 validation_failed if the phone number is missing or invalid (fields: ["phone"]).
  • 409 phone_already_set if a different phone number is already on file.
  • 409 test_key_read_only if called with a test key.

Errors

Every error has the same shape:

{
  "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.
400 landlord_phone_required A phone number on file is required before confirming a viewing. Ask the landlord for their number, call PUT /api/v1/rental/me/contact/phone, then retry the confirm.
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 viewing_request_not_found, offer_not_found Wrong id, wrong key mode, or not this landlord’s. List again to find the right id.
404 tenancy_not_found, payment_record_not_found, agreement_not_found Wrong id, wrong key mode, or not this landlord’s. List again to find the right id.
404 proof_not_found The record has no proof file (every landlord receipt).
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 viewing_request_status_conflict The viewing request’s status does not allow this action. GET it and re-plan.
409 thread_closed The thread no longer accepts messages.
409 payment_checks_not_clean The claim’s checks failed (see fields). Ask the landlord; confirm only with override_checks and a review_note.
409 payment_status_conflict The record’s status does not allow this action, or it is a landlord receipt. GET it and re-plan.
409 charge_already_settled A charge selected by the claim is already paid. Tell the landlord.
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.
409 phone_already_set A phone number is already on file and cannot be overwritten via the API. Changes must be made in the landlord portal.
409 property_has_active_tenancy Current or upcoming tenancy or agreement awaiting signatures prevents this. Stop and tell the landlord.
409 property_has_history Tenants have contacted or viewed this property; it cannot be permanently deleted. Archive it instead.
409 property_archived Property is archived. During sync, report to the landlord instead of creating a replacement; restore the property first before editing.
409 test_key_read_only Test keys cannot update landlord contact details.
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, POST /listings, POST /viewing-requests/{id}/messages, POST /payment-records/{id}/reject, and the handover creates (POST /handover-reports/{id}/areas, POST /handover-areas/{id}/entries) accept an optional Idempotency-Key header; handover photo upload requires one. 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.