# List conversions

`GET /v1/campaigns/{campaign_id}/conversions`

Returns one conversion row per lead of a campaign, oldest first.

**Scope:** `leads:read`

Rows are ordered by `created_at`, oldest first, so a lead created while you page shows up on a later page and rows already returned are never repeated. Use it to see which marketing leads became customers and to send those outcomes back to an ad platform.

A deal is closed when its card is in a stage whose outcome is set to Won in the pipeline's stage settings, and lost in a stage whose outcome is Lost; the stage's name does not matter. Set the outcome of every winning and losing stage in DialEcho; leads already in those stages then report the close from when they entered the stage.

The campaign must be one the key may reach, otherwise `404`. Reading does not need the member's permission to manage leads.

## Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `campaign_id` | `UUID` | Yes | A campaign the key may reach. |

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `created_after` | `timestamp` | No | Only leads created at or after this time. ISO 8601 with a UTC offset, for example `2026-11-01T00:00:00Z` (URL-encode `+` as `%2B`). |
| `created_before` | `timestamp` | No | Only leads created before this time. After `created_after`. |
| `milestone` | `string` | No | Only leads that reached it: `called` (`first_call_at` set), `appointment_booked`, `appointment_held` (`appointment_status` is `held`), `closed` (`closed_at` set) or `lost` (`lost_at` set). One of `called`, `appointment_booked`, `appointment_held`, `closed`, `lost`. |
| `limit` | `integer` | No | Rows per page. 1 to 100. Default: `50`. |
| `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. Opaque: never build or edit one. Up to 4,096 characters. |

## Response

**Status:** `200 OK`

A lead that has not got anywhere yet has every milestone `null` and, if it is not on the board, `"card": null`. Every key is always present.

| Name | Type | Description |
| --- | --- | --- |
| `data` | `array of objects` | Conversion rows, oldest first. |
| `data.lead_id` | `UUID` | The lead. |
| `data.campaign_id` | `UUID` | The lead's campaign. |
| `data.created_at` | `timestamp` | When the lead was created in DialEcho (RFC 3339, UTC). |
| `data.contact` | `object` | The lead's details as `GET /v1/leads/{lead_id}` returns them, with the same field visibility: a field the member's role hides is `null`, one it masks comes back masked. |
| `data.contact.first_name` | `string \| null` | First name. |
| `data.contact.last_name` | `string \| null` | Last name. |
| `data.contact.email` | `string \| null` | Email address. |
| `data.contact.phone` | `string \| null` | Phone number in E.164 (`+14155550132`); a stored number that is not a valid phone number is returned as stored. |
| `data.contact.city` | `string \| null` | City. |
| `data.contact.state` | `string \| null` | State. |
| `data.contact.postal_code` | `string \| null` | ZIP or postal code. |
| `data.contact.email_sha256` | `string \| null` | SHA-256 (lowercase hex) of the email trimmed and lowercased: Meta's hashed `em`. `null` without an email, or unless the member sees the email in full. |
| `data.contact.phone_sha256` | `string \| null` | SHA-256 (lowercase hex) of the number's digits with country code and no `+` (`14155550132`): Meta's hashed `ph`. `null` without a valid number, or unless the member sees the phone in full. |
| `data.attribution` | `object \| null` | The attribution stored on the lead under the 16 `attribution` keys `POST /v1/leads` accepts (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `utm_id`, `adset_id`, `ad_id`, `fbclid`, `fbc`, `fbp`, `gclid`, `meta_event_id`, `page_url`, `form_name`, `referrer`), whether sent through the API or set another way such as an import. Every key is present, `null` when never set. The whole object is `null` when the member's role hides custom fields; when it masks them, set values read `Restricted`. |
| `data.milestones` | `object` | How far the lead got. Timestamps are RFC 3339 in UTC; `null` until the lead reaches the milestone. |
| `data.milestones.first_call_at` | `timestamp \| null` | When the first outbound call to the lead by one of the campaign's AI agents started: a `POST /v1/calls` call, a campaign or a pipeline agent's call. Calls by your team and calls the lead made are not counted. |
| `data.milestones.first_call_outcome` | `string \| null` | How that call went: `reached` (a real conversation), `answered` (picked up but no conversation), `voicemail`, `no_answer` (not answered, busy or not connected) or `in_progress`. One of `in_progress`, `reached`, `answered`, `voicemail`, `no_answer`. |
| `data.milestones.appointment_booked_at` | `timestamp \| null` | When the lead's first appointment in the campaign was booked, however it was booked, even if it was later moved or cancelled. |
| `data.milestones.appointment_at` | `timestamp \| null` | When the appointment is or was scheduled: the first one that was held; if none was held, the latest one not cancelled; if all were cancelled, the latest one. |
| `data.milestones.appointment_status` | `string \| null` | That appointment's status. Attendance your team records decides (shown up is `held`, no-show is `no_show`); otherwise `held` when your team's appointment call reached the lead and `no_show` when your team recorded nobody answered. An unanswered call with nothing recorded leaves it `scheduled`. One of `scheduled`, `held`, `no_show`, `cancelled`. |
| `data.milestones.appointment_held_at` | `timestamp \| null` | For a `held` appointment, when the call that reached the lead started; for one only marked as shown up, its scheduled start. |
| `data.milestones.closed_at` | `timestamp \| null` | When the card entered the Won stage it is in now (a deal reopened and won again reports the latest win). `null` unless `card.deal_status` is `won`. |
| `data.milestones.lost_at` | `timestamp \| null` | The same for a Lost stage. `null` unless `card.deal_status` is `lost`. |
| `data.card` | `object \| null` | The card `GET /v1/leads/{lead_id}` returns as `card`, or `null` if the lead has none the member can open: its main card (the deal), or, for a member who may open only a follow-through card of the lead, that card with its own pipeline, stage, `deal_status`, `closed_at` and `lost_at` and the deal value it shares with the main card. Nothing of a card the member may not open is shown. Always shown, whatever the member's field visibility. |
| `data.card.id` | `UUID` | The card. |
| `data.card.pipeline_id` | `string \| null` | The card's pipeline. |
| `data.card.pipeline_name` | `string \| null` | The pipeline's name. |
| `data.card.stage` | `string \| null` | Slug of the current stage. |
| `data.card.stage_name` | `string \| null` | Name of the current stage. |
| `data.card.deal_status` | `string` | `abandoned` when your team marked the deal abandoned, otherwise the outcome set on the card's stage in DialEcho (Won, Lost or Open), whatever the stage is called. A follow-through card the deal moves into after it is won is reported only to a member who may open that card and not the main card. One of `open`, `won`, `lost`, `abandoned`. |
| `data.card.deal_value` | `number \| null` | The deal value, shared by the main card and its follow-through cards, or `null`. |
| `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when there is none. |
| `has_more` | `boolean` | `true` while more pages may follow. Keep paging until it is `false`. |

