> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prov.ae/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a CRM lead

> Creates a lead in the Provident CRM and attaches it to a contact.

**Only hard requirement:** at least one entry in `leadPhones` or `leadEmails`.

**Contact resolution** — an existing contact is matched by phone first, then by email; if nothing matches, a new contact is created from the supplied name/company/phone/email fields. Send phone numbers in E.164 (`+971501234567`) for reliable matching.

**Lookup fields** (`leadType`, `funnel`, `source`, `subSource`, `eventType`, `marketingType`, `campaignName`, `advertisingCampaign`, `website`, `currency`, `localeCountry`, `areaOfInterest`, `propertyTypeInterest`, `marketSegment`, `agentCategory`, `languages`, `assignedBy`, `distributionType`, `listingId`/`referenceNo`) are matched case-insensitively after trimming against lists configured in the CRM. An unmatched value never fails the request — the lead is created and the original text is returned in `intakeUnresolved`.

**Which pipeline the lead lands in** is `funnel`. Omit it and the lead goes to the main sales pipeline, which is where every lead posted to this API went before the field existed; send `Leasing`, `Show Room`, `PvH`, … for an enquiry that belongs to another business line.

**Extra fields** not defined in this schema are accepted and stored verbatim with the lead rather than rejected.

**Not idempotent:** the same payload posted twice creates two leads.

**Quiz answers** — landing pages that ask a question set can send a `quiz` object. Its questions and options register themselves on first sight, so a new quiz needs no API change. Send a stable `questionKey` per question and `optionKey` per dropdown answer: those are the only language-independent identity an answer has, and without them each language registers as a separate question. Free-text numbers are parsed best-effort (`2 million`, `2,000,000`, `AED 2M`, `٢ مليون` → 2000000); ambiguous dates like `03/04/2026` are deliberately refused rather than guessed. Anything unreadable is kept verbatim and flagged, never dropped.



## OpenAPI

````yaml /api-reference/openapi.json post /public/leads
openapi: 3.0.3
info:
  title: Provident — Lead Intake API
  version: 1.4.0
  description: >-
    Server-to-server API for pushing leads into the Provident CRM.


    Core endpoints:

    1. `POST /oauth2/token` — exchange client credentials for a bearer token.

    2. `POST /public/leads` — create a CRM lead.

    3. `PUT /public/leads/{id}` — add details you did not have when you created
    it (quiz answers the customer finished afterwards, a budget, an area). Send
    only what changed; phones and emails are appended, never replaced, and the
    lead's stage, status, owner and funnel are not writable.


    Optional read-only reference data, using the same token: locations,
    developers, projects and project statuses. Use them to send values that
    match ours, or to show project content on your own pages. They share the
    lead endpoint's rate limit, so cache them.


    Authentication is OAuth 2.0 Client Credentials; there is no user login. Note
    that the `/v2` prefix is part of the server URL and is mandatory.


    The intake is deliberately forgiving: name-based lookup fields that cannot
    be matched do NOT fail the request — the lead is still created and the
    unmatched values are returned in `intakeUnresolved`.


    The endpoint is not idempotent at the transport level, but intake hygiene
    runs before the lead is created: a second enquiry from the same phone/email
    within the merge window (~24h) is MERGED into the existing lead and returns
    a different, much shorter 201 body — `{ id, merged: true, matchedOn }` —
    with no contactId, intakeUnresolved, needsIntakeReview or createdAt. Always
    branch on `merged` before reading anything else. Enquiries from
    property-portal sources (Property Finder, Bayut, Dubizzle) are exempt and
    never merge. Retry only on network timeouts, 429 and 5xx.
  contact:
    name: Provident API support
servers:
  - url: https://devapi.prov.ae/v2
    description: Staging / development — build and test here
  - url: https://prodapi.prov.ae/v2
    description: Production — live leads
security: []
tags:
  - name: Authentication
    description: OAuth 2.0 client-credentials token issuance
  - name: Lead intake
    description: Public lead creation
  - name: Reference data
    description: Read-only lookups — locations, developers, projects, project statuses
  - name: Lead follow-up
    description: >-
      Reading back who a lead was assigned to, and recording the agent response
      time
  - name: Webhooks
    description: >-
      Calls Provident makes to YOUR server. Documented here for the payload
      shape; these are not endpoints you call.
