Skip to main content
This is the single source of truth for the public lead-intake endpoint, and the version to hand to external developers. The machine-readable twin is provident-lead-intake-openapi.json next to this file — keep the two in step when either changes. (Two earlier write-ups, docs/PUBLIC_LEADS_ENDPOINT.md and public-lead-full-example.md, were folded into this document and deleted on 2026-07-31; nothing else describes this endpoint.)
Changed 2026-11-13.
  1. New optional buyerType on POST and PUT/PATCH /v2/public/leads (§4.2): who the enquirer says they are, such as a form’s “I am an Agent / Investor” choice. Send the choice as the visitor saw it — Agent is accepted and recorded as Broker.
  2. Nothing existing changed.
Changed 2026-11-04.
  1. New POST /v2/public/whatsapp-check (§17). It tells you whether phone numbers are on WhatsApp, so you can leave out the ones that are not before you message them. Up to 50 numbers per request, any country.
  2. It is enabled on your credentials separately (see Credentials).
  3. Sections 17–19 of the previous revision are now 18–20.
  4. Nothing existing changed.
Changed 2026-10-26.
  1. New POST /v2/public/phone-screening/check (§16) runs the DNCR check, the broker check and the staff check in one call. At most 20 numbers, because that is the DNCR limit. It is enabled on its own: access to it does not include the three separate endpoints, and the other way round.
  2. Nothing existing changed.
Changed 2026-10-19.
  1. New phone screening endpoints (§16): POST /v2/public/phone-screening/brokers tells you which numbers belong to real-estate brokers, and POST /v2/public/phone-screening/employees which belong to Provident staff. Use them to take agents and staff off a contact list before a campaign. Up to 1000 numbers per request.
  2. Each endpoint is enabled separately on your credentials (see Credentials).
  3. Sections 16–18 of the previous revision are now 17–19.
  4. Nothing existing changed.
Changed 2026-10-15.
  1. New optional location (name) and locationId (uuid) on POST and PUT/PATCH /v2/public/leads (§4.3). They fill the lead’s location, which is what Provident routes the lead on. Prefer locationId, taken from the locations endpoints (§14.1): names repeat across the tree, ids do not. Sending both is safe and recommended: if the id matches nothing (locations are occasionally merged), the name is tried instead.
  2. areaOfInterest keeps working exactly as before. It records the area the customer says they want and does not route the lead. New integrations should send location / locationId; existing ones can switch whenever convenient.
  3. Nothing existing changed. If you send neither new field, every request and response behaves exactly as before.
Changed 2026-10-12.
  1. A merge now only happens within one funnel (§7). An enquiry for a different funnel than the person’s recent lead becomes its own lead, marked as a repeat, rather than being folded into the other team’s lead. If you send one person to two funnels, expect two leads, not a merge.
  2. A merged enquiry’s details now reach the lead (§7): languages are added, new phones and emails are appended, and fields the lead has empty are filled. Nothing the lead already has is overwritten. Responses are unchanged.
Changed 2026-10-06.
  1. New POST /v2/public/dncr/check (§15). It checks up to 20 UAE mobile numbers against the Do Not Call Registry before you contact them.
  2. Sections 15–17 of the previous revision are now 16–18.
  3. New optional recruitment fields on POST and PUT/PATCH /v2/public/leads (§4.12): jobPosition (the role an applicant applied for) and resumeUrl (a link to their CV, which we download into our own storage). Responses carry resume (null when you sent no CV).
  4. Nothing existing changed. If you send neither field, every request and response behaves exactly as before, apart from the extra resume: null.
  5. Credentials are now scoped. Each client_id is allowed only the endpoints agreed for your integration (see Credentials). Calling any other endpoint returns 403 OAUTH_CLIENT_GRANT_MISSING instead of being served. Existing integrations were given exactly the endpoints they already use, so nothing you call today changes.
Changed 2026-09-19 — read this if you are already integrated.
  1. New comments[] on both POST and PUT/PATCH /v2/public/leads (§4.11) — relay a conversation that happened somewhere else onto the lead’s own thread, up to five comments per request, each with its own date so a chat sent an hour later still reads in the order it happened.
  2. Both responses now carry comments: { added, skipped } (null when you sent none). skipped is customer messages that were not kept — going over the cap of five does not fail your request, so nothing else will tell you.
  3. Nothing existing changed. If you do not send comments, every request and response behaves exactly as before.
Changed 2026-09-06 — read this if you are already integrated.
  1. New PUT /v2/public/leads/{id} (§9) — send details you did not have when you created the lead, most obviously quiz answers the customer finished afterwards. Send only what changed; phones and emails are added, never replaced; the lead’s stage, status, owner and funnel stay Provident’s.
  2. Sections 9–16 of the previous revision are now 10–17.
Changed 2026-08-24 — read this if you are already integrated.
  1. Every 201 from POST /v2/public/leads — including a merged one — now carries assignedTo: the agent the lead landed on, with the phone and email to reach them (§8). Merged responses previously had only three fields.
  2. New GET /v2/public/leads/{id}/assignment (§10) tells you who a lead ended up with, and crucially distinguishes “routing hasn’t finished” from “nobody took it”. Read it before assuming an ownerless lead is a failure.
  3. New POST /v2/public/leads/{id}/response-time (§11) records how long the agent took to respond.
  4. Provident can now call you when a lead is created or assigned — see §12, webhooks. If you take the webhooks you do not need to poll §10 at all.
Changed 2026-08-19 — read this if you are already integrated.
  1. A second enquiry from the same phone/email inside ~24h is now merged into the existing lead instead of creating a new one, and that response has a different, much shorter body. Branch on merged (§7, §8).
  2. A lead that names a listing now inherits that listing’s lead type, developer, location, property type, bedrooms, currency and price — into fields you left empty (§4.4).
  3. GET /v2/public/developers moved from page to limit/offset; page is now rejected with 422, not ignored (§14.2).
  4. There is now a standalone locations endpoint (§14.1).
  5. New optional field integrationRef — tag each lead with the integration that produced it (§4.6.1). Worth sending from day one; it is what lets Provident answer “which of your automations sent this” without guessing.