## Example request

**curl**

```bash
curl "https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/conversions?created_after=2026-11-01T00:00:00Z&milestone=closed&limit=50" \
  -H "Authorization: Bearer dei_your_key"
```

**JavaScript**

```js
const response = await fetch("https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/conversions?created_after=2026-11-01T00:00:00Z&milestone=closed&limit=50", {
  headers: {
    "Authorization": "Bearer dei_your_key",
  },
});
const data = await response.json();
```

## Example response

**Status:** `200 OK`

```json
{
  "data": [
    {
      "lead_id": "aaaaaaaa-0000-4000-8000-000000000001",
      "campaign_id": "cccccccc-0000-4000-8000-000000000001",
      "created_at": "2026-11-02T15:55:55Z",
      "contact": {
        "first_name": "Pat",
        "last_name": "Lee",
        "email": "pat@example.com",
        "phone": "+14155550132",
        "city": "San Francisco",
        "state": "CA",
        "postal_code": "94105",
        "email_sha256": "fe9733fcc96501276776fc927df08a395835461a2c5b12c65ebab3454877e227",
        "phone_sha256": "11072303eb5c8cb5c5a1ebb762f11b37b07131265b46fdfe1276196bfbd3ca51"
      },
      "attribution": {
        "form_name": "quote-step-1",
        "page_url": "https://www.example.com/get-a-quote",
        "referrer": null,
        "utm_source": "facebook",
        "utm_medium": "paid_social",
        "utm_campaign": "fall-promo",
        "utm_term": null,
        "utm_content": null,
        "utm_id": "120200000000000001",
        "adset_id": "120200000000000002",
        "ad_id": "120200000000000003",
        "fbclid": "IwAR0exampleClickId",
        "fbc": null,
        "fbp": "fb.1.1791900000000.1234567890",
        "gclid": null,
        "meta_event_id": null
      },
      "milestones": {
        "first_call_at": "2026-11-02T15:56:04Z",
        "first_call_outcome": "reached",
        "appointment_booked_at": "2026-11-02T15:59:41Z",
        "appointment_at": "2026-11-04T18:00:00Z",
        "appointment_status": "held",
        "appointment_held_at": "2026-11-04T18:02:13Z",
        "closed_at": "2026-11-09T16:30:00Z",
        "lost_at": null
      },
      "card": {
        "id": "dddddddd-0000-4000-8000-000000000001",
        "pipeline_id": "eeeeeeee-0000-4000-8000-000000000001",
        "pipeline_name": "Sales",
        "stage": "won",
        "stage_name": "Won",
        "deal_status": "won",
        "deal_value": 18500.0
      }
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

## Errors

| HTTP | Code | When |
| --- | --- | --- |
| 403 | `insufficient_scope` | The key does not have `leads:read`. Checked before the query is validated. |
| 404 | `not_found` | The campaign does not exist or the key cannot reach it. |
| 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again from the first page. |
| 422 | `invalid_cursor` | The cursor was not issued to this key for this request, was changed, or was sent with different parameters than the page it came from. |
| 422 | `invalid_request` | A parameter is invalid: `campaign_id` not a UUID, a timestamp without a UTC offset, `created_before` not after `created_after`, an unknown `milestone`, a `limit` outside 1 to 100 or a `cursor` over 4,096 characters. `error.fields` names it (`query.milestone`, `campaign_id`, ...). |
| 503 | `dependency_unavailable` | The report or the access check is temporarily unavailable. Retry the same request with backoff. |

Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview.

## Sending conversions to Meta

DialEcho does not send anything to Meta. Your server, or your marketing team's tool, reads this report and sends the events to Meta's Conversions API (`POST https://graph.facebook.com/{api-version}/{pixel-id}/events`), so Meta's optimization learns from leads that became appointments and sales. Keep the `event_id`s you have sent and send each event once.