paths:
  /public/leads:
    post:
      tags:
        - Lead intake
      summary: Create a CRM lead
      description: >-
        Creates a lead in the Provident CRM and attaches it to a contact.


        **Only hard requirement:** at least one entry in `leadPhones` or
        `leadEmails`.


        **Contact resolution** — an existing contact is matched by phone first,
        then by email; if nothing matches, a new contact is created from the
        supplied name/company/phone/email fields. Send phone numbers in E.164
        (`+971501234567`) for reliable matching.


        **Lookup fields** (`leadType`, `funnel`, `source`, `subSource`,
        `eventType`, `marketingType`, `campaignName`, `advertisingCampaign`,
        `website`, `currency`, `localeCountry`, `areaOfInterest`,
        `propertyTypeInterest`, `marketSegment`, `agentCategory`, `languages`,
        `assignedBy`, `distributionType`, `listingId`/`referenceNo`) are matched
        case-insensitively after trimming against lists configured in the CRM.
        An unmatched value never fails the request — the lead is created and the
        original text is returned in `intakeUnresolved`.


        **Which pipeline the lead lands in** is `funnel`. Omit it and the lead
        goes to the main sales pipeline, which is where every lead posted to
        this API went before the field existed; send `Leasing`, `Show Room`,
        `PvH`, … for an enquiry that belongs to another business line.


        **Extra fields** not defined in this schema are accepted and stored
        verbatim with the lead rather than rejected.


        **Not idempotent:** the same payload posted twice creates two leads.


        **Quiz answers** — landing pages that ask a question set can send a
        `quiz` object. Its questions and options register themselves on first
        sight, so a new quiz needs no API change. Send a stable `questionKey`
        per question and `optionKey` per dropdown answer: those are the only
        language-independent identity an answer has, and without them each
        language registers as a separate question. Free-text numbers are parsed
        best-effort (`2 million`, `2,000,000`, `AED 2M`, `٢ مليون` → 2000000);
        ambiguous dates like `03/04/2026` are deliberately refused rather than
        guessed. Anything unreadable is kept verbatim and flagged, never
        dropped.
      operationId: createPublicLead
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateLeadRequest'
            examples:
              minimal:
                summary: Minimum valid request
                value:
                  leadPhones:
                    - '+971501234567'
                  leadFirstName: John
                  leadLastName: Doe
              websiteForm:
                summary: Typical website / paid-search form submission
                value:
                  leadPhones:
                    - '+971501234567'
                  leadEmails:
                    - john.doe@example.com
                  leadFirstName: John
                  leadLastName: Doe
                  leadType: Primary
                  source: Website
                  subSource:
                    - Callback Form
                  eventType: Submit Form
                  marketingType: Paid
                  website: provident.ae
                  campaignName: Spring 2026 Landing Page
                  developerName: Sobha Realty
                  formName: contact-popup-form
                  areaOfInterest: Dubai Marina
                  propertyTypeInterest: Apartment
                  budgetMin: '1000000'
                  budgetMax: '2500000'
                  currency: AED
                  initialInquiry: Looking for a 2BR with sea view, ready Q4.
                  languages:
                    - English
                    - ar
                  localeCountry: AE
                  pageUrl: https://prov.ae/en/dubai-marina
                  pathLocale: en
                  gclid: Cj0KCQ...
                  utmSource: google
                  utmMedium: cpc
                  utmCampaign: spring-2026
                  utmTerm: dubai marina apartments
                  utmContent: hero-cta
                  integrationRef: make:meta-leadgen-eu
              allFields:
                summary: Every supported field — send as much of this as you have
                description: >-
                  The complete surface of the endpoint. Nothing is required
                  except one phone or email; omit fields you do not have rather
                  than sending empty strings or nulls.
                value:
                  leadPhones:
                    - '+971501234567'
                    - '+971559876543'
                  leadEmails:
                    - john.doe@example.com
                    - j.doe@work.example.com
                  leadFirstName: John
                  leadMiddleName: Ahmad
                  leadSecondName: A.
                  leadLastName: Doe
                  leadCompanyName: Acme Real Estate
                  leadType: Primary
                  priority: High
                  marketSegment: Luxury
                  agentCategory: A
                  areaOfInterest: Dubai Marina
                  propertyTypeInterest: Apartment
                  budgetMin: '1000000'
                  budgetMax: '2500000'
                  currency: AED
                  initialInquiry: Looking for a 2BR with sea view, ready to move in Q4.
                  languages:
                    - English
                    - ar
                  listingId: 8f3c1e2a-1122-3344-5566-778899aabbcc
                  referenceNo: PR-123456
                  source: Website
                  subSource:
                    - Callback Form
                    - Team Page
                  marketingType: Paid
                  eventType: Submit Form
                  campaignName: Spring 2026 Landing Page
                  advertisingCampaign: Google Ads — Q3
                  developerName: Sobha Realty
                  website: provident.ae
                  pageUrl: >-
                    https://prov.ae/en/dubai-marina?utm_source=google&utm_medium=cpc
                  pathLocale: en
                  gclid: Cj0KCQjw1234567890
                  fbclid: IwAR2abcdefghijklmno
                  utmSource: google
                  utmMedium: cpc
                  utmCampaign: spring-2026
                  utmTerm: dubai marina apartments
                  utmContent: hero-cta
                  formName: contact-popup-form
                  adsetName: test ww
                  referrer: https://sobha-city.provident.ae/?utm_source=Google+Ads
                  ipAddress: 217.165.113.16
                  userAgent: >-
                    Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
                    AppleWebKit/537.36
                  submittedAt: '2026-07-27T13:45:31.728Z'
                  localeCountry: AE
                  assignedBy: agent@providentestate.com
                  metaFacebook:
                    metaLeadId: '7636865386872307975'
                    metaCreatedAt: '2026-05-06T19:38:09.000Z'
                    platform: facebook
                    adId: '120000000000'
                    adName: Marina Launch — Video
                    adType: Video
                    adgroupId: '930000000000'
                    adgroupName: TARGETED
                    metaCampaignId: '230000000000'
                    metaCampaignName: Marina Q2
                    formId: '550000000000'
                    formName: Marina Callback
                    adAccountId: act_123456789
                    businessAccountId: '456789123'
                    sourceAction: Form
                    finalPageName: Thank You
                    customFields:
                      هل ترغب في حضور هذا المعرض الحصري؟: نعم
                      Preferred call time: Evening
                      Ready to buy: Within 3 months
                  quiz:
                    quizKey: off-plan-investor-2026
                    quizName: Off-plan investor quiz
                    locale: ar
                    version: '3'
                    answers:
                      - questionKey: budget
                        questionLabel: ما هي ميزانيتك؟
                        optionKey: 2m-5m
                        answerLabel: ٢-٥ مليون
                        value: 2000000
                        currency: AED
                      - questionKey: purchase_date
                        questionLabel: متى ترغب في الشراء؟
                        value: '2026-12-01'
                      - questionKey: interests
                        questionLabel: ما الذي يهمك؟
                        optionKey:
                          - villa
                          - townhouse
                        answerLabel:
                          - فيلا
                          - تاون هاوس
                  integrationRef: make:meta-leadgen-eu
              metaLeadAd:
                summary: Meta / Facebook lead-ad submission
                value:
                  leadPhones:
                    - '+971559876543'
                  leadEmails:
                    - sara@example.com
                  leadFirstName: Sara
                  leadLastName: Ahmad
                  leadType: Primary
                  source: Facebook
                  eventType: Submit Form
                  marketingType: Paid
                  campaignName: Marina Q2
                  marketSegment: Luxury
                  agentCategory: A
                  languages:
                    - ar
                  metaFacebook:
                    metaLeadId: '7636865386872307975'
                    metaCreatedAt: '2026-05-06T19:38:09.000Z'
                    platform: facebook
                    adId: '120000000000'
                    adName: Marina Launch — Video
                    adType: Video
                    adgroupId: '930000000000'
                    adgroupName: TARGETED
                    metaCampaignId: '230000000000'
                    metaCampaignName: Marina Q2
                    formId: '550000000000'
                    formName: Marina Callback
                    adAccountId: act_123456789
                    businessAccountId: '456789123'
                    sourceAction: Form
                    finalPageName: Thank You
                    customFields:
                      هل ترغب في حضور هذا المعرض الحصري؟: نعم
                      Preferred call time: Evening
                  integrationRef: make:meta-leadgen-eu
              quizSubmission:
                summary: Landing page with a dynamic quiz (Arabic)
                description: >-
                  A quiz answered in Arabic. The option keys are identical to
                  the English version of the same quiz, so both resolve to the
                  same answer in reporting.
                value:
                  leadPhones:
                    - '+971501234567'
                  leadFirstName: John
                  source: Website
                  website: provident.ae
                  quiz:
                    quizKey: off-plan-investor-2026
                    quizName: Off-plan investor quiz
                    locale: ar
                    version: '3'
                    answers:
                      - questionKey: budget
                        questionLabel: ما هي ميزانيتك؟
                        optionKey: 2m-5m
                        answerLabel: ٢-٥ مليون
                        value: 2000000
                        currency: AED
                      - questionKey: purchase_date
                        questionLabel: متى ترغب في الشراء؟
                        value: '2026-12-01'
                      - questionKey: interests
                        questionLabel: ما الذي يهمك؟
                        optionKey:
                          - villa
                          - townhouse
                        answerLabel:
                          - فيلا
                          - تاون هاوس
                  integrationRef: make:meta-leadgen-eu
      responses:
        '201':
          description: >-
            Accepted. EITHER a new lead (CreatedLead) OR an enquiry merged into
            an existing one (MergedLead) — branch on `merged` before reading any
            other field. A CreatedLead body carries many further fields that are
            not part of this contract.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CreatedLead'
                  - $ref: '#/components/schemas/MergedLead'
              examples:
                createdLead:
                  summary: A new lead
                  value:
                    id: a1b2c3d4-5566-7788-99aa-bbccddeeff00
                    merged: false
                    contactId: 0f1e2d3c-4455-6677-8899-aabbccddeeff
                    intakeUnresolved:
                      advertisingCampaign: Google Ads — Q3
                    needsIntakeReview: true
                    isRepeatLead: false
                    repeatOfLeadId: null
                    isAgentEnquiry: false
                    agentEnquiryReason: null
                    createdAt: '2026-07-27T06:31:04.512Z'
                    assignedTo: null
                repeatLead:
                  summary: A new lead, outside the merge window, marked as a repeat
                  value:
                    id: b2c3d4e5-6677-8899-aabb-ccddeeff0011
                    merged: false
                    contactId: 0f1e2d3c-4455-6677-8899-aabbccddeeff
                    intakeUnresolved: null
                    needsIntakeReview: false
                    isRepeatLead: true
                    repeatOfLeadId: a1b2c3d4-5566-7788-99aa-bbccddeeff00
                    isAgentEnquiry: false
                    agentEnquiryReason: null
                    createdAt: '2026-07-29T09:02:11.004Z'
                mergedEnquiry:
                  summary: Merged into an existing lead — note the missing fields
                  value:
                    id: a1b2c3d4-5566-7788-99aa-bbccddeeff00
                    merged: true
                    matchedOn: phone
        '400':
          description: >-
            Neither a phone nor an email was supplied, or the body was not valid
            JSON. Do not retry unchanged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              examples:
                noContactPoint:
                  summary: No phone and no email
                  value:
                    statusCode: 400
                    code: BAD_REQUEST
                    message: Provide at least one value in leadPhones or leadEmails
                    path: /v2/public/leads
                    timestamp: '2026-07-27T06:31:04.512Z'
                invalidJson:
                  summary: Malformed JSON body
                  value:
                    statusCode: 400
                    code: INVALID_JSON
                    message: Invalid JSON payload
                    path: /v2/public/leads
                    timestamp: '2026-07-27T06:31:04.512Z'
        '401':
          description: >-
            Missing, invalid, revoked or expired bearer token. Fetch a new token
            and retry once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              examples:
                missing:
                  summary: No Authorization header
                  value:
                    statusCode: 401
                    code: OAUTH_TOKEN_MISSING
                    message: OAuth token missing
                    path: /v2/public/leads
                    timestamp: '2026-07-27T06:28:41.816Z'
                expired:
                  summary: Token past expiry
                  value:
                    statusCode: 401
                    code: OAUTH_TOKEN_EXPIRED
                    message: OAuth access token expired
                    path: /v2/public/leads
                    timestamp: '2026-07-27T06:28:41.816Z'
        '403':
          description: >-
            The caller's IP address is not on the allowlist configured for this
            client.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              example:
                statusCode: 403
                code: FORBIDDEN
                message: >-
                  Access denied. Your IP address (203.0.113.10) is not
                  permitted.
                path: /v2/public/leads
                timestamp: '2026-07-27T06:31:04.512Z'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: Server-side failure. Retry with exponential backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              example:
                statusCode: 500
                code: INTERNAL_ERROR
                message: Internal server error
                path: /v2/public/leads
                timestamp: '2026-07-27T06:31:04.512Z'
      security:
        - bearerAuth: []