Server-to-server API for pushing leads into the Provident CRM. Two endpoints are involved: Provident can also call your server when a lead is created or assigned, which replaces polling #4 entirely — see §12. Authentication is OAuth 2.0 Client Credentials. There is no user login, no redirect, no consent screen — your server exchanges a client id + secret for a bearer token and uses that token to post leads.

1. Environments

The /v2 prefix is mandatory. https://devapi.prov.ae/public/leads (without /v2) returns 404. Every path in this document already includes it.
All requests and responses are application/json; charset=utf-8. Send UTF-8 — Arabic and other non-ASCII content is fully supported.

Credentials

You will be issued a separate client_id / client_secret pair per environment: Treat the secret like a password: store it in your server-side configuration or a secrets manager, never in browser JavaScript, a mobile app bundle, or a public repository. This API must only be called from your backend — calling it from a browser would expose your credentials and is blocked by CORS. If your credentials leak, contact Provident and they will be rotated (the old secret stops working immediately). Your credentials only open the endpoints agreed for your integration. A client_id that was issued to submit leads can submit leads and nothing else; reading lead assignments, reference data, running DNCR checks, each of the two phone-screening checks, the combined screen (POST /v2/public/phone-screening/check) and WhatsApp checks are enabled separately. Having the combined screen does not give you the three separate checks, nor they it. Anything outside your access answers 403 with code: "OAUTH_CLIENT_GRANT_MISSING", and the response names the access it needs in details.requiredGrant. If you need another endpoint, ask Provident to enable it — retrying will not help.

2. Get an access token

Request body

Success — 200 OK

The token is an opaque string, not a JWT — do not try to decode it.

Failure — 401 Unauthorized

Causes: wrong client_id/client_secret, or the client has been deactivated. A grant_type other than client_credentials also fails here.

Token handling rules

  • Cache the token in memory for expires_in seconds (refresh ~60s early). Do not request a new token for every lead — token calls count against your rate limit and create a token record on each call.
  • On any 401 from the lead endpoint, fetch a fresh token once and retry the request. If it fails again with 401, stop and alert — do not loop.
  • Tokens can be revoked server-side; always be prepared to re-authenticate.

3. Create a lead

The only hard requirement

At least one entry in leadPhones or leadEmails. Everything else is optional.
Without a phone or an email the request is rejected with 400, because the CRM cannot create or match a contact.

Minimum valid request

Complete request — every supported field

This is the full surface of the endpoint. Send everything you have — nothing here is required except one phone or email, and every field you can fill improves routing, attribution and reporting on our side. Omit what you don’t have (don’t send empty strings or null placeholders).
Field-by-field meaning is in §4. Three notes on the example:
  • listingId and referenceNo both identify a listing — send whichever you have. If you send both, listingId wins.
  • metaFacebook is only for leads coming from Meta / Instagram / TikTok lead forms. Leave the whole object out for website and other sources.
  • quiz is only for landing pages that ask a question set. Unlike every other field here, its questions do not need to be agreed with us in advance — see §4.9.

4. Field reference

Legend for Match:
  • exact — free text stored as sent.
  • lookup — matched case-insensitively (after trimming) against a list of accepted values configured in the CRM. An unmatched value does not fail the request; see Matching behaviour.

4.1 Contact identity — at least one of phone/email required

4.2 Lead classification

funnel — which pipeline the lead lands in

Provident runs several pipelines beside the main sales one. Leave funnel out and the lead goes to the main sales pipeline, which is what almost every enquiry wants and what every lead posted to this API did before the field existed. Send a value only when the enquiry belongs to a different business line: Matched case-insensitively, ignoring spaces, hyphens and underscores — so Show Room, show_room and showroom are the same funnel. The lead lands on that funnel’s own first stage, which is not always called the same thing (New Registration on Show Room, New Seller - Landlord on Landlord/Sellers Listings). funnel is not derived from leadType, deliberately. The two do not line up: a Tenant enquiry may belong to Leasing or to PvH, and a Primary Buyer to sales, Show Room or Provident the Agency. Only you know which business line the enquiry came from, so only you can say. An unrecognised funnel never fails the request. The lead is created on the main sales pipeline and the value you sent comes back in intakeUnresolved.funnel. Watch for that — a typo ("Leasng") is indistinguishable from omitting the field in every other respect, so the only symptom is a funnel team quietly receiving nothing.

4.3 Requirement / interest

4.4 Listing the lead is about (optional)

A resolved listing fills in what your form did not ask. Every field in this table is taken from the listing only where your payload left it empty — anything you send always wins, and a quiz answer (§4.9) mapped to the same field beats the listing too. This matters beyond convenience: lead type, developer and location are all inputs to Provident’s routing, so a lead that names a listing is routed on what it actually is instead of falling through to a catch-all. Sending a value we then fail to match (an unknown developer name, say) is still reported in intakeUnresolved (§5) even though the listing goes on to fill that field — the mismatch stays visible so it can be corrected on your side. Linking a listing also makes the lead inherit that listing’s agent as its owner where one is set; that agent wins over assignedBy (§4.7).

4.5 Source & attribution

4.6 Page context, click ids & UTM

4.6.1 Submission provenance

Stored verbatim on the lead — no lookup, nothing here can end up in intakeUnresolved.
Top-level formName is your web form. It is unrelated to metaFacebook.formName (§4.8), which is the Meta lead-form name — send both when both apply. If you send neither, the Meta block fills them. A lead with no top-level formName / adsetName takes metaFacebook.formName and metaFacebook.adgroupName instead (truncated to 255). So a scenario that just forwards Meta verbatim no longer produces leads with an empty ad set — which is the column these are reported on. Anything you send at the top level wins.
About integrationRef. Most senders run more than one thing against this API — several Make scenarios, a website form and a chatbot, one automation per campaign. When a batch of leads turns out to be junk, or a field stops mapping, the first question is always which one produced these, and nothing else in the payload answers it: formName is the visitor’s form, source is marketing attribution, and both are frequently identical across your whole estate.
  • Keep it stable. It identifies an integration, not a submission — the same scenario sends the same value every time. A per-lead unique id makes it useless for grouping.
  • Make it specific, e.g. make:meta-leadgen-eu, wp-contact-form-7, chatbot-v2.
  • It is never validated: nothing to match, so it can never appear in intakeUnresolved (§5) and can never cost you a lead. An unknown value is simply a new value.
  • Don’t put your own name in it. Who you are is recorded automatically and separately, from your access token — see below. Values are only distinguished within one sender, so two partners can both use scenario-1 without colliding.
