Saudi real-estate listings and market data, aggregated from 20+ sources, deduplicated and quality-filtered. The same inventory powers darak.app.
Quickstart
- Get an API key (
dk_live_…) from your Darak account. - Make a request:
curl "https://api.darak.app/v1/listings?city=riyadh&listing_type=rent&limit=5" \
-H "Authorization: Bearer $DARAK_API_KEY"
Or with an official SDK, which adds types, paging and retries. Both cover every endpoint here and are generated from this spec.
// npm install @darak-app/sdk
import { Darak } from "@darak-app/sdk";
const darak = new Darak(); // reads DARAK_API_KEY
const page = await darak.listings.search({ city: "riyadh", listing_type: "rent", limit: 5 });
# pip install darak
from darak import Darak
darak = Darak() # reads DARAK_API_KEY
page = darak.listings.search(city="riyadh", listing_type="rent", limit=5)
- Use
GET /citiesandGET /cities/{city}/neighborhoodsto find the city slugs and neighborhood ids that filters accept, andGET /enumsfor allowed values.
All responses are JSON. Single objects come back as { "data": { … } }, lists as { "data": [ … ], "pagination": { … } }.
Where things are. The OpenAPI document is at /v1/openapi.json — no key needed — and generates both SDKs, so you can generate your own client from it too. @darak-app/sdk is on npm and darak on PyPI. Changes are announced in the changelog, which has an RSS feed, and availability is on the status page. If something is wrong, look the call up yourself with GET /organization/request-logs?request_id=… — it carries the status, the error and the parameter at fault — or email [email protected] quoting the request_id.
Authentication
Send your key in the Authorization header on every request:
Authorization: Bearer dk_live_…
- Keys are secret. Call the API from your servers only: never from browsers, mobile apps or public repositories.
- A key passed in the URL (
?api_key=) is rejected withapi_key_in_query. Rotate any key that was ever put in a URL. - Accounts can hold several keys, grouped into projects. A key can be limited to some APIs and given an expiry date; calls outside its APIs fail with
scope_not_in_key, and an expired key getsexpired_api_key. - To rotate a key, use Rotate in the dashboard: you get a new key with the same settings, and the old one keeps working for the grace period you choose, so you can deploy without downtime. Revocation takes effect immediately.
Storing a key. Keep it in an environment variable or a secret manager, never in source control. Keys carry a fixed dk_live_ or dk_admin_ prefix so secret scanners can spot one easily — if a key does reach a public repository, rotate it straight away and revoke the old one once your deploy is through.
Two kinds of key. A data key (dk_live_…) reads listings, market data, analytics and projects. An admin key (dk_admin_…), created by an organization owner, manages the organization itself — keys, projects, members, usage and audit events — and is the only thing the /v1/organization endpoints accept. The two never overlap: a data key on an admin endpoint gets admin_key_required, an admin key anywhere else gets admin_key_not_allowed. Admin calls aren't metered.
Test keys. A dk_test_… key reads the same live data as a live key, under a fixed allowance of 1,000 units a month at 30 units a minute, 25 results a page and 250 deep. It counts against its own buckets, so a test key left running in CI can't eat the quota your production integration depends on, and it can never generate a bill — overage is off whatever the plan allows. It reaches the same APIs your plan does, so what you build against is what you ship against. Create one on the API keys page; an organization can hold three, separate from the plan's limit on live keys.
OAuth is for the MCP server, not direct calls. People connect AI assistants through Darak MCP by signing in with OAuth. The MCP server exchanges their token for a short-lived API credential bound to the organization they chose at consent, so those calls count against that organization's plan and appear in its logs. Tokens issued to MCP clients are not accepted by the API directly: a server, script or scheduled job should hold a key.
What a listing is
- Prices.
price.yearly_saris the comparable number: rentals posted monthly, weekly or daily are converted to a yearly amount, and sales are the total price.price.as_postedkeeps the advertiser's original amount and period. - One listing per property. When the same property is posted on several sources, Darak keeps one listing and lists the others in
also_listed_on. - Quality filtering. Search and counts exclude listings with implausible prices or sizes, the same filter darak.app uses.
GET /listings/{id}and/listings/batchskip that filter, so they return listings search won't. - Photos are a condition of being served. A listing is only available once Darak has copied its photos to its own CDN — a newly scraped listing appears a little later, and one that never gets photos never appears at all. Land is exempt, since land is routinely advertised without any. This applies to detail and batch lookups too: a photo-less apartment is a
404, and comes back inmissing_ids. - Freshness.
first_seen_atis when Darak first saw a listing.updated_atis its last update at the source, or the last time Darak saw it if the source doesn't publish update times.last_seen_atis when Darak last confirmed it is live. - Asking prices. Listings are asking prices, not transaction prices. Data comes from third-party sources and may be incomplete or out of date.
- Attribution. Where you show listings to your users, credit Darak, link the listing's
url, and credit its source by name (source.name). On paid planssource.urlis a darak.app link that forwards to the original ad; on Free it andsource.listing_idare null.
Limits and quotas
Every request costs units (shown in X-Request-Units, and as x-units on each endpoint in this reference):
- List endpoints (listing search, project lists): 1 unit per 10 results asked for with
limit, counted up to your plan's page size. A page of 100 costs 10. - Listing batch: 1 unit per 10 ids.
- Single resources, reference data and market data: 1 unit.
- Analytics: 10 (market position, price band), 15 (comparables, rental yield, neighborhood compare, trends), or 2 per 10 results (deals).
Your plan sets:
- Rate limit: units per minute. Headers:
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(seconds). - Monthly quota: units per calendar month (UTC). Headers:
X-Quota-Limit,X-Quota-Remaining,X-Quota-Reset. - Page size: the largest
limityou can request. - Paging depth: how many results of a single query you can page through. Past it you get
result_window_exceeded; narrow the query, or sync withupdated_since.
| Plan | Units / minute | Units / month | Page size | Paging depth | Active keys |
|---|---|---|---|---|---|
| Free | 30 | 250 | 25 | 500 | 5 |
| Starter | 60 | 50,000 | 50 | 5,000 | 25 |
| Growth | 150 | 200,000 | 100 | 5,000 | 25 |
| Pro | 300 | 750,000 | 100 | 20,000 | 25 |
Enterprise plans set these individually. Your own are on the Plan page in the dashboard, and GET /limits returns them to the key making the call — it costs no units, so read it at start-up rather than hard-coding a page size, and the same code works on every plan.
Extra usage. On any paid subscription (Starter, Growth or Pro), requests past the monthly quota keep working and are billed per 1,000 units at the end of the billing period, up to the monthly spend cap an owner sets on the Billing page. At the cap you get 429 spend_cap_reached until the quota resets. With a cap of 0, the quota is a hard limit (429 monthly_quota_exceeded).
Over a limit you get 429 with a Retry-After header. Wait that many seconds, then retry. Requests that fail (4xx, 5xx, and 429s) don't count toward your monthly quota.
Pagination
List endpoints return a page plus:
"pagination": { "limit": 25, "next_cursor": "eyJvIjoy….pkFEfJUOpRbZ…", "has_more": true, "result_window_reached": false }
- To get the next page, repeat the request with
cursor=<next_cursor>and the same parameters. A cursor used with different filters or sort is rejected. - Treat a cursor as an opaque string: pass back exactly what you were given. Cursors are signed, so an edited or hand-built one is rejected with
invalid_cursor, and their contents will change when the underlying pagination does. result_window_reached: truemeans more results exist but your plan's paging depth is reached. Narrow the query (neighborhood, price, property type) instead of paging deeper.- Listing search doesn't return a total. Use
GET /listings/countwith the same filters.
Cursors carry an offset, so under sort=newest or a price sort a listing added or removed between two pages can shift rows across the boundary and be served twice or skipped. That is inherent to paging a live table, and it is why a sync uses sort=updated_asc — see Keeping a copy in sync.
Retrying safely
A GET can be repeated freely. For a POST, send an Idempotency-Key header — any unique string of up to 255 characters, a UUID is ideal — and the call will happen at most once however many times you send it:
Idempotency-Key: 8f14e45f-ea6a-4cbb-9a2f-3d1c0b7e21aa
- Retry with the same key and the same body and you get the first call's response back, with
Idempotent-Replay: true. Nothing runs twice. - The same key with a different body is rejected with
409 idempotency_key_reuse. Generate a key per logical operation, not per process. - A call that failed doesn't hold its key: retrying it runs again, which is what you want after a timeout or a
5xx. - Keys are remembered for 24 hours.
Without the header a retried POST runs again in full. On POST /organization/keys that means a second key you were only ever shown the secret for once, so send the header.
Errors
Every error has the same shape:
{
"error": {
"type": "invalid_request",
"code": "unknown_parameter",
"message": "Unknown parameter 'bed'.",
"param": "bed",
"request_id": "req_4f1c2d9a8b7e6f5a4b3c2d1e",
"doc_url": "https://platform.darak.app/docs/guides/errors#unknown-parameter"
}
}
Branch on code: codes are never renamed, though new ones may be added. message is for humans and may change. Unknown query parameters are errors, not ignored, so typos surface immediately. Quote request_id when you contact us.
-
400
invalid_request— The request is wrong. Fix it; sending it again unchanged fails the same way.invalid_value— A parameter's value isn't accepted.paramnames it.missing_parameter— A required parameter was omitted.paramnames it.unknown_parameter— No such parameter. Unknown ones are rejected, not ignored, so typos surface.invalid_body— The JSON body was malformed or didn't match the schema.unknown_city— That city isn't covered.GET /citieslists the slugs that are.result_window_exceeded— Paging past your plan's depth. Narrow the query instead.limit_reached— An account limit is already at its maximum.invalid_cursor— The cursor is malformed, or belongs to a query with different filters or sort. Start the query again.
-
401
authentication_error— The key was missing, malformed or is no longer usable. Don't retry.missing_api_key— NoAuthorization: Bearerheader.invalid_api_key— No key matches. Check for a truncated or wrong-environment key.revoked_api_key— This key was revoked. Create a new one.expired_api_key— This key passed its expiry date. Create a new one.api_key_in_query— A key was passed in the URL, where it leaks into logs. Move it to the header and rotate it.
-
403
permission_error— The key is valid but isn't allowed this call. Don't retry.scope_not_in_plan— Your plan doesn't include this API. Upgrade to reach it.scope_not_in_key— Your plan includes this API but this key isn't allowed it. Edit the key.admin_key_required— This endpoint needs an admin key (dk_admin_…).admin_key_not_allowed— Admin keys only reach/v1/organization. Use a data key here.forbidden_action— Your role in the organization doesn't allow this.cr_outside_allowlist— That commercial registration isn't on your account's allowlist.ip_not_allowed— Your organization's IP allowlist doesn't include this caller's address.client_suspended— The account is suspended. Contact us.client_expired— The account's term has ended. Contact us.
-
404
not_found— No such resource. For a listing, it may simply no longer be live.not_found— No such resource.city_not_found— No such city.GET /citieslists them.route_not_found— No such endpoint. The spec is at/v1/openapi.json.listing_not_found— No live listing with that id. It may have been delisted.neighborhood_not_found— No such neighborhood in that city.project_not_found— No such off-plan project.period_not_found— The source has published nothing for that period.details.available_periodslists what it has.release_not_found— No published release of the Darak Property Index matches.details.available_releaseslists them.export_not_found— No export with that id for this organization. Exports are kept for 90 days, their files for 7.
-
405
method_not_allowed— Right path, wrong HTTP method.Allowlists the ones it takes.method_not_allowed— That path exists, but not for this method.
-
409
conflict— The request can't be applied as things stand. Change it, then retry.conflict— The resource's current state rules this out.idempotency_key_reuse— ThisIdempotency-Keywas already used with a different body. Use a new key.key_limit— You already hold the most keys your plan allows. Revoke one first.project_limit— You already hold the most projects your plan allows.name_taken— Something with that name already exists.export_limit— Too many exports in progress, or created today. Wait for one to finish;detailssays which limit.
-
429
rate_limit_error— Slow down.Retry-Aftersays how long to wait.rate_limited— Over your per-minute unit rate. WaitRetry-Afterseconds.monthly_quota_exceeded— This month's unit quota is spent. It resets at the start of the next UTC month, or raise the spend cap.spend_cap_reached— Extra usage hit the monthly spend cap an owner set. Raise it to continue.project_cap_exceeded— This key's project hit its own monthly unit cap.
-
500
api_error— Our fault. Retry with exponential backoff.internal_error— Something broke on our side. Retry with backoff; quoterequest_idif it lasts.
5xx errors are safe to retry with exponential backoff.
Versioning
The version is in the path (/v1). Within a version we only make additive changes: new endpoints, optional parameters, response fields and enum values. Your code must ignore fields and values it doesn't recognize.
Breaking changes ship as a new version. The old version keeps working for at least 12 months after the new one launches. Anything deprecated is announced in the changelog (with an RSS feed) and by email, and its responses carry a Deprecation header (RFC 9745) and, once the date is set, a Sunset header (RFC 8594). Watch for those headers in your logs.
Terms in practice
Use of the API is subject to the Darak API terms of use. Five clauses shape how you build, so they are worth knowing before you design a sync rather than after:
- Storage is capped at 30 days. You may keep Darak data to run your application for up to 30 days from when you retrieved it; after that, refresh it through the API or delete it. Aggregated statistics you derive for internal reporting may be kept longer, as long as they can't be reversed into listing-level data. The sync recipe above is built for exactly this: it keeps a copy current rather than accumulating one.
- Delisted listings come down within 7 days. Once a listing stops being returned — sold, rented, withdrawn, merged as a duplicate — stop showing it within 7 days. A listing you can no longer fetch by id, or that comes back in
missing_ids, is your signal. - Takedowns are 3 business days. If we tell you specific data must be removed, delete it and stop showing it within 3 business days. Keep the listing ids you have stored addressable so you can act on that.
- Attribution is required where you display data. Show "Data by Darak" near it with a link to darak.app, and where you show an individual listing, link its
urland credit the source by name ("Listed on Aqar"), or linksource.url, which forwards to the original ad. That credits the original publisher and lets people verify the ad. - Showing data to your users needs a paid plan. Free is for building and testing, with your own team and test users. No self-serve plan lets you redistribute Darak data as a dataset, feed or API; Enterprise can license redistribution of derived aggregates (medians, indices, yields) that can't be reversed into listings.
- Government open data is different. Responses that carry an
attributionobject (the registered-transaction endpoints under /transactions and /market/transactions, and /market/land-prices) serve Saudi open data under the Saudi Open Data License. None of the rules above apply to it: store it, publish it and resell it as you like, as long as theattributionnotice and dataset links travel with it (section 3.4 of the API terms).
Standard endpoints are built to exclude advertiser names, phone numbers and commercial registration numbers. Listing text and images may still contain personal data an advertiser chose to include, and you are an independent controller of whatever you store — the Saudi Personal Data Protection Law applies to you directly.