components:
  schemas:
    CreateLeadRequest:
      type: object
      description: >-
        Lead intake payload. At least one entry in `leadPhones` or `leadEmails`
        is required; every other field is optional. Unknown fields are accepted
        and stored verbatim with the lead.
      properties:
        leadPhones:
          type: array
          items:
            type: string
            maxLength: 64
          description: >-
            Phone numbers. Used FIRST to match an existing contact. Use E.164
            format for reliable matching. Required unless `leadEmails` is
            supplied.
          example:
            - '+971501234567'
        leadEmails:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            Email addresses. Used to match an existing contact when no phone
            matches. Required unless `leadPhones` is supplied.
          example:
            - john.doe@example.com
        leadFirstName:
          type: string
          maxLength: 255
          nullable: true
          example: John
        leadMiddleName:
          type: string
          maxLength: 255
          nullable: true
          example: Ahmad
        leadSecondName:
          type: string
          maxLength: 255
          nullable: true
          description: Second given name — used when no middle name is supplied.
          example: A.
        leadLastName:
          type: string
          maxLength: 255
          nullable: true
          example: Doe
        leadCompanyName:
          type: string
          maxLength: 255
          nullable: true
          description: Company name for corporate enquiries.
          example: Acme Real Estate
        leadType:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. Lead type display name, e.g. `Primary Buyer`, `Secondary
            Buyer`, `Primary Buyer and Secondary Buyer`, `Tenant`, `Landlord`,
            `Broker`, `Seller`, `Owner`, `Mortgages`. Ask Provident for the full
            list. The shorthand `Primary` / `Secondary` / `Primary and
            Secondary` is accepted as an alias for the corresponding `… Buyer`
            type, in either direction. Omit this on a listing enquiry and it is
            derived from the listing — see `listingId`.
          example: Primary Buyer
        funnel:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. Which CRM funnel (pipeline) the enquiry belongs to. OMIT IT
            for the main sales pipeline — that is the default and what almost
            every lead wants. Send a value only for an enquiry that belongs to a
            different business line: `Leasing`, `Show Room`, `PvH`, `Mortgages`,
            `Property Management`, `Landlord/Sellers Listings`, `Provident the
            Agency`, `Events`, `Recruitment`, `Precision Inspections`, `811
            services`, `Prism`. The display name or the stable key both work
            (`Show Room` and `show_room` are the same funnel), matched
            case-insensitively and ignoring spaces, hyphens and underscores.
            Deliberately NOT derived from `leadType`: a Tenant enquiry can
            belong to Leasing or PvH and a Primary Buyer to sales, Show Room or
            the Agency — only the sender knows which. An unrecognised funnel
            does not fail the request: the lead is created on the main sales
            pipeline and the value is returned in `intakeUnresolved.funnel`.
          example: Leasing
        priority:
          type: string
          maxLength: 64
          nullable: true
          description: Free text priority indicator.
          example: High
        marketSegment:
          type: string
          maxLength: 255
          description: LOOKUP. Marketing's segmentation of the lead.
          enum:
            - Standard
            - Luxury
            - Super Luxury
          example: Luxury
        agentCategory:
          type: string
          maxLength: 255
          description: LOOKUP. Marketing's grading of the lead.
          enum:
            - A
            - B
            - C
            - D
          example: A
        areaOfInterest:
          type: string
          nullable: true
          description: LOOKUP. Location the lead is interested in.
          example: Dubai Marina
        propertyTypeInterest:
          type: string
          maxLength: 255
          nullable: true
          description: >-
            LOOKUP. Property type the lead is interested in. Matched
            case-insensitively after trimming, and a regular English plural also
            matches the singular catalogue entry (`Apartments` → `Apartment`,
            `Townhouses` → `Townhouse`), so a form offering plural choices needs
            no mapping on your side.
          example: Apartment
        budgetMin:
          type: string
          nullable: true
          description: Minimum budget. Numeric string; a JSON number is also accepted.
          example: '1000000'
        budgetMax:
          type: string
          nullable: true
          description: Maximum budget. Numeric string; a JSON number is also accepted.
          example: '2500000'
        currency:
          type: string
          maxLength: 3
          description: LOOKUP. ISO 4217 currency code for the budget values.
          example: AED
        initialInquiry:
          type: string
          nullable: true
          description: Free-text message or callback reason from the form.
          example: Looking for a 2BR with sea view, ready to move in Q4.
        languages:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            LOOKUP. Languages the lead speaks, by code or name. Unrecognised
            entries are ignored.
          example:
            - ar
            - English
        listingId:
          type: string
          format: uuid
          description: >-
            LOOKUP. Provident listing UUID. Takes precedence over `referenceNo`
            when both are sent.


            A resolved listing also fills in what the form did not ask. Each
            field below is taken from the listing ONLY where this payload leaves
            it empty — anything you send always wins, and a quiz answer mapped
            to the same field beats the listing too:

            - `leadType` — from the listing category and offering type: Primary
            -> Primary Buyer, Secondary -> Secondary Buyer, and any rental
            listing -> Tenant whatever its category.

            - developer, location, property type, bedrooms, currency — copied
            from the listing. The location fills the lead's own location and
            does not overwrite `areaOfInterest`.

            - `budgetMin` — the listing price, unless the price is on
            application or it would exceed a `budgetMax` you sent. `budgetMax`
            is never derived.


            Sending a value we then fail to match (an unknown developer name,
            say) is still reported in `intakeUnresolved` even though the listing
            goes on to fill that field.


            The listing's own agent, where it has one, becomes the lead owner
            and wins over `assignedBy`.
        referenceNo:
          type: string
          maxLength: 128
          description: >-
            LOOKUP. Listing reference number, used when `listingId` is not
            supplied. A reference number matching MORE THAN ONE listing counts
            as unresolved, and nothing is inherited. See `listingId` for what a
            resolved listing fills in.
          example: PR-123456
        source:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. Lead source. Send this — an unrecognised or omitted source
            leaves the lead with no source attribution.
          example: Website
        subSource:
          type: array
          items:
            type: string
            maxLength: 255
          description: >-
            LOOKUP. Sub-sources. Only those linked to the resolved `source` are
            kept; ignored entirely when `source` did not resolve.
          example:
            - Callback Form
            - Team Page
        marketingType:
          type: string
          maxLength: 255
          description: LOOKUP. Marketing type, e.g. Organic or Paid.
          example: Organic
        eventType:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. What the user did — e.g. Submit Form, Call, Whatsapp Click,
            DM, Webpush, Gamification.
          example: Submit Form
        campaignName:
          type: string
          maxLength: 255
          description: LOOKUP. CRM campaign name.
          example: Spring 2026 Landing Page
        advertisingCampaign:
          type: string
          maxLength: 255
          description: >-
            CREATED ON FIRST SIGHT. Advertising campaign name — the one field
            here that is not a strict lookup: a name Provident has never seen
            creates the campaign instead of being discarded. Omit it when you
            send `metaFacebook`; the Meta campaign fills it.
          example: Google Ads — Q3
        developerName:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. Developer the enquiry is about, matched on name or slug —
            `Sobha Realty` or `sobha-realty`. Values come from `GET
            /public/developers`.
          example: Sobha Realty
        developerId:
          type: string
          description: >-
            Developer uuid from `GET /public/developers`. Takes precedence over
            `developerName`; a non-uuid value here is treated as a name instead
            of being discarded.
          example: 8ecf4fcd-ec63-5764-8524-7cd917494dbe
        website:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. Website NAME or DOMAIN the lead came from — either resolves,
            matched case-insensitively. Prefer the domain (`provident.ae`,
            `providentestate.com`); it survives a display-name change. Ask
            Provident to add your domain before you start sending it.
          example: provident.ae
        pageUrl:
          type: string
          nullable: true
          description: Full URL of the page the submission originated from.
          example: https://prov.ae/en/dubai-marina?utm_source=google
        pathLocale:
          type: string
          maxLength: 32
          nullable: true
          description: Locale segment of the URL path, e.g. `en`.
          example: en
        gclid:
          type: string
          maxLength: 512
          nullable: true
          description: Google Ads click id.
        fbclid:
          type: string
          maxLength: 512
          nullable: true
          description: Facebook click id.
        utmSource:
          type: string
          maxLength: 512
          nullable: true
          example: google
        utmMedium:
          type: string
          maxLength: 512
          nullable: true
          example: cpc
        utmCampaign:
          type: string
          maxLength: 512
          nullable: true
          example: spring-2026
        utmTerm:
          type: string
          maxLength: 512
          nullable: true
          example: dubai marina apartments
        utmContent:
          type: string
          maxLength: 512
          nullable: true
          example: hero-cta
        formName:
          type: string
          maxLength: 255
          nullable: true
          description: >-
            Name of the web form the visitor submitted. Falls back to
            `metaFacebook.formName` (truncated to 255) when omitted.
          example: contact-popup-form
        adsetName:
          type: string
          maxLength: 255
          nullable: true
          description: >-
            Ad set / ad group name on your side. Free text — distinct from the
            `campaignName` lookup. Falls back to `metaFacebook.adgroupName`
            (truncated to 255) when omitted.
          example: test ww
        referrer:
          type: string
          nullable: true
          description: Referring URL the visitor arrived from.
          example: https://sobha-city.provident.ae/?utm_source=Google+Ads
        ipAddress:
          type: string
          maxLength: 64
          nullable: true
          description: Submitter IP. IPv4, IPv6 and proxy chains all fit.
          example: 217.165.113.16
        userAgent:
          type: string
          nullable: true
          description: Browser user-agent string captured at submission.
          example: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
        submittedAt:
          type: string
          format: date-time
          nullable: true
          description: >-
            When the visitor submitted the form on your side. Distinct from the
            CRM's own createdAt, which is when we received it. An unparseable
            value is ignored rather than failing the request.
          example: '2026-07-27T13:45:31.728Z'
        localeCountry:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. Country code (e.g. AE) or full country name. When omitted,
            the API attempts to infer it from a trailing two-letter segment of
            `pageUrl`.
          example: AE
        assignedBy:
          type: string
          maxLength: 255
          description: >-
            LOOKUP. Assign the lead to a specific Provident agent by email
            address. Omit to let Provident's automatic assignment engine route
            the lead. When the lead also resolves a listing that has an agent of
            its own, the listing's agent wins.
          example: agent@providentestate.com
        distributionType:
          type: string
          maxLength: 64
          description: >-
            How you would like this lead routed. This is a HINT, not an
            instruction: the value is stored on the lead and only affects who
            receives it if Provident has configured a routing rule that claims
            it. A value no rule claims changes nothing, so sending one is always
            safe.


            Where a rule does claim the value it OUTRANKS the normal rule order:
            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 matched first. Which types are claimed is
            configuration on our side and changes — ask before relying on it.


            Matched case-insensitively after trimming, on both sides of the
            comparison, so capitalisation never decides routing; the leading
            word on its own also works (`campaign`, `personal`, `developer`,
            `language`, `location`, `default`, `roadshow`).


            An unrecognised value is stored exactly as sent rather than rejected
            — a lead is never lost over this field, it simply matches no rule.


            `Personal distribution` carries one extra meaning: it marks the lead
            as having a named owner, and such a lead is never re-assigned
            automatically and runs no follow-up clock at all. It only takes
            effect together with a resolved `assignedBy` — the two fields are
            one instruction. If `assignedBy` is missing or names nobody we can
            match, the lead is still created and routed normally but the marker
            is dropped, and both values come back in `intakeUnresolved`.


            **Omit it and a Meta lead form can carry it instead.** A
            `Distribution` or `Distribution Type` entry in
            `metaFacebook.customFields` is read as a fallback and normalised the
            same way, because the value is usually set by whoever built the ad
            form rather than by whoever posts the lead. A top-level
            `distributionType` always wins.
          enum:
            - Personal distribution
            - Campaign distribution
            - Developer distribution
            - Language distribution
            - Location distribution
            - Default distribution
            - Developer and Area distribution
            - Area distribution Secondary
            - Roadshow distribution
          example: Campaign distribution
        metaFacebook:
          $ref: '#/components/schemas/MetaFacebookPayload'
        quiz:
          $ref: '#/components/schemas/QuizSubmission'
        integrationRef:
          type: string
          maxLength: 128
          nullable: true
          description: >-
            Your handle for the specific automation behind this lead — a Make
            scenario name, a webhook id, a form build. Free text: stored
            verbatim, never matched against anything, and never reported in
            `intakeUnresolved`.


            It identifies an INTEGRATION, not a submission, so keep it stable
            across every lead that automation sends; a per-lead unique id makes
            it useless for grouping. Values are only distinguished within one
            sender, so two partners can both use `scenario-1`.


            Do NOT put your own name here. The submitting OAuth client is
            recorded automatically from the access token — there is no field for
            it, because attribution a sender can write is not attribution.
          example: make:meta-leadgen-eu
      additionalProperties: true
    CreatedLead:
      type: object
      description: >-
        A newly created lead (`merged` is false). The response actually carries
        the whole lead record; only the properties listed here are contractual
        and safe to depend on.
      properties:
        id:
          type: string
          format: uuid
          description: >-
            UUID of the created lead. Store this against your own submission
            record.
        contactId:
          type: string
          format: uuid
          description: >-
            UUID of the contact the lead was attached to (matched or newly
            created).
        intakeUnresolved:
          type: object
          nullable: true
          additionalProperties:
            type: string
          description: >-
            Map of field name → the original value you sent, for every lookup
            value that could not be matched. `null` when everything resolved.
            Quiz answers that could not be read appear namespaced as
            `quiz.<quizKey>.<questionKey>` — unlike a failed lookup, the answer
            IS still stored on the lead, and a Provident admin can map the value
            once so every future submission of it resolves automatically.
          example:
            source: Web-Site
            quiz.off-plan-investor-2026.free_budget: somewhere around a lot
        needsIntakeReview:
          type: boolean
          description: '`true` when `intakeUnresolved` is non-empty.'
        merged:
          type: boolean
          enum:
            - false
          description: >-
            Always `false` here. `true` means the enquiry was folded into an
            existing lead — see MergedLead.
        isRepeatLead:
          type: boolean
          description: >-
            True when this person already had a lead older than the merge
            window. The lead is routed and worked normally; this is a marker,
            not a routing instruction.
        repeatOfLeadId:
          type: string
          format: uuid
          nullable: true
          description: The earlier lead's id when `isRepeatLead` is true, otherwise null.
        isAgentEnquiry:
          type: boolean
          description: >-
            True when the enquiry was recognised as coming from a competing
            agent rather than a buyer. The lead is still created and worked; the
            flag keeps it out of lead-volume and cost-per-lead reporting.
        agentEnquiryReason:
          type: string
          nullable: true
          enum:
            - known_agent
            - prior_lead
            - competitor_domain
            - manual
            - null
          description: Why, when `isAgentEnquiry` is true; null otherwise.
        createdAt:
          type: string
          format: date-time
        assignedTo:
          allOf:
            - $ref: '#/components/schemas/Agent'
          nullable: true
          description: >-
            The agent the lead landed on. NULL means 'not decided yet' — the
            routing engine runs after this response is sent — NOT 'nobody'.
            Reliable only when you sent referenceNo/listingId or assignedBy;
            otherwise use GET /public/leads/{id}/assignment or the lead.assigned
            webhook.
      additionalProperties: true
    MergedLead:
      type: object
      description: >-
        The enquiry was folded into a lead created inside the merge window
        (~24h) rather than creating a new one. The status is still 201 but the
        body is ONLY these four fields: there is no contactId, intakeUnresolved,
        needsIntakeReview or createdAt. `id` is a lead you already have — do not
        record it as a second submission. `assignedTo` is usually populated here
        (the lead already exists, so its owner is already known).
      required:
        - id
        - merged
        - matchedOn
      properties:
        id:
          type: string
          format: uuid
          description: UUID of the EXISTING lead the enquiry was folded into.
        merged:
          type: boolean
          enum:
            - true
          description: Always `true` here.
        matchedOn:
          type: string
          enum:
            - phone
            - email
          description: >-
            Which identifier matched the existing lead. Phone is checked before
            email.
        assignedTo:
          allOf:
            - $ref: '#/components/schemas/Agent'
          nullable: true
          description: >-
            The agent who already owns the lead the enquiry was folded into, or
            null if it has none.
      additionalProperties: false
    ApiError:
      type: object
      description: Common error envelope used by every endpoint.
      properties:
        statusCode:
          type: integer
          example: 400
        code:
          type: string
          description: >-
            Stable machine-readable error code — branch on this, not on
            `message`.
          example: BAD_REQUEST
        message:
          type: string
          description: Human-readable description.
        errors:
          type: array
          items:
            type: string
          description: 'Present on 422 only: one entry per field that failed validation.'
        details:
          type: object
          additionalProperties: true
          description: Optional structured extras attached by the failing handler.
        path:
          type: string
          example: /v2/public/leads
        timestamp:
          type: string
          format: date-time
    MetaFacebookPayload:
      type: object
      description: >-
        Meta / Facebook / Instagram (or TikTok) lead-ads metadata. Send only for
        leads originating from an ad-platform lead form. The standard fields
        above still drive routing.


        Stored alongside the lead AND copied onto it: `metaCampaignId` +
        `metaCampaignName` become the lead's advertising campaign (matched on
        the Meta campaign id, and created if Provident has never seen it, so
        campaign names need no prior agreement); `finalPageName`,
        `adName`/`adId`, `adgroupName`/`adgroupId` and `formName`/`formId`
        become filterable columns on the lead. Matching on the id is what makes
        a rename safe — rename a campaign in Ads Manager and its leads stay on
        one campaign in the CRM. `campaignName` is a different, strictly-matched
        field: the CRM's own campaign, which drives routing.


        The whole object is discarded when `metaLeadId` is absent. `metaLeadId`
        is unique across the CRM: it prevents the metadata being attached twice,
        but it does NOT prevent a duplicate lead — de-duplicate on your side.
      properties:
        metaLeadId:
          type: string
          maxLength: 64
          description: Meta-issued lead id. Required for this block to be stored.
          example: '7636865386872307975'
        metaCreatedAt:
          type: string
          format: date-time
          description: Submission time reported by Meta (ISO-8601).
          example: '2026-05-06T19:38:09.000Z'
        platform:
          type: string
          maxLength: 32
          description: >-
            Source platform. The short forms `fb` and `ig` are accepted and
            normalised to `facebook` / `instagram`, so a sender forwarding Meta
            verbatim needs no translation; anything else is stored lowercased
            rather than rejected.
          enum:
            - facebook
            - instagram
            - tiktok
            - fb
            - ig
          example: facebook
        adId:
          type: string
          maxLength: 64
        adName:
          type: string
          maxLength: 512
          example: Marina Launch — Video
        adType:
          type: string
          maxLength: 64
          example: Video
        adgroupId:
          type: string
          maxLength: 64
        adgroupName:
          type: string
          maxLength: 255
          example: TARGETED
          description: >-
            Ad set / ad group name. Also fills the lead's `adsetName` when you
            send no top-level one.
        metaCampaignId:
          type: string
          maxLength: 64
          description: >-
            Meta's own campaign id — NOT the CRM campaign (use `campaignName`
            for that).
        metaCampaignName:
          type: string
          maxLength: 512
          example: Marina Q2
        formId:
          type: string
          maxLength: 64
        formName:
          type: string
          maxLength: 512
          example: Marina Callback
          description: >-
            Meta lead-form name. Also fills the lead's `formName` when you send
            no top-level one.
        adAccountId:
          type: string
          maxLength: 64
          example: act_123456789
        businessAccountId:
          type: string
          maxLength: 64
        sourceAction:
          type: string
          maxLength: 64
          example: Form
        finalPageName:
          type: string
          maxLength: 255
          example: Thank You
          description: The Facebook/Instagram page the ad ran under. Copied onto the lead.
        customFields:
          type: object
          additionalProperties: true
          description: >-
            Per-form custom question/answer pairs and any other ad-platform-only
            fields. Keys are preserved verbatim (non-ASCII safe).
          example:
            هل ترغب في حضور هذا المعرض الحصري؟: نعم
            Preferred call time: Evening
    QuizSubmission:
      type: object
      description: >-
        A dynamic quiz / survey submitted with the lead — for landing pages that
        ask a set of questions. Unlike every other field in this API, the
        questions do NOT have to be agreed with Provident in advance: questions
        and options register themselves the first time they arrive, so a new
        quiz starts capturing answers with no API change on either side. Answers
        are searchable in the CRM, shown on the lead, and — for questions
        Provident maps to a CRM field — used to fill that field on the lead
        itself.
      properties:
        quizKey:
          type: string
          maxLength: 128
          description: >-
            Stable identifier for the quiz. REQUIRED — without it the whole
            `quiz` object is ignored. Keys are scoped to the quiz, so `budget`
            in quiz A and `budget` in quiz B are independent; you do not need
            globally unique question keys.
          example: off-plan-investor-2026
        quizName:
          type: string
          maxLength: 255
          description: Display name. Used the first time this `quizKey` is seen.
          example: Off-plan investor quiz
        locale:
          type: string
          maxLength: 16
          description: >-
            Language the quiz was answered in. Labels are stored per locale so
            the CRM shows what the visitor actually saw.
          example: ar
        version:
          type: string
          maxLength: 32
          description: Your version identifier for the quiz, recorded on each answer.
          example: '3'
        answers:
          type: array
          items:
            $ref: '#/components/schemas/QuizAnswer'
          maxItems: 60
          description: >-
            The answers, in the order the questions were asked. Up to 60 per
            submission register new questions; anything beyond is still recorded
            and flagged for review, but does not extend the catalogue. A quiz is
            capped at 100 questions and a question at 200 options.
      additionalProperties: false
    Agent:
      type: object
      description: >-
        An agent, as returned anywhere in this API. Work contacts only —
        personal numbers are never published.
      required:
        - id
        - slug
        - name
        - email
        - phone
        - whatsappPhone
        - active
      properties:
        id:
          type: string
          format: uuid
          description: >-
            Portal user id. The stable, immutable identifier for an agent — key
            your records on this.
        slug:
          type: string
          nullable: true
          description: >-
            Website profile segment (/team/<slug>/), or null if not published.
            Human-readable but admin-editable; `id` is not.
          example: sarah-ahmed
        name:
          type: string
          nullable: true
          description: Display name. Safe to say to a customer.
          example: Sarah Ahmed
        email:
          type: string
          nullable: true
          description: Work email.
        phone:
          type: string
          nullable: true
          description: Work phone.
        whatsappPhone:
          type: string
          nullable: true
          description: >-
            The number to open a WhatsApp chat on. Read this field rather than
            reusing `phone` — they differ for some agents.
        active:
          type: boolean
          description: False for a deactivated account. Do not route work to them.
    QuizAnswer:
      type: object
      description: >-
        One answer to one quiz question. `questionKey` (and `optionKey` for
        dropdowns) are the contract: they are the only language-independent
        identity an answer has. Labels are display text and change with language
        and copy edits, so they are never used for matching when a key is
        present.
      properties:
        questionKey:
          type: string
          maxLength: 128
          description: >-
            Stable machine key for the question, unique within the quiz. Must be
            the SAME across languages and stable over time — do not regenerate
            it when you edit the wording. Without it the answer is keyed off a
            hash of its label and will not merge with the same question in
            another language.
          example: budget
        questionLabel:
          type: string
          maxLength: 512
          description: The question as shown to the visitor, in the quiz locale.
          example: ما هي ميزانيتك؟
        optionKey:
          description: >-
            Stable machine key of the chosen option, for select questions. Send
            an ARRAY for multi-select — each option becomes its own answer
            record.
          oneOf:
            - type: string
              maxLength: 128
            - type: array
              items:
                type: string
                maxLength: 128
          example: 2m-5m
        answerLabel:
          description: >-
            The answer as shown to the visitor. For a select answer this is the
            option label; for free text it is the answer itself. Array form
            pairs positionally with an array `optionKey`.
          oneOf:
            - type: string
              maxLength: 512
            - type: array
              items:
                type: string
                maxLength: 512
          example: ٢-٥ مليون
        value:
          description: >-
            Machine value when you have one — a number, boolean, or ISO-8601
            date string. TRUSTED OVER THE LABEL: sending 2000000 removes any
            dependence on our parsing of "٢-٥ مليون". Free-text numbers are
            parsed best-effort instead ("2 million", "2,000,000", "AED 2M", "٢
            مليون" all resolve to 2000000); an unparseable value is kept
            verbatim and flagged for review rather than dropped.
          oneOf:
            - type: string
            - type: number
            - type: boolean
          example: 2000000
        currency:
          type: string
          maxLength: 3
          description: >-
            ISO 4217 currency for a monetary answer, when the value does not
            imply it.
          example: AED
      additionalProperties: false
  headers:
    XRateLimitLimit:
      description: Requests allowed in the current window.
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requests remaining in the current window.
      schema:
        type: integer
    XRateLimitReset:
      description: Seconds until the counter resets (end of the UTC day).
      schema:
        type: integer
  responses:
    RateLimited:
      description: >-
        Request quota for this client exceeded. Wait `Retry-After` seconds, then
        resume.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            statusCode: 429
            code: RATE_LIMITED
            message: Too Many Requests
            path: /v2/public/leads
            timestamp: '2026-07-27T06:31:04.512Z'
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Opaque
      description: >-
        The `access_token` returned by `POST /oauth2/token`, sent as
        `Authorization: Bearer <access_token>`. It is an opaque string, not a
        JWT.

````