You are identified automatically. Every lead records the OAuth client it was submitted with, resolved server-side from your token. There is no field for it and no way to send one — attribution a sender can write is not attribution. This is also why a client id and secret must never be shared between two integrations that you would want to tell apart: use integrationRef for that, or ask Provident for a second client.

4.7 Assignment (optional)

When the lead also resolves a listing (§4.4) and that listing has an agent of its own, the listing’s agent wins — assignedBy is used only when the listing has none.

distributionType — a routing hint

distributionType records how you would like the lead routed. It is stored on the lead and only changes who receives it if Provident has configured a routing rule that reads it. Sending one is safe: a value no rule claims changes nothing, and it is a label you can send today and Provident can act on later, without you changing anything. Where a rule does claim the value, it wins. Provident’s routing rules are tried in a set order, and a lead usually matches the first rule whose conditions fit — but a lead that names a distribution type goes to the rule that claims that type, ahead of that order. So a lead sent as Location distribution that also carries a campaign is routed by the location rule, not by the campaign rule that would otherwise have taken it first. Ask which types are claimed before you rely on this; it is configuration on our side and it changes. Accepted values, matched case-insensitively after trimming (the leading word on its own also works, so campaign is the same as Campaign distribution): An unrecognised value is stored exactly as you sent it rather than rejected. A lead is never lost over this field — it simply matches no rule. That also means a typo fails silently, so check the value if you expect routing to depend on it. Capitalisation is not a typo: default, Default and Default Distribution are all the same value, on your side and on ours. Personal distribution is the one value with behaviour attached today. It marks the lead as having a named owner, and a lead marked that way is never re-assigned automatically — it will not be taken off its owner by the system for going untouched, and no follow-up clock runs against it at all. It only takes effect together with a resolved assignedBy. The two fields are one instruction: assignedBy names the owner, Personal distribution says the owner keeps the lead. If assignedBy is missing, or is an address we cannot match to a current Provident agent, the lead is still created and routed normally — but the Personal distribution marker is dropped rather than applied, because a lead exempted from follow-up with nobody on it is a lead nothing chases. When that happens the value you sent comes back in intakeUnresolved under distributionType, alongside assignedBy, so the mismatch is visible in the response:
If you leave distributionType out, a Meta lead form can carry it instead. A Distribution or Distribution Type entry in metaFacebook.customFields (§4.8) is read as a fallback and normalised through the same table above. That is there because the value is usually set by whoever built the ad form, not by whoever posts the lead — so you do not have to lift it out of the custom fields yourself. A top-level distributionType always wins.

4.8 metaFacebook — Meta / Facebook / Instagram lead ads

Send this object only when the lead came from a Meta (or TikTok) lead form. The standard fields above still drive routing; this block preserves the ad-platform metadata alongside the lead. It is no longer only stored. Five of these fields are copied onto the lead itself, where they are searchable, filterable and reportable next to every other lead column: Matching on the id is what makes a rename safe: rename a campaign in Ads Manager and its leads stay on the one campaign in the CRM, under the name it was first seen with. campaignName (§4.5) is a different thing and is still a strict lookup — it is the CRM’s own campaign, which drives routing.

4.9 quiz — dynamic quiz / survey answers

For landing pages that ask a set of questions (“What is your budget?”, “When do you want to buy?”, dropdowns, and so on). Unlike every other field in this document, the questions do not have to be agreed with Provident in advance — a new quiz starts capturing answers the first time it posts one, with no API change on either side. Answers are searchable and filterable in the CRM, shown on the lead, and — for questions Provident maps to a CRM field — used to fill that field on the lead.

The one thing you must get right

Send a stable questionKey for every question, and a stable optionKey for every dropdown answer. These are the only language-independent identity an answer has. Labels are display text: the same question ships in English and Arabic with different wording, the same bracket reads 2-5 million or ٢-٥ مليون, and copy gets edited.
  • Keys must be stable across languages — the Arabic and English versions of one question send the same questionKey.
  • Keys must be stable over time — do not regenerate them when you edit the wording.
  • Keys are scoped to the quiz, so budget in quiz A and budget in quiz B are independent. You do not need globally unique keys.
If you cannot produce keys, still send the labels: answers are kept and stay visible on the lead, but each language registers as a separate question until someone at Provident merges them by hand, and reporting is split until they do.

The block

Each entry in answers:

How free-text answers are read

Numbers are parsed best-effort, so you do not have to normalize them yourself. All of these land as 2000000: 2 million, 2,000,000, 2.000.000, 2 000 000, AED 2M, 2m aed, Dirhams 2M, ٢ مليون, ٢٠٠٠٠٠٠. Ranges keep both ends (2-5 million, 2m to 5m). Currency words in English and Arabic are recognised and recorded separately. Dates accept ISO-8601 (2026-12-01) and unambiguous text (1 December 2026). 03/04/2026 is deliberately rejected — day-first and month-first are both plausible and guessing would file half your leads under the wrong month. Send ISO-8601 or a value. Yes/no answers are recognised across languages (yes/no, نعم/لا, да/нет, …). Anything that will not parse is kept verbatim and flagged for review at Provident — it is never dropped, and it never fails your request.

Limits

Up to 60 answers per submission register new questions; anything beyond that is still recorded and flagged, but does not extend the catalogue. A quiz is capped at 100 questions and a question at 200 options. These exist to stop a malformed submission growing the catalogue without bound — a real quiz will not come near them.

4.10 Extra fields you send

Any field not listed above is accepted rather than rejected, so adding a field on your side will never start failing your requests. It will not populate a structured CRM field either — if you have data that should drive routing or reporting, ask Provident to add it to this specification first, or send it through quiz (§4.9), which is designed for exactly that and needs no change on our side.

4.11 comments — relaying a conversation