Pull at least daily: Meta only accepts recent `event_time`s (7 days for most events) and deals often close weeks after the lead. A daily job requesting `?created_after=<today minus your longest sales cycle>&milestone=closed` (and `milestone=appointment_booked`) catches every close while it is still recent.

`action_source` says where the conversion happened: `phone_call` for an appointment or deal made on a call (the usual case here), `website` for a form or a booking your site made (Meta then requires the visitor's user agent and page, which the report does not have, so send those from your website's server when they happen), `other` for in person.

| Meta `event_name` | Send it for rows where | `event_time` (Unix seconds) | `event_id` | `custom_data` |
| --- | --- | --- | --- | --- |
| `Lead` | Only leads your website's server did not already send, for example a lead that phoned you. | `created_at` | `attribution.meta_event_id` when set (deduplicates against the Pixel), otherwise `dialecho-lead-{lead_id}` | none |
| `Schedule` | `milestones.appointment_booked_at` is set | `appointment_booked_at` | `dialecho-schedule-{lead_id}` | none |
| `Purchase` | `milestones.closed_at` is set | `closed_at` | `dialecho-purchase-{lead_id}` | `{"value": card.deal_value, "currency": "USD"}`; DialEcho stores no currency and shows deal values in US dollars |

## Meta user_data from a row

Send these `user_data` keys with every event. Omit a key whose source is `null`.

| Meta key | From the row |
| --- | --- |
| `em` | `[contact.email_sha256]` (already hashed) |
| `ph` | `[contact.phone_sha256]` (already hashed) |
| `fn`, `ln` | SHA-256 of `contact.first_name` / `last_name`, trimmed and lowercased |
| `ct` | SHA-256 of `contact.city`, lowercased with spaces and punctuation removed |
| `st` | SHA-256 of `contact.state` as a lowercase 2-letter code (`ca`) |
| `zp` | SHA-256 of the first 5 digits of `contact.postal_code` (US) |
| `country` | SHA-256 of `us` for US leads |
| `external_id` | SHA-256 of `lead_id`, the same on every event of a lead |
| `fbc` | `attribution.fbc` when set; otherwise, when `attribution.fbclid` is set, `fb.1.{created_at in Unix milliseconds}.{fbclid}`. Not hashed. |
| `fbp` | `attribution.fbp` when set. Not hashed. |

## Related endpoints

- `GET /v1/leads/{lead_id}/conversion` · [Retrieve a conversion](/api/reporting/get-conversion)
- `POST /v1/leads` · [Create or update a lead](/api/leads/create)
- `GET /v1/campaigns/{campaign_id}/pipeline` · [Retrieve the pipeline](/api/reporting/pipeline-board)
