Changelog
Changes to the Darak API. Within /v1 we only add; anything deprecated is announced here first. See the versioning policy.
Reference data is free
- changedGET /cities, /cities/{city}/neighborhoods, /cities/{city}/directions and /enums now cost 0 units, like /limits. Resolve neighborhood names and valid filter values as often as you need without spending quota.
Timestamp inputs now require seconds
- changedBreaking validation change in v1: ISO date-time inputs must include seconds. For example, 2026-09-21T10:00:00Z is accepted; the previously accepted 2026-09-21T10:00Z is now rejected. This affects listings updated_since, organization key expires_at, and request-log since/until filters. Zod 4.6 also narrows the OpenAPI date-time patterns on responses; the server continues to emit timestamps with seconds.
- changedOpenAPI nullable fields now use JSON Schema type arrays (for example, [string, null]) instead of equivalent anyOf unions. Generated example responses may show representative non-null values where they previously showed null; runtime response fields remain nullable.
Webhooks
- addedSubscribe an HTTPS URL to listing events instead of polling for them.
POST /organization/webhookstakes a URL, the event types you want and the listing filters that decide which listings count — the same vocabularyGET /listingstakes. A city is required: without one the subscription is every new listing in the Kingdom, and neither of us finds that out until the volume arrives.listing.createddelivers today;listing.price_changedandlisting.delistedare accepted on a subscription now and start arriving when they land. - addedDeliveries are signed with
Darak-Signature: t=…,v1=…, HMAC-SHA256 over{timestamp}.{body}, in the scheme Stripe popularised — enough libraries and enough people already know it. The timestamp is inside what was signed, so a captured request can't be replayed later. The webhooks guide carries the verification code rather than describing it. - addedDelivery is at least once: retry six times over about eight hours, deduplicate on
Darak-Event-Id, which is stable across retries and replays. A4xxis retried as well as a5xx, since a 404 usually means a deploy is in flight rather than that the event is unwanted. Twenty deliveries that exhaust their attempts disable the endpoint rather than keep posting at something long gone. - added
GET /organization/webhooks/{id}/deliveriesshows what was sent and what your server answered, including the first 500 characters of its reply, and…/replaysends one again once you've fixed whatever rejected it.
- addedSubscribe an HTTPS URL to listing events instead of polling for them.
Test keys
- addedA
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. Create one on the API keys page; an organization can hold three, and they don't count against the live-key limit your plan sets. - addedTest usage is metered against its own counters, so a test key left running in CI cannot eat the quota your production integration depends on, and it never appears in usage or billing. Overage is off on a test key whatever your plan allows, so it can't generate a charge.
- changedA test key reaches the same APIs your plan does — a sandbox that answered
403where production answers200would send you debugging the wrong thing. It cannot reach/v1/organization, which still needs an admin key.
- addedA
Read your own limits, and your own request log
- added
GET /limitsreturns the plan, rate limit, monthly quota, page size and paging depth that apply to the key making the call, and the APIs that key can reach. It costs no units and is on every plan.GET /organizationhas carried the same numbers all along, but it takes an admin key and admin keys are owners-only — so a running service had no way to ask what its own page size was, and found out by requesting too many and reading the 400. Read this at start-up instead of hard-coding a page size, and the same code works on every plan. - added
GET /organization/request-logsreturns the calls your organization made, newest first, within your retention window. Filter bykey_id,route,statusor a time range — or byrequest_idto look up one call, which ignores every other filter. Until now the log existed but only the dashboard could read it, so answering "my job got a 400 at 03:00, what was wrong with it" meant a person opening a web page. - addedFailed calls now record the message and the parameter at fault alongside the error code, so the log can say *which* value was rejected rather than only that something was. Both follow the same privacy switch as request parameters and IP addresses: organizations that turn off request details get neither.
- added
Listing and Pagination are named schemas
- changed
ListingandPaginationare defined once undercomponents.schemasand referenced, instead of being written out in full in every response that returns them. Nothing about the responses changes — the same fields, in the same shape — but a client generated from the spec now gets a namedListingtype rather than an anonymous inline one repeated per endpoint, and the document is 61% smaller (750 KB to 295 KB), which is most of a second off loading the reference. - fixedThe three copies of the pagination schema had already drifted apart in their wording; there is now one.
- changed
Filters that didn't do what they said
- addedA path that exists, called with a method it doesn't take, now returns
405 method_not_allowedin the usual error shape with anAllowheader naming the methods that work.POST /v1/listingsused to get an empty 405 from the framework — no body, norequest_id. A CORS preflight to any endpoint now gets a204rather than HTML. - fixed
qis documented as matching the advertiser's title and description, which is what it has always done — it was described as matchingheadline, a field Darak composes for the response and never searches. Its query syntax is documented too: several words must all appear,"a quoted phrase"matches whole, and|means either. - fixedThe docs claimed
GET /listings/{id}and/listings/batchreturn any live listing. They don't: a listing is served once Darak has copied its photos to its own CDN, so a photo-less listing is a404and comes back inmissing_ids. Land is exempt, being routinely advertised without photos. What those endpoints actually skip is the quality filter, so they return listings search won't. - fixed
advertiser.typeshowedcompanyas its example — a value the column never holds. It returns the source's own wording (agency,developer,individual_ownerand so on);companyandindividualare what the *filter* takes, grouping those, so a response value can't be passed straight back as a filter. - changedThe quality filter is described accurately: per-tier price fences from the median and MAD of log price, with bedroom and area caps and a floor at 30% of the neighborhood median. The coverage page had been calling it winsorized percentiles, which it was until March 2026 and hasn't been since.
- changedEight filters had no description at all (
beds_min,beds_max,bathrooms_min,bathrooms_max,price_max,area_max,furnished,days_on_market_max), and four described behaviour loosely enough to mislead:bedsis exact at every value including 5 (darak.app treats 5 as five-or-more);floor=groundandfloor=upperdon't partition, since a listing with no stated floor is in neither;verified=falseincludes listings the source said nothing about;neighborhood_id_excludekeeps listings with no neighborhood whileneighborhood_iddrops them; and a misspelledsourcenarrows to nothing rather than erroring.
- addedA path that exists, called with a method it doesn't take, now returns
The guide has its own pages, and says what your plan allows
- addedThe written guide is now at [platform.darak.app/docs/guides](https://platform.darak.app/docs/guides/quickstart) — quickstart, authentication, limits, pagination, retrying, errors, data notes, versioning and terms, one page each. The same text still opens the API reference, so there is one copy of it; the pages just have room for tables and are readable without JavaScript.
- addedEvery plan's limits are published: units per minute, units per month, page size, **paging depth** and active keys, for Free, Starter, Growth and Pro. Paging depth in particular appeared nowhere except Pro's, so there was no way to size a sync without hitting
result_window_exceededin production. - changed
doc_urlin an error body now links to that specific code —…/docs/guides/errors#invalid-cursor— rather than to the whole errors section. - addedAuthentication documents the two key kinds and what each reaches, how to store a key, and OAuth access tokens: what they can do, and which organization they act for when someone belongs to several.
- addedTerms now states in the docs the four rules that shape how you build: storage is capped at 30 days from retrieval, delisted listings come down within 7 days, takedowns are 3 business days, and attribution is required where you display data. They were only in the terms of use, one link away from the sync recipe that makes a local copy.
- addedThe quickstart links the OpenAPI document, both SDKs, the changelog feed, the status page and an address to write to with a
request_id.
The reference now describes what the API actually sends
- fixedThe Administration endpoints are documented as taking an admin key (
dk_admin_…). The spec declared a singledk_live_…scheme for everything, so a client generated from it failed every Administration call with nothing in the reference to explain why. - addedEvery endpoint shows an example response, built from the schema so it can never describe a shape the API doesn't return. Nullable fields with nothing characteristic to show appear as
nullrather than invented values. - fixedTimestamps are marked
format: date-time. They have always been ISO 8601 in UTC, but nothing said so, so generated clients typed them as plain strings. - fixedThe documented response headers match the ones sent:
X-Request-Units,X-Quota-LimitandX-Quota-Resetwere missing,Retry-Afterwas described only in prose, and theDeprecation,SunsetandLinkheaders a deprecated endpoint sends were undocumented. Administration endpoints no longer list rate-limit headers, which they never sent — they are unmetered. - addedEach section of the reference now says what it covers and which plan reaches it, and the spec links the terms of use.
- fixedThe Administration endpoints are documented as taking an admin key (
Pagination cursors are signed
- changedCursors now carry a signature, so the API accepts only cursors it issued. **Cursors issued before this change no longer work** and return
400 invalid_cursor; a paging loop that was mid-query when this shipped starts again from its first page. Nothing else changes: keep passingnext_cursorback exactly as you received it. - changedA malformed, edited or mismatched cursor now reports
invalid_cursorrather than the genericinvalid_value. It still arrives as a400withparam: "cursor". - fixedA cursor's contents could previously be edited to page to any offset, which sidestepped the paging depth your plan allows. Cursors have always been documented as opaque values to pass back unchanged, and that is now enforced rather than assumed — which also means their contents can change when pagination does, without breaking anyone.
- changedCursors now carry a signature, so the API accepts only cursors it issued. **Cursors issued before this change no longer work** and return
Idempotency keys, and conflicts that say so
- addedPOST endpoints accept an
Idempotency-Keyheader. Send the same key with the same body and you get the first call's response back — markedIdempotent-Replay: true— instead of the work happening twice. A key reused with a different body is rejected with409 idempotency_key_reuse. This makesPOST /organization/keyssafe to retry: before it, a request that timed out after the key was created minted a second key on retry, and its secret was only ever shown once. Keys are remembered for 24 hours. - fixedCreating keys faster than the hourly limit allows now returns
429 rate_limitedwith aRetry-Afterheader. It used to return400 limit_reachedwith no wait hint, so a caller that should have backed off read it as a permanent bad request. - changedRequests that clash with the current state of an organization — the active-key limit, the project limit, a name already taken — now return
409with aconflict-type error naming which limit (key_limit,project_limit,name_taken), instead of a single400 limit_reached. Only/v1/organizationendpoints are affected. - added
invalid_cursordistinguishes a malformed or mismatchedcursorfrom other bad values; it used to report asinvalid_value. - changedThe errors section of these docs now explains every code — what it means, whether retrying helps, and what to do — rather than listing code names. Notably
rate_limited(wait seconds) andmonthly_quota_exceeded(wait for the next UTC month) no longer read as the same instruction. - fixedA request to an unknown
/v1path now returns theX-Request-Idheader as well asrequest_idin the body, and a CORS preflight to one gets a JSON-shaped answer instead of HTML.
- addedPOST endpoints accept an
Broker listings endpoint removed
- removed
POST /broker-listingsand thebrokersscope are removed. The endpoint returned advertisers' names and phone numbers, which Darak no longer shares outside the listing page, and no client had called it.
- removed
Sign in with OAuth, as well as an API key
- added
Authorization: Bearernow accepts an OAuth access token as well as an API key. Tokens are issued by platform.darak.app, last an hour, and act for the person who granted them, so a tool can read data on their behalf without ever holding a key. API keys are unchanged and remain the way a server, script or scheduled job authenticates. - changedAn OAuth token can read listings, market data, analytics, projects and reference data. It cannot submit broker listings or manage an organization, whatever the plan allows — those need a key.
- added
Project filters, and enums for all of them
- added
GET /projectsandGET /project-unitsacceptfeaturesandbanks(a project must have all the ids you ask for), and/project-unitsacceptsbathroomsandbathrooms_min. - added
GET /enumsnow listsamenities,project_features,project_banksandadvertiser_types, so every enumerated filter has a source of truth. - changed
advertiser_typeonGET /listingstakescompanyorindividual. The underlying column holds eight source-specific values, so these two group them; an intermediary who does not say whether they are a firm or a person matches neither.
- added
sort=recommended
- added
GET /listingsacceptssort=recommended, the ranking darak.app itself defaults to: asking price against comparable listings, how long the ad has been up, how complete it is and how sought-after the neighborhood is, with no single source allowed to fill the top of the page. It is a ranking rather than an ordering, so it is retuned from time to time and the same query can reorder between releases — page through it freely, but keep usingsort=updated_ascwithupdated_sinceto sync. The default sort is unchanged (newest).
- added
The rest of the listing filters
- added
GET /listingsandGET /listings/countacceptq(free text over the headline and description),amenitiesandamenities_exclude,floor,verified,advertiser_type,compoundandin_compound,max_age,rent_frequency,source_exclude,neighborhood_id_exclude,livings_min,days_on_market_min/days_on_market_max, andbathrooms/bathrooms_maxalongside the existingbathrooms_min. These have always been part of darak.app's own search; they were simply never exposed here. - changed
bathroomsis an exact number andlivings_minis a minimum, each said explicitly rather than by convention — the same waybedsis exact andbeds_minis a minimum. No existing parameter changed meaning.
- added
New plans, and units priced by results
- changedList endpoints now cost 1 unit per 25 results asked for with
limit(up to your plan's page size), and listing batch 1 unit per 25 ids. A request for 25 results still costs 1 unit. Analytics endpoints cost 2–5 units. Each endpoint's cost isx-unitsin the reference, and every response'sX-Request-Unitsheader. - addedA Growth plan (200,000 units a month, analytics included). Pro now includes 750,000 units a month and paging up to 20,000 results. Plans can be paid yearly (2 months free) and in SAR or USD.
- added
GET /listings/{id}/price-historyreturnshistory_window_days: the Free plan sees the last 90 days of price changes, paid plans the full history.
- changedList endpoints now cost 1 unit per 25 results asked for with
Market data, analytics and off-plan projects
- addedMarket data (Starter and up):
GET /market/summary,/market/price-distribution,/market/area-distribution,/market/supply,/market/vacancyand/market/neighborhoods. Every figure uses one sample: live listings updated in the last 30 days, with the same outlier filter as darak.app, and each response states its window. - addedAnalytics:
GET /listings/{id}/comparables,/listings/{id}/market-position,/market/deals,/market/rental-yield,/market/trends(monthly medians from real listing history since March 2026) and/market/neighborhoods/compare. - addedOff-plan projects:
GET /projects,/projects/{id},/projects/{id}/units,/project-unitsand/developers. - addedError codes
neighborhood_not_foundandproject_not_found(404).
- addedMarket data (Starter and up):
Listing headlines
- addedListings have a
headline: a short English description built from their fields (for example "3-bedroom apartment for rent in Al Malqa, Riyadh"), always present.titleis still the advertiser's own title and is null for sources that don't have one.
- addedListings have a
Self-serve plans and spend caps
- addedSubscribe to Starter or Pro from the dashboard, paying monthly by card in SAR. Past the monthly quota, requests keep working and are billed per 1,000 units, up to a monthly spend cap you set on the Billing page.
- addedError code
429 spend_cap_reached: this month's extra usage reached your spend cap. Raise the cap, or wait for the quota to reset on the 1st.
Admin API
- addedAdmin keys (
dk_admin_…), created by owners in the dashboard, manage your organization from code:GET /organization,GET/POST /organization/keys,POST /organization/keys/{key_id}/revoke,GET /organization/projects,/organization/usage,/organization/membersand/organization/audit-events. Admin calls aren't metered. - addedError codes
admin_key_requiredandadmin_key_not_allowed(admin and data keys each call only their own endpoints), plusforbidden_actionandlimit_reachedfor admin API requests your role or plan doesn't allow.
- addedAdmin keys (
Restricted keys, key expiry and project caps
- addedKeys can be limited to some APIs. Calls outside them fail with
403 scope_not_in_key. - addedProjects can have a monthly unit cap. Past it, that project's keys get
429 project_cap_exceededuntil the 1st. - changedAn expired key now gets
401 expired_api_key(it used to getrevoked_api_key). Revoked keys are unchanged.
- addedKeys can be limited to some APIs. Calls outside them fail with
Listings endpoints
- added
GET /listings,GET /listings/count,GET /listings/batch,GET /listings/{id}andGET /listings/{id}/price-history. - addedSync a copy with
sort=updated_ascandupdated_since. Paging depth per query depends on your plan (result_window_reached). - addedListings carry financing details where the source publishes them: rent-now-pay-later, bank financing and landlord payment plans.
- added
Darak API v1
- addedAPI keys, plans with rate limits and monthly quotas, and the
RateLimit-*andX-Quota-*headers. - addedReference data:
GET /cities,GET /cities/{city}/neighborhoods,GET /cities/{city}/directionsandGET /enums. - addedBroker listings for enterprise plans:
POST /broker-listings.
- addedAPI keys, plans with rate limits and monthly quotas, and the