For the case the rest of this API has no answer for: the enquiry that produced the lead happened somewhere else, in a chat, and the lead arrives without it. comments[] writes that conversation onto the lead’s own thread, where the agent already reads everything else about it. Each entry is one comment: Accepted on POST and on PUT/PATCH, identically. On an update they are added to what the lead already has; nothing you sent earlier is replaced or removed.

date is not the time you send the request

This is the whole point of the field. A conversation relayed an hour after it happened is still displayed, and still sorted, at the time each message was actually sent — so it reads in order beside the agent’s own notes rather than arriving as a block stamped “now”. The time it reached us is recorded separately and is never lost. Two limits: an unparseable date falls back to now, and a date more than 24 hours in the future is ignored the same way — a clock-skewed sender cannot park a message at the top of a thread forever. A date in the past is always honoured; that is what the field is for.

The order is the order you send

The first entry in the array is the eldest, and appears at the bottom of the CRM timeline. Entries you send without a date keep the order you sent them in. Entries that carry the same timestamp — a transcript accurate only to the minute — also keep it.

Maximum 5 per request, and going over does not fail

Anything past the fifth entry is skipped, and so is any entry with an empty body. The request still succeeds, because failing it would throw away a real customer enquiry over a comment. The count comes back as comments.skipped on the response:
Read it. A sender that ignores it loses customer messages silently. Send a longer conversation as consecutive PUTs, five at a time, oldest batch first.

What these are, and are not

  • They are visible comments — not internal notes, not an audit trail.
  • They are not agent activity: posting them never restarts the lead’s response clock and never counts as somebody having worked it.
  • @-mentions in the text notify nobody.
  • They are attributed to your integration, not to a person.
One caveat worth knowing before you build on this: comments are a placeholder for a proper chat view, chosen because it puts the conversation in front of an agent today. Send type and chatChannel on every message even though nothing reads them yet — they are what will let these be lifted into a real chat later without guesswork.

4.12 Job applicants — jobPosition and resumeUrl

For the Recruitment funnel: a job application is posted as a lead like any other enquiry (the applicant’s phone or email is still required), with "funnel": "Recruitment" and two extra fields.
How the CV is handled. We do not keep your link. Shortly after responding we download the file and store our own private copy on the lead, so your link has to stay reachable for a few minutes and never longer. Downloading happens after the response so a slow file host can never make your request time out — which matters, because a timed-out request you retry is a duplicate application. What the link must be.
  • A public https:// (or http://) URL on the default port, with no username or password in it, on a host reachable from the internet.
  • It must return the file itself: PDF, Word (.doc, .docx), RTF, OpenDocument text (.odt), PNG or JPEG, at most 10 MB. The type is read from the file’s own bytes, not from your Content-Type header or the file name.
  • A link that returns a web page instead fails — a “view” page, a sign-in page, a share page. Two common share links are converted for you: Google Drive …/file/d/<id>/view and Dropbox …?dl=0. The file must still be shared publicly (“anyone with the link”).
  • Up to 5 redirects are followed.
What you get back. Every response carries resume — null when you did not send resumeUrl, otherwise: A pending download can still fail afterwards — the host refuses, the file is over 10 MB, it turns out to be a web page. That outcome is recorded on the lead for the recruiter and is not reported back to you, so test your links once by hand. Replacing and removing. Sending a new resumeUrl — on a PUT, or on a second application that is merged into the first (§7) — replaces the lead’s CV. On a PUT, "resumeUrl": null removes it and "jobPosition": null clears the position. Omitting either field leaves it alone. Nothing about the CV can cost you the lead. A bad link never fails the request: the lead is created exactly as it would have been without one.

5. Matching behaviour and intakeUnresolved

Fields marked lookup are matched case-insensitively (after trimming) against lists maintained inside the CRM. The intake is deliberately forgiving — a bad lookup value never loses you a lead:
  • Matched → the lead is linked to that record.
  • Not matched (or ambiguous) → the lead is still created (201), the field is left empty, and the original text you sent is echoed back in intakeUnresolved so it can be reviewed and corrected.
Use this in your integration: log intakeUnresolved whenever it is non-empty. A field that consistently appears there means your value doesn’t match Provident’s list — fix the value on your side or ask for the correct spelling. A lead whose source did not resolve loses its attribution. funnel (§4.2) is the one worth alerting on rather than merely logging. Every other unmatched lookup leaves a visibly empty field on the lead; an unmatched funnel instead files a perfectly complete lead onto the main sales pipeline, which looks exactly like a lead that never asked for a funnel at all. intakeUnresolved.funnel is the only signal that a whole business line has stopped receiving its leads. Quiz entries (§4.9) behave slightly differently from the rest: the answer is stored on the lead regardless, and a Provident admin can map the value once so every future submission of it resolves automatically. A quiz key appearing here repeatedly usually means a free-text question that would be better as a dropdown, or a numeric question where sending value would remove the guesswork.
A merged response (§7) carries no intakeUnresolved field at all — the enquiry was folded into an existing lead rather than creating one. Guard for its absence wherever you log this.

6. How a lead becomes a contact

You never send a contact id. The API resolves the contact for you:
  1. Each value in leadPhones is normalised and matched against existing contacts. First match wins.
  2. If no phone matches, each value in leadEmails is matched the same way.
  3. If nothing matches, a new contact is created from the name/company/phone/ email fields you sent.
Consequence: repeat enquiries from the same person attach to the same contact — which is exactly what the sales team wants. Send phone numbers in E.164 (+971501234567) so matching is reliable.
This is not the same question as lead merging (§7). Contact resolution asks “who is this?” and always runs; merging asks “is this the same enquiry?” and only applies inside the merge window. Two leads a month apart share one contact and stay two leads.

7. Duplicates, merging and retries — important

