Webhooks
Rather than polling for new listings, have them posted to you. Subscribe an HTTPS URL to the events you want, with the filters that decide which listings count:
curl -X POST "https://api.darak.app/v1/organization/webhooks" \
-H "Authorization: Bearer $DARAK_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/darak-webhook",
"event_types": ["listing.created"],
"filters": { "city": "riyadh", "listing_type": "rent", "beds_min": "3" }
}'
The response carries the signing secret once. Store it; you cannot read it back.
Your URL must be https://, on the default port, and resolve to a public address — we check that when you register it and again before every delivery, and we don't follow redirects. A receiver that moves needs updating here rather than forwarding.
Filters use the same vocabulary as GET /listings, and a city is required — without one the subscription is "every new listing in the Kingdom", and neither of us finds that out until the delivery volume arrives.
What you receive
{
"id": "evt_9f2c4a1b8e7d6c5b4a3f2e1d",
"type": "listing.created",
"created": "2026-09-21T10:00:00.000Z",
"data": {
"listing_id": 128647,
"city": "Riyadh",
"listing_type": "rent",
"property_type": "apartment",
"price_yearly_sar": 60000,
"url": "https://darak.app/en/listing/128647"
}
}
Deliberately small — an id, what changed, and where to get the rest. A full listing would be stale by the time a retry landed hours later, and the 30-day storage limit applies to anything you keep from it. Fetch GET /listings/{id} when you need the detail.
Headers: Darak-Signature, Darak-Event-Id, Darak-Event-Type and Darak-Delivery-Attempt.
Verifying a delivery
Anyone can POST to your URL. Check the signature before you trust the body:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(header: string, body: string, secret: string): boolean {
const parts = new Map(
header.split(",").map((p) => p.trim().split("=", 2) as [string, string]),
);
const t = Number(parts.get("t"));
const v1 = parts.get("v1");
if (!v1 || !Number.isFinite(t)) return false;
// Reject anything older than five minutes: the timestamp is inside what was
// signed, so this is what stops a captured request being replayed later.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}
Sign the raw body, before any JSON parsing — a re-serialised object will not match.
Delivery, retries and failure
- At least once. A delivery that times out after you committed will arrive again. Deduplicate on
Darak-Event-Id, which is stable across retries and replays. - Answer 2xx to accept. Anything else is retried, including a
4xx— a 404 usually means a deploy is in flight, not that the event is unwanted, and dropping it would lose data you can't ask for again. - Six attempts over about eight hours: after a minute, then five, thirty, two hours and six. Answer quickly and do the work afterwards; a receiver that holds the connection open is a receiver we time out at ten seconds.
- Repeated failure disables the endpoint. Twenty deliveries that exhaust their attempts and we stop and tell you, rather than keep posting at something that has been gone for days. Deliveries are kept either way — fix the receiver and replay them.
Limits
At most 500 events per endpoint per run. Beyond that the rest wait for the next run rather than being dropped, so a busy subscription catches up instead of losing events.
Events come from what Darak can see, which means a listing that appeared and disappeared between two scrapes was never there to tell you about. Sources are read twice a day.
All three types deliver. Each is tracked separately, so subscribing to a new one starts it from your endpoint's creation rather than from wherever the others had got to.
listing.price_changed fires only when the number actually moved — a listing being priced for the first time isn't a change, and has nothing to put in previous_price_yearly_sar.
listing.delisted covers only listings you could have seen. A listing Darak hides — a duplicate of one it already shows, or one without photos that isn't land — never produced a listing.created, so its disappearance produces nothing either. Roughly a fifth of daily deactivations fall into that group.
Managing them
GET /organization/webhooks lists your endpoints; …/deliveries shows what was sent and what your server said, including the first 500 characters of its reply; …/deliveries/{id}/replay sends one again once you've fixed whatever rejected it. All of these take an admin key (dk_admin_…).