Integration docs

Send partner orders into Contractor Leads.

Server-to-server intake for construction orders, lead forms, supplier CRM payloads and source-specific mappings.

Production endpoint

Lead intake API

Public endpoint
POST https://contractorleads.app/api/v1/lead-intake
POST Method JSON Format 202 queued Result Source headers Auth

Endpoint

Authenticate every source separately.

Send requests from your backend. Never expose the source key in public form JavaScript.

Required headers

  • Content-Type application/json
  • X-Lead-Source-Code website or partner code
  • X-Lead-Source-Key source secret key
  • Idempotency-Key stable external order ID

Retry safely

Keep the same idempotency key when retrying the same order. This prevents duplicate intake requests and duplicate leads.

Payload

Canonical fields first, flexible mapping when needed.

The endpoint stores raw JSON first. Use a native payload when possible, mapped JSON for stable supplier fields, or AI normalization for arbitrary CRM JSON.

canonical_v1

Native fields when the partner can send our expected JSON shape.

mapped_json_v1

Deterministic source mapping when supplier keys differ but remain stable.

openai_json_v1

AI normalization for arbitrary CRM JSON that cannot be controlled upstream.

service_category

Work category: roofing, remodeling, exterior, and so on.

project_description

What the customer wants done.

customer_name

Customer name.

customer_phone

Customer phone number.

city, state, zip_code

Project location.

consent

Customer consent to share the request. Must be true.

{
  "external_order_id": "partner-order-123",
  "source_site": "partner-site.com",
  "landing_page": "/roofing/seattle-wa",
  "service_category": "roofing",
  "project_type": "residential",
  "project_description": "Need a roof replacement estimate after storm damage.",
  "budget_min": 15000,
  "budget_max": 25000,
  "timeline": "Within 30 days",
  "customer_name": "Jane Customer",
  "customer_phone": "(206) 555-0101",
  "customer_email": "[email protected]",
  "preferred_contact_method": "phone",
  "address_line1": "1200 1st Ave",
  "city": "Seattle",
  "state": "WA",
  "zip_code": "98101",
  "country": "US",
  "consent": true,
  "utm": {
    "utm_source": "partner-site",
    "utm_campaign": "roofing-seattle"
  },
  "tracking": {
    "click_id": "click-123"
  },
  "answers": {
    "roof_type": "asphalt",
    "project_stage": "ready_to_hire"
  },
  "files": [
    {
      "url": "https://partner-site.com/uploads/roof-photo.jpg",
      "name": "roof-photo.jpg",
      "mime_type": "image/jpeg"
    }
  ]
}

Processing

Raw intake first, system lead later.

Incoming requests are stored with status=received. Cron then leases queued rows, normalizes payloads, creates lead records and starts the admin price-estimation workflow.

01

Receive

Authenticate source headers, keep the original JSON, and return a request ID.

02

Normalize

Cron leases queued rows and converts each request into canonical lead fields.

03

Review

Accepted leads enter price estimation and the admin review workflow.

Run once

php yii cron/run

Cron

* * * * * cd /path/to/contractorleads && php yii cron/run >> runtime/logs/project-cron.log 2>&1

Processor result

{
  "processed_count": 1,
  "accepted_count": 1,
  "failed_count": 0,
  "items": [
    {
      "request_id": 9102,
      "status": "accepted",
      "lead_id": 5832,
      "error_message": null,
      "duplicate_candidate": false,
      "duplicate_candidate_count": 0
    }
  ]
}

AI payloads

Normalize arbitrary CRM JSON with OpenAI.

Configure a source as openai_json_v1 when the supplier cannot send canonical fields. The AI result still passes backend validation before a lead is created.

In production, create supplier sources from Admin -> Sources. Leave the API key blank to generate a one-time secret, select openai_json_v1, and keep or adjust the OpenAI hints JSON.

Create source

php yii lead-source/create suppliercrm "Supplier CRM" "replace-with-source-api-key" "" openai_json_v1 docs/api/openai-json-lead-source.example.json

Supplier JSON example

{
  "supplier": "supplier.example",
  "order": {
    "id": "supplier-123",
    "trade": "Roofing",
    "property_type": "Residential",
    "details": "Need roof replacement after wind damage.",
    "budget": 18000,
    "timeline": "Within 30 days",
    "priority": "high"
  },
  "customer": {
    "name": "Jane Customer",
    "phone": "(206) 555-0101",
    "email": "[email protected]"
  },
  "job_location": {
    "address": "1200 1st Ave",
    "city": "Seattle",
    "state": "WA",
    "zip": "98101"
  },
  "customer_consent": true
}

Responses

The HTTP result confirms intake, not final approval.

202 Queued

Raw JSON was stored and will be processed by cron.

200 Duplicate

The same idempotency key was replayed; no duplicate request or lead was created.

400 Bad Request

JSON is invalid, too large, or required headers are missing.

403 Forbidden

Source code, key, or source status is invalid.

{
  "request_id": 9102,
  "lead_id": null,
  "status": "received",
  "duplicate": false,
  "queued": true,
  "duplicate_candidate": false,
  "duplicate_candidate_count": 0
}
The HTTP response does not mean a system lead already exists. lead_id is filled after cron normalizes the queued request.
If processing fails, retry with the same idempotency key after the supplier fixes the payload. Inspect failed raw requests with php yii lead-source/failed-requests 20 suppliercrm 1.

Example

curl

curl -X POST https://contractorleads.app/api/v1/lead-intake \
  -H "Content-Type: application/json" \
  -H "X-Lead-Source-Code: partner-site" \
  -H "X-Lead-Source-Key: replace-with-source-api-key" \
  -H "Idempotency-Key: partner-order-123" \
  -d @order.json

Backend

Node.js fetch

const response = await fetch('https://contractorleads.app/api/v1/lead-intake', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Lead-Source-Code': process.env.CONTRACTOR_LEADS_SOURCE_CODE,
    'X-Lead-Source-Key': process.env.CONTRACTOR_LEADS_SOURCE_KEY,
    'Idempotency-Key': order.id
  },
  body: JSON.stringify(order)
});

if (!response.ok) {
  throw new Error(await response.text());
}

const result = await response.json();

Source onboarding

Each website needs its own source code and API key.

Separate sources let you disable one partner, change mapping rules and track lead quality by project.

Request access