The endpoint is not idempotent at the transport level. There is no request id or Idempotency-Key header: a retried request can reach us twice, and metaLeadId does not protect you — it only stops the Meta metadata block being attached twice. What does protect you is intake hygiene, which runs before the lead is created and decides what a second enquiry from the same person actually is. Matching is on the normalised phone first, then the email — one more reason to send E.164. The window is a Provident-side setting (24 hours today). Treat it as “about a day”, not as a constant to hard-code. An enquiry that sends no funnel belongs to the main sales funnel, and merges only with leads there. What a merge does to the lead. Your payload is kept verbatim either way, and its details are also folded onto the lead, add-only: languages are added to the lead’s (English then Persian leaves the lead with both), new phones and emails are appended, and the customer’s own details — name parts, inquiry, budget, area, property type, bedrooms, quiz answers — fill only fields the lead has empty. Nothing the lead already has is overwritten, and attribution (source, sub-source, campaign, UTM, click and ad ids) always stays the first enquiry’s. To correct a lead, use PUT (§9). Two consequences you have to handle:
  1. id is not always a new lead. When merged is true the id you get back is one you already have. Don’t record it as a second submission, and don’t overwrite the original submission’s timestamps with this one’s.
  2. The merged body is short — id, merged, matchedOn and nothing else. No contactId, no intakeUnresolved, no needsIntakeReview, no createdAt (§8).
Portal exemption. Enquiries whose source is a property portal (Property Finder, Bayut, Dubizzle) are never merged: those are listing-driven, so the same person legitimately enquires about several properties held by different agents. A website or ad-form source is not exempt — if that is you, expect merges.

Retry policy

  • Never retry a request that returned any 2xx, 400, 401/403 or 422 — the lead was either already created (or merged) or will never be accepted.
  • Only retry on network timeouts, 429 and 5xx, with exponential backoff (e.g. 1s, 4s, 15s, max 3 attempts).
  • A timeout is ambiguous — the lead may have been created. Retry anyway: inside the merge window the retry folds into the first attempt instead of duplicating it, which is exactly the case this protects.
  • Store the returned lead id against your own submission record. It is the key Provident will use for any question about a specific lead.

8. Responses

Both outcomes below return 201. Branch on merged before reading anything else.

201 Created — a new lead

assignedTo — when you can rely on it

assignedTo is filled in the same transaction that creates the lead in exactly two cases, and is reliable in both:
  • you sent a referenceNo (or listingId) that resolved to a listing — the lead goes to that listing’s agent;
  • you sent assignedBy with an agent’s email.
For every other lead, an assignment engine chooses the agent, and it runs after this response is sent. assignedTo will be null and that means “not decided yet”, not “nobody”. Do not treat it as a failure and do not tell the customer anything about it. To find out who it landed on, either poll §10 or — better — take the lead.assigned webhook in §12 and be told. The agent object is the same shape everywhere it appears in this API: active: false is reachable and you must handle it. Routing never assigns to a deactivated account, but a lead can be assigned to someone who is deactivated afterwards — 1.4% of leads created in the last 90 days are in that state (read 2026-08-24). The agent is still returned rather than nulled, because who owns the lead is a fact worth recording. Do not message them and do not name them to a customer: that promises an introduction nobody will make. Treat it as you would assignedTo: null for messaging purposes, and keep the details for your own records. Personal phone numbers are never returned by any endpoint in this API. The response carries the whole lead record, well beyond the table above. Those extra fields are not part of this contract, may change at any time, and must not be used in your logic.

201 Created — merged into an existing lead

When intake hygiene folds the enquiry into a lead created inside the merge window (§7), the status is still 201 but the body is only these few fields:
Any comments[] you sent are written to the surviving lead — the conversation belongs on the lead the customer actually has. comments is null when you sent none. The same goes for a resumeUrl (§4.12): it is attached to the surviving lead, replacing any CV it had, and resume reports it exactly as on a new lead. contactId, intakeUnresolved, needsIntakeReview and createdAt are absent here. Code that reads them unconditionally breaks on the first repeat enquiry. assignedTo is usually populated on a merged response, and more reliably than on a new one: the lead already exists, so its owner is already known. A merged enquiry is a second customer message on a lead whose agent may have been notified once already — if you notify agents, this is who to notify, and you will also receive a lead.assigned webhook with trigger: "merged" (§12).

Error format

Every error uses the same envelope:
422 adds one more key, errors — an array naming each field that failed validation.

9. Updating a lead — details that arrive later

For the case where you do not have the whole enquiry at once: you send the name, email and phone the moment the customer appears, and the quiz answers, the budget and the area they actually want arrive minutes or hours later, once they have finished answering. Send only what changed. The field names, the lookups and the intakeUnresolved reporting are exactly those of POST /v2/public/leads (§4) — this is the same body, with three fields removed (below). Everything is optional, including phone and email: an update that carries nothing but a quiz block is normal and expected.

The five rules

  1. A field you omit is left alone. This is a PATCH in behaviour whichever verb you use; PUT does not blank the fields you left out.
  2. A field you send wins over what the lead already had, including a value an agent typed. You are the source; do not send a field you are not authoritative about.
  3. An explicit null clears the column. "budgetMax": null empties it; omitting budgetMax does not.
  4. A value that matches no CRM record leaves the column as it was and is reported in intakeUnresolved. A typo’d developer name never erases the developer already on the lead.
  5. Comments are added, never replaced. comments[] (§4.11) appends up to five comments per request to the lead’s thread, oldest first. Nothing you sent earlier is touched, so a long conversation can be relayed across consecutive calls and still read in order.

Phones and emails are added, never replaced

leadPhones and leadEmails are appended. A number or address the lead already has is ignored (matching is on the normalised form, so formatting differences do not duplicate); anything new is added. Nothing is ever deleted, and the primary number only changes when there was not one before. Re-sending the same phone on every update is therefore free.

Quiz answers replace, per quiz

Answers for a quizKey you send supersede whatever that quiz previously recorded on the lead, so re-sending the same submission is idempotent and a corrected answer replaces the wrong one. Quizzes you do not mention are untouched. A quiz answer mapped to a lead field (budget, area, bedrooms…) is promoted onto the lead under the same precedence as on create: a field you sent explicitly in the same request beats the answer.

What you cannot change

The lead’s workflow belongs to Provident and is not writable here, whatever you send: stage, status, the agent it is assigned to, the funnel board it sits on, and the aging clock. Three create-only fields are therefore absent from this body: Sending them anyway is not an error — they are ignored, and kept verbatim in the lead’s intake record.

Routing

New details do send a lead nobody holds back through the assignment engine — the answers you just sent (area, developer, lead type, budget, languages) are exactly what it matches on, so a lead that could not be placed when all we knew was a phone number gets a second chance. The response says so in rerouted, and the outcome is asynchronous: poll §10 or wait for the lead.assigned webhook. A lead an agent already holds is never taken off them by an update.

