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-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/leadsincluding 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).

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 winsassignedBy 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.

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. 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 shortid, 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 three fields:
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 four 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.

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.

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 areaOfInterest / 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 name, not the id. The lead payload has no location-id field: areaOfInterest (§4.3) is matched by name. And 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, expect that field not to link.
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, and areaOfInterest against whichever of the two you populated your form from.

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 and property types map by name only (there is no id field for either on a lead): send the name from the filters call — or, for locations, from GET /v2/public/locations/search — as areaOfInterest and propertyTypeInterest. A location name shared by more than one node in the tree cannot be matched; see §14.1. 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. Reference implementations

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

Python

PHP (cURL)


16. 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.
  • 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.

17. 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.