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

# GET /leads

> Leads — the core entity behind every revenue and conversion metric. Incremental, ordered by updated_at ascending; won/lost comes from the stage, not the lead.

<Info>
  **Required.** `/leads` returns your leads. It is
  [incremental](/concepts/how-sync-works#incremental-sync) and syncs **after**
  `/pipelines`, so each lead's won/lost can be resolved from its stage.
</Info>

## Purpose

A lead is one sales opportunity in your CRM. HunterAI reads `/leads` to compute
revenue, win-rate, conversion, and goal progress (from the lead's value, its
stage, and its timestamps), traffic and source analytics (from `source` and the
`utm` fields), loss analysis (from `loss_reason`), and per-manager and
per-contact attribution. This is the highest-value endpoint: if it is empty,
almost every metric is empty.

## Request

```http theme={null}
GET /api/hunterai/v1/leads?limit=100&page=1&updated_since=2025-07-01T00:00:00+05:00 HTTP/1.1
Host: crm.example.uz
Authorization: Bearer <token>
Accept: application/json
```

| Query param     | Value       | Notes                                                        |
| --------------- | ----------- | ------------------------------------------------------------ |
| `limit`         | `100`       | Always sent. Return up to this many leads per page.          |
| `page`          | `1`, `2`, … | 1-based. Page until `has_more` is `false`.                   |
| `updated_since` | timestamp   | Return leads with `updated_at >= updated_since` (inclusive). |

You **must** return the leads ordered by `updated_at` **ascending**. HunterAI
advances its watermark page by page and relies on that order; an out-of-order
page can cause older leads on a later page to be skipped.

## Response envelope

```json 200 OK theme={null}
{
  "data": [
    {
      "id": "L-9001",
      "name": "Toshkent ofis — 20 litsenziya",
      "price": 48000000,
      "pipeline_id": "pipe-sales",
      "stage_id": "stg-won",
      "responsible_user_id": "u-1001",
      "contact_ids": ["c-5001", "c-5002"],
      "loss_reason": null,
      "source": "instagram",
      "utm": {
        "source": "instagram",
        "medium": "cpc",
        "campaign": "summer-2025",
        "content": "banner-a",
        "term": null
      },
      "custom_fields": { "employees": "20", "sector": "retail" },
      "created_at": "2025-06-28T10:00:00+05:00",
      "updated_at": "2025-07-02T16:45:00+05:00",
      "closed_at": "2025-07-02T16:45:00+05:00",
      "is_deleted": false
    },
    {
      "id": "L-9002",
      "name": "Samarqand filiali",
      "price": 12500000,
      "pipeline_id": "pipe-sales",
      "stage_id": "stg-lost",
      "responsible_user_id": "u-1002",
      "contact_ids": ["c-5003"],
      "loss_reason": "Byudjet yetarli emas",
      "source": "referral",
      "utm": {
        "source": "referral",
        "medium": "partner",
        "campaign": "q3-partners",
        "content": null,
        "term": null
      },
      "custom_fields": { "sector": "manufacturing" },
      "created_at": "2025-06-25T09:00:00+05:00",
      "updated_at": "2025-07-03T11:20:00+05:00",
      "closed_at": "2025-07-03T11:20:00+05:00",
      "is_deleted": false
    }
  ],
  "has_more": false,
  "page": 1,
  "limit": 100
}
```

The envelope (`data`, `has_more`, `page`, `limit`) is the same on every endpoint
— see [How sync works → The envelope](/concepts/how-sync-works#the-envelope).
Only `data` and `has_more` are read.

## Fields

| Field                 | Type           | Required | Nullable | Format / Constraint                                                                 | Example                                                                                                | Notes                                                                                                                             |
| --------------------- | -------------- | -------- | -------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `id`                  | string         | Yes      | No       | max 255 chars; stable and unique per connection; never changes for the same lead    | `"L-9001"`                                                                                             | The upsert key together with the connection. An absent or over-length `id` fails its page.                                        |
| `name`                | string         | No       | Yes      | max 255 chars                                                                       | `"Toshkent ofis — 20 litsenziya"`                                                                      | Silently truncated to 255 chars.                                                                                                  |
| `price`               | number         | No       | No       | numeric; may be sent as a numeric string (`"48000000"`); must be numeric if present | `48000000`                                                                                             | Stored as a whole number — any fractional part is dropped. Currency is not stored. A non-numeric value is rejected.               |
| `pipeline_id`         | string         | No       | Yes      | must match an `id` from [`/pipelines`](/endpoints/pipelines)                        | `"pipe-sales"`                                                                                         | The lead's pipeline.                                                                                                              |
| `stage_id`            | string         | No       | Yes      | must match a stage `id` returned by [`/pipelines`](/endpoints/pipelines)            | `"stg-won"`                                                                                            | Sets won/lost via the stage's `kind`.                                                                                             |
| `responsible_user_id` | string         | No       | Yes      | must match an `id` from [`/users`](/endpoints/users)                                | `"u-1001"`                                                                                             | The owning manager; drives per-manager attribution.                                                                               |
| `contact_ids`         | array\<string> | No       | Yes      | each element a contact `id` from [`/contacts`](/endpoints/contacts)                 | `["c-5001", "c-5002"]`                                                                                 | Only the **first** element is kept; the rest are ignored.                                                                         |
| `loss_reason`         | string         | No       | Yes      | max 500 chars                                                                       | `"Byudjet yetarli emas"`                                                                               | Free text. HunterAI derives a de-duplicated loss-reason record from it; identical text maps to the same record. Truncated to 500. |
| `source`              | string         | No       | Yes      | max 255 chars                                                                       | `"instagram"`                                                                                          | Free-text lead source. Truncated to 255.                                                                                          |
| `utm`                 | object         | No       | Yes      | container for the five UTM sub-fields below                                         | `{ "source": "instagram", "medium": "cpc", "campaign": "summer-2025", "content": null, "term": null }` | Attribution container.                                                                                                            |
| `utm.source`          | string         | No       | Yes      | max 255 chars                                                                       | `"instagram"`                                                                                          | Truncated to 255.                                                                                                                 |
| `utm.medium`          | string         | No       | Yes      | max 255 chars                                                                       | `"cpc"`                                                                                                | Truncated to 255.                                                                                                                 |
| `utm.campaign`        | string         | No       | Yes      | max 500 chars                                                                       | `"summer-2025"`                                                                                        | Truncated to 500.                                                                                                                 |
| `utm.content`         | string         | No       | Yes      | max 500 chars                                                                       | `"banner-a"`                                                                                           | Truncated to 500.                                                                                                                 |
| `utm.term`            | string         | No       | Yes      | max 500 chars                                                                       | `"crm software"`                                                                                       | Truncated to 500.                                                                                                                 |
| `custom_fields`       | object         | No       | Yes      | flat key→value map with string keys                                                 | `{ "employees": "20", "sector": "retail" }`                                                            | Stored as name/value pairs.                                                                                                       |
| `created_at`          | string         | No       | Yes      | timestamp (see [Timestamp format](#timestamp-format))                               | `"2025-06-28T10:00:00+05:00"`                                                                          | Lead creation time. An unparseable value becomes `null`.                                                                          |
| `updated_at`          | string         | Yes      | No       | timestamp (see [Timestamp format](#timestamp-format))                               | `"2025-07-02T16:45:00+05:00"`                                                                          | Drives `updated_since` and the watermark. Must be present, parseable, and must advance on every modification.                     |
| `closed_at`           | string         | No       | Yes      | timestamp (see [Timestamp format](#timestamp-format))                               | `"2025-07-02T16:45:00+05:00"`                                                                          | When the lead reached a won/lost stage. An unparseable value becomes `null`.                                                      |
| `is_deleted`          | boolean        | No       | No       | `true` or `false`                                                                   | `false`                                                                                                | Tombstone. `true` soft-deletes the lead. Defaults to `false` if omitted.                                                          |

<Note>
  **`loss_reason` needs no separate endpoint.** Send the reason as free text on the
  lead; HunterAI synthesizes a stable loss-reason record from the text and links
  the lead to it. Identical text across leads maps to the same record.
</Note>

### Timestamp format

`created_at`, `updated_at`, and `closed_at` accept a calendar date and time in
these concrete forms:

* With an explicit offset — `2025-07-02T16:45:00+05:00`.
* With a trailing `Z` for UTC — `2025-07-02T16:45:00Z`.
* With no zone at all — `2025-07-02T16:45:00`, which is read as UTC.
* The date and time may be separated by a `T` or by a single space
  (`2025-07-02 16:45:00`).
* A bare date — `2025-07-02` — is accepted; the time becomes `00:00` UTC.

Prefer the explicit-offset form. **An unparseable value becomes `null` for that
field, and no error is raised** — the lead is still ingested.

<Warning>
  **Consequence when this hits `updated_at`:** a `null` `updated_at` carries no
  modification time, so the lead cannot advance the
  [watermark](/concepts/how-sync-works#ordering-and-the-watermark) and is not
  selected by any later `updated_since` filter. In effect the lead — and every
  future change to it — drops out of incremental sync. Keep `updated_at` a valid
  timestamp that advances on every change.
</Warning>

## Fields the adapter ignores

Sending any of these has **no effect** — do not spend effort populating them:

* `currency` — dropped. `price` is stored as a plain number with no currency
  attached, so a connection is effectively single-currency. See
  [Limitations → Currency](/limitations#currency-is-not-stored).
* `contact_ids` beyond the first element — only `contact_ids[0]` is kept; the
  rest are ignored.
* `is_won` / `is_lost` — there is no such field on a lead. Won/lost is derived
  from the lead's stage `kind`, never from the lead itself.
* Any other key not listed in the [Fields](#fields) table — ignored.

## Identity & cross-references

* **Identity:** `id` is the stable primary key. HunterAI upserts by
  `(connection, id)`, so `id` **must never change** for the same lead — a changed
  `id` is ingested as a brand-new lead.
* `pipeline_id` → resolves against [`/pipelines`](/endpoints/pipelines).
* `stage_id` → resolves against the stages nested in
  [`/pipelines`](/endpoints/pipelines). The stage's `kind` is what makes a lead
  won or lost — a `kind` outside `open`, `won`, or `lost` is an error at
  [`/pipelines`](/endpoints/pipelines#stage-kind-classification), not something
  the lead can override.
* `responsible_user_id` → resolves against [`/users`](/endpoints/users).
* `contact_ids[0]` → resolves against [`/contacts`](/endpoints/contacts).

## Incremental sync

`/leads` is incremental. HunterAI sends `updated_since`; return only leads whose
`updated_at` **is greater than or equal to** it (inclusive — re-returning the
boundary lead is harmless, since the upsert is idempotent).

* `updated_at` drives the per-entity **watermark** — the largest `updated_at`
  HunterAI has committed. The next run resumes from there.
* You **must** return leads ordered by `updated_at` **ascending**, so the
  watermark can advance page by page without skipping an older lead on a later
  page.
* `updated_at` **must change on every modification** of a lead — a stage move, an
  owner change, a field edit, or a soft-delete. A change that does not advance
  `updated_at` falls at or below the watermark and is never pulled again.

See [How sync works](/concepts/how-sync-works#incremental-sync) for the shared
model.

## Deletion behaviour

Leads are the only entity that supports a tombstone. Set `is_deleted: true` to
soft-delete a lead; it defaults to `false`. A lead that simply stops appearing in
your responses is **not** deleted — the upsert only inserts and updates, never
removes a row. See
[Limitations → Tombstones](/limitations#tombstones-apply-to-leads-only).

## Failure modes

A page of leads (up to 100 records) is validated and written **as one
transaction**. The consequences of that:

* **A single malformed lead fails the whole page.** If any lead on a page cannot
  be processed — a missing `id`, a non-numeric `price` — none of the \~100 leads
  on that page are saved. This is page-level, not per-record.
* **A failed page fails the sync.** Because `/leads` is required, a page that
  fails takes the leads entity down with it, and the overall sync is reported
  *failed*. Pages that already committed stay committed, and the next run resumes
  from the watermark.
* **A lead whose stage was not synced cannot be classified.** It lands with
  `is_won = is_lost = false`; this is counted and surfaced in the run summary, not
  silent. Always return every stage a lead can reference on `/pipelines`.
* **An unparseable timestamp does not fail anything** — the field becomes `null`
  and the lead is still ingested. See the consequence for `updated_at` above.
* **`429` responses** are retried with backoff up to 5 times, then the entity
  fails. HunterAI applies its request rate limit **per connection**, so two
  connections to the same base URL are throttled independently.
* **`5xx` responses and request timeouts** are retried with exponential backoff
  up to 5 times; a request that times out is retried the same way as a `5xx`.
  Persistent failure fails the entity.
* **A repeated or unbounded page** — a page whose IDs match the previous page, or
  a run past the page ceiling — fails the entity. See
  [How sync works → Paging](/concepts/how-sync-works#paging).

## If this endpoint is missing

<Warning>
  `/leads` is **required**. A `404` fails the leads entity and the sync is
  reported failed. This is the core entity, so revenue, win-rate, conversion,
  loss analysis, and per-manager and per-contact attribution would all be empty.
</Warning>