The agent is told

Every update that changes something posts a note on the lead’s own timeline naming your integration and what changed, so the agent working it sees that new details arrived rather than discovering them by chance. An update carrying only comments posts no such note — the comments are themselves what the agent reads, and a note announcing them would sit on top of them on every batch.

Request

Response — 200 OK

Errors

There is no 422 here: like POST /public/leads, this endpoint does not reject a body over a field it does not recognise or cannot match.

Notes worth reading once

  • Use the id from the create response — including a merged one, where the id is the existing lead you were folded into (§7). Updating it updates that lead, which is usually what you want and always what you asked for.
  • This is not a way to avoid the merge window. If the customer enquires again, POST it; a second POST inside the window is merged and keeps both enquiries. An update is for more detail about the same enquiry, not for a new one.
  • Order does not matter, but time does. Nothing stops a lead being updated after an agent has worked it for a week. Rule 2 still applies — you will overwrite what they changed, so only send fields you own.
  • Every update is recorded in Provident’s audit log with your client, the changed field list, and the verbatim body.

10. Who a lead was assigned to

{id} is the lead id from the create response — a new one or a merged one, both work. Why this exists. For most leads the agent is chosen by a routing engine that runs after POST /v2/public/leads has answered you. So a lead with no owner on the create response is normal, and the question you actually need answered is not “is there an agent” but “is there going to be one”. That is what status tells you.

Response — 200 OK

The four states — branch on status, not on agent

agent is non-null only when status is assigned. retryAfterSeconds is non-null only when status is pending.
unassigned and pool are common — roughly 30% of leads. They are not errors and not something to retry your way out of. Treating them as “still routing” leaves a customer waiting forever for an introduction that is not coming.

Polling

If you are not using the webhooks in §12, poll like this:
  • First check ~30 seconds after creating the lead. Routing is normally much faster, but there is no benefit to asking sooner.
  • While status is pending, wait retryAfterSeconds and ask again.
  • Stop at ~2 minutes. Past that, pending will have resolved to something final on its own; a lead still pending then is escalating and may take much longer.
  • assigned, unassigned and pool are all final answers — stop polling.
Assignment can change later (a manual reassignment, an escalation). If you care about that, take the lead.assigned webhook — polling will not tell you.

What it does not return

Only the agent’s work contacts and the lead’s status. Nothing about the customer, the enquiry, or the lead’s content. If you need those, you already have them — you sent them.

Errors


11. Recording the agent’s response time

How long the assigned agent took to respond to this lead, in whole seconds, measured from whatever you consider the customer’s first contact. Provident reports on this figure, so send the number you would defend to a manager.

Response — 200 OK

agentRespondedAt is set by Provident at the moment of the call, not by you. It is what distinguishes a genuine 47-second response from a figure backfilled three days later. Last write wins. Calling this twice for the same lead overwrites; it is safe to retry. Timing is not sensitive — minutes or hours after the fact is fine. A lead with no assignee is accepted, not rejected. A tracking link can be clicked by an agent who claimed the lead out of the pool, or by a previous owner, and the lead’s assignee at the moment you write is not necessarily who responded. Rejecting those writes would lose exactly the slowest responses — the ones the metric exists to surface — so the endpoint records the number regardless of status.

Errors


12. Webhooks — being told instead of asking

Provident can POST to your endpoint when something happens to a lead. This replaces polling §10 entirely and is the recommended integration. To switch it on, send Provident:
  1. a URL for each event you want (they can be the same URL);
  2. whether you want a shared secret, and what to call the header if not the default.

Authentication

Every call carries the shared secret in a header:
Compare it in constant time and reject anything else with 401. The secret is the only thing proving the call came from Provident — nothing in the body is authentication.

The two events

Nothing is sent when a lead ends up with no owner. “Nobody took it” is a state, not an event — ask §10 for it. If you are waiting for a lead.assigned that never comes, that is the answer, and it is why you should still have a timeout.
lead.created will include the leads you sent us, unless you ask Provident to exclude your API client — which you almost certainly want, or you will greet the same customer twice. Say so when you request the webhook.

lead.created

Everything in property is a display name, not an id — it is meant to go straight into a message a person reads. community is null for some leads (the location tree is uneven), which is why location and locationPath travel alongside it; fall back to those rather than leaving a hole in a sentence. agent may be null: this event fires when the lead is created, which is often before it has an owner. Wait for lead.assigned. lead.url and lead.pageUrl are not interchangeable. url opens the lead inside the Provident CRM and is for staff. pageUrl is the public page the enquiry came from — the listing or project page the customer was actually looking at — and is the link to put in a message to an agent. It is null when there was no public page (Meta lead-ads, leads created inside the CRM by staff); render that as “N/A” rather than falling back to url, which an agent may not be able to open.

lead.assigned

Same lead, customer, property and agent blocks, plus: agent is always populated on this event. merged is the one worth special-casing: it means a second customer message arrived on a lead whose agent you may already have notified once. The customer is not new; the enquiry is.

Your endpoint’s contract

  • Answer 2xx as soon as you have stored the event. Do the work afterwards. Provident waits 10 seconds and no longer.
  • 4xx is permanent. Anything other than 408 or 429 is taken as “understood and refused” and is never retried. Do not use 400 for a temporary problem.
  • 5xx, 408, 429 and timeouts are retried at 30s, 1m, 5m, 10m, 15m, 30m, then given up on. A partner outage of about half an hour loses nothing.
  • Be idempotent on deliveryId. A retry after your 2xx was lost in transit is indistinguishable from a first attempt, so at-least-once is the guarantee — not exactly-once.
  • Order is not guaranteed. A lead.assigned that was retried can arrive after a later reassigned for the same lead. occurredAt is stamped when the body was built and is what to compare.

13. Rate limits

Every authenticated request is counted per client, per UTC day. The response carries the current state: Exceeding it returns 429 Too Many Requests with a Retry-After header (seconds). The default quota is modest and is raised per client on request — tell Provident your expected daily lead volume before go-live so the limit is set correctly. Token requests count too, which is another reason to cache the token.

