iMatch Connect is a REST API over the iMatch market: live listings, buyer and tenant inquiries, the Dubai project catalogue, distress deals, and the matches between your records and everyone else's. The same key also opens signed webhooks, an embeddable set of our tools, a Copilot chat endpoint and an MCP server for AI clients.
Base URL for every call:
https://api.imatch.ae/v1
https://app.imatch.ae/api/v1 is the same API on the panel host — every URL under it keeps working forever; the front door above is simply the shorter address to configure.
1. Get a key
Access is granted by iMatch — there is no self-serve sign-up. Request access and you will receive a key that looks like imc_live_ followed by 40 letters and digits. Store it as a secret on your server. It is shown once; we keep only a hash of it.
Ask for a test key (imc_test_…) first: it works on every endpoint below with real reads, simulated writes, masked contacts and synthetic webhook events, so nothing you do while integrating can touch a real person — see Sandbox. Switch to the live key when you are ready; nothing else changes.
external_id is your own id for the record. Send the same one again and the row is updated, never duplicated. A new Active listing is matched against live inquiries automatically; the matches show up under /matches and, if you subscribe, arrive as match.created webhooks.
Where to go next
The full machine-readable contract: openapi.json (OpenAPI 3.1).
Make "my listings" mean a *person*, not the brokerage — one login, once: Agent links.
Authentication
One bearer key per integration. Nothing else to configure.
Every request carries your key in one of two headers. Both are equivalent; use whichever your HTTP client makes easier.
Authorization: Bearer imc_live_YOUR_KEY
X-Api-Key: imc_live_YOUR_KEY
Keys
A key is imc_live_ + 40 letters and digits. Anything else is refused as unauthorized before we look it up.
We store only a SHA-256 hash. The full key is shown to you once, when it is created. If you lose it, ask for a new one; the old one is revoked.
A key belongs to a partner and inherits that partner's feature switches and limits. A key can additionally be scoped to a subset of features; calling outside the scope is feature_disabled.
Keys can carry an expiry. An expired key answers key_expired; a revoked key answers key_revoked. Both are 401.
Keep the key on your server. For anything that runs in a browser, mint a short-lived embed token instead — see Embeds & SDK.
What a refused call looks like
HTTP
error.code
Meaning
401
unauthorized
No key, a malformed key, or a key we do not recognise.
401
key_revoked
The key was revoked.
401
key_expired
The key's expiry date has passed.
403
partner_disabled
Your partner account is switched off. Every key answers this until it is switched back on.
403
feature_disabled
The endpoint needs a feature your account (or this key's scope) does not have. The message names the switch.
429
rate_limited
Too many failed authentications from your IP address — 60 per minute — and the block lasts until the window resets. A correct key from that IP is refused too, so fix the key before retrying in a loop.
Feature switches
Every endpoint in this reference names the switch it needs. The switches are set per partner by iMatch; GET /me shows yours. They are:
Switch
What it opens
listings_read
Search and read live listings.
projects_read
The project catalogue.
units_read
Unit-by-unit developer price lists on a project (include=units). Off by default.
inquiries_read
Live buyer and tenant inquiries.
distress_read
The /distress feed and the discount fields on listings. Off by default.
areas_read
Areas and communities.
matches_read
Matches on your own listings and inquiries.
listings_write
Create, update, unlist and mark your listings sold.
inquiries_write
Create, update and close your inquiries.
photos_write
Attach and remove photos on your listings.
photos_replace
Replace a listing's photos with exactly the list you send (PUT …/photos). Off by default.
webhooks
Register endpoints and receive signed deliveries.
tools_embed
Embed the offer and poster creators.
copilot
The Copilot chat endpoint and the Copilot embed.
The /openapi.json and /postman.json documents need no key at all.
Endpoints
get/me
Who am I — verify a key and read limits + usage
any active key
Response 200
Partner, key and usage.
Response shape — placeholder values, not real data
Postman collection (v2.1) generated from this document
no key needed
Import into Postman: one folder per tag, every operation with its parameters and an example body, bearer auth at collection level. Set the apiKey collection variable to your key; baseUrl defaults to the production server.
Response 200
Postman Collection Format v2.1 JSON.
Envelope & errors
Every response has the same two shapes.
Every response is JSON and has one of exactly two shapes. There is no third.
code is stable and is what your code should branch on. message is for a human reading a log and may change wording. details is present only when there is something structured to say — validation issues, or the numbers behind a quota refusal.
Every error code
HTTP
code
When
400
validation_error
The query or body did not pass validation. details lists each issue with its path.
400
photo_fetch_failed
A photo url could not be fetched as an image. The upstream reason is deliberately not disclosed. details.failed names every failing photo: [{ index, url, reason, message }].
401
unauthorized
No key, a malformed key, or an unknown key.
401
key_revoked
This key was revoked.
401
key_expired
This key's expiry has passed.
403
partner_disabled
Your partner account is switched off.
403
feature_disabled
The endpoint needs a feature switch you do not have. The message names it.
403
no_allowed_origins
POST /embed/sessions before any allowed origin is configured for your account.
404
not_found
No such record — or a record that is not yours, for the endpoints that only see yours.
405
method_not_allowed
The verb is not supported on that path. Allow lists the ones that are.
409
limit_reached
Creating an eleventh webhook.
409
too_soon
Re-delivering a webhook delivery that succeeded less than a minute ago. Retry-After is set.
409
photos_changed
A listing's photos changed while your photo call ran. Nothing was written — send it again.
413
batch_too_large
More than 50 items in a write batch.
413
payload_too_large
A body or an image over its size cap (images: 15 MB).
415
unsupported_media
A photo that is not an image.
429
rate_limited
Over a per-minute budget — yours, the write budget, the Copilot budget, or the failed-auth budget. Retry-After says how long to wait.
429
quota_exceeded
Over your daily call quota.
429
reveal_quota_exceeded
This request would exceed your monthly contact-reveal ceiling. Nothing is charged; details carries quota, used, requested.
500
internal_error
Something failed on our side. The message never carries internals. Retry once; if it persists, tell us the time and the path.
Every 429 carries Retry-After (seconds) and, where a budget applies, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. See Rate limits & quotas.
Batch writes: 200 vs 207
POST /listings and POST /inquiries accept up to 50 items and answer per item. When every item succeeded the status is 200; when some failed it is 207 and each failed item carries error (text) and error_code (stable: duplicate_external_id, constraint_violation, internal_error). The successful items in a 207 are already saved — do not resend them.
The contract is frozen
/v1 only ever gains fields. A field, an enum value or an endpoint that exists today will not be renamed or removed. Parse what you know and ignore what you don't, and an update on our side never breaks you.
Pagination & sync
Pages for reading, a cursor feed for staying in sync.
Pages
Every list endpoint takes page (1-based, default 1) and per_page (1–100, default 25) and answers with the page and its position in the whole:
Keep calling with page + 1 while has_more is true. Pages are ordered by the endpoint's default sort (usually newest update first) or by sort where the endpoint offers one; prefix a sort key with - for descending.
updated_since — the cheap way to catch up
Listings, inquiries and projects accept updated_since (ISO 8601). Pass the time of your last successful run and you get only what changed after it, paged as usual. Simple and sufficient for a nightly refresh.
Two things it cannot tell you: that a row was deleted, and — under heavy concurrent writing — a row whose updated_at landed a moment before your timestamp while its transaction was still committing. For a mirror that must be exact, use the change feed.
/changes — the exact way
GET /changes returns ids and change kinds, never full rows, ordered by (updated_at, id):
since is either an ISO date-time to start from or the next_cursor you were given last time. The cursor is opaque — store it as a string, hand it back as a string.
types is a comma-separated subset of listings, inquiries, projects, locations. Each needs its read feature (areas_read for locations); with no types you get every one of listings / inquiries / projects you may read — locations only when you ask for it.
types=locations returns the places of the locations tree that changed: { id, change, at, merged_into_id }, change being created, updated or deleted (switched off, or merged into merged_into_id). It runs 600 seconds (10 min) behind real time instead of 10 seconds (below). GET /locations/changes is the same feed with each place in full — the two cursors are interchangeable.
A row you keep alive with an identical re-POST (see *Expiry & keep-alive* under Listings) shows here as updated once a day, because the keep-alive moves its clock — the row itself has not changed.
change is one of created, updated, unlisted, sold, deleted. For anything but deleted, fetch the row through the read endpoint for its fields. external_id is present on rows you created.
Up to 500 rows per type per call (limit). When has_more is true, call again with next_cursor immediately; when it is false, keep polling with the same cursor at whatever interval suits you — every minute is fine.
The cursor is strict on the (updated_at, id) pair, so nothing is skipped or repeated even while rows are being written.
The 10-second safety lag
Changes from the last 10 seconds are held back, and next_cursor never advances past now − 10 s. That guarantees a write whose transaction took a few seconds to commit is still picked up on your next call rather than falling behind your cursor forever. In practice: a change appears in the feed at least 10 seconds after it happened, never before. If you need something faster than that, subscribe to webhooks and use the feed as the safety net.
The locations tree is held back 600 seconds — 10 min (types=locations, /locations/changes): a place's change is stamped with the moment its batch started, and a batch that re-files hundreds of places can run for minutes. That is several times the longest one we have measured; the tree changes slowly, so nothing you need waits on it.
Idempotency
Apply every change as an upsert keyed on the iMatch id. Re-reading the last 10 seconds on each call means you will see the same row twice from time to time; an upsert makes that harmless.
Endpoints
get/changes
What changed since your last call (ids only)
any active key
Each requested type needs its own read feature (listings_read, inquiries_read, projects_read, areas_read for locations); with no types you get every one of listings / inquiries / projects you may read — locations only when you ask for it. Rows are ordered by (updated_at, id) and the cursor is strict, so nothing is skipped or repeated under concurrent writes. Safety lag: changes from the last 10 seconds are held back and next_cursor never advances past now − 10 s, so a write that took a few seconds to commit is still picked up on your next call — expect a row to appear at least 10 seconds after it changed, never before. Pass meta.next_cursor back as since; keep polling with the same cursor when nothing changed. change is one of created / updated / unlisted / sold / deleted — fetch the row through the read API for its fields. types=locations (2026-09-26): the places of the tree, change created / updated / deleted with merged_into_id on every row, held TEN MINUTES back instead of 10 seconds (a place-writing batch can run for minutes) — GET /locations/changes is the same feed with each place in full. Listings and inquiries you are not licensed to read (allowed_sources) never appear.
Parameter
In
Type
Description
since
query
string
The next_cursor from your previous call, or an ISO 8601 date-time to start from.
types
query
string
Comma-separated: listings, inquiries, projects, locations. Default: listings, inquiries and projects you may read — locations only when you ask for it.
limit
query
integer
Max rows per type, default and ceiling 500
Response 200
Changes per requested type.
Response shape — placeholder values, not real data
A listing is a property somebody in the network holds for sale or rent. GET /listings reads the live market as your partner account may see it; POST /listings and the by-external endpoints manage the listings you push from your own system.
Reading
Only listings that are live (live_status = "Active", not deleted) are returned. If your account is licensed for a subset of sources (allowed_sources on GET /me), only those sources appear.
Useful filters — every one is documented on the endpoint below: free text q; purpose (Sale | Rent); property_type; location by area, community, project or developer; bedrooms (studio, 1, 2 …, 5+); bathrooms; min_price / max_price; min_sqft / max_sqft; furnished; completion_status (Ready | Off-Plan); source; updated_since / created_since; ids (comma-separated); and external_id for one of your own.
Sort with sort=-updated_at (default), created_at, price, or discount_percent (needs distress_read). Prefix with - for descending.
The listing shape
Identity and provenance: id, reference_no, external_id (yours, on rows you created), source, created_at, updated_at. The property: purpose, property_type, category, area, community, project, developer, floor, bedrooms, bathrooms, bua_sqft, plot_sqft, view, furnished, completion_status, handover_date, vacancy, description, ai_summary, extra_details, extra_features, permit_number. Money: selling_price, rent_price, price_per_sqft, service_charges, roi_percent, rental_income, currency, payment_plan, paid_percent. State: status, live_status, date_listed, expires_after. People and media: agent (name, brokerage, and phone only with include=contact), photos (id, kind, url, source_url, position), and the matched project — project_id and project_photo_url. Where it is: location_id — the place of the locations tree it is filed under (GET /locations/{id}; null while it is not placed) — and location: that place's id, name, level and breadcrumb (the places it sits in, the city first). With the locations_refs feature location also carries refs: the place's primary outside ids (its Bayut id, DLD ids, legacy ids). See Locations. location rides on every read and webhook; webhooks never carry refs.
Distress-only fields — distress_type, original_price, market_price, op_plus_dld, discount_amount, discount_percent, seller_urgency, total_on_transfer, already_paid, premium, dld_fee, commission, pending_payment, payment_notes — are present only when your account has distress_read. Without it those keys are absent and category reads "Normal" for every row. See Distress. category and every distress field are read-only: iMatch classifies a deal as distress from its own pricing evidence, and a partner cannot post one — a category sent on a write is ignored.
url — the public page for the listing (https://app.imatch.ae/listings/{id}), viewable without an iMatch login, that you can link to from your CRM.
Pictures, and whose they are
Most listings in the market carry no photo of their own. When a listing has none and we can match its project to our catalogue, that project's pictures are appended to photos with kind: "project", so a list you render straight from photos shows something. **A kind: "project" photo is the building or development the listing sits in — it is not a photo of that unit.** Say so in your UI if you show it; never present it as the property.
The rules, exactly:
A listing's own images (kindphoto, floor_plan, master_plan) always come first and are never moved or replaced.
A listing that has any image of its own gets no project photos in photos. We never pad.
A listing with none, and a project we matched, gets up to four project images — the project's main photo first.
A listing with none and no project we could match gets photos: [] and project_id: null, project_photo_url: null. We do not invent a picture.
project_photo_url (the project's main photo) and project_id are on every listing we matched, whether or not it has its own photos — so you can show a labelled building photo, or link through to the project.
Project photos have negative ids and are not yours: they cannot be deleted through DELETE /listings/{id}/photos/{photoId}.
GET /projects/{id} with the project_id gives you the development itself — full gallery, payment plan, amenities, location.
Contact details cost a reveal
agent.phone is never in a default response. Add include=contact to get it, and each listing whose phone you receive for the first time in 30 days counts as one reveal against your monthly reveal quota (if you have one — GET /me shows it). Your own listings are never charged. If a request would cross the quota it is refused whole with reveal_quota_exceeded; nothing is charged and nothing is silently missing.
Some listings never carry a contact, whatever your plan. Rows with source: "public-intelligence" were captured from public broker WhatsApp groups: the number on them belongs to another brokerage and is not ours to pass on. The listing itself is fully yours to read, search and match; only the contact is withheld, and it says so — agent.phone is null and agent.contact_withheld carries { "code": "contact_not_available_for_source", "message": "…" }. Nothing is charged for one, so they never eat your reveal quota. If the person who owns such a listing has linked their iMatch account to you, their own rows are unaffected.
Send one object or { "listings": [ … ] } with up to 50. Every item needs an external_id — your own id. The pair (you, external_id) is the identity: the first send creates, every later send updates the same row. Nothing is ever duplicated.
The response is per item: { external_id, id, reference_no, action, warnings } with action one of created, updated, noop (you sent exactly what is stored — nothing changed, nothing re-matched; a noop item also carries expires_after, see below). 200 when all succeeded, 207 when some failed; failed items carry error_code.
Where the listing goes. Best first: location_id — our place id, from Locations (search, the export, or GET /locations/resolve) — puts it exactly there. Otherwise, with the locations_refs feature, external_ref: { "source": "bayut", "id": "5003" } (or a DLD / legacy id, or the shorthand bayut_location_id) puts it in the place that outside id names. Otherwise it is placed from area, community and project, which we read as names and aliases (GET /locations/resolve shows you the answer before you push). A place given by id always wins over the text; if you send no text with it, the listing takes its place's names (area, community, building) so it reads right everywhere. warnings tells you when an id named no live place (it was ignored — the listing is placed as if you had not sent it), when our id was merged into another (store the new one), and when a text value could not be matched to our vocabulary and was stored as sent. Next to it, warning_codes gives each placement warning a stable code, in the same order — branch on these, never on the prose: location_unknown, location_merged:<new id>, location_conflict (your location_id and outside id name different places — placed by location_id), bayut_id_ignored (external_ref was sent too), external_ref_unknown_source, external_ref_unknown_id, external_ref_feature_off (placing by an outside id needs locations_refs). Other warnings have no code; warning_codes is [] when there is nothing to say.
Fields we do not have are ignored, not refused — your own bookkeeping keys can ride along. But the item's warnings names every one of them, so a typo cannot cost you a field silently: send sellingPrice and you get back ignored unknown field: sellingPrice (did you mean selling_price?) — it was not saved. The same applies to PATCH, and to sandbox writes, which report exactly what live would.
The agent: agent_name and agent_phone attach the listing to a person on your side. Give both, and give the same phone consistently; it is how your listings roll up under one agent.
A new Active listing is matched against live inquiries the moment it is saved. Matches appear on GET /matches and as match.created webhooks.
Then, per row:
PATCH /listings/by-external/{external_id} changes any subset of fields. live_status accepts Active, Unlisted, Sold; Sold also sets status to Sold. location_id / external_ref move the listing; without text in the same PATCH its area / community / project are rewritten to the new place's names.
DELETE /listings/by-external/{external_id} unlists. Nothing is ever hard-deleted through the API — an unlisted row keeps its id and can be re-activated with a PATCH.
Both only see rows you created through Connect; anything else is not_found.
Expiry & keep-alive
A listing stays live for 21 days from the moment it is created or re-activated (expires_after on the row says exactly when). When that passes, iMatch takes it off the market — live_status becomes Unlisted and, if you subscribe, a listing.unlisted webhook fires — because a listing nobody has confirmed in three weeks is usually gone.
Any re-POST keeps it alive: an identical re-send is a noop, but it still moves expires_after to the full 21 days (at most once a day — sending hourly is fine and costs nothing more), and the noop item in the response carries the new expires_after. A sync that re-sends your whole live catalogue at least once every three weeks never sees a listing expire.
Mind the other direction: live_status defaults to Active on POST, so a bare re-POST of a listing that is Unlisted — by your own DELETE, by expiry, or by iMatch — puts it back on the market (and re-matches it). If your sync still carries a listing you no longer want live, send live_status: "Unlisted" explicitly, or stop sending it.
Photos
POST /listings/{id}/photos attaches up to 20 images per call, each as a public url we fetch (10 s timeout, image/* only, 15 MB max) or as base64. kind is photo, floor_plan or master_plan. Images are re-encoded to WebP. DELETE /listings/{id}/photos/{photoId} removes one. The listing must be yours. Photos can also ride along in the write batch as photos: [ … ] on each item — they are attached after the upsert. The fourth kind you may see on a read, project, is added by iMatch and cannot be posted or deleted — see Pictures, and whose they are. Attaching your first photo to a listing also stops us appending project photos to it.
Attaching is idempotent. A photo already on the listing — the same url and kind, or the same image — is not added again and is not downloaded again, so a retry, or an upsert that re-sends a listing's photos, never stacks copies. The response still has one entry per photo you sent, in your order, pointing at the existing photo where there was one; meta.added counts what was really stored and meta.skipped_duplicates what was already there.
Every photo remembers where it came from. On your own listings each photo carries source_url — the url you sent (null for base64) — and every photo carries position, its 0-based place in the listing's own photos, in display order. Together they let you check what iMatch holds without re-sending anything. source_url is shown only to the partner who owns the listing: on anyone else's listing — in search, as the other side of a match, in a webhook, in an embed — it is null, because it is often a link into your own storage. Photos attached before 2026-09-26 have source_url: null: we did not keep it until then. One PUT (below) fills it in. If you attach an image that is already on the listing under a different url, the existing photo is returned and keeps its original source_url; a PUT re-points it to the url you send.
If a photo fails, nothing is stored, and error.details.failed names every failing photo — [{ index, url, reason, message }], with reason one of fetch_failed, timeout, unsupported_media, too_large, decode_failed, invalid — so you can tell the agent which photo to fix.
Replacing a listing's photos
PUT /listings/{id}/photos — or PUT /listings/by-external/{external_id}/photos — makes the listing's photos exactly the list you send, in that order: 0 to 100 photos, and [] removes them all. It needs photos_write and photos_replace; photos_replace is off until iMatch switches it on for you, and GET /me shows it. For each photo, in order:
one already on the listing with the same url and kind is kept — not downloaded again, and it keeps its id;
otherwise we fetch it, and one already on the listing with the same image (same kind) is kept and learns your url. This is how photos attached before source_url existed get matched, and how old duplicate copies are cleared;
otherwise it is added.
Every photo the listing held that was not kept is removed. Sending a url under a new kind (a photo that is really a floor plan) removes the old one and adds a new one with a new id — it is never the same photo with its kind changed. The same url twice in one body, or two urls that turn out to be the same image, is a 400 validation_error.
It is all or nothing: every photo is fetched before anything changes, and if one fails nothing is written and error.details.failed names it. Two calls on one listing never interleave; if the photos changed while your call ran you get 409 photos_changed and nothing was written — send it again. The call is synchronous: at most 10 downloads run at once, each capped at 10 s plus a few seconds to re-encode, so 100 new photos take at most about 130 s — usually a few seconds, and repeating an unchanged set downloads nothing. Set your client timeout to 180 s. A replace is not a listing write: it does not refresh the listing's 21-day expiry and sends no webhook, like the other photo endpoints. The response is the listing's photos after the replace, each with source_url and position, and meta carries kept, added and removed. After [] the listing has no photos of its own, so a read may show kind: "project" pictures of the building again — see Pictures, and whose they are; a replace never touches those.
Endpoints
get/listings
Search live listings
requires listings_read
Most listings in the market carry no photo of their own. When one does not and we can match its project to our catalogue, that project's photos are appended to photos with kind: "project" — those are pictures of the BUILDING or development, not of this unit. project_id and project_photo_url are returned on every listing we could match, with or without its own photos, so you can label the picture honestly or link through to the project.
Parameter
In
Type
Description
as_agent
query
integer
Act as the person behind this agent link, so ownership-aware reads return THEIR rows and the Copilot is theirs. The header X-Connect-Agent is accepted instead. A link id that is not yours, was revoked, whose iMatch account was deactivated, whose brokerage is no longer yours, or that is not a plain integer → 403 agent_link_invalid. A link the person granted narrower scopes than this call needs → 403 agent_link_scope_denied.
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
q
query
string
Free text over reference, project, community, area, description
purpose
query
string
Sale | Rent
property_type
query
string
area
query
string
community
query
string
project
query
string
developer
query
string
bedrooms
query
string
studio | 1 | 2 | … (exact); 5+ means five or more
bathrooms
query
integer
min_price
query
number
max_price
query
number
min_sqft
query
number
max_sqft
query
number
furnished
query
string
completion_status
query
string
Ready | Off-Plan
category
query
string
Normal | Distress — only honoured with distress_read
source
query
string
updated_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
created_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
One listing object or { listings: [...] } (max 50). Re-sending the same external_id updates the row. 200 when every item succeeded, 207 when some failed.
Attach photos / floor plans to one of your listings
requires photos_write
Up to 20 per call, each a public url or base64 (image/*, ≤15 MB). Stored re-encoded as WebP. Idempotent: a photo already on the listing — the same url and kind, or the same image — is not added again and not downloaded again; the response still has one entry per photo you sent, in your order, pointing at the existing photo. meta.added counts what was really stored, meta.skipped_duplicates what was already there. A new photo keeps the url you sent as source_url; an image already on the listing under another url keeps its original source_url (use PUT to re-point it). If any photo fails, nothing is stored and error.details.failed names every one: [{ index, url, reason, message }], reason ∈ fetch_failed · timeout · unsupported_media · too_large · decode_failed · invalid.
400Invalid body, undecodable image, or a url that could not be fetched as an image (photo_fetch_failed — the upstream reason is never disclosed). error.details.failed names each failing photo.
Needs photos_writeandphotos_replace (off by default — check GET /v1/me). Makes the listing's photo set EXACTLY the list you send, in that order (0–100 photos; [] removes them all). For each photo, in order:
1. a photo already on the listing with the same urlandkind is kept — it is not downloaded again, and keeps its id;
2. otherwise the url is fetched and re-encoded, and a photo already on the listing with the same image (same kind) is kept and learns your url as its source_url — this is how photos attached before source_url existed are matched, and how old duplicate copies disappear;
3. otherwise it is added.
Every photo the listing held that was not kept is removed. A url that changes kind (photo → floor_plan) is a remove + add with a new id, never a kept photo. The same url twice in one body, or two urls that are the same image, is a 400 validation_error.
All or nothing: every photo is fetched before anything is written; if any fails, nothing changes and error.details.failed names each one (index, url, reason). Two calls on one listing never interleave. Latency: synchronous; at most 10 downloads run at once, each capped at 10 s plus a few seconds to re-encode, so the worst case is about 130 s for 100 new photos — typically seconds, and a repeat of an unchanged set downloads nothing. Set your client timeout to 180 s. Not a listing write: it does not refresh the listing's 21-day expiry and emits no webhook, exactly like POST …/photos and DELETE …/photos/{photoId}. Response meta: kept, added, removed. After [], a read of the listing may show kind: "project" photos again — those are iMatch's pictures of the building, never yours, and are not affected by a replace.
Parameter
In
Type
Description
id*
path
integer
iMatch listing id
* required
Request body
The listing's complete photo set, in display order.
Replace the photos of one of your listings, by your own external id
requires photos_replace
Needs photos_writeandphotos_replace (off by default — check GET /v1/me). Makes the listing's photo set EXACTLY the list you send, in that order (0–100 photos; [] removes them all). For each photo, in order:
1. a photo already on the listing with the same urlandkind is kept — it is not downloaded again, and keeps its id;
2. otherwise the url is fetched and re-encoded, and a photo already on the listing with the same image (same kind) is kept and learns your url as its source_url — this is how photos attached before source_url existed are matched, and how old duplicate copies disappear;
3. otherwise it is added.
Every photo the listing held that was not kept is removed. A url that changes kind (photo → floor_plan) is a remove + add with a new id, never a kept photo. The same url twice in one body, or two urls that are the same image, is a 400 validation_error.
All or nothing: every photo is fetched before anything is written; if any fails, nothing changes and error.details.failed names each one (index, url, reason). Two calls on one listing never interleave. Latency: synchronous; at most 10 downloads run at once, each capped at 10 s plus a few seconds to re-encode, so the worst case is about 130 s for 100 new photos — typically seconds, and a repeat of an unchanged set downloads nothing. Set your client timeout to 180 s. Not a listing write: it does not refresh the listing's 21-day expiry and emits no webhook, exactly like POST …/photos and DELETE …/photos/{photoId}. Response meta: kept, added, removed. After [], a read of the listing may show kind: "project" photos again — those are iMatch's pictures of the building, never yours, and are not affected by a replace.
Parameter
In
Type
Description
external_id*
path
string
Your external id
* required
Request body
The listing's complete photo set, in display order.
What buyers and tenants are looking for — read it, or post yours.
An inquiry is a requirement: what a buyer or tenant is looking for, posted by the broker who holds that client. Reading inquiries tells you what the market wants right now; posting yours gets them matched against every live listing in the network.
Reading
Only live inquiries (live_status = "Active") are returned, subject to the same allowed_sources licence as listings. Filter with q, purpose, location (matches the area wanted or the preferred communities), project, property_type, bedrooms, min_budget / max_budget, source, updated_since / created_since, ids, or external_id for one of yours. Sort by updated_at or created_at.
The shape mirrors what a broker writes down: purpose, location_wanted, preferred_communities, project_wanted, developer_wanted, property_type_wanted, bedrooms_wanted, bathrooms_wanted, min_size_sqft / max_size_sqft, budget_min / budget_max, currency, preferred_status, handover_preference, preferred_type, furnished_preference, view_preference, floor_preference, vacancy_preference, payment_plan_preference, special_requirements, ai_summary; plus id, reference_no, external_id, live_status, date_received, expires_after, source, timestamps and agent (name, brokerage; phone only with include=contact, charged as a reveal exactly as for listings). Inquiries captured from public groups (source: "public-intelligence") never hand out a contact — agent.contact_withheld says so and nothing is charged; see Listings.
The client's own identity is never part of an inquiry. You are matching with the broker, not with their client.
One object or { "inquiries": [ … ] }, up to 50, each with your external_id. Same upsert rule as listings: the first send creates, later sends update.
The minimum is purpose and either location_wanted or project_wanted. A budget and a property type make the match far better; bedrooms_wanted accepts a single value or a range (2-3).
A new Active inquiry is matched against live listings immediately. Matches appear on GET /matches and as match.created webhooks; the owners of matching listings are alerted by iMatch as they would be for any inquiry in the network.
PATCH /inquiries/by-external/{external_id} updates; DELETE /inquiries/by-external/{external_id} closes it (live_status becomes Unlisted; nothing is hard-deleted). Both see only rows you created.
The response is per item, exactly as for listings (action of created, updated or noop; a noop item also carries expires_after).
Expiry & keep-alive
An inquiry stays live for 7 days from the moment it is created or re-activated (expires_after on the row). A requirement nobody has confirmed in a week is usually filled or dropped, so iMatch closes it — live_status becomes Unlisted and an inquiry.unlisted webhook fires.
Any re-POST keeps it alive: an identical re-send is a noop but still moves expires_after to the full 7 days (at most once a day), and the noop item carries the new value. Re-send every requirement you still hold at least weekly and none will expire.
The other direction holds too: a bare re-POST of an inquiry that is Unlisted — by your DELETE, by expiry, or by iMatch — re-opens it and matches it again. Send live_status: "Unlisted" explicitly to keep a closed requirement closed.
Fields we do not have are ignored rather than refused, and the item's warnings names each one (ignored unknown field: budgetMax (did you mean budget_max?) — it was not saved) — see Listings.
Endpoints
get/inquiries
Search live buyer inquiries
requires inquiries_read
Parameter
In
Type
Description
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
q
query
string
Free text over reference, location, project, requirements
purpose
query
string
location
query
string
Matches location_wanted / preferred_communities
project
query
string
property_type
query
string
bedrooms
query
string
min_budget
query
number
max_budget
query
number
updated_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
created_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
Create or update inquiries (upsert on external_id)
requires inquiries_write
One inquiry or { inquiries: [...] } (max 50). Needs purpose and location_wanted or project_wanted. A new Active inquiry is matched against live listings automatically.
The developer catalogue, with plans, payment terms and unit lists.
The project catalogue is iMatch's directory of developments across Dubai and the wider UAE — the same catalogue behind imatch.ae/projects/directory — with developer, location, status, price floor, amenities, payment plan, photos, floor plans, master plans and documents.
Listing projects
GET /projects pages through the live catalogue. Filter by q (search text, below), developer, area, district, project_type, property_type, status, sale_status, completion_status, min_price / max_price, and updated_since. Sort by relevance (the default when q is given), name (the default without q), min_price or expected_completion_date; name, min_price and expected_completion_date take a - prefix for descending.
Searching — q and sort=relevance
For a search box or a type-ahead, send q and leave sort out (or send sort=relevance): the best match comes first.
What matches.q is split into words. Every word must appear in the project's name, one of its aliases, its district, its area or its developer — so hartland finds the projects of Sobha Hartland, and sobha hartland crest finds The Crest. Case and punctuation do not matter. A q with no letters or digits matches nothing.
What comes first. The name is compared with q (a leading "The" ignored on both sides): the name or an alias equal to q first, then names that start with q, then names with a word that starts with q, then names that contain q, then projects found only through an alias, the place or the developer. When q mixes a place or developer with a name (sobha hartland crest), the place and developer words are set aside and the rest (crest) is compared with the name the same way. Ties go by name.
q with any other sort works as it always has: projects whose name or an alias contains q, in that order. For a one-word q, relevance returns those same projects plus the ones whose place or developer carries the word.
GET /v1/projects?q=crest&per_page=8
Withdrawn projects are not in the feed.
One project
GET /projects/{id} returns the full record: identity (id, name, slug, aliases), url (the public project page on imatch.ae — the link to show in your CRM), developer and developer_logo_url, area ({ id, name }), district, project_type, property_type, status, sale_status, completion_status, expected_completion_date, construction_progress_pct, post_handover, ownership_type, furnishing, has_escrow, service_charge, min_price_aed, max_floor, available_units, latitude / longitude, description, amenities, payment_plan (with stages and has_post_handover), travel_times, parking_plan, and media as public URLs: photo_url, gallery, floor_plans, masterplan, documents ({ name, url }).
Developer unit lists
Add ?include=units to get the developer's current unit-by-unit price list for the project — unit_ref, unit_no, building, floor, unit_type, bedrooms, bathrooms, sizes, view, orientation, parking, price_aed, price_per_sqft, payment_plan_raw, availability, handover_date, completion_status, updated_at. This needs the units_read feature, which is off by default; without it the call answers feature_disabled and the rest of the project is not returned either — drop include=units to read the project itself.
A unit that stops appearing in the developer's newer price lists is simply no longer offered; it is never reported as sold unless the developer's own file says so.
Endpoints
get/projects
Dubai projects feed
requires projects_read
Parameter
In
Type
Description
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
q
query
string
Search text. With sort=relevance (the default when q is given) every word must appear in the name, an alias, the district, the area or the developer, best match first. With any other sort: name or alias contains q.
developer
query
string
area
query
string
district
query
string
project_type
query
string
property_type
query
string
status
query
string
sale_status
query
string
completion_status
query
string
min_price
query
number
max_price
query
number
updated_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
relevance (default when q is given; without q it lists by name) or name (default without q), min_price, expected_completion_date; prefix - for descending
Response 200
Projects page.
Response shape — placeholder values, not real data
Discounted deals and the fields behind the discount.
A distress listing is one being sold below what the seller paid or below the market — a motivated seller, a discount, and the numbers that explain it. iMatch classifies these from the broker's own message and stores the benchmark the discount is measured against.
Distress is a separate feature switch, distress_read, off by default. iMatch enables it deliberately per partner.
With distress_read
GET /distress is GET /listings with category forced to Distress, every discount field always present, and a default sort of biggest discount first (-discount_percent). All the listing filters work, and so does everything about pictures — including the kind: "project" fallback, which is a photo of the building, not the unit (see Listings).
On GET /listings, distress rows carry their category as "Distress" and the discount fields, and you may filter with category=Distress or sort by discount_percent.
A new distress listing going live also fires the distress.created webhook, if you subscribe to it.
The discount fields: distress_type, original_price (what the seller paid), market_price (what comparable stock asks), op_plus_dld, discount_amount, discount_percent, seller_urgency, total_on_transfer, already_paid, premium, dld_fee, commission, pending_payment, payment_notes. selling_price is always the asking price, on every listing, distress or not.
Without it
The same listings are still returned by GET /listings — a discounted apartment is still an apartment for sale — but the discount fields are absent, category reads "Normal", a category filter is ignored, sort=discount_percent is refused as a validation_error, and GET /distress answers feature_disabled.
Read the numbers honestly
discount_percent is measured against original_price or market_price, whichever the broker gave. A benchmark is a claim by the listing broker, not a valuation by iMatch; treat it as the seller's story and verify before you quote it.
Endpoints
get/distress
Distress deals (discounted listings)
requires distress_read
Same filters as /listings; category is forced to Distress and the discount fields are always present. Default sort: biggest discount first. Project photos and project_id / project_photo_url work exactly as on /listings — a kind: "project" photo is the building, not this unit.
Parameter
In
Type
Description
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
q
query
string
Free text over reference, project, community, area, description
purpose
query
string
Sale | Rent
property_type
query
string
area
query
string
community
query
string
project
query
string
developer
query
string
bedrooms
query
string
studio | 1 | 2 | … (exact); 5+ means five or more
bathrooms
query
integer
min_price
query
number
max_price
query
number
min_sqft
query
number
max_sqft
query
number
furnished
query
string
completion_status
query
string
Ready | Off-Plan
category
query
string
Normal | Distress — only honoured with distress_read
source
query
string
updated_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
created_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
Every place in the UAE in one tree, with the same ids on both sides — and the vocabulary every other endpoint speaks.
Location is the first thing a match is scored on, so both sides need to speak about the same places. iMatch keeps every place in the UAE in one tree — city → region → area → community → sub-community → cluster → building, about 22,500 places — and gives you the same ids we use, so your records and ours can point at the same place.
The tree
GET /locations — the top: the eight emirate cities.
GET /locations/{id} — one place (node), the places it sits in (ancestors, the city first) and the first page of the places directly inside it (children).
GET /locations/{id}/children?cursor= — every place directly inside, A–Z, keyset-paged: pass meta.next_cursor back until it is null.
GET /locations/search?q= — what a user typed, best first: the whole text IS a name or an alias (JVC), then a name starting with it, then a word starting with it, then a close spelling — Dubai before the other emirates at each step. meta.exact says whether anything matched exactly.
A place is id, name, name_ar (the Arabic name, when we have one), level, parent_id (null for a city), breadcrumb (the places it sits in, the city first, the place itself excluded), is_umbrella (too big to be one neighbourhood — every city and region, and large areas like Dubai South) and lat / lng. Area ids are the same ids GET /areas returns.
On GET /locations/{id} the place also carries aliases: every other text iMatch reads as that place — spellings, abbreviations, former and marketing names, the names of places merged into it, and the names of buildings we file under it. Each is { "text": "…", "lang": "en" | "ar" }.
A place that was merged into another answers as the survivor, with meta.merged_from set to the id you asked for — store the new id. A place taken out of the tree answers 404. GET /locations/retired lists every id that no longer answers as a place of its own and where it went (merged_into, or null).
Keeping a copy: the export
GET /locations/export walks the whole tree, parents before children, up to 2,000 places a page (limit, default 1,000): pass meta.next_cursor back as cursor until it is null. Every parent_id on a page was served on an earlier page or on the same one, so you can insert as you go.
Every page carries an ETag — the version of the tree you are looking at, also in meta.version. Keep it. Next time, send it as If-None-Match on the first page: an unchanged tree answers **304 Not Modified** with no body, so a daily "did anything change?" is one cheap call. If the version on your last page differs from the one on your first, the tree changed while you walked — walk again. After a walk, re-point any stored id that GET /locations/retired lists.
Keeping a copy current: the change feed
GET /locations/changes lists the places that changed since a point, oldest first — take the export once, then follow this feed instead of walking the tree again:
change — created; updated (the place itself, one of its aliases or outside ids, or a place above it — so its breadcrumb — changed); deleted (no longer a live place: switched off, or merged — merged_into_id is where it went; re-point anything you stored).
place — the place exactly as the export gives it to you. With locations_refs that is the full node, and a merged or switched-off node is shown as it is (active: false, merged_into_id), never as its survivor; schools and nurseries appear too. Without it, the compact place — null once it is no longer a live place (schools and nurseries never appear).
since starts a walk (an ISO date-time, or any cursor we issued); cursor (= meta.next_cursor) continues one and wins when both are sent. Up to 2,000 places a page (limit, default 500); call again right away while has_more is true, then keep polling with the same cursor — daily, hourly, whatever suits you.
600 seconds behind real time (meta.lag_minutes: 10). Changes younger than that are held back and the cursor never passes now − 10 min (meta.until): a place's change carries the moment its batch STARTED, and a batch that re-files hundreds of places can run for minutes. You will re-read the last few minutes now and then — upsert by id and that is harmless.
GET /changes?types=locations is the ids-only view of the same rows; the cursors work on both.
Prefer the export for your first copy — it is ordered parents-first; the feed is ordered by time.
The tree is read-only through the API: nobody edits it through Connect. A name or alias you think is wrong — tell us.
**Pushed instead of polled — location.updated.** Subscribe a webhook to location.updated and every few minutes we POST the places that changed since the last delivery: data = { scope, locations: [ …the same items… ], cursor }, each place once, at most 100 per delivery. cursor is the feed position right after the last place of that delivery — resume /locations/changes from it if you ever missed a delivery. It follows the feed, so it arrives about 10–15 minutes after the change. See Webhooks.
The full tree (locations_refs)
With the locations_refs feature (iMatch turns it on per partner) you see everything we hold, not the compact picture:
GET /locations/export walks every node — merged signposts, retired places, schools and nurseries included — with every field: depth, path (root → self ids), slug, kind, is_place, active, merged_into_id, the umbrella figures, the neighbourhood (nbhd_top_id), the geocode source and time, the legacy area / community / project ids and coordinates, created_at, updated_at — on top of the compact fields. meta.scope reads "full".
aliases are the alias rows, inactive ones included, each with its kind (official, variant, abbreviation, former_name, marketing, catalogue_name, legacy_*), origin, active, norm (the resolver's normal form) and its own id.
refs are the place's outside ids: its Bayut location id, its Dubai Land Department area / master project / project / building ids — each marked primary or secondary, with what that system calls the place — and our older lists' ids (legacy_area, legacy_community, legacy_project).
GET /locations/{id} answers the same full shape, for any node the export carries.
GET /locations/by-ref/{source}/{external_id} turns an outside id into our place: /locations/by-ref/bayut/5003, /locations/by-ref/dld_project/1234, /locations/by-ref/legacy_community/88.
Every listing's location also carries its place's primary refs, and you can place a listing by an outside id (below).
The exact field list is the LocationFull schema in the reference below. Without locations_refs none of these outside ids or provenance fields ever reach you.
Text → place: resolve
GET /locations/resolve?area=&community=&project= answers where iMatch would place a listing with that text — the same reading POST /listings makes when no id is sent: our area vocabulary first, then the database's resolver (exact names, aliases and catalogue projects, Dubai first, never a fuzzy guess). Add within=<id> to look only inside one place.
status: "resolved" — one place, no guess.
status: "ambiguous" — several places fit. place is the one iMatch would choose (null when it would not choose); candidates says how many fit.
status: "unresolved" — no place. Fix the spelling, or tell us the name and we add the alias.
conflicting_fields lists the fields that name a place somewhere else than place (["community"] when your community text points at another district) — usually a text worth correcting.
Placing a listing
Best first: send location_id (our id) on POST / PATCH /listings and the listing is filed exactly there. With locations_refs, external_ref: { "source": "bayut", "id": "5003" } (or bayut_location_id) files it under the place that outside id names. Otherwise the listing is placed from its area / community / project text, as resolve shows. Every listing you read back carries location_id and location (the place and its breadcrumb). See Listings.
The older lists: areas and communities
GET /areas returns every active area — id, name, latitude, longitude, and aliases — with the same ids as the tree. GET /communities returns the older community list: id (that list's own id), community, parent_area, hierarchy_level, and location_id — the same community as a place of the tree. Prefer /locations for anything new; places added to the tree since it was built appear only there.
Caching
The tree changes slowly. search, /locations, /locations/{id}, children, resolve and by-ref answer with Cache-Control: private, max-age=300; /areas and /communities with max-age=3600; the export and retired with private, no-cache and an ETag (revalidate, pay a 304). Every call still counts against your per-minute limit, so keep a local copy rather than asking per request.
When a name does not resolve
A listing whose area we cannot map is still saved, with a warning in the write response (area "…" is not in iMatch's location vocabulary — matching will be weak). Fix the spelling on your side, place it by id, or tell us the name and we add the alias — the vocabulary is curated, and a real place that is missing is a bug we want to hear about.
Endpoints
get/areas
Areas with aliases
requires areas_read
Response 200
Every active area. Client-cacheable for an hour (Cache-Control: private, max-age=3600).
Response shape — placeholder values, not real data
Every place in the UAE sits in one tree: city → region → area → community → sub-community → cluster → building. Results are ranked: the whole query IS a name or an alias (JVC) → a name starting with it → a word starting with it → a close spelling. Each place carries its breadcrumb (the places it sits in, the city first), so a picker can show where it is. meta.exact is true when a name or alias matched the whole query. Area ids are the same ids /v1/areas returns. Client-cacheable for five minutes (Cache-Control: private, max-age=300).
Parameter
In
Type
Description
q*
query
string
What the user typed — a name, an abbreviation (JVC) or its start
limit
query
integer
Default 12, max 30
level
query
string
Comma list of levels to keep, e.g. area,community
under
query
integer
Only places inside this place id
* required
Response 200
Places, best first.
Response shape — placeholder values, not real data
Every place that sits in nothing — the eight emirate cities — A–Z. The starting point of a walk: continue with /locations/{id}/children, or take the whole tree at once with /locations/export. Client-cacheable for five minutes (Cache-Control: private, max-age=300).
Response 200
The cities.
Response shape — placeholder values, not real data
Parents before children (depth, then id), so every parent_id on a page was served on an earlier page or this one. Walk with meta.next_cursor until it is null. meta.scope says what you get: compact — every live place (the place fields plus aliases); full — with the locations_refs feature: EVERY node (merged signposts, retired places, schools and nurseries too) with every column we hold, every alias row with its kind, and every outside id (refs: Bayut, DLD, legacy — primary and secondary). Every page carries ETag = the version of what you see (meta.version too): send it back as If-None-Match and an unchanged tree answers 304 Not Modified with no body. If the version on your last page differs from your first, the tree changed while you walked — walk again.
Parameter
In
Type
Description
cursor
query
string
next_cursor from the previous page
limit
query
integer
Places per page. Default 1000, max 2000
If-None-Match
header
string
The ETag of a page you already hold — an unchanged tree answers 304.
Response 200
One page of places.
Response shape — placeholder values, not real data
The places that changed since a point — incremental sync of the tree
requires areas_read
Take /locations/export once, then keep your copy current with this feed. Every item is { id, change, at, merged_into_id, place }: change is created, updated (the place, one of its aliases or outside ids, or a place above it — its breadcrumb — changed) or deleted (no longer a live place: switched off, or merged — merged_into_id says where; re-point what you stored). place is the place exactly as the export shows it: every field with the locations_refs feature (merged and retired nodes as they are, schools and nurseries included), the compact place otherwise (null when it is no longer a live place; compact never lists schools or nurseries). Ordered by (updated_at, id) with a strict cursor — nothing skipped or repeated. Ten-minute safety lag: changes from the last 10 minutes are held back and next_cursor never passes now − 10 min (meta.until), because a place-writing batch can run for minutes and its rows carry its start time. Start with since (an ISO date-time, or any cursor we issued); continue with cursor = meta.next_cursor (it wins when both are sent). With neither, the feed starts from the tree's beginning — prefer the export for a first copy. GET /changes?types=locations is the ids-only view of the same rows; the cursors are interchangeable. Read-only: the tree is never modified through the API.
Parameter
In
Type
Description
since
query
string
Where to start: an ISO 8601 date-time, or a next_cursor you were given. Ignored when cursor is sent.
cursor
query
string
meta.next_cursor from your previous call — continue exactly where it stopped
limit
query
integer
Places per page. Default 500, max 2000
Response 200
One page of changed places.
Response shape — placeholder values, not real data
Place ids that no longer answer as a place of their own
requires areas_read
Merged into another place (merged_into = the live survivor — re-point what you stored) or taken out of the tree (merged_into: null). The companion of the compact export for a CRM that stores our ids. Same version and conditional GET as the export's tree.
Parameter
In
Type
Description
If-None-Match
header
string
A version you already hold.
Response 200
Every retired id.
Response shape — placeholder values, not real data
Where a listing with this location text would be placed
requires areas_read
The same reading POST /listings makes of the same area / community / project (when no location_id is sent): our area vocabulary, then the database's resolver — exact names, aliases and catalogue projects, Dubai first, never a fuzzy guess. status is resolved (one place), ambiguous (several fit — place is the one iMatch would choose, null when it would not) or unresolved. conflicting_fields names fields that point somewhere else — usually a text worth fixing. Check a placement before you push, or map your own location list once.
Parameter
In
Type
Description
area
query
string
The listing's area text, exactly as you would send it to POST /listings
community
query
string
The listing's community text
project
query
string
The listing's project or building text
within
query
integer
Only look inside this place id
Response 200
The placement.
Response shape — placeholder values, not real data
A Bayut location id, a Dubai Land Department area / master project / project / building id, or the id of one of our older lists (legacy_area = /areas id, legacy_community = /communities id, legacy_project) → the place, with every field. A place merged since answers as its survivor. Off unless iMatch enabled locations_refs for you.
One place, the places it sits in, and the places inside it
requires areas_read
node is the place and its aliases (the other texts iMatch reads as it). With the locations_refs feature it is the FULL place: every column we hold, every alias row with its kind, every outside id — and any node the full export carries (a school, a retired place) answers too. children is the first page of the places directly inside, A–Z — continue with /locations/{id}/children. A merged place answers as the place it was merged into, with meta.merged_from set to the id you asked for — store the new id.
Parameter
In
Type
Description
id*
path
integer
Place id
* required
Response 200
The place.
Response shape — placeholder values, not real data
Where your listings met someone's requirement, or the other way round.
A match is iMatch's judgement that one listing fits one inquiry: same location, compatible type and bedrooms, budget within band, plus the softer signals. Matching runs the moment a listing or an inquiry goes live, in both directions.
GET /matches returns the matches on your own records — where your listing met someone's inquiry, or your inquiry met someone's listing. You never see matches between two other parties.
id, score (0–100), reason (a sentence explaining the score), status, created_at.
kind: exact or nearby (see below), and is_nearby — the same fact as a boolean.
mine: which side is yours — listing, inquiry, or both when you own both ends.
listing and inquiry: the full public shapes of each side, as on their own endpoints — including the embedded listing's photos, project_id and project_photo_url, where a kind: "project" photo is the building rather than the unit (see Listings). The other party's agent.phone is included only with include=contact, and that is charged as a reveal; your own side is never charged.
Filter with since (matches created at or after an instant), status, side (listing | inquiry — which side is yours), listing_id, inquiry_id, min_score. Pages as everywhere else.
Exact and nearby matches
Most matches are exact: the listing is in the very place the buyer asked for. Some are nearby — right on budget, type and bedrooms, in the right area, but not the exact place: another building in the same community, or a community inside the wider area the buyer named. reason ends with what made it nearby (for example nearby: in JVC, building not stated).
iMatch shows nearby matches on their own, under their own heading, and never counts them as matches. Your integration gets the same default:
Reads return exact matches only. Add nearby to include to get both kinds — include=nearby, or include=contact,nearby — each one marked by kind; meta.total counts what you asked for. The same holds for GET /me/matches and the embedded matches screen; over MCP, my_matches takes include_nearby: true.
Pushes are exact matches only.match.created fires for a nearby match only when iMatch has switched on the matches_nearby feature for you (GET /me shows it); the payload then says kind: "nearby".
Keep them apart. If you take nearby matches, show them in their own list, never mixed in with the exact ones.
To rehearse a nearby push with a test key: POST /sandbox/events with { "event": "match.created", "sample": { "match": { "kind": "nearby", "is_nearby": true } } }.
Getting them pushed instead
Subscribe a webhook to match.created and every new exact match on your records arrives within seconds, carrying the same { match, mine } shape — minus contacts, which you fetch with include=contact when you decide to act. Nearby matches come too only with matches_nearby (above). See Webhooks.
What iMatch does on its side
When a match is created, iMatch also alerts the broker on the other side through the iMatch app and WhatsApp, exactly as it does for any two members of the network. Your integration does not need to do anything for that to happen, and cannot switch it off — being matchable is what the network is for.
Endpoints
get/matches
Matches on your own listings and inquiries
requires matches_read
EXACT matches only unless you send include=nearby: then NEARBY matches too (the right area, not the exact building), each marked kind: "nearby" / is_nearby: true — meta.total counts what you asked for. Show nearby matches apart from exact ones.
Parameter
In
Type
Description
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
since
query
string
Only matches created at/after this instant
status
query
string
side
query
listing | inquiry
Which side of the match is yours
listing_id
query
integer
inquiry_id
query
integer
min_score
query
number
include
query
string
Comma-separated extras. contact adds the other party's agent.phone (charged as a reveal). nearby adds NEARBY matches (the right area, not the exact building) — left out unless you ask; each match says which it is in kind.
Response 200
Matches page.
Response shape — placeholder values, not real data
We call you when something happens. Signed, retried, never silent.
Register an HTTPS endpoint and iMatch calls it when something happens on your records — a new match, a listing you pushed changing state, a new distress deal. Every delivery is signed, retried on failure, logged, and re-sendable from your side. Needs the webhooks feature.
Events
Event
When
ping
A test delivery you asked for (POST /webhooks/{id}/test).
match.created
A new match on one of your listings or inquiries. Exact matches only, unless iMatch switched matches_nearby on for you — then nearby ones too, marked kind: "nearby". See Matches.
listing.created
One of your listings was created through Connect.
listing.updated
One of your listings was changed through Connect, or put back on the market.
listing.unlisted
One of your listings was taken off the market — by you, by expiry, or by iMatch.
listing.sold
One of your listings was marked sold — by you, or by iMatch.
inquiry.created
One of your inquiries was created through Connect.
inquiry.updated
One of your inquiries was changed through Connect, or re-opened.
inquiry.unlisted
One of your inquiries was closed — by you, by expiry, or by iMatch.
distress.created
A new distress deal went live anywhere in the market (needs distress_read).
agent_link.connected
One of your users connected their iMatch account. See Agent links.
agent_link.revoked
An agent link ended — revoked_by says whether it was the agent, you, their brokerage, iMatch, or their account being deactivated. Never carries a phone or an email.
location.updated
Places of the locations tree changed — created, renamed, moved, re-aliased, merged or retired (needs areas_read). See Locations.
Subscribe to a list of these, or to * for all of them. Up to 10 webhooks per partner.
A change you make yourself through the API is announced once, right after your call returns. A change iMatch makes to one of your rows — a listing expiring after 21 days, an inquiry after 7 (see each guide's *Expiry & keep-alive*), an admin taking a row down — is announced the same way, from inside the database, usually within seconds and always within a few minutes. reason and actor (below) say which it was. location.updated follows the locations change feed and so arrives about 10–15 minutes after the change.
The response carries secret — once. Store it next to the URL; you will verify every delivery with it. PATCH /webhooks/{id} with rotate_secret: true mints a new one (again shown once; the old one stops working immediately). The URL must be https:// and resolve to a public host — private and loopback addresses are refused.
The body is always the same envelope: id (the delivery id — your idempotency key), event, created_at, partner (your slug) and data. data is { listing, reason, actor } / { inquiry, reason, actor } for the lifecycle events of your own rows (below), { listing } for distress.created, { match, mine }, { scope, locations, cursor } for location.updated, or { webhook_id } for a ping — serialised with exactly the shapes the read endpoints use. A contact is never inside a webhook — fetch it with include=contact when you decide to act on the record.
Who did it — reason and actor
Every listing.created, listing.updated, listing.unlisted, listing.sold, inquiry.created, inquiry.updated and inquiry.unlisted event about one of YOUR rows carries two fields next to the row in data, so your system can tell its own echo from a real take-down:
reason
actor
Meaning
partner
your partner slug
Your own API call (REST or the MCP write tools). The echo of what you just did — safe to ignore.
expiry
system:auto-unlist
iMatch's daily expiry took it off the market (a listing after 21 days, an inquiry after 7, unless your sync kept it alive). Re-send it if it is still on the market.
imatch
imatch:admin
An iMatch team member changed it.
imatch
imatch:user
A signed-in iMatch user — the listing's agent or their brokerage — changed it in the iMatch app, website or Copilot.
imatch
imatch:system
An iMatch rule changed it with no person behind it (for example the sale-price floor holding a sale under AED 300,000 off-live), or no person can be named for the change.
actor never names a person — never a name, an id, a phone or an email — only your own slug or one of the words above. distress.created and match.created are not about one of your rows' lifecycle and carry neither field.
Respond with any 2xx within 10 seconds. Do the work afterwards, not before you answer.
Retries and giving up
A non-2xx answer, a timeout or a connection failure is retried at 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours after the first attempt — six attempts in all — then the delivery is marked failed. Deliveries are at-least-once: you may see the same id twice, so dedupe on it.
After 50 consecutive failed deliveries the webhook is switched off (active: false) so a dead endpoint is not hammered forever. Re-enable it with PATCH /webhooks/{id} { "active": true }, which also resets the counter.
GET /webhooks/deliveries lists what was sent (status, attempts, response code, last error); GET /webhooks/deliveries/{id} shows one with its exact payload; POST /webhooks/deliveries/{id}/redeliver sends it again with a fresh retry budget (refused with too_soon if it succeeded less than a minute ago).
Verifying the signature
X-Connect-Signature is t=<unix seconds>,v1=<hex> where
v1 = HMAC-SHA256( secret, t + "." + rawBody )
rawBody is the exact bytes you received — sign the raw request body, never a re-serialised copy of the parsed JSON. Compare in constant time, and reject a t more than 5 minutes from your clock to shut out replays.
Below is a fixed test vector. Run your verifier on it before wiring anything else: if your code produces this v1 for this secret, timestamp and body, your HMAC is right and any later mismatch is in what you signed.
secret : whsec_test_0123456789abcdef
t : 1789171200
body : {"id":"1","event":"ping","created_at":"2026-09-12T00:00:00.000Z","partner":"acme","data":{"webhook_id":1}}
v1 : 564f7e018a1a9aec73579a593f6e9098e778aa4187a926de95a9a1f56f900f6d
header : t=1789171200,v1=564f7e018a1a9aec73579a593f6e9098e778aa4187a926de95a9a1f56f900f6d
Node
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyConnectSignature(secret, header, rawBody, toleranceSec = 300, now = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/i.test(parts.v1 ?? "")) return false;
if (toleranceSec >= 0 && Math.abs(now - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
const got = Buffer.from(parts.v1, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}
// The published test vector — must print true.
const secret = "whsec_test_0123456789abcdef";
const header = "t=1789171200,v1=564f7e018a1a9aec73579a593f6e9098e778aa4187a926de95a9a1f56f900f6d";
const body = '{"id":"1","event":"ping","created_at":"2026-09-12T00:00:00.000Z","partner":"acme","data":{"webhook_id":1}}';
console.log(verifyConnectSignature(secret, header, body, -1)); // -1 = ignore the clock for this offline check
// In an HTTP handler: read the RAW body (e.g. express.raw({ type: "*/*" })), then
// if (!verifyConnectSignature(process.env.IMATCH_WEBHOOK_SECRET, req.get("X-Connect-Signature"), req.body.toString("utf8"))) return res.status(401).end();
PHP
<?php
function verifyConnectSignature(string $secret, ?string $header, string $rawBody, int $toleranceSec = 300, ?int $now = null): bool {
$now = $now ?? time();
$t = null; $v1 = null;
foreach (explode(',', (string) $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k === 't') $t = $v;
if ($k === 'v1') $v1 = $v;
}
if ($t === null || !ctype_digit($t) || $v1 === null || !preg_match('/^[0-9a-f]{64}$/i', $v1)) return false;
if ($toleranceSec >= 0 && abs($now - (int) $t) > $toleranceSec) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($v1));
}
// The published test vector — must print true.
$secret = 'whsec_test_0123456789abcdef';
$header = 't=1789171200,v1=564f7e018a1a9aec73579a593f6e9098e778aa4187a926de95a9a1f56f900f6d';
$body = '{"id":"1","event":"ping","created_at":"2026-09-12T00:00:00.000Z","partner":"acme","data":{"webhook_id":1}}';
var_dump(verifyConnectSignature($secret, $header, $body, -1)); // -1 = ignore the clock for this offline check
// In a controller: $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_CONNECT_SIGNATURE'] ?? null;
Python
import hmac, hashlib, time
from typing import Optional
def verify_connect_signature(secret: str, header: Optional[str], raw_body: bytes, tolerance_sec: int = 300, now: Optional[int] = None) -> bool:
now = int(time.time()) if now is None else now
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or len(v1) != 64:
return False
if tolerance_sec >= 0 and abs(now - int(t)) > tolerance_sec:
return False
expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1.lower())
# The published test vector — must print True.
secret = "whsec_test_0123456789abcdef"
header = "t=1789171200,v1=564f7e018a1a9aec73579a593f6e9098e778aa4187a926de95a9a1f56f900f6d"
body = '{"id":"1","event":"ping","created_at":"2026-09-12T00:00:00.000Z","partner":"acme","data":{"webhook_id":1}}'
print(verify_connect_signature(secret, header, body.encode("utf-8"), -1)) # -1 = ignore the clock for this offline check
# In a Flask/Django view: raw_body = request.get_data() / request.body; header = request.headers.get("X-Connect-Signature")
A header may carry more than one v1= during a secret rotation on your side; the reference implementation in iMatch accepts any one of them. The snippets above check the single v1, which is what you will normally receive.
Endpoints
get/webhooks
Your webhooks
requires webhooks
Response 200
Every webhook, secrets never included.
Response shape — placeholder values, not real data
Create a webhook (the signing secret is returned ONCE)
requires webhooks
We POST a JSON envelope { id, event, created_at, partner, data } with headers X-Connect-Event, X-Connect-Delivery-Id and X-Connect-Signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + rawBody). Verify in constant time and reject t older than 5 minutes. Respond 2xx within 10 s; anything else is retried at 1m, 5m, 30m, 2h and 12h, then marked failed. Fifty consecutive failed deliveries switch the webhook off. Deliveries are at-least-once — dedupe on id.
Events: ping — A test delivery you asked for. · match.created — A new match on one of your listings or inquiries — exact matches; nearby ones too only with the matches_nearby feature. · listing.created — One of your listings was created through Connect. · listing.updated — One of your listings was changed through Connect, or put back on the market. · listing.unlisted — One of your listings was taken off the market — by you, by expiry, or by iMatch. · listing.sold — One of your listings was marked sold — by you, or by iMatch. · inquiry.created — One of your inquiries was created through Connect. · inquiry.updated — One of your inquiries was changed through Connect, or re-opened. · inquiry.unlisted — One of your inquiries was closed — by you, by expiry, or by iMatch. · distress.created — A new distress deal went live (needs the distress feature). · agent_link.connected — One of your users connected their iMatch account. · agent_link.revoked — An agent link ended — by the agent, by you, by their brokerage, by iMatch, or because the account was deactivated. · location.updated — Places in the locations tree changed — created, renamed, moved, re-aliased, merged or retired (needs areas_read).
Request body
HTTPS url, the events to receive (or *), optional description.
rotate_secret: true mints a new secret and returns it ONCE; the old one stops working immediately. Setting active: true also resets the consecutive-failure counter.
Queues one ping delivery to this webhook regardless of its subscriptions. Poll GET /webhooks/deliveries/{delivery_id} for the outcome (status, response code, error).
Parameter
In
Type
Description
id*
path
integer
Webhook id
* required
Response 202
Queued.
Response shape — placeholder values, not real data
“My listings” belongs to a person. One login, once, and it is the same account through every door.
Your API key identifies your CRM and your brokerage. It is the right identity for the market feeds, for your own inventory, and for everything your system does on behalf of the company. It is the wrong identity for the word "my".
"My listings", "my buyers", "my matches", "my Copilot" belong to a *person*. So a person has to say so. An agent link is one of your CRM users connecting their own iMatch account, once, by signing in to iMatch themselves and agreeing. After that it is the same account and the same Copilot whether they arrive from your CRM, from WhatsApp, or from the iMatch app — and either side can disconnect it at any time.
Your key can never assert who somebody is. The name and phone your CRM sends us grant nothing and always will grant nothing — the name is display text, and the phone can only ever *narrow* who is allowed to finish a connection, never widen it (see phone below).
The button
Put a Connect my iMatch account button in your CRM's user settings. Pressing it runs four steps.
external_user_id is your id for that seat — one live link per id. Store link_id against it now. return_url must be on one of your allowed origins (Limits → Allowed origins), or you get 400 return_url_not_allowed; without one we simply show the agent a "you're connected" page.
scopes is what you are asking for. Leave it out and you ask for all of them. Whatever you ask for is what the agent reads on the consent screen, and it is exactly what we enforce afterwards — ask for ["profile", "listings"] and every inquiry, match and Copilot call through that link answers 403 agent_link_scope_denied. The five:
Scope
What it opens
What the agent reads
profile
always granted — a link IS a person
"See your name in iMatch, so the app knows which account it is connected to."
listings
/v1/me/listings, ?as_agent= on listing reads and writes, the framed Listings screen
"See your listings — including the ones you have not published."
inquiries
/v1/me/inquiries, ?as_agent= on inquiry reads and writes
"See the buyer inquiries you have posted."
matches
/v1/me/matches, the framed My-matches screen
"See your matches, and the people on the other side of them."
copilot
/v1/copilot/chat and the framed Copilot, as them
"Talk to your Copilot as you — the same conversation you already have in iMatch and on WhatsApp."
Ask for the narrowest set you actually use: a short list is the one people agree to.
display_name and email are hints. We may show display_name on the consent screen as *your claim about who this is*. Neither is stored against the link's identity and neither is matched against an iMatch account — the identity arrives when the person logs in.
phone is a hint and a fence, and it is worth sending. It still identifies nobody and grants nothing: we cannot look an account up by it. What it does is stop the wrong person finishing the connection. An authorize_url binds whoever is signed in when it is opened — so if one gets forwarded, read over a shoulder, or left in a shared browser, somebody else can take the seat, and everything you then route to that seat goes to them. Send the number you have for that user and an iMatch account with a different verified WhatsApp number is refused with wrong_person; an account that has verified no number at all is still let through, because that is every brand-new agent on their first day. The number is never stored and never returned — we keep a one-way fingerprint for the life of the request. Send nothing and the behaviour is exactly as it was: any signed-in account may connect.
**2. Send the agent's browser to authorize_url. Not a redirect from your server, not a fetch: their browser, because they are about to sign in. The token in that URL is single-use and worth half an hour**, and it is never returned again. Do not store it, do not log it, do not email it.
3. They sign in to iMatch and agree. We ask them in plain words: this app wants to see your listings, inquiries and matches, act as you in the tools, and talk to your Copilot; it cannot change your account or see your password; you can disconnect any time. They press Connect or Cancel.
4. We send them back to your return_url with ?link=ok&link_id=3041, or ?link=cancelled. You can also just wait for the agent_link.connected webhook.
If something is wrong we say exactly what, on the screen and in the API:
Code
What happened
link_expired
More than half an hour passed. Press the button again.
link_cancelled
The link was already used, cancelled or revoked.
already_linked_elsewhere
That iMatch account is already connected to a different user of yours.
seat_already_linked
That external_user_id already has a live link.
wrong_brokerage
The agent's brokerage is not yours (see below).
wrong_person
You sent a phone and the account signing in has a different verified number. Start the connection again from that user's own seat.
agent_link_scope_denied
The link is fine, but the person did not agree to what this call needs. Ask for that scope when you start the link.
The brokerage rule
A link may only be created when the agent's own brokerage is your brokerage. A Chelsea agent cannot connect to the Luxehomes key, however the link was started. The one exception is an independent agent — someone who belongs to no brokerage in iMatch: their data is their own, so they may connect to any partner that invites them. iMatch staff accounts can never be linked; they are not agents.
Using a link
Add ?as_agent=<link_id> to any /v1 call — or send the header X-Connect-Agent: <link_id>, which is identical. The call then runs with that person's own identity, so ownership-aware reads return their rows.
Three convenience endpoints exist so you do not have to hand-roll the filters:
Endpoint
Returns
GET /v1/me/listings
that person's own listings
GET /v1/me/inquiries
that person's own buyer requirements
GET /v1/me/matches
matches where either side is theirs
They take every filter and page parameter their brokerage-wide siblings take, and they requireas_agent — an endpoint that answers for one person must know which person, and it will never quietly fall back to the whole brokerage (400 agent_link_required).
**Without as_agent, nothing changes.** /v1/listings, /v1/inquiries, /v1/matches and every other endpoint behave exactly as they always have: brokerage-wide.
**A malformed as_agent is refused, not ignored.** ?as_agent=undefined — the classic templating slip — answers 403 agent_link_invalid rather than quietly returning your whole brokerage under a heading that says "My listings". Send the parameter or do not; do not send a blank.
Writing as them
POST /v1/listings?as_agent=3041 and POST /v1/inquiries?as_agent=3041 stamp the row with that person's agent record, so the very next GET /v1/me/listings through the same link returns it. The row is still filed under your brokerage, with your external_id namespace and your idempotency — a link adds an owner, it does not move the row.
Two writes we refuse rather than get wrong:
the connected account has no agent record yet → 409 no_agent_record (the same answer the reads give);
the person is an independent agent (they belong to no brokerage) → 409 agent_link_write_unsupported. Every Connect row is filed under your company, so writing theirs here would quietly make their own inventory your brokerage's. Post it as your own row instead — drop as_agent, and name the agent on the payload — or have them join your brokerage in iMatch. Reading their rows through the link is unaffected.
Ownership is the agent record on the row and nothing else. A listing that merely quotes someone's phone number — a scrape, an import, a row posted before their account existed — is not theirs, and we will not call it theirs. If the connected account has no agent record yet, /v1/me/* answers 409 no_agent_record rather than an empty page, so your sync can tell "owns nothing" from "cannot own anything yet".
GET /v1/me?as_agent=3041 grows an agent block — the person's name, your own external_user_id for them, and the scopes — so you can prove to yourself that you are acting as the right seat. No phone, no email, no iMatch user id: you already know your own people.
Contacts
Unchanged. Their own rows are their own data and cost nothing. Somebody else's contact is still a metered reveal (include=contact), on their matches as much as anywhere else.
The embedded tools and the Copilot
Pass agent_link_id when you mint an embed session and the framed screens show that person's own data, and the Copilot inside the frame is their Copilot:
The agent block on the mint body stays what it always was: a display name for the poster's byline. It grants nothing. The link is what grants.
With a link, the Copilot is one thread per person, not one per conversation id: the same thread they see in the iMatch panel and on WhatsApp. Ask it "what have I got in Marina" and it answers about their inventory. It stays read-only through Connect — writes go through this API, where your external_id contract and your audit trail apply.
MCP clients get the same thing: pass as_agent to search_listings, search_inquiries or my_matches.
Disconnecting — four doors, all of them real
You, on the agent's behalf: DELETE /v1/agent-links/{link_id}. Idempotent — deleting an already-ended link answers 200 with meta.revoked: false.
The agent, in iMatch: their profile has a *Connected apps* section listing every CRM they linked, with Disconnect.
Their brokerage admin, in iMatch: the agent's row in the people list shows their linked apps, with Revoke.
iMatch: from the partner console, and automatically whenever your partner account is switched off or deleted.
And the one that matters most: deactivating the agent's iMatch account revokes every link they have, immediately. The next as_agent call is 403 agent_link_invalid, any open embed session refuses within a minute, and their Copilot thread is unreachable. One account, one switch — because it was only ever one account reached through different doors.
A link id is a reference, not a credential. On its own it opens nothing: authority is still your API key, and a link is only ever valid for the partner that owns it. An id that is not yours, was revoked, or whose account was deactivated is always the same answer — 403 agent_link_invalid — and never tells you which.
Webhooks
Subscribe to agent_link.connected and agent_link.revoked and you never have to poll.
revoked_by is one of agent, partner, brokerage, platform or deactivation, so you can word your own UI honestly. The first four are delivered the moment they happen. deactivation is written by the database, inside the update that switched the account off, so it is announced by our five-minute sweep instead — the ACCESS ends immediately either way; only the notification waits. There is no phone and no email in the payload — you already know your own user.
In the sandbox
A test key gets a sandbox link. It behaves the same from your side — start, an authorize_url, a link_id, as_agent, the three /v1/me/* endpoints — but it binds a synthetic agent, never a person. A sandbox key cannot connect a real iMatch account, and if a real person opens a sandbox authorize URL nothing about them is stored. GET /v1/me/listings under a sandbox link returns the rows that key itself sent, which is the only honest meaning of "mine" in a sandbox. Sandbox link ids are negative (-501), so they can never be confused with a live one.
Endpoints
post/agent-links/start
Ask one of your users to connect their iMatch account
any active key
Call this from YOUR SERVER when the agent presses “Connect my iMatch account”. Store link_id against that seat and send the agent's BROWSER to authorize_url. They sign in to iMatch as themselves and agree, and we send them back to your return_url with ?link=ok&link_id=… (or ?link=cancelled). The token inside authorize_url is single-use, worth half an hour, and is never returned again — it is not a credential to keep. email is a hint for the consent screen: never stored, never matched against an iMatch account. phone is a hint AND a fence — still never stored (we keep a one-way fingerprint for the life of the request) and still able to find nobody, but an iMatch account signing in with a DIFFERENT verified WhatsApp number is refused, so a forwarded authorize_url cannot bind the wrong person to the seat. A TEST key gets a SANDBOX link that binds a synthetic agent — a sandbox key can never connect a real person.
{
"type": "object",
"properties": {
"external_user_id": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "YOUR id for the CRM user being connected. One live link per id."
},
"return_url": {
"description": "Where we send the browser after the agent connects or cancels (we append `?link=ok&link_id=…` or `?link=cancelled`). Must be on one of your allowed origins.",
"type": "string",
"maxLength": 2000,
"format": "uri"
},
"display_name": {
"description": "How that user is named in your CRM. Shown on the consent screen as your claim — it is never taken as identity.",
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"email": {
"description": "Hint only. Never stored, never matched against an iMatch account.",
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
]
},
"phone": {
"description": "Optional fence, and the one thing worth sending. The number you believe this user has. It still finds nobody and grants nothing — but if the iMatch account that signs in has a DIFFERENT verified WhatsApp number, the connection is refused (`wrong_person`), which is what stops a forwarded authorize_url binding the wrong person to this seat. Never stored and never returned: we keep a one-way fingerprint for the life of the request. Omit it and any signed-in account may complete the link.",
"anyOf": [
{
"type": "string",
"maxLength": 40
},
{
"type": "null"
}
]
},
"scopes": {
"description": "What to ask for. Default: all of them. Shown to the agent in plain words before they agree.",
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"enum": [
"profile",
"listings",
"inquiries",
"matches",
"copilot"
]
}
}
},
"required": [
"external_user_id"
]
}
Response 201
Link started.
Response shape — placeholder values, not real data
Ends the link on the agent's behalf (revoked_by: partner). Idempotent: a link that was already ended answers 200 with meta.revoked: false. The agent can also disconnect it in iMatch, their brokerage admin can revoke it, and deactivating their iMatch account revokes it automatically.
Parameter
In
Type
Description
id*
path
integer
Link id
* required
Response 200
Ended (or already ended).
Response shape — placeholder values, not real data
The same search as /listings, narrowed to the person named by as_agent. Requires the link: an endpoint that answers for ONE person must know which person, and it will never fall back to the brokerage. Ownership is the agent link on the row — a listing that merely quotes their phone number is not theirs. Unlike /listings, this returns their rows in ANY state, including the ones they have NOT published (Unlisted, Sold) — that is what the consent screen promises them. Read live_status on each row to filter. Under a TEST key this returns the rows that key itself sent.
Parameter
In
Type
Description
as_agent
query
integer
Act as the person behind this agent link, so ownership-aware reads return THEIR rows and the Copilot is theirs. The header X-Connect-Agent is accepted instead. A link id that is not yours, was revoked, whose iMatch account was deactivated, whose brokerage is no longer yours, or that is not a plain integer → 403 agent_link_invalid. A link the person granted narrower scopes than this call needs → 403 agent_link_scope_denied.
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
q
query
string
Free text over reference, project, community, area, description
purpose
query
string
Sale | Rent
property_type
query
string
area
query
string
community
query
string
project
query
string
developer
query
string
bedrooms
query
string
studio | 1 | 2 | … (exact); 5+ means five or more
bathrooms
query
integer
min_price
query
number
max_price
query
number
min_sqft
query
number
max_sqft
query
number
furnished
query
string
completion_status
query
string
Ready | Off-Plan
category
query
string
Normal | Distress — only honoured with distress_read
source
query
string
updated_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
created_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
The same search as /inquiries, narrowed to the person named by as_agent. Requires the link. Returns their inquiries in ANY state, including closed ones — read live_status on each row to filter.
Parameter
In
Type
Description
as_agent
query
integer
Act as the person behind this agent link, so ownership-aware reads return THEIR rows and the Copilot is theirs. The header X-Connect-Agent is accepted instead. A link id that is not yours, was revoked, whose iMatch account was deactivated, whose brokerage is no longer yours, or that is not a plain integer → 403 agent_link_invalid. A link the person granted narrower scopes than this call needs → 403 agent_link_scope_denied.
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
q
query
string
Free text over reference, location, project, requirements
purpose
query
string
location
query
string
Matches location_wanted / preferred_communities
project
query
string
property_type
query
string
bedrooms
query
string
min_budget
query
number
max_budget
query
number
updated_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
created_since
query
string
ISO 8601 date or date-time, e.g. 2026-09-01T00:00:00Z
Matches where EITHER side is theirs. The other party's contact is still a metered reveal (include=contact); their own side's number is free — it is their own. Requires the link. Exact matches only unless you send include=nearby, as on GET /matches.
Parameter
In
Type
Description
as_agent
query
integer
Act as the person behind this agent link, so ownership-aware reads return THEIR rows and the Copilot is theirs. The header X-Connect-Agent is accepted instead. A link id that is not yours, was revoked, whose iMatch account was deactivated, whose brokerage is no longer yours, or that is not a plain integer → 403 agent_link_invalid. A link the person granted narrower scopes than this call needs → 403 agent_link_scope_denied.
page
query
integer
1-based page, default 1
per_page
query
integer
1–100, default 25
since
query
string
Only matches created at/after this instant
status
query
string
side
query
listing | inquiry
Which side of the match is yours
listing_id
query
integer
inquiry_id
query
integer
min_score
query
number
include
query
string
Comma-separated extras. contact adds the other party's agent.phone (charged as a reveal). nearby adds NEARBY matches (the right area, not the exact building) — left out unless you ask; each match says which it is in kind.
Response 200
Their matches.
Response shape — placeholder values, not real data
Our offer, poster and Copilot tools inside your CRM.
Six iMatch surfaces can run inside your own CRM, in an iframe, with your agent already signed in. Three creators — the sales offer creator, the poster creator, and the Copilot chat — and three screens — Listings (market search), Projects (the Dubai catalogue) and My matches (who iMatch paired with your listings and inquiries). Your users never leave your page; the documents they make are stored and shared exactly as they would be inside iMatch, and the screens show exactly what the /listings, /projects and /matches endpoints answer — or, when the session carries an agent link, what that one person's /v1/me/matches answers — so a CRM can be fully connected with nothing to build on your side.
offer and poster need the tools_embed feature; copilot needs copilot. The screens need the read switch of what they show: listings needs listings_read, projects needs projects_read, matches needs matches_read — the same switches the matching endpoints need.
How it fits together
Your server asks iMatch for a short-lived embed token, using your API key.
Your page loads our small script and mounts the tool with that token.
The tool runs in an iframe on https://app.imatch.ae/embed/v1/…; your page hears about what happens through postMessage events.
The API key stays on your server. Only the token — good for at most one hour and only for the tools it names — reaches the browser.
1. Allowed origins
Before anything can be framed, iMatch must know which of your pages may do the framing. Give us the exact origins (https://crm.example.com, https://app.example.com:8443 — scheme and host, no path) and we list them on your account. The embed pages answer with a Content-Security-Policy: frame-ancestors header that names those origins and nothing else, so nobody can frame your session from a page you did not approve. With no origins listed, POST /embed/sessions refuses with no_allowed_origins.
tools — which of offer, poster, copilot, listings, projects, matches this token may open. A request naming several kinds must have every feature.
agent — who is driving. name is printed on what they create; phone ties the work to that agent in iMatch. Optional but recommended.
listing — pre-select a listing by your external_id (must be one of yours), by iMatch id, or hand the listing over inline (a listing that lives only in your CRM — see the next section).
ttl_sec — up to 3600. Mint a new token when the page reloads; there is no refresh. Keep expires_at on your side: before it passes, mint again and hand the frame the new token with setToken() (below) — or reload the page with a fresh one. An expired token is not something the frame can tell you about (see the events).
2b. Inline listings — a listing that lives only in your CRM
You do not have to send a listing to iMatch to make an offer or a poster for it. Mint the session with listing.inline and the tools open on the details you hand over:
The response is the same token / expires_at / embed_base as above; mount the tool exactly as in step 3 and leave the listing option out — the token already names the listing.
external_id (required, ≤ 80) is your id. It is printed as the reference on the offer and the poster, and everything the agent saves comes back to you as created.reference_no with that exact value — so the artefact keys straight back to your record.
title or description (≤ 4000) — at least one. The other fields are optional and use the same names and values as POST /listings: purpose (Sale \| Rent), property_type, area, community, project, developer, bedrooms (0 = Studio), bathrooms, bua_sqft, plot_sqft, selling_price, rent_price (AED per year), currency (only AED today), furnished, completion_status, handover_date, payment_plan, view, floor, permit_number, agent (name, phone, brokerage — printed on the poster).
photos — up to 20 { url, kind? }, https only, on a host we can reach from the internet. We fetch each photo at render time, when the agent's tool needs it, with a 10-second limit and a 15 MB cap per image; a URL that points at a private or internal address is refused when you mint (validation_error), and one that resolves to a private address is refused at fetch time (the poster or offer then simply renders without that photo). The first floor_plan and the first master_plan land in the offer's plan slots; plain photos feed the poster's photo strip.
The whole inline block must stay under 64 KB (payload_too_large otherwise).
iMatch never stores an inline listing beyond the session hour. Nothing lands in listings, no photo bytes are kept — the details live only for the token's lifetime, in a session store, and are gone when it expires. If that store is unreachable when you mint, the call answers 503 inline_unavailable and you can retry or mint by external_id instead; we never fall back to packing the listing into the token. Inside the tool the agent sees the listing as the fixed starting point — there is no "Change listing" picker for an inline listing, because you chose it. A session opened after its details expired shows the agent "The listing details from your CRM are no longer available — reopen the tool from your CRM."
Inline listings are for the offer and poster tools. The Copilot tool ignores listing.inline — it answers about the iMatch market, not about a listing that is not in it.
3. Mount it (browser side)
<div id="imatch-offer"></div>
<script src="https://app.imatch.ae/connect/v1/connect.js"></script>
<script>
IMatchConnect.mount({
el: "#imatch-offer",
tool: "offer",
token: EMBED_TOKEN, // from step 2, injected by your server
listing: { external_id: "CRM-1001" }, // optional; overrides the token's listing
height: "auto", // grows with the content (default), or a number of px
onReady: function () { console.log("ready"); },
onCreated: function (c) { console.log(c.kind, c.url, c.reference_no, c.artifact_id); },
onError: function (e) { console.log(e.code, e.message); }
});
</script>
A screen mounts the same way — tool: "listings" — and reports picks instead of documents:
IMatchConnect.mount({
el: "#imatch-listings",
tool: "listings",
token: EMBED_TOKEN,
height: 720, // screens paginate; a fixed height scrolls inside
onSelected: function (picked) { // { kind: "listing", id, reference_no, external_id }
openOfferFor(picked.id); // e.g. mount the offer tool with listing: { id: picked.id }
}
});
The script is plain ES2017 with no dependencies, under 8 KB, served with a five-minute cache. TypeScript declarations: connect.d.ts. A complete page: example.html.
{ id } or { external_id } (or a bare id). Optional; the offer and poster tools only. Leave it out for a session minted with listing.inline — the token carries that listing.
theme
"light" (default) or "dark".
height
"auto" (default; resizes to content) or a fixed number of pixels. For the three screens prefer a fixed height (say 720) or IMatchConnect.open(): they show 30 rows a page and open details in a side sheet, and auto would make the frame as tall as the whole page of results.
onReady / onCreated / onSelected / onError
Callbacks — see the events below.
The handle has iframe, destroy(), setToken(token) (hand the frame a fresh token before the old one expires) and postMessage(data) (a free-form message to the embed). IMatchConnect.open(options) takes the same options minus el and height, adds onClose, and returns the same handle plus close(); Esc, the backdrop and the × close it.
Events (from the iframe to your page)
Every message the embed posts has source: "imatch-connect" and a type. The SDK checks the message's origin against the embed origin before it trusts it; if you listen yourself instead of using the SDK, do the same.
type
Payload
When
ready
{ tool }
The tool has loaded and accepted the token.
resize
{ height }
The content height changed (the SDK applies it when height is "auto").
created
{ kind, url, reference_no, artifact_id }
The agent produced an offer or a poster. url is the public share link of the PDF / PNG.
Your user clicked "Use this listing / project / match" on a screen. Ids and one label only — fetch the rest through /v1, or hand id straight to the offer / poster tool as listing: { id }. external_id is your own id when the listing is one you created; null on market rows.
error
{ code, message }
A save failed (save_failed) — the offer or poster could not be stored. This is the only error the frame can report.
**What onError cannot tell you.** A rejected, expired or missing token never produces an event: the embed page answers such a request with Content-Security-Policy: frame-ancestors 'none', so the browser refuses to render the frame at all and nothing inside it ever runs. The same is true of a page framed from an origin you have not listed. Detect expiry yourself from the session's expires_at and re-mint before it passes; a frame that stays blank is a token or origin problem, not a save failure.
Messages from your page to the embed: { source: "imatch-connect", type: "set-token", token } and { source: "imatch-connect", type: "host", data } — the handle's setToken() and postMessage() send these for you.
What the embedded tools do
Offer — builds a sales offer for a listing (yours, any live listing the agent picks in the embed, or an inline listing from your CRM) and produces a PDF plus a share link.
Poster — builds a marketing poster for a listing (the same three sources) and produces a PNG plus a share link.
Copilot — the iMatch Copilot chat, read-only, bound to your partner account: it searches and explains listings, projects, areas and market data, and draws cards and charts. It does not read inquiries or matches from inside the frame (those are GET /v1/inquiries and GET /v1/matches), does not produce reports, and cannot change records.
Offers and posters created in the embed are attributed to your partner account; iMatch keeps an audit row for every Copilot tool call made under your key.
The three screens
Each screen is the matching endpoint with a face on it — same query, same rules, same fields:
Listings (tool: "listings", needs listings_read) — market search with the same filters as GET /v1/listings (keyword, area, purpose, type, bedrooms, price band, sort), 30 a page, and a detail sheet with photos and every field the endpoint returns. Your allowed_sources apply; distress deals and their economics show only with distress_read; the listing's agent is named but not reachable until Reveal contact (below). "Use this listing" posts selected with the iMatch id (and your external_id when the listing is yours). You can pre-fill the form from the URL: ?q=&area=&purpose=&property_type=&bedrooms= on the frame's src (the SDK does not add these — append them to the mounted iframe's src or open the page yourself).
Projects (tool: "projects", needs projects_read) — the Dubai catalogue with the GET /v1/projects filters (name or alias, developer, area, status, price band, sort) and a detail sheet: gallery, payment plan, amenities, floor plans, masterplan and documents. The developer's unit-by-unit list appears only with units_read; without it the sheet says so in one line. "Use this project" posts selected { kind: "project", id, name }.
My matches (tool: "matches", needs matches_read) — the pairings iMatch made, grouped into "For my listings" (a buyer's requirement matched one of yours) and "For my inquiries" (a market listing matched a requirement you posted). Each row shows the other side with the score and the reason, the match status, Reveal contact, and "Use this match" (selected { kind: "match", id, listing_id, inquiry_id }). An empty screen says how matches arrive: list a property or post an inquiry and iMatch pairs it with the market.
Whose matches, exactly. A plain session is the brokerage's: GET /v1/matches, every pairing stamped with your company, and the screen is headed "Brokerage matches". Mint the session with agent_link_id (see Agent links) and it becomes that one person's — GET /v1/me/matches, their own listings' and inquiries' pairings and nobody else's — headed "My matches". If the linked account has not finished setting up its iMatch profile it owns no rows at all, and the screen says so in a plain line instead of showing an empty list (409 no_agent_record, the same answer /v1/me/* gives).
Reveal contact and your budget
A phone number is the one thing a partner pays for. On the Listings and My-matches screens the other party's agent is named — brokerage and all — but the number appears only when your user clicks Reveal contact. That click is a reveal in exactly the sense include=contact is on the API: it is written to the same ledger, a contact revealed in the last 30 days is free again, your own rows are never charged, a row with no phone on file costs nothing, and it counts against the same monthly ceiling (reveal_quota_month) as your API calls. When the ceiling is reached the screen says "Reveal budget reached — N of M used this month" and shows no number; nothing is written. There is no way to see a phone through a screen without a reveal, and no way for a screen to spend reveals your API could not.
Mobile apps (iOS / Android)
The same pages run inside your app in a web view. No SDK and no iframe: open https://app.imatch.ae/embed/v1/<tool>?token=… top-level — the token is the whole gate (frame-ancestors only concerns iframes), and your server mints it exactly as it does for the web (POST /embed/sessions, your API key never in the app). Watch expires_at and re-mint before it passes; hand the page the new token by evaluating window.IMatchConnectEmbed.setToken("<token>") in the web view.
Every event above is delivered to a native handler when one is present, as the same object serialised to a JSON string: { "source": "imatch-connect", "type": "selected", "kind": "listing", "id": 88123, … }.
iOS — a WKScriptMessageHandler named imatch:
import WebKit
final class IMatchEmbedView: UIViewController, WKScriptMessageHandler {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "imatch") // window.webkit.messageHandlers.imatch
webView = WKWebView(frame: view.bounds, configuration: config)
view.addSubview(webView)
let token = "…" // minted by YOUR server: POST /embed/sessions
webView.load(URLRequest(url: URL(string: "https://app.imatch.ae/embed/v1/listings?token=\(token)")!))
}
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "imatch", let json = message.body as? String,
let data = json.data(using: .utf8),
let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
switch event["type"] as? String {
case "selected": print("picked", event["kind"] ?? "", event["id"] ?? "")
case "created": print("document", event["url"] ?? "")
default: break
}
}
func refresh(token: String) { // before expires_at
webView.evaluateJavaScript("window.IMatchConnectEmbed.setToken(\"\(token)\")")
}
}
Android — a @JavascriptInterface object registered as IMatchAndroid:
class IMatchBridge(private val onEvent: (JSONObject) -> Unit) {
@JavascriptInterface
fun onMessage(json: String) = onEvent(JSONObject(json)) // window.IMatchAndroid.onMessage
}
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(IMatchBridge { event ->
when (event.getString("type")) {
"selected" -> Log.d("imatch", "picked ${event.getString("kind")} #${event.getLong("id")}")
"created" -> Log.d("imatch", "document ${event.getString("url")}")
}
}, "IMatchAndroid")
val token = "…" // minted by YOUR server: POST /embed/sessions
webView.loadUrl("https://app.imatch.ae/embed/v1/listings?token=$token")
// before expires_at:
webView.evaluateJavascript("window.IMatchConnectEmbed.setToken(\"$newToken\")", null)
resize events arrive too; a web view sized by its layout can ignore them. The iMatch apps themselves do not host these pages — this is for your app.
Endpoints
post/embed/sessions
Mint an embed token for our tools and screens
any active key
Call this from YOUR SERVER with your API key and hand the returned token to the browser — the key itself never reaches a page. tools names what the token may open: the creators offer and poster (feature tools_embed) and copilot (feature copilot), and the screens listings (listings_read), projects (projects_read) and matches (matches_read) — the same read switches the matching /listings, /projects and /matches endpoints need; a request naming several kinds must pass every switch. The partner must have at least one allowed origin (Limits → Allowed origins in iMatch Connect) or the call is refused with no_allowed_origins. Open <embed_base>/<tool>?token=<token> in an iframe on one of those origins, or use the SDK (/connect/v1/connect.js). Tokens live at most one hour; mint a new one when the page reloads. listing.inline opens the offer / poster tools on a listing that exists only in your CRM (never sent to /listings): its details are kept for the session only (never beyond the hour, never in listings), photos are fetched from your https URLs at render time, and what the agent saves carries your external_id as its reference. The Copilot tool ignores it. See Embeds & SDK → Inline listings. agent_link_id opens the session AS a linked person: the screens show their own data and the Copilot is theirs, continuing the same thread they see in the iMatch app and on WhatsApp. The agent block stays display-only either way — a name your CRM sends is not a login. See Agent links.
Request body
Which tools, optionally who is driving them and which listing to open — by reference, or inline.
503listing.inline cannot be held right now — the session store is unreachable (inline_unavailable). Retry, or mint by external_id.
Copilot API
The iMatch Copilot as a chat endpoint, read-only, in your identity.
The iMatch Copilot is the assistant inside the iMatch app: it searches listings, buyer demand, projects, areas and market data, and explains what it finds in plain language. POST /copilot/chat gives you the same brain as an endpoint, running as your partner account with your feature switches. Needs the copilot feature.
Through this API the Copilot is read-only. It can find and explain; it cannot create or change records, send messages, or read account profiles. To write, use the REST endpoints or the MCP write tools, where your external_id contract applies.
conversation_id — 8–64 characters (letters, digits, _, -). Omit it to start a conversation and receive the id we minted; pass your own to key the thread yourself (your CRM ticket id works well). An unknown or expired id simply starts fresh.
context — what the user is looking at in your CRM: listing_id or one of your external_ids. It is prompt context only — it does not filter results and it grants nothing.
reply is plain text, no markdown. actions lists the records the answer is about, each with its REST URL, so you can link them in your UI. usage reports tool calls and replayed history, not tokens.
Conversations are kept for 24 hours
A conversation lives for 24 hours from its last message, keyed by your partner account and the conversation_id. Within that window each turn sees the earlier turns; after it, the same id starts a new conversation. Nothing about a conversation is stored beyond that — if you need a transcript, keep it on your side.
Streaming
POST /copilot/chat/stream takes the same body and answers with text/event-stream:
: keepalive
event: action
data: {"type":"listing","ref":"IM12345","url":"https://app.imatch.ae/api/v1/listings/88123","tool":"search_listings"}
event: token
data: {"text":"Three two-bedroom apartments in Dubai Marina are listed under AED 2.5M right now."}
event: done
data: {"conversation_id":"crm-thread-42","reply":"…","actions":[…],"usage":{…}}
: keepalive comments arrive every 10 seconds while tools run, so a proxy does not cut the connection.
action frames come one per record; token frames carry the reply in sentence-sized pieces; done carries the complete object /copilot/chat would have returned; error carries { code, message } and closes the stream.
Today the brain composes its answer in one piece, so the token frames arrive together at the end of the turn. The stream's value right now is the keepalive during tool use; when the brain streams tokens the frames become progressive with no change to the contract.
A bad body is an ordinary 400 JSON envelope, not a stream.
Budget
A separate budget of 30 messages per minute per partner, on top of your per-key limit. Over it: rate_limited with Retry-After. A Copilot turn does not spend your write budget. iMatch keeps an audit row for every Copilot tool call made under your key.
The same Copilot, inside your CRM
If what you want is a chat window rather than an endpoint, the Copilot is also one of the embeddable tools — the same read-only brain, with the agent already signed in.
Endpoints
post/copilot/chat
Ask the iMatch Copilot (one turn)
requires copilot
Runs the same Copilot brain the iMatch panel uses, as your partner identity: it searches and explains listings, buyer inquiries, projects, areas and DLD market data across the market. It is READ-ONLY here — it cannot create or change records, send WhatsApp messages, generate files or read account profiles (write through the REST API or the MCP write tools). Conversations are kept for 24 h from the last message, keyed by your partner + conversation_id; omit the id to start one, or pass your own. actions lists the records the answer is about with their REST URLs. usage reports tool calls and replayed history, not tokens. Separate budget: 30 messages per minute per partner (rate_limited with Retry-After).
Request body
The message, optionally the conversation to continue and what the user is looking at.
POST https://app.imatch.ae/api/v1/copilot/chat
Content-Type: application/json
{
"conversation_id": "crm-thread-42",
"message": "Any two-bed under 2.5M in Dubai Marina?",
"context": {
"external_id": "CRM-9"
}
}
Schema
{
"type": "object",
"properties": {
"conversation_id": {
"description": "Continue a conversation. Omit to start one (we mint an id); pass your own id to key the thread yourself. Unknown or expired (24 h idle) ids start fresh.",
"type": "string",
"pattern": "^[A-Za-z0-9_-]{8,64}$"
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 4000,
"description": "The user's message"
},
"context": {
"description": "What the user is looking at in your CRM. Prompt context only — never consent, never a filter.",
"type": "object",
"properties": {
"listing_id": {
"description": "iMatch listing id the user is looking at",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"external_id": {
"description": "Your own listing id (one of yours)",
"type": "string",
"minLength": 1,
"maxLength": 200
}
}
}
},
"required": [
"message"
]
}
Response 200
The reply.
Response shape — placeholder values, not real data
500The AI backend failed (internal_error). Retry once; the conversation is unaffected.
post/copilot/chat/stream
Ask the iMatch Copilot — Server-Sent Events
requires copilot
Same request and same brain as /copilot/chat, delivered as text/event-stream. Frames: : keepalive comments every 10 s while tools run; event: action (one per record the answer is about); event: token ({ text }, sentence-sized pieces of the reply); event: done (the full /copilot/chat data object); event: error ({ code, message }). Today the brain answers in one piece, so token frames arrive together at the end of the turn — the stream's value is the keepalive during tool use; when the brain streams tokens the frames become progressive without a contract change. A bad body is a normal 400 envelope, not a stream.
Request body
Same as /copilot/chat.
POST https://app.imatch.ae/api/v1/copilot/chat/stream
Content-Type: application/json
{
"conversation_id": "crm-thread-42",
"message": "And which of those has a sea view?"
}
Schema
{
"type": "object",
"properties": {
"conversation_id": {
"description": "Continue a conversation. Omit to start one (we mint an id); pass your own id to key the thread yourself. Unknown or expired (24 h idle) ids start fresh.",
"type": "string",
"pattern": "^[A-Za-z0-9_-]{8,64}$"
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 4000,
"description": "The user's message"
},
"context": {
"description": "What the user is looking at in your CRM. Prompt context only — never consent, never a filter.",
"type": "object",
"properties": {
"listing_id": {
"description": "iMatch listing id the user is looking at",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"external_id": {
"description": "Your own listing id (one of yours)",
"type": "string",
"minLength": 1,
"maxLength": 200
}
}
}
},
"required": [
"message"
]
}
Plug the API into Claude, Cursor and other MCP clients.
iMatch Connect is also an MCP server, so an AI client — Claude, Cursor, or anything else that speaks the Model Context Protocol — can search the market, read your matches and push records by talking to the same API you would call by hand.
POST https://api.imatch.ae/mcp
Authorization: Bearer imc_live_YOUR_KEY
(https://app.imatch.ae/api/v1/mcp is the same endpoint on the panel host and stays valid; the front door is the address to configure.)
Transport is Streamable HTTP with plain JSON responses (there is no server-initiated stream; GET answers 405 with a hint, DELETE is a no-op — every call is self-contained). Protocol version 2025-06-18. Methods: initialize, notifications/initialized, ping, tools/list, tools/call; batches (a JSON array of messages) are accepted, up to 20 messages per request (-32600 above that). Every message in a batch counts as one request against your per-minute limit and daily quota — once either is spent, that message and the rest of the batch answer -32004 (rate_limited / quota_exceeded) without running.
Tools
Every tool runs the same code as the REST route it mirrors and needs the same feature switch. Its input schema is published by tools/list, generated from the same validation the REST route uses. A result carries the REST envelope twice: as compact JSON text in content[0].text and as structuredContent ({ data, meta }). A tool that reaches a feature you do not have is JSON-RPC error -32003 (feature_disabled); a tool-level refusal the model should reason about — not_found, a reveal quota — comes back as a normal result with isError: true.
Tool
Needs
What it does
search_listings
listings_read
Search live property listings across the whole Dubai market (sale and rent). Filters: free text, purpose, property type, area / community / project / developer, bedrooms, price and size ranges, furnishing, completion status, updated_since. Paginated (per_page ≤ 100). Photos are included as URLs. Pass `as_agent` to search ONE linked person's own listings instead of the market.
get_listing
listings_read
One listing by iMatch id, or one of YOUR listings by your own external_id.
search_inquiries
inquiries_read
Search live buyer / tenant inquiries (demand) across the market: purpose, location, project, property type, bedrooms, budget range, updated_since. Paginated. Pass `as_agent` to read ONE linked person's own inquiries instead of the market.
get_inquiry
inquiries_read
One inquiry by iMatch id, or one of YOUR inquiries by your own external_id.
search_projects
projects_read
Dubai off-plan / ready development projects: name or alias, developer, area, district, type, status, completion status, price range. Paginated. With q the best match comes first (sort relevance, the default: every word of q in the name, an alias, the place or the developer); or sort by name, min_price or expected_completion_date.
get_project
projects_read
Full detail of one project (amenities, payment plan, gallery, floor plans, documents, aliases). include_units adds the developer's current unit list and needs the units_read feature.
list_areas
areas_read
Every active Dubai area with its aliases (the location vocabulary listings and inquiries are resolved against). include_communities adds the community → parent-area hierarchy.
search_locations
areas_read
Search the locations tree — every place in the UAE, city → region → area → community → sub-community → cluster → building — by name, alias or abbreviation (`JVC`), best first, Dubai first. Each place carries its breadcrumb (the places it sits in). Filters: level (e.g. `area,community`), under (a place id).
get_location
areas_read
One place of the locations tree by id: the place with its aliases (every field we hold, with locations_refs), the places it sits in, and the first page of the places directly inside it. A merged id answers as the place it was merged into (meta.merged_from).
resolve_location
areas_read
Where iMatch would place a listing with this area / community / project text — the same reading a listing push makes (exact names, aliases and catalogue projects, Dubai first, never a fuzzy guess). Answers status (resolved | ambiguous | unresolved), the place, how many places the text could mean, and which fields point somewhere else. within = only look inside that place id.
get_location_by_ref
locations_refs
Our place for an OUTSIDE location id — a Bayut location id, a DLD area / master project / project / building id, or an older iMatch list's id (legacy_area / legacy_community / legacy_project) — with every field we hold.
my_matches
matches_read
Matches on YOUR OWN listings and inquiries (the buyer found for your listing, the listing found for your buyer). Filters: since, status, side (listing | inquiry), listing_id, inquiry_id, min_score. `mine` says which side is yours; include_contact reveals the OTHER party's phone (a reveal). EXACT matches only unless include_nearby is true; every match carries `kind` (exact | nearby). Pass `as_agent` for ONE linked person's own matches rather than the whole brokerage's.
create_listingwrite
listings_write
Create or update ONE of your listings (upsert on external_id — re-sending the same external_id updates the row). Needs external_id; give purpose, property_type, area and a price for a matchable row, plus bedrooms for a home (offices, shops, warehouses, plots, land and whole buildings have none — leave it out). A new Active listing is matched against live buyer inquiries automatically. A SALE under AED 300,000 is saved Unlisted with a sale_price_below_floor warning — it is almost always a rental; send purpose Rent to publish it. Returns { external_id, id, reference_no, action, warnings }.
update_listingwrite
listings_write
Partially update one of YOUR listings by external_id. Send only the fields to change; live_status Active | Unlisted | Sold (Sold also marks status Sold). Returns the changed field names.
create_inquirywrite
inquiries_write
Create or update ONE of your buyer / tenant inquiries (upsert on external_id). Needs external_id, purpose, and location_wanted or project_wanted; budget_min/max, bedrooms_wanted and property_type_wanted make it matchable. A new Active inquiry is matched against live listings automatically.
distress_deals
distress_read
Distress deals — listings priced below their original or market price, biggest discount first. Same filters as search_listings; the discount block (original_price, market_price, discount_percent, seller_urgency, …) is always present. Always the whole market: for ONE linked person's own distress stock use search_listings with as_agent and category=distress.
list_webhooks
webhooks
Your webhook endpoints (url, subscribed events, active, consecutive failures, last delivery). Secrets are never returned.
distress_deals is listed only when your account has distress_read. Every other tool is listed regardless and refuses at call time, so the model can tell the user exactly which switch to ask iMatch for. Write tools spend your write budget (30 per minute) exactly as the REST writes do.
ChatGPT's custom connectors (Settings → Connectors → Create, developer mode) take an MCP server URL — https://api.imatch.ae/mcp — but at the time of writing that form authenticates with OAuth or with nothing, and cannot send a fixed Authorization header. Until iMatch offers OAuth for MCP, use ChatGPT through a local bridge that injects the header (mcp-remote, as in the Claude Desktop example, exposed to ChatGPT), or use one of the clients above.
Each tools/call is logged as one request against your account (as /api/v1/mcp#<tool>), so your usage figures count what the AI actually did, not how many HTTP envelopes it used.
Endpoints
post/mcp
MCP server — JSON-RPC 2.0 over Streamable HTTP (for Claude, ChatGPT, Cursor, …)
any active key
Model Context Protocol, protocol version 2025-06-18. Methods: initialize, notifications/initialized, ping, tools/list, tools/call; batch arrays are accepted (at most 20 messages; every message counts as one request against the key's limit and daily quota — once spent, the rest of the batch answers -32004). Responses are plain JSON (no server-initiated stream). Each tool runs the SAME code as the REST route it mirrors and needs the same feature switch — a missing switch is JSON-RPC error -32003 (feature_disabled). Tools: search_listings (listings_read), get_listing (listings_read), search_inquiries (inquiries_read), get_inquiry (inquiries_read), search_projects (projects_read), get_project (projects_read), list_areas (areas_read), search_locations (areas_read), get_location (areas_read), resolve_location (areas_read), get_location_by_ref (locations_refs), my_matches (matches_read), create_listing (listings_write), update_listing (listings_write), create_inquiry (inquiries_write), distress_deals (distress_read), list_webhooks (webhooks). Configure a client with this URL and Authorization: Bearer imc_live_…, e.g. claude mcp add --transport http imatch https://api.imatch.ae/mcp --header "Authorization: Bearer <key>".
Request body
A JSON-RPC 2.0 request, notification, or an array of them.
JSON-RPC response (or array). Tool results carry content[0].text (compact JSON) and structuredContent ({ data, meta }, the REST envelope); isError: true marks a tool-level refusal such as not_found.
Response shape — placeholder values, not real data
Test keys: real reads, simulated writes, masked contacts, synthetic events. Zero risk.
A test key lets you build and exercise your whole integration against the real API with zero risk to real people. It starts with imc_test_ instead of imc_live_, is issued the same way (ask iMatch — the console mints either), and works on every endpoint in this document. Every response to a test key carries "sandbox": true in meta, so a log line can never leave you guessing which key made a call.
The rule, in one line: reads are real, writes are simulated, contacts are masked, events are synthetic. Nothing a test key does can reach an iMatch user.
What is real
Everything you read. Listings, inquiries, projects, distress deals, areas, the change feed — a test key sees the same market a live key sees, through the same filters, with the same pagination. Your integration's search, mapping and sync code runs against production data from day one.
What is simulated
Every write. POST /v1/listings, POST /v1/inquiries, PATCH and DELETE on by-external, and the photo endpoints all validate your body exactly as live — the same schema, the same 400 with the same details — and answer in the live shape: the same results[], the same action (created, updated, noop, unlisted), the same warnings about an unknown area or a missing agent phone. Two things differ, on purpose:
**ids are negative and references start with SBX-** — { "id": -1001, "reference_no": "SBX-1" } — so a sandbox row can never be confused with a real one, and a live key pointed at a sandbox id gets a clean 400;
nothing is written to iMatch. No listing, no inquiry, no agent record, no photo, no feed event. Nothing is matched, nobody is notified. meta.sandbox_note says so on every write.
To make your sync loop testable, the API remembers your sandbox rows for 24 hours, visible only to the test key that made them: GET /v1/listings/by-external/{id}, GET /v1/listings?external_id=, GET /v1/listings/{id} (with the negative id), PATCH, DELETE and GET /v1/changes all see them. Re-send an identical row and you get noop; change a field and you get updated; delete and you get unlisted. This is an illusion of persistence and we say so plainly — the rows exist for 24 h, only to your test key, and up to 500 per key. Photos are validated and counted, never fetched and never stored; the URLs returned for them resolve to nothing. PUT …/photos works on your sandbox rows too and applies the same rules by url — kept, added, removed, a repeated url refused — but because nothing is fetched it cannot match by image content.
What is masked
include=contact still works, and agent.phone is still present — so your CRM's field mapping can be tested — but the number is masked: +9715••••••67 (the country code, the first subscriber digit and the last two digits survive). No reveal is charged, no reveal is recorded, and meta.sandbox_note reads contacts are masked in sandbox. The same applies to the MCP tools' include_contact and to the Copilot endpoints, where a partner never sees a contact in either mode.
Webhooks in the sandbox
A webhook you register with a test key is a test webhook. It is a separate list from your live webhooks (a test key lists, edits and deletes only test endpoints; a live key only live ones — ten of each), and it receives exactly two kinds of delivery:
the ping from POST /v1/webhooks/{id}/test;
synthetic events you ask for with POST /v1/sandbox/events.
The payload is built with the same serializers a live event uses — from your own sandbox rows when you have any (the listing you just POSTed shows up in its own listing.created), else from a canned sample — and it is signed and delivered by the same worker as a live event, with one extra request header: X-Connect-Mode: test. Listing and inquiry samples carry reason: "partner" and your slug as actor, like the echo of your own write — send "sample": { "reason": "expiry", "actor": "system:auto-unlist" } to rehearse a take-down. location.updated announces one real place (Dubai Marina, id 46) as updated, in your own tree scope. sample is deep-merged over the generated data, so you can push any field to any value to exercise your validation. The response is 202 with the delivery_ids created (one per subscribed test webhook), how many were enqueued, and the data half of the envelope exactly as your endpoint will receive it.
A real event — a real match, a real distress deal, a real status change — never reaches a test webhook, and a synthetic one never reaches a live webhook. Both sides of that rule are a predicate in the delivery SQL, not a convention. A live key calling /v1/sandbox/events gets 400 live_key.
Budgets
A test key shares your per-minute rate limit and your daily quota (a read costs us the same in either mode). It never consumes reveals, and its simulated writes spend their own flood guard — 60 per minute, separate from the live 30 — so an integration test can never eat into a production sync's allowance, or the other way round.
Embeds and the Copilot
Embed sessions minted with a test key open every tool as usual; the mint response carries meta.sandbox: true. What a user saves from inside a sandbox session ("Save to CRM") is stored under your partner — harmless, and tagged SBX- in its reference so it is never counted as a real offer or poster. The Copilot endpoints answer under a test key as they do under a live one, with meta.sandbox set and the model told it is in the sandbox.
Switching to live
When the integration is ready, ask for a live key (or mint one in the console) and change one string in your configuration: the key. Nothing else — no base URL, no headers, no endpoint — is different. Re-register your webhooks with the live key (test webhooks stay in the sandbox), and remember that a live POST /v1/listings with live_status absent creates an Active row that is matched against real buyers within minutes. That is the whole point of having tested first.
Endpoints
post/sandbox/events
Synthesise a webhook event (test keys only)
requires webhooks
Builds a realistic payload for event — from your own sandbox rows when you have any, else a canned sample — and delivers it to your TEST webhooks exactly like a live event: same envelope, same signature, same worker, plus X-Connect-Mode: test. sample is deep-merged over the generated data so you can shape it (≤ 16 KB). A live key answers 400 live_key. The event's feature gate is the live one (match.created needs matches_read, distress.created needs distress_read).
Request body
The event to synthesise, with an optional sample override.
Every budget, what it protects, and what a 429 tells you.
Eight budgets protect the API and the brokers behind it. GET /me shows the ones set for your account.
Budget
Scope
Default
Over it
Calls per minute
per key
120 (set per partner by iMatch)
429 rate_limited
Writes per minute
per partner, POST / PATCH / DELETE only
30
429 rate_limited
Copilot messages per minute
per partner — the raw API and the embedded Copilot share it
30
429 rate_limited
Calls per day
per partner
none (optional)
429 quota_exceeded
Contact reveals per month
per partner
none (optional)
429 reveal_quota_exceeded, request refused whole
Failed authentications per minute
per client IP
60
429 rate_limited before any lookup
Embed calls per minute
per partner, everything the embedded tools fetch
the partner's calls-per-minute number
429 rate_limited
Embed saves per minute
per partner, offers + posters saved from the frame
20
429 rate_limited
An MCP batch counts as one request per message it holds (at most 20 per request).
Every 429 carries Retry-After (seconds). Where a budget applies it also carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). Back off for Retry-After and try again; do not retry in a tight loop.
Why writes are separate
Every listing you create that goes live is matched against the whole market, and every match alerts a real broker on their phone. A sync loop that mints fresh external_ids by mistake would turn a read allowance of 120 a minute into thousands of new listings a minute, each pinging real people. So writes have their own, smaller budget. Batch: one POST /listings with 50 items is one write.
Reveals
A reveal is a contact phone you receive with include=contact on a record that is not yours, counted once per record per 30 days. Rows without a phone on file and your own rows are never charged. Rows whose contact is withheld by source (contact_not_available_for_source) are never charged either. When a request would cross your monthly ceiling it is refused entirely, with details: { quota, used, requested } — you are never handed a response with phones quietly missing.
Good citizenship
Cache /areas and /communities (they say max-age=3600).
Use updated_since or /changes to catch up instead of re-reading everything.
Subscribe to webhooks for anything you would otherwise poll for.
One integration, one key. Ask for a second key when you have a second environment, not a second developer.
Connect any CRM in 4 steps
The generic recipe, with a runnable mirror you can copy.
iMatch Connect is generic on purpose: a bearer key, JSON over HTTPS, and a small number of ideas — upsert on your own id, a cursor feed, signed webhooks, an iframe. Any CRM with a place to run a little code can be wired in with these four steps. Everything below is what a full integration does; take what your system needs.
Step 1 — A key, and one call
Get a key from iMatch, keep it on the server, and confirm it works:
Read the features you were given — they decide which of the steps below apply — and the limits.
Step 2 — Initial pull, then mirror with /changes
Page through the endpoints you care about (/listings, /inquiries, /projects at per_page=100) into your own tables, keyed on the iMatch id. Take a /changes cursor before you start the pull, so anything that moves during it is replayed afterwards. Then poll /changes with that cursor — every minute is plenty — and for every row it names, fetch the record and upsert it. Two rules make this bulletproof: every write is an upsert on the iMatch id, and the cursor is stored exactly as given.
If you also push your own inventory, this is where it goes: POST /listings and POST /inquiries with your external_id on every item, in batches of up to 50, whenever a record changes on your side.
Step 3 — Webhooks for the moments that matter
Register an HTTPS endpoint (POST /webhooks), store the secret, verify every delivery's X-Connect-Signature, answer 2xx within 10 seconds, and apply the event. match.created is the one most CRMs want first — it is the reason to be in the network. Keep the feed from step 2 running underneath; webhooks are fast, the feed is complete.
Step 4 — Embed our tools
If your users make sales offers or posters, give them ours: mint an embed token server-side (POST /embed/sessions) and mount IMatchConnect on the listing page. The documents they create are stored and shared by iMatch; you get a created event with the URL to keep.
The whole thing, runnable
The file below does steps 1–3 in one Node process with no dependencies: initial pull with paging, a cursor persisted to disk, a webhook receiver with the signature check, and idempotent upserts into a JSON file that stands in for your database. Copy it, swap the JSON file for your tables, and you have a working mirror.
node-mirror.mjs — also at https://app.imatch.ae/connect/v1/examples/node-mirror.mjs
#!/usr/bin/env node
/**
* iMatch Connect — a minimal, complete mirror of the market in ~150 lines.
* Node 18+, no dependencies. https://imatch.ae/connect/docs#connect-any-crm
*
* What it does:
* 1. INITIAL PULL — pages through /listings and /inquiries into a local store.
* 2. STAY IN SYNC — polls /changes with a cursor persisted to disk, upserts
* what changed (ids only → fetch the row → upsert).
* 3. WEBHOOKS — an HTTP receiver that verifies X-Connect-Signature and
* applies the event immediately (the feed is the safety net).
* 4. IDEMPOTENT — every write is an upsert keyed on the iMatch id, so a
* row seen twice (webhook + feed, or the feed's 10-second
* re-read) is harmless.
*
* The "database" is a JSON file (mirror.json) so the example runs anywhere;
* replace `store` with your CRM's tables and keep the shape of the calls.
*
* Run:
* IMATCH_KEY=imc_live_… IMATCH_WEBHOOK_SECRET=whsec_… node node-mirror.mjs
* then register https://your-host/imatch/webhook with POST /v1/webhooks.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
const BASE = process.env.IMATCH_BASE ?? "https://app.imatch.ae/api/v1";
const KEY = process.env.IMATCH_KEY;
const WEBHOOK_SECRET = process.env.IMATCH_WEBHOOK_SECRET ?? "";
const PORT = Number(process.env.PORT ?? 8787);
const STORE_FILE = process.env.MIRROR_FILE ?? "mirror.json";
const POLL_MS = 60_000;
if (!KEY) {
console.error("Set IMATCH_KEY to your imc_live_… key");
process.exit(1);
}
// ── Local store: { cursor, listings: { [id]: row }, inquiries: { [id]: row } } ──
const store = existsSync(STORE_FILE)
? JSON.parse(readFileSync(STORE_FILE, "utf8"))
: { cursor: null, listings: {}, inquiries: {} };
function save() {
writeFileSync(STORE_FILE, JSON.stringify(store, null, 2));
}
/** Idempotent upsert keyed on the iMatch id. Returns "created" | "updated" | "unchanged". */
function upsert(type, row) {
const table = store[type];
const before = table[row.id];
if (before && JSON.stringify(before) === JSON.stringify(row)) return "unchanged";
table[row.id] = row;
return before ? "updated" : "created";
}
/** Mark a row gone (unlisted / sold / deleted) without losing it — your CRM may want the history. */
function retire(type, id, change) {
const row = store[type][id];
if (row) row.mirror_state = change;
}
// ── HTTP helper: envelope-aware, Retry-After-aware ────────────────────────────
async function api(path, init = {}) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...(init.headers ?? {}) },
});
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 5);
console.warn(`429 on ${path} — waiting ${wait}s`);
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message}`);
return body; // { data, meta }
}
throw new Error(`gave up on ${path} after repeated 429s`);
}
// ── 1. Initial pull ───────────────────────────────────────────────────────────
async function pullAll(type) {
let page = 1;
let n = 0;
for (;;) {
const { data, meta } = await api(`/${type}?per_page=100&page=${page}`);
for (const row of data) {
upsert(type, row);
n++;
}
if (!meta.has_more) break;
page++;
}
console.log(`initial pull: ${n} ${type}`);
}
// ── 2. Change feed ────────────────────────────────────────────────────────────
async function applyChange(type, change) {
if (change.change === "deleted") return retire(type, change.id, "deleted");
// Fetch the current row; a row we may no longer read (unlisted, not ours) is a 404 → retire it.
try {
const { data } = await api(`/${type}/${change.id}`);
const action = upsert(type, data);
if (change.change === "unlisted" || change.change === "sold") retire(type, change.id, change.change);
console.log(`${type} ${change.id}: ${change.change} → ${action}`);
} catch (err) {
if (String(err.message).startsWith("404")) retire(type, change.id, change.change);
else throw err;
}
}
async function syncOnce() {
if (!store.cursor) throw new Error("no cursor — take one before the initial pull (see main)");
for (;;) {
const { data, meta } = await api(`/changes?since=${encodeURIComponent(store.cursor)}&types=listings,inquiries`);
for (const type of ["listings", "inquiries"]) {
for (const change of data[type] ?? []) await applyChange(type, change);
}
store.cursor = meta.next_cursor; // opaque string; persist it exactly
save();
if (!meta.has_more) break; // else loop immediately with the new cursor
}
}
// ── 3. Webhook receiver with signature verification ───────────────────────────
function verifyConnectSignature(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/i.test(parts.v1 ?? "")) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
const got = Buffer.from(parts.v1, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}
const seenDeliveries = new Set(); // dedupe on the delivery id (deliveries are at-least-once)
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/imatch/webhook") {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", async () => {
const raw = Buffer.concat(chunks).toString("utf8"); // sign the RAW body, never a re-serialised copy
if (!verifyConnectSignature(WEBHOOK_SECRET, req.headers["x-connect-signature"], raw)) {
res.writeHead(401).end();
return;
}
res.writeHead(200).end(); // answer first (within 10 s), work after
const env = JSON.parse(raw); // { id, event, created_at, partner, data }
if (seenDeliveries.has(env.id)) return;
seenDeliveries.add(env.id);
try {
if (env.event.startsWith("listing.")) {
upsert("listings", env.data.listing);
if (env.event === "listing.unlisted" || env.event === "listing.sold") retire("listings", env.data.listing.id, env.event.split(".")[1]);
} else if (env.event.startsWith("inquiry.")) {
upsert("inquiries", env.data.inquiry);
if (env.event === "inquiry.unlisted") retire("inquiries", env.data.inquiry.id, "unlisted");
} else if (env.event === "match.created") {
// env.data = { match, mine }. Contacts are never inside a webhook — fetch with include=contact when you act.
console.log(`match ${env.data.match.id} (mine: ${env.data.mine}) score ${env.data.match.score}`);
} else if (env.event === "ping") {
console.log("ping received — webhook wiring works");
}
save();
} catch (err) {
console.error("webhook handling failed:", err);
}
});
}).listen(PORT, () => console.log(`webhook receiver on :${PORT}/imatch/webhook`));
// ── Main ──────────────────────────────────────────────────────────────────────
const { data: me } = await api("/me");
console.log(`connected as ${me.partner.name} (${me.partner.slug})`);
if (!store.cursor) {
// Take the cursor BEFORE the snapshot, a minute back to be safe: anything
// that changes while the pull is running is then replayed by the feed
// instead of falling between the two.
store.cursor = new Date(Date.now() - 60_000).toISOString();
await pullAll("listings");
await pullAll("inquiries");
save();
}
await syncOnce();
setInterval(() => syncOnce().catch((err) => console.error("sync failed:", err)), POLL_MS);
Run it with your key and webhook secret in the environment, then register https://your-host/imatch/webhook with POST /webhooks and send yourself a ping with POST /webhooks/{id}/test.
Postman
A Postman collection is generated from the API's own OpenAPI document, so it always matches: one folder per area, every request with its parameters and an example body.
In Postman: Import → Link → paste https://api.imatch.ae/postman.json → Import.
Open the collection's Variables tab and set apiKey to your key. baseUrl is already the production server.
Send Account → Who am I. Everything else is one folder away; query filters are listed on each request and switched off until you tick them.
Marketplace apps
Ready-made apps for HubSpot, Zoho, Bitrix24 and PropSpace are on the roadmap. Each needs a vendor developer account and a listing review on that vendor's marketplace, so none exists yet — and we would rather say so than show you a button that does nothing. Every one of them will be built on exactly the four steps above, which you can do today.
Changelog
What changed, and when.
/v1 is frozen: fields, values and endpoints are only ever added. Every addition is listed here.
2026-09-29
**Project search finds the right project first — sort=relevance.** GET /projects (and the MCP search_projects tool) has a new sort value, relevance, and it is the default whenever q is given without a sort. Every word of q must appear in the project's name, an alias, its district, its area or its developer, and the best match comes first: the name (or an alias) equal to q, then names starting with it, then names with a word starting with it, then names containing it, then projects found only through an alias, the place or the developer. q=crest now returns The Crest first (it was 18th by name); q=hartland and q=sobha hartland crest now find it. What changes for you: a call with q and no sort used to be ordered by name and matched on name or alias only; it is now ordered by relevance and also matches on place and developer words — for a one-word q, every project you got before still comes back, plus the ones whose place or developer carries the word. Nothing changes for a call that sends sort=name (or any other sort) — send sort=name to keep the old behaviour exactly — or for a call without q. See Projects → Searching.
2026-09-26
**Replace a listing's photos — PUT /listings/{id}/photos and PUT /listings/by-external/{external_id}/photos.** Makes the listing's photos exactly the list you send, in order (0–100; [] removes them all). A photo already there with the same url and kind is kept without being downloaded again; otherwise one with the same image is kept and learns your url; the rest are added, and anything left over is removed. All or nothing; meta carries kept, added, removed. Needs photos_write and the new photos_replace feature (off by default — GET /me shows it). See Listings → Replacing a listing's photos.
**source_url and position on every photo.** Every photo now carries its 0-based place in display order (position), and on your own listings the url you attached it with (source_url, null for base64) — everywhere a listing is returned and in webhook payloads. On listings that are not yours source_url is always null. Project photos carry null for both. Photos attached before today have source_url: null — it was not kept until now; one PUT fills it in.
Attaching photos is idempotent.POST /listings/{id}/photos and photos sent with a listing upsert no longer add a photo the listing already has (same url and kind, or the same image) and no longer download it again. The response keeps one entry per photo you sent, in your order; meta.added counts what was stored and the new meta.skipped_duplicates what was already there. Until today every retry added another copy.
Photo failures name the photo. When a photo cannot be used, nothing is stored and error.details.failed lists every failing photo as { index, url, reason, message }. The message now starts with photos[i] and ends with "Nothing was written." New error code 409 photos_changed: the listing's photos changed during your call; nothing was written, send it again.
Nearby matches — marked, and left out unless you ask. iMatch's matching update of 2026-09-25 marks some matches *nearby*: right on budget, type and bedrooms, in the right area, but not the exact place (another building in the same community, or a community inside the area the buyer named). That covers new matches and older ones it re-examined, so some matches you already hold are now nearby. Every match now carries kind (exact | nearby) and is_nearby, on GET /matches, GET /me/matches, over MCP and in match.created. By default reads return exact matches only — add include=nearby (or include=contact,nearby; MCP my_matches: include_nearby: true) to get both kinds, each marked — and match.created fires for exact matches only unless iMatch switches on the new matches_nearby feature for you (off for everyone by default; GET /me shows it). Until today nearby matches were served and pushed like exact ones, unmarked: to find which of the matches you stored are nearby, read them once with include=nearby and look at kind. Show nearby matches apart from exact ones, never mixed in. See Matches → Exact and nearby matches.
**Keep your copy of the tree current — GET /locations/changes.** The places that changed since a point, oldest first, each in your scope's shape: { id, change, at, merged_into_id, place }. change is created, updated (the place, one of its aliases or outside ids, or a place above it — its breadcrumb — changed) or deleted (no longer a live place: switched off, or merged into merged_into_id — re-point what you stored). With locations_refs, place is the full node exactly as the export shows it (a merged or retired node as it is, schools and nurseries included); otherwise the compact place, or null once it is gone. Start with since (an ISO date-time or any cursor we issued), continue with cursor = meta.next_cursor. Paged by (updated_at, id) with a strict cursor, up to 2,000 a page. Held 600 seconds (10 min) behind real time (meta.lag_minutes, meta.until): a place-writing batch can run for minutes and its places carry its start time, so a shorter lag could let one slip behind your cursor. The tree is read-only through the API — nobody edits it through Connect. See Locations → Keeping a copy current.
**/changes?types=locations.** The same rows, ids only: { id, change, at, merged_into_id }, the same 600-second lag, and the cursor is interchangeable with /locations/changes. Opt-in: a /changes call without types still returns listings, inquiries and projects only. Needs areas_read.
**New webhook event location.updated** (needs areas_read). Every few minutes, the places that changed since the last delivery, in your scope's shape: data = { scope, locations: [ …the same items as /locations/changes… ], cursor } — each place once, at most 100 per delivery, cursor = where to resume the feed after this delivery. It follows the feed, so it arrives about 10–15 minutes after the change. Subscribe to it explicitly (or to *).
**Stable codes for placement warnings — warning_codes.** Every POST /listings item result and PATCH /listings/by-external/{id} result now carries warning_codes: string[] next to the unchanged warnings: one stable code per placement warning, in the same order — location_unknown, location_merged:<new id>, location_conflict, bayut_id_ignored, external_ref_unknown_source, external_ref_unknown_id, external_ref_feature_off. Test keys get the same codes. See Listings → Where the listing goes.
**Who took your row down — reason + actor.** Every listing.created / updated / unlisted / sold and inquiry.created / updated / unlisted event about one of your own rows now carries, next to the row in data: reason — partner (your own API call: the echo you can ignore), expiry (iMatch's daily expiry: 21 days for a listing, 7 for an inquiry) or imatch (someone or something on iMatch's side) — and actor — your partner slug for partner, system:auto-unlist for expiry, and imatch:admin (an iMatch team member), imatch:user (a signed-in iMatch user — the listing's agent or their brokerage — in the iMatch app, website or Copilot) or imatch:system (an iMatch rule such as the sale-price floor, or a change no person can be named for) for imatch. actor never names a person. See Webhooks → Who did it.
2026-09-25
The locations tree. Every place in the UAE in one tree — city → region → area → community → sub-community → cluster → building — with the same ids on both sides; area ids equal /areas ids. GET /locations/search (ranked, Dubai first), GET /locations/{id} (the place, the places it sits in, the first page inside it; a merged id answers as its survivor with meta.merged_from) and GET /locations/{id}/children (keyset-paged). New today: GET /locations (the top: the cities), parent_id and name_ar on every place, and aliases on GET /locations/{id}. See Locations.
Export with conditional GET.GET /locations/export walks the whole tree, parents before children, up to 2,000 places a page. Every page carries an ETag (and meta.version); send it back as If-None-Match and an unchanged tree answers 304 Not Modified. GET /locations/retired lists ids that no longer answer as a place of their own and where they went.
Resolve.GET /locations/resolve?area=&community=&project= — where a listing with that text would be placed: status (resolved | ambiguous | unresolved), the place, candidates, conflicting_fields. The same reading POST /listings makes; never a fuzzy guess.
**The full tree — new feature locations_refs.** Off unless iMatch enables it for you. With it the export walks every node (merged, retired, schools and nurseries too) with every field we hold, every alias row with its kind, and every outside id (refs: Bayut, Dubai Land Department, legacy ids — primary and secondary); GET /locations/{id} answers the same full shape; GET /locations/by-ref/{source}/{external_id} turns an outside id into our place.
Listings know their place. Every listing now carries location_id and location (the place's id, name, level and breadcrumb; with locations_refs also its primary refs) — on reads, inside /matches, over MCP and in webhook payloads (webhooks never carry refs). On POST / PATCH /listings, location_id places a listing exactly; with locations_refs, so does external_ref: { source, id } or bayut_location_id. Text stays the fallback. Additive; nothing was renamed or removed.
Communities.GET /communities rows gain location_id — the same community as a place of the tree.
MCP. New tools search_locations, get_location, resolve_location, and get_location_by_ref (listed only with locations_refs).
2026-09-13
Agent links — "my listings" now means a person. Your key identifies your CRM and your brokerage; it never asserts who somebody is. An agent connects their own iMatch account once, by signing in themselves: POST /agent-links/start → send their browser to authorize_url → they agree. After that any /v1 call may carry ?as_agent=<link_id> (or X-Connect-Agent), and GET /me/listings, /me/inquiries and /me/matches answer for that person. Embed sessions take agent_link_id, MCP tools take as_agent, and with a link the Copilot is *their* Copilot — the same thread they see in the iMatch app and on WhatsApp. Either side can disconnect; deactivating the agent's iMatch account revokes every link immediately. New events agent_link.connected / agent_link.revoked; new endpoints GET /agent-links, GET/DELETE /agent-links/{id}. Without as_agent nothing changes — every existing call is still brokerage-wide. See Agent links.
**Listings — project photos and project_id.** Very few listings in the market carry a photo of their own, so a listing that has none now inherits its matched project's pictures inside photos, marked kind: "project" — a photo of the building or development, never of that unit. A listing that already has its own images is left exactly as it was. Every listing we could match to a project also gains project_id and project_photo_url, whether or not it has its own photos, so you can label the picture honestly or open the project. Both are additive; nothing was renamed, removed or reordered. Same on /listings, /listings/{id}, /listings/by-external/{…}, /distress, the listing inside /matches, the MCP listing tools and the embedded screens. See Listings → Pictures, and whose they are.
Embeds — inline listings.POST /embed/sessions accepts listing.inline — a listing that exists only in your CRM — so the offer and poster tools can open it without a /listings row. Kept for the session hour only; photos fetched from your https URLs at render time; artefacts carry your external_id as their reference. New responses: 413 payload_too_large, 503 inline_unavailable. The Copilot tool ignores it. See Embeds & SDK → Inline listings.
v1 — 2026-09-12
The first release of iMatch Connect.
REST API — listings (read, upsert on external_id, patch, unlist, photos), inquiries (read, upsert, patch, close), projects (catalogue and detail, developer unit lists behind units_read), distress feed (behind distress_read), areas and communities, matches on your own records, GET /me. One envelope, stable error codes, per-key and per-partner budgets, contact reveals charged per record per 30 days.
Change feed — GET /changes with an opaque cursor, strict ordering on (updated_at, id), and a 10-second safety lag.
Webhooks — up to 10 HTTPS endpoints per partner; ten events; X-Connect-Signature HMAC-SHA256 over t.body; retries at 1m / 5m / 30m / 2h / 12h; auto-disable after 50 consecutive failures; delivery log, test ping and redeliver from the API.
Embeds & SDK — the offer, poster and Copilot tools in an iframe on your allowed origins; POST /embed/sessions; connect.js with mount / open and ready / resize / created / error events.
Copilot API — POST /copilot/chat and /copilot/chat/stream, read-only, conversations kept 24 hours.
MCP server — POST /mcp, protocol 2025-06-18, one tool per REST operation.
Documents — GET /openapi.json (OpenAPI 3.1), GET /postman.json (Postman v2.1), and this page.