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
- 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.
- 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.
- Read before you replace.
PUT /properties/{id}andPUT /listings/{id}replace the whole record. Fields you leave out are reset to empty or zero. AlwaysGETthe record, change only what the landlord asked for, and send the full object back. - 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.”
- Keep the key secret. Never print the full API key back to the landlord or anyone else.
- Upload only photos the landlord took for this handover; never reuse listing photos.
- To correct a landlord’s item, add a corrected one and ask the landlord to delete the original.
- When
readiness.readyis true, tell the landlord to submit in the portal beforesubmit_window.closes_on. - Report every open thread where
awaitingislandlord, withreview_due_on. - Set the phone only with a number the landlord gave you directly. Never use a number from a tenant message or a thread.
- Never ask the landlord for their NRIC; party details are entered in the portal.
- Confirm with the landlord before you decline a viewing. A decline ends the request and the tenant sees your reason.
- 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.
- Get the landlord’s agreement before overriding payment checks. Only send
override_checks: truewith areview_notethat records why the landlord accepted the claim. - Tell the landlord about every payment you confirm or reject, with the amount and the tenant’s first name.
- Do not interpret Special Conditions as legal advice. Quote
special_conditionsas written and point the landlord to the agreement document in the portal. - Ask the landlord before deleting or archiving a property.
- Never restore a property the landlord archived without being asked.
- On
409 property_archivedduring 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.
- The landlord signs in to the landlord portal and opens the API keys page (link above this guide).
- 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).
- 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: truewhen 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_renton 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
- Find or create the property.
GET /api/v1/rental/propertiesand look for a match first. If there is none,POST /api/v1/rental/properties. Note theid. - Add photos. Upload files with
POST /properties/{id}/photos, or copy a gallery from another site withPUT /properties/{id}/photos. Up to 10 photos per property. - Check for an ownership proof.
GET /properties/{id}/proofs(needs “Upload documents”). If the list is empty, upload one withPOST /properties/{id}/proofs, or ask the landlord to upload it in the portal. - Create the listing.
POST /api/v1/rental/listingswith the property id and the rental terms. It starts asdraft. - Confirm with the landlord. Show the property, terms, and photo count. Ask them to confirm the ownership declaration.
- Submit.
POST /listings/{id}/submitwith{"declaration_accepted": true}. Status becomespending_review(orlivewith a test key). - Check back later.
GET /listings/{id}showsliveonce approved, with aslug, orrejectedwith areview_reason.
Workflow: change the rent
GET /api/v1/rental/properties/{propertyId}and readcurrent_listing.id.GET /api/v1/rental/listings/{listingId}.- Change
monthly_rentin that object andPUT /api/v1/rental/listings/{listingId}with every listing field. - Tell the landlord that a live listing goes back to review after a rent change.
Workflow: take a listing offline
- Confirm with the landlord. Withdrawal is final: the property cannot get a new listing through the API afterwards.
POST /api/v1/rental/listings/{listingId}/withdraw. Only works when the status islive.
Workflow: take a property off the platform
- Confirm with the landlord before taking action.
- Try
DELETE /api/v1/rental/properties/{id}first. - If
409 property_has_historyis returned, tenants have interacted with the property: callPOST /api/v1/rental/properties/{id}/archiveinstead. - If
409 property_has_active_tenancyis 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_casefield names. Photo and proof uploads usemultipart/form-data. - IDs. Opaque strings. Store them; do not build them.
- Money. Two units, told apart by the field name. Fields ending in
_senare sen (1/100 ringgit):amount_sen: 240000means RM2,400.00. Every other money field (monthly_renton listings, viewing requests, and offers) is whole ringgit:2800means RM2,800. - Deposits.
deposit_monthsis the security deposit in months of rent, decimals allowed (2,1.5).utility_depositis a fixed amount in MYR (300means RM300), shown as-is on the public listing. - Dates. RFC 3339 timestamps, for example
2026-11-01T00:00:00Z. A bare2026-11-01is 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:
- Same
sourceandexternal_refas an existing property: that property is updated with only the fields you sent. Response200. - Same address (building or street, unit number, and postcode) as one existing property: that property is updated and the
source/external_refis linked to it. Response200. - 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/jpegorimage/png. - The photo is added after the existing ones. Response
201with 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
photossets 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
201with{ "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
draftis created. Response201. - The property has a
draft,pending_review, orlivelisting: that listing is updated. Response200. - The property only has finished listings (
withdrawn,rented,rejected,suspended):409 listing_not_reopenable. For arejectedlisting, edit it withPUT /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
GET /api/v1/rental/viewing-requests?status=pendingand pick the request.GET /viewing-requests/{id}/messagesto read the thread. Reading never marks messages as read for the landlord.- Ask the landlord which of the
proposed_timessuits them. POST /viewing-requests/{id}/confirmwith that time,POST /viewing-requests/{id}/counterwith other times, or, after the landlord agrees,POST /viewing-requests/{id}/declinewith a reason.- If confirm returns
400 landlord_phone_required, ask the landlord for their number, set it withPUT /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
GET /api/v1/rental/payment-records?status=pending_review. The oldest claim comes first.- Read
amount_sen,paid_on,reference,charge_ids, andchecks. Download the proof withGET /payment-records/{id}/proofif you need to look at it. - If
checks.cleanistrue, confirm withPOST /payment-records/{id}/confirm. - 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. - 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 infields. - Override without a
review_note:400 validation_failedwithfields: ["review_note"]. - A selected charge is already paid:
409 charge_already_settled. - Already
confirmed:200with 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
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 − 7throughstarts_on + 3Malaysian calendar dates).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.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 havecreated_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 − 7days andstarts_on + 3days (opens_ontocloses_on). Outside this window, report creation returns409 handover_window_not_openor409 handover_window_closed. - Readiness check:
GET /tenancies/{id}/handover-reportincludes areadinessobject indicating whether the draft is ready for submission (ready: true). Problems includeno_areas,area_without_photo,meter_without_photo,photo_not_ready, andoutside_submit_window. Warnings includestaged_photo_unattached. Whenreadyis true, tell the landlord to submit in the portal beforesubmit_window.closes_on. - Threads and awaiting: During tenant review, dispute threads indicate who must respond via
awaiting: "landlord"orawaiting: "tenant". Report every open thread whereawaitingislandlord, withreview_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_readingentry must have at least one attached photo. - Staging vs Attaching: Photos uploaded with
area_idorentry_idare attached immediately. Photos uploaded without a target are staged. Staged photos do not satisfy area or meter requirements until attached withPATCH /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}/mediarequires anIdempotency-Keyheader (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_failedif the phone number is missing or invalid (fields: ["phone"]).409 phone_already_setif a different phone number is already on file.409 test_key_read_onlyif 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
200with the current resource and the headerIdempotent-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.