IP allowlisting (optional)

Provident can restrict a client to a set of source IPs / CIDR ranges. If that is enabled for you, requests from anywhere else get 403 Forbidden with the offending IP in the message. Send your production egress IPs when you request credentials.

14. Reference data endpoints (optional)

Alongside lead submission you can read Provident’s locations, developers and projects. Use them to populate dropdowns on your side, to send locationId / propertyTypeInterest values that match ours, or to show project content on your landing pages. All of them:
  • use the same bearer token as the lead endpoint — no extra credentials,
  • are GET and read-only,
  • count against the same rate limit, so cache the results (these lists change rarely — a daily refresh is plenty; do not call them per form submission).
The two paging styles are not interchangeable. Locations and developers take limit / offset; projects still take page / limit. An unrecognised query parameter is rejected, not ignored — sending ?page= to the developers endpoint returns 422 VALIDATION_ERROR (property page should not exist), not page 1.

14.1 Locations

Two ways in, for two different jobs. A type-ahead picker — search the whole community / area tree:
GET /v2/public/locations/{id} returns one node in the same shape, for re-displaying a location the visitor picked earlier. path is the full ancestry ("Ajman, Al Zorah, Marina") and is there because names are not unique — the tree has thousands of nodes and hundreds of repeated leaf names. Show path in your picker so the user can tell two “Marina”s apart.
Send the id as the lead’s locationId (§4.3). That is exact, whatever the name. Sending the name as location also works, but a name that matches more than one node is treated as unmatched — the lead is still created, the field is left empty and the text comes back in intakeUnresolved (§5). If your picker offers an area whose name is not unique, only the id will link it.
The bulk lookup lists — locations, developers, property types and statuses in one call, which is what you want for pre-populating static dropdowns:
Note the two lists answer different questions: the filters call returns only the locations that have projects attached, the search endpoint covers the whole tree. Validate propertyTypeInterest against the filters call. A locationId from either list is valid on a lead.

14.2 Developers

Changed 2026-08. This endpoint used to take page and return meta: { page, limit, total, totalPages }. It now takes limit / offset and returns meta: { limit, offset, total }. page is no longer accepted at all — sending it returns 422 VALIDATION_ERROR, it is not ignored. Update any integration written against the old shape.
GET /v2/public/developers/search is the same endpoint with relevance ranking applied; it takes exactly the same parameters and falls back to the plain list when no term is given.
Only enabled developers are returned. GET /v2/public/developers/{id} returns a single developer, or 404 if it does not exist or is disabled.

14.3 Projects

Only published projects are returned. Useful query parameters: The UUIDs for developerIds, locationIds, statusIds and propertyTypes all come from GET /v2/public/projects/filters.
GET /v2/public/projects/{id} returns one project (?simplified=false for the full record), 404 if it is not published. GET /v2/public/projects/map takes required latMin, latMax, lngMin, lngMax bounds plus the same filters and returns map pins.

14.4 Attaching this data to a lead

Developers map directly. Send the developer’s name (or slug) as developerName, or its UUID as developerId — see §4.5. Locations map the same way: send the location’s id as locationId, or its name as location — see §4.3. A location name shared by more than one node in the tree cannot be matched, so prefer the id; see §14.1. Property types map by name only (there is no id field on a lead): send the name from the filters call as propertyTypeInterest. Projects have no top-level field. Sending projectId at the top level is accepted but ignored (§4.10). Two ways to tie a lead to a project:
  • Via a quiz question (§4.9). Ask the project as a quiz question and have Provident map that question to the project field — the answer then lands on the lead as a real project link, with no change to this specification. This is the supported route.
  • Otherwise, put the project name in initialInquiry, campaignName or utmCampaign and it will at least be searchable as text.
Approved listings are also readable — GET /v2/public/listings and GET /v2/public/listings/filters — which is where the referenceNo and listingId values for a lead come from. Ask Provident if you need that flow.

15. Do Not Call Registry check (optional)

Before you call, SMS or WhatsApp a UAE mobile number for marketing, you can check it against the UAE Do Not Call Registry (DNCR). Provident runs the check against Etisalat’s registry and returns one answer per number.
  • Uses the same bearer token as everything else. No extra credentials.
  • At most 20 numbers per request. Sending 21 returns 400 DNCR_TOO_MANY_NUMBERS, and nothing is checked. To check more, split them into batches of 20.
  • UAE mobiles only. These forms are accepted: 05XXXXXXXX, 5XXXXXXXX, 9715XXXXXXXX, +9715XXXXXXXX and 009715XXXXXXXX. Spaces and dashes are ignored. Anything else, such as a landline or a foreign number, comes back as invalid and the rest of the request still runs.
  • Answers are cached for 180 days. A number checked recently is answered straight from the cache. Send "fresh": true (or ?fresh=true) only when you need the registry’s answer as of right now, for example just before a campaign goes out. Each checkedAt tells you how old an answer is.
  • Counts against the same rate limit as the other endpoints (§13).

Response — 200 OK

One row per number you sent, in the order you sent them. A number sent twice is checked once and comes back twice.

Statuses: branch on status

not_found does not mean you may contact the number. It means the registry could not answer for it. What you do with those numbers is your compliance decision. Do not treat them the same as not_registered just because onDncr is not true.

Errors


16. Phone screening (optional)

Before a campaign goes out, you can take two kinds of number off your list: real-estate brokers (agents at other agencies) and Provident’s own staff. There is one endpoint for each, and each is enabled on your credentials separately. If you were given only one, the other answers 403 OAUTH_CLIENT_GRANT_MISSING (see Credentials). Both take the same request:
  • Uses the same bearer token as everything else.
  • At most 1000 numbers per request. Sending more returns 400 PHONE_SCREENING_TOO_MANY_NUMBERS and nothing is checked. Split larger lists into batches of 1000.
  • Any country. Send each number as a string. These are all the same number: +971501234567, 00971501234567, 971501234567, 0501234567, 501234567 and +971 50 123 4567. Spaces, dashes and brackets are ignored. A number with no country code and no leading 0 is read as already including its country code, except a 9-digit number starting with 5, which is read as a UAE mobile.
  • A number that cannot be read as a phone number comes back as invalid. The rest of the request still runs.
  • Counts against the same rate limit as the other endpoints (§13).

Response — 200 OK

One row per number you sent, in the order you sent them. phone is the number in international form (+ and digits), which is what was matched.

Brokers

  • matches lists up to 20 broker records holding the number. matchCount is the full count. An agency’s office line can belong to hundreds of brokers, so it can be larger than matches.
  • source says which register the record came from: dld is the Dubai Land Department broker register, and bb is a second broker list. brokerNameAr and licenseEndDate exist only on dld records, and companyName only on bb records. Any field can be null.
  • The broker register is a copy taken at a point in time, not a live feed. Some of its numbers are known to be wrong, and some are agency office lines rather than a broker’s own mobile.

Employees

  • The check covers employees’ work and personal numbers, and it is live: a new joiner matches as soon as they are added.
  • Former employees also match, with currentEmployee: false. Remove every match from a client list, not just the ones with currentEmployee: true.
  • employees is always null on this endpoint. It tells you whether a number is staff, never who.

All three in one call

This is the DNCR check, the broker check and the staff check on the same numbers. Your credentials must be enabled for this endpoint specifically; access to the three separate endpoints does not include it, and access to this one does not include them. Without it the request is 403 OAUTH_CLIENT_GRANT_MISSING.
  • At most 20 numbers, the DNCR limit. More than that is 400 DNCR_TOO_MANY_NUMBERS and nothing is checked. fresh (or ?fresh=true) means the same thing it means on the DNCR endpoint: skip the cache and ask the registry again.
  • A number that is not a UAE mobile is invalid on dncr and is still screened for agents.
  • ourAgent is a Provident employee number, work or personal, current or former. It says whether, never who. Remove every match, not only currentEmployee: true.
  • possibleAgent.status: "match" means the number is on the broker register (another agency).
The three separate endpoints are unchanged. Use this one when you want all three answers together; use those when your integration is enabled for them, or when you are screening more than 20 numbers for agents and do not need DNCR. If the registry cannot be reached (502) or DNCR is not configured (503), nothing in the request is answered — the agent checks are not returned on their own.

Statuses: branch on status

Errors


17. WhatsApp number check (optional)

Find out whether a number has a WhatsApp account before you message it. Enabled on your credentials separately; without it, this endpoint answers 403 OAUTH_CLIENT_GRANT_MISSING (see Credentials).
  • Uses the same bearer token as everything else. No user token is involved.
  • At most 50 numbers per request. Sending more returns 400 WHATSAPP_CHECK_TOO_MANY_NUMBERS and nothing is checked. Split larger lists into batches of 50.
  • Any country. Send each number as a string, in international form where you can. The same spellings are accepted as for phone screening (§16), and duplicates are checked once.
  • Answers are kept for 30 days. A number checked in that time is answered from the store. Send "fresh": true to ask again for every number in the request. Use it sparingly: each new answer is billed to Provident.
  • Counts against the same rate limit as the other endpoints (§13).

Response — 200 OK

One row per number you sent, in the order you sent them. e164 is the number in international form, which is what was checked.

Statuses: branch on status

A number that cannot be answered never fails the request: it gets its own status and the rest are answered.

Errors


18. Reference implementations

Node.js (18+, built-in fetch)

Python

PHP (cURL)


19. Go-live checklist

  • Integration built and tested against https://devapi.prov.ae/v2.
  • Credentials stored server-side only (env vars / secrets manager), never in client-side code or version control.
  • Access token cached in memory and reused until ~60s before expiry.
  • 401 triggers exactly one token refresh + retry; no retry loops.
  • Retries only on timeout / 429 / 5xx, with exponential backoff.
  • Returned lead id stored against your own submission record.
  • merged checked on every 201 before reading contactId, intakeUnresolved, needsIntakeReview or createdAt — a merged response has none of them (§7, §8).
  • A merged id recognised as an existing lead, not recorded as a new one.
  • assignedTo: null on a 201 understood as “not decided yet”, not “nobody” — the routing engine runs after the response is sent (§8).
  • If you notify agents: either the lead.assigned webhook is wired up (§12) or you poll GET /leads/{id}/assignment (§10) — and in either case you branch on all four status values, with unassigned and pool handled as final answers rather than retried.
  • If you take webhooks: your endpoint verifies X-Provident-Webhook-Secret, answers 2xx within 10 seconds before doing any work, and is idempotent on deliveryId.
  • If you take lead.created: you asked Provident to exclude your own API client, so the leads you submit are not sent back to you.
  • intakeUnresolved logged and reviewed — no field appearing there routinely.
  • Phone numbers sent in E.164 format (+9715…).
  • source, subSource, eventType, marketingType values confirmed against Provident’s accepted lists.
  • Reference-data calls (§14) use the right paging for each endpoint — limit/offset for locations and developers, page/limit for projects. An unknown query parameter is rejected with 422, not ignored.
  • If you send quiz (§4.9): every question has a stable questionKey and every dropdown answer a stable optionKey, identical across languages and unchanged when the wording is edited. Confirm this before launch — retrofitting keys after leads have arrived means merging the duplicates by hand.
  • If you send quiz: numeric and date answers send a machine value where you have one, rather than relying on us parsing the label.
  • If you use the DNCR check (§15): batches of at most 20 numbers, fresh only when you need it, and a deliberate decision about not_found numbers, which are not a clearance.
  • If you use phone screening (§16): batches of at most 1000 numbers, and every match removed from the employee check, including former staff (currentEmployee: false).
  • If you use WhatsApp checks (§17): batches of at most 50 numbers, and unknown not treated as a no.
  • Expected daily volume communicated so the rate limit is sized correctly.
  • Production egress IPs shared (if IP allowlisting will be enabled).
  • Production credentials issued and base URL switched to https://prodapi.prov.ae/v2.

20. Support

Send Provident the following when reporting a problem:
  • Environment (dev / prod) and full request URL
  • Timestamp (UTC) of the request
  • The code, message and path from the error response
  • The lead id if one was returned
  • The request body with personal data redacted
A machine-readable specification of both endpoints is supplied alongside this document as provident-lead-intake-openapi.json (OpenAPI 3.0.3) — import it into Postman, Insomnia, or a client generator.