> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.dealsignal.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.dealsignal.com/_mcp/server.

# Contact Profile Refresh Requests Overview

> Refresh individual contact profiles on demand — every profile comes back inline with the contact matched so far; poll for the rest.

A **Contact Profile Refresh Request** looks up and refreshes a small set of contacts on demand. You send the contacts in `profiles`, and each one comes back as an entry of the request's `contact_profile_refreshes`. Unlike Lead Enrichment, which is designed for bulk batches that run in the background, a refresh request is built for per-contact freshness — for example refreshing a CRM record when a sales rep opens it, or periodically re-checking the contacts you track for job changes. There is no saved list to refresh by ID.

One create call does two things:

* **Immediately** returns every profile with the contact matched so far. `contact_data.id` is `null` until a contact is matched. A matched contact that is already fresh is usually `completed` right away; when `requested_attributes` includes contact details such as `email`, it can stay in progress until they are fetched. A stale one reads `refreshing`.
* **In the background** refreshes the stale contacts and searches for the ones it could not match. You poll for the results; a `webhook` can optionally tell you when the request completes.

A contact is **stale** when its LinkedIn profile was last verified more than `contact_refresh_time` days before the request was created (30 days when the parameter is omitted).

---

## Lifecycle

### Submit profiles

`POST /api/v1/contact_profile_refresh_requests` with a list of `profiles`. Each profile should carry at least one identifier:

* `ds_contact_id` — Dealsignal's contact ID (most reliable when you have it — send it without other identifiers; adding `metadata` is fine).
* `linkedin_url`
* `email`
* `first_name` + `last_name` + `company_name`

By default (`synchronous: true`) the response already contains `contact_profile_refreshes`: every profile, with the contact matched so far. The envelope carries the `unique_identifier` for polling.

### Poll for the rest

Stale and unmatched contacts are refreshed in the background. Poll until the request's `status` is `completed`:

`GET /api/v1/contact_profile_refresh_requests/request_status?unique_identifier=…`

`request_status` always returns every profile of the request, so it is the source of truth for the results. If the create response already reads `status: completed`, it is the final result for every profile and there is nothing to poll for.

Status transitions: `running → completed`.

### Optional: get notified

Set a `webhook` to be told when the request completes (see [Webhook payload](#webhook-payload)). A POST can repeat and doesn't carry every profile, so treat it as a signal and read the results with `request_status`.

---

## Quickstart

```bash
curl -X POST "https://app.dealsignal.com/api/v1/contact_profile_refresh_requests?api_key=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "profiles": [
      { "ds_contact_id": 9207460 },
      { "linkedin_url": "https://www.linkedin.com/in/jane-doe-example",
        "metadata": { "crm_record": "003Dn00000AbCdE" } }
    ]
  }'
```

The `api_key` may also go inside the JSON body instead of the query string. `contact_refresh_time` is omitted here, so the 30-day freshness rule applies and the response echoes it as `null`.

The response is the request envelope plus every profile with the contact matched so far:

```json
{
  "id": 301,
  "unique_identifier": "d4d8e35d-932e-42e9-a9c2-0dabbef16f46",
  "webhook": null,
  "status": "running",
  "contact_refresh_time": null,
  "user": { "id": 10, "email": "user@example.com", "name": "User Name" },
  "contact_profile_refreshes": [
    {
      "custom_columns": { "ds_contact_id": 9207460 },
      "contact_data": { "id": 9207460, "first_name": "Jane", "search_status": "completed", "match_status": "found_and_verified", "...": "…" }
    },
    {
      "custom_columns": { "linkedin_url": "https://www.linkedin.com/in/jane-doe-example", "metadata": { "crm_record": "003Dn00000AbCdE" } },
      "contact_data": { "id": null, "search_status": "refreshing", "match_status": "not_found", "...": "…" }
    }
  ]
}
```

Then poll until `status` is `completed`. `response_format=summary` is the lightest check; drop it to fetch the profiles:

```bash
curl "https://app.dealsignal.com/api/v1/contact_profile_refresh_requests/request_status\
?api_key=YOUR_API_KEY\
&unique_identifier=d4d8e35d-932e-42e9-a9c2-0dabbef16f46\
&response_format=summary"
```

To be notified when the request completes, add `"webhook": "https://your-server.com/webhooks/dealsignal"` to the create call — then still read the results with `request_status`.

> **Tip**
>
> Send `Accept: application/json` from API clients so that authentication errors come back as JSON.

---

## Request fields

| Field                    | Required | Default                                                | Description                                                                                                                                                                                                                                                                                                                                    |
| ------------------------ | -------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api_key`                | yes      | —                                                      | In the query string **or** the JSON body.                                                                                                                                                                                                                                                                                                      |
| `profiles`               | yes      | —                                                      | Array of profile objects (below).                                                                                                                                                                                                                                                                                                              |
| `contact_refresh_time`   | no       | 30 days                                                | Freshness threshold in days. Contacts verified more recently are returned as they are; older ones are refreshed. `0` refreshes every contact not verified on the day of the request. Omit for the 30-day default (the response then echoes `null`).                                                                                            |
| `webhook`                | no       | —                                                      | Optional URL to notify when the request completes. Omit it (or send `""`) to rely on polling alone — `request_status` is where the results come from either way.                                                                                                                                                                               |
| `synchronous`            | no       | `true`                                                 | `true`: every profile comes back inline with the contact matched so far; an unmatched profile has `contact_data.id` `null` and is either still in progress or already closed. `false`: only the request envelope is returned; poll `request_status` for the profiles. The background refresh runs either way.                                  |
| `find_match_and_refresh` | no       | `true`                                                 | When `false`, profiles that do not match a Dealsignal contact with a valid LinkedIn profile are closed at creation instead of being searched for in the background: with `contact_data.id` `null` when no contact matched, as `completed` when the matched contact has no valid LinkedIn profile. Matched contacts are refreshed as usual.     |
| `requested_attributes`   | no       | `[{ "name": "contact_profile", "priority_order": 0 }]` | Data to request for each profile. Sets the maximum credits a profile can cost — see [Pricing](#pricing).                                                                                                                                                                                                                                       |
| `test`                   | no       | `false`                                                | `true` matches against test contacts only, completes immediately and consumes no credits. It always behaves as `synchronous: true`, and your balance must still cover the request. A profile with no matching test contact is also closed `completed`, with `contact_data.id` `null`. In test mode, read the results from the create response. |

> **Warning**
>
> `synchronous` does **not** mean "wait for enrichment". The call doesn't wait for the background refresh; the flag only decides whether the profiles are also returned inline or only through `request_status`.

### Profile object

| Field                                     | Description                                                                                                       |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `ds_contact_id`                           | Existing Dealsignal contact ID. Profiles that carry it count as `requested_with_ds_contact_id` in credit metrics. |
| `first_name`, `last_name`, `company_name` | Name + company triple, used to match when no stronger identifier is given.                                        |
| `email`                                   | Contact email.                                                                                                    |
| `linkedin_url`                            | LinkedIn profile URL.                                                                                             |
| `metadata`                                | Arbitrary object, echoed back inside `custom_columns`.                                                            |

A profile with no identifier and no complete name + company triple is closed at creation, with `contact_data.id` `null` and the remark `Invalid input data!`. So is a profile that carries only a `linkedin_url` (plus `metadata`) that isn't a linkedin.com URL, such as a shortened link.

> **Info**
>
> **The whole profile object is echoed back.** Every profile in every response and webhook payload carries `custom_columns` — the object exactly as you submitted it. It is the only correlation key in the webhook payload, so put your own record ID in `metadata`. Put your own fields in `metadata`; don't add other keys to the profile object.

---

## Pricing

Each completed profile with a matched contact is charged in credits. `requested_attributes` sets the most a profile can cost:

| `requested_attributes` (all names within)                              | Tier                                   | Maximum credits per profile |
| ---------------------------------------------------------------------- | -------------------------------------- | --------------------------- |
| `contact_profile` only, or omitted                                     | Contact Profile                        | 0.25                        |
| … plus `email`                                                         | Lead Profile                           | 1.00                        |
| … plus `corporate_phone`                                               | Full Prospect Profile                  | 1.25                        |
| … plus any of `direct_phone`, `mobile_number`, `cma`, `personal_email` | Full Prospect Profile with Direct Dial | 1.50                        |

A profile is charged at the highest tier its data actually supports, up to that maximum. For example, if you request `email` but no verified work email is available, the profile is charged as a Contact Profile.

* **Balance check.** The call is rejected with `422` when your balance is too low for the submitted profiles. Keep a margin: a profile can cost up to the maximum for the attributes you request.
* **Every refresh is charged.** A contact you have refreshed before is charged again when you refresh it through this endpoint.
* **Not charged:** profiles that end without a matched contact (`contact_data.id` `null`) or `not_found`, profiles whose contact has no current position, and every profile of a `test` request.

> **Note**
>
> Send only the seven names listed above. Other names are not supported and can be charged at the highest tier. Whatever you request, `contact_data` always carries the same fixed set of keys, and emails and phones are not part of it.

---

## Response shape

Both the create call and `request_status` return the **request envelope**:

| Field                       | Description                                                                                                                                                                                                                  |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                        | Request ID.                                                                                                                                                                                                                  |
| `unique_identifier`         | UUID to poll with.                                                                                                                                                                                                           |
| `webhook`                   | The `webhook` URL you sent; `null` when omitted.                                                                                                                                                                             |
| `status`                    | `running` while background processing continues; `completed` once every profile is resolved — possibly already in the create response.                                                                                       |
| `contact_refresh_time`      | The threshold you sent, echoed back; `null` when omitted.                                                                                                                                                                    |
| `user`                      | The authenticated account (`id`, `email`, `name`).                                                                                                                                                                           |
| `contact_profile_refreshes` | The profiles. Create call: present only for `synchronous: true`. `request_status`: a flat **array** for a synchronous request, a paginated **object** (`total`, `page_no`, `page_size`, `profiles`) for an asynchronous one. |

Each profile is `{ "custom_columns": {…}, "contact_data": {…} }`. `contact_data` always carries the same **33 keys**, whatever you requested:

`id`, `first_name`, `last_name`, `logo_url`, `company_name`, `title`, `changed_company`, `changed_title`, `linkedin_url`, `company_website`, `seniorities`, `departments`, `contact_industry`, `contact_location`, `contact_country`, `contact_state`, `contact_city`, `skills`, `profile_completeness`, `enriched_by`, `contact_updated_at`, `primary_position`, `contact_headline`, `contact_about_me`, `contact_education`, `current_company_and_title`, `previous_companies_and_titles`, `search_status`, `match_status`, `remark`, `enrichment_status`, `current_position_details`, `old_position_details`.

Six are nested:

* `primary_position` and `current_company_and_title`: objects of `company`, `title`, `company_linkedin_url`, `company_domain`.
* `previous_companies_and_titles`: an array of the same object.
* `contact_education`: an array of `university`, `major`, `degree`, `start_date`, `end_date`, `logo_url` (keys without a value are omitted).
* `current_position_details` / `old_position_details`: arrays of `title`, `company_name`, `company_linkedin_url`, `start_date`, `end_date`, `country`, `company_logo_url`.

`profile_completeness` is one of `complete`, `rich`, `basic`, `partial`, `poor`. Contact-derived fields are `null` until a contact is matched, except `current_company_and_title` (`{}`) and `previous_companies_and_titles` (`[]`).

`current_company_and_title` and `previous_companies_and_titles` are snapshots that may predate the refresh. For the refreshed state, read `title`, `company_name`, `primary_position` and the position details.

`primary_position` is selected independently of `ds_primary_company` / `ds_primary_title` in the [`changes` format](#response_formatchanges-and-the-ds_-fields) and can differ from it on some contacts. The change verdict (`changed_company`, `changed_title` and the `ds_*` fields) is always about the `ds_primary_*` position.

| Status field                        | Values                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `search_status`                     | Processing state of the profile. `completed` and `not_found` are final; `refreshing` means the profile is being refreshed. Any other value means the profile is still in progress — rely on the request's `status` rather than on specific in-progress values. `null` for a profile that was closed without a final status when the request completed. Whether a contact was matched is told by `contact_data.id`, not by `search_status`: a finished profile with `contact_data.id` `null` has no matched contact. |
| `enrichment_status`                 | Human-readable label of `search_status`: `Finding A Match`, `Found And Refreshing`, `Found And Enriching`, `Completed`, `Not Found`; `null` for states without a label. A profile that finished (`completed` or `not_found`) without a match reports `Not Found`.                                                                                                                                                                                                                                                   |
| `match_status`                      | `found_and_verified`, `found_and_unverified` (matched, not verified by this refresh), `invalid_li_profile`, `not_found`, or a verification note (`Verified`, `Contact Not Found`, `Invalid Source Data - …`). Don't use it to decide whether a profile is finished — use `search_status` and the request's `status`.                                                                                                                                                                                                |
| `changed_company` / `changed_title` | Always `true` or `false`. Whether the contact's **primary** employer / title changed compared with your account's previous refresh of this contact. Same values as `ds_company_change` / `ds_title_change` below. `false` also covers "no verdict" — see [`undetermined`](#response_formatchanges-and-the-ds_-fields).                                                                                                                                                                                              |

### Pagination

`page_no` and `page_size` apply **only to asynchronous requests**. Defaults are `1` and `50`; both must be `1` or greater. The wrapper echoes the values you sent — a query-string value comes back as a string (`"2"`), an omitted one as the integer default. Typed clients should accept both.

---

## Webhook payload

The webhook is optional and is only a completion signal: read the results with `request_status`, which always returns every profile.

When the request **completes**, Dealsignal POSTs to your `webhook` as JSON (`Content-Type: application/json`, 60-second timeout). The body is a **bare JSON array** of profile objects in exactly the shape above — **no envelope**, so neither `id` nor `unique_identifier` is included. Correlate through `custom_columns`.

A create response that reads `status: completed` is the final result for every profile. POSTs for such a request can still arrive, even before the create response (the request completed during the create call); treat them as duplicates.

Up to two POSTs are sent, one after the other. Profiles that were already `completed` in a synchronous create response, with or without a matched contact, are normally not sent again: read them from the create response.

1. **Refreshed profiles** — profiles that completed with a matched contact. Skipped when there is nothing to report.
2. **Not-found profiles** — profiles that ended `not_found` without a matched contact (`contact_data` carries only the status fields). Always sent; with nothing to report the body is empty (not `[]`).

The webhook does not carry every profile of every request; `request_status` always returns all of them.

```json
[
  {
    "custom_columns": { "email": "ana.ruiz@globex.example", "metadata": { "crm_record": "003Dn00000XyZ99" } },
    "contact_data": { "id": 9207462, "first_name": "Ana", "search_status": "completed", "match_status": "found_and_verified", "...": "…" }
  }
]
```

> **Warning**
>
> **Build an idempotent receiver.**
>
> * Deliveries can repeat. If a delivery fails (non-2xx, timeout, connection error), it is sent again the next time you call `request_status` — if your endpoint was down, call it to receive the results. You may also receive additional deliveries with an empty body. Deduplicate on your own key in `custom_columns.metadata`.
> * Accept an empty body. Acknowledge each POST with a 2xx status right away, and process it after responding.
> * Webhook requests are not signed. Use a hard-to-guess URL (for example with a secret token in it) and act only on profiles you can match to your own records.

---

## Response formats for polling

`request_status` takes an optional `response_format`:

* **omitted** — the full envelope described above.
* **`summary`** — `{ "request_id", "status", "unique_identifier" }` only. The smallest response for checking whether the request is done.
* **`changes`** — job-change monitoring: the status fields plus a compact `profiles` collection carrying the `ds_*` change-detection fields instead of the full `contact_data`.

### `response_format=changes` and the `ds_*` fields

```json
{
  "request_id": 304,
  "status": "completed",
  "unique_identifier": "8e4f1a6b-2c7d-4b9e-93f0-5a1d6c8b2e74",
  "profiles": [
    {
      "custom_columns": { "first_name": "Sam", "last_name": "Lee", "company_name": "Acme", "linkedin_url": "https://www.linkedin.com/in/sam-lee-example" },
      "id": 456,
      "first_name": "Sam",
      "last_name": "Lee",
      "linkedin_url": "https://www.linkedin.com/in/sam-lee-example",
      "search_status": "completed",
      "match_status": "found_and_verified",
      "ds_position_state": "single_current",
      "ds_current_position_count": 1,
      "ds_primary_company": "Globex",
      "ds_primary_title": "Senior Engineer",
      "ds_primary_start_date": "2026-09-01",
      "ds_position_primacy_confidence": "high",
      "ds_baseline_primary_company": "Acme",
      "ds_baseline_primary_title": "Engineer",
      "ds_first_seen": false,
      "ds_company_change": true,
      "ds_job_changes": true,
      "ds_title_change": true,
      "ds_company_change_type": "job_change",
      "ds_title_change_type": "title_change_promotion",
      "ds_change_events": [
        { "name": "job_change", "confidence": 100, "previous_company": "Acme", "current_company": "Globex" },
        { "name": "title_change", "confidence": 100, "previous_title": "Engineer", "current_title": "Senior Engineer" }
      ],
      "ds_change_description": "Moved from Engineer of Acme to Senior Engineer of Globex",
      "ds_change_status": { "company": "determined", "title": "determined", "reasons": [] }
    },
    {
      "custom_columns": { "email": "john@globex.example" },
      "id": null,
      "first_name": null,
      "last_name": null,
      "linkedin_url": null,
      "search_status": "not_found",
      "match_status": "not_found"
    }
  ]
}
```

`profiles` follows the same synchronous / asynchronous split as the full format (flat array vs. paginated wrapper).

| Group                     | Fields                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Identity (always present) | `custom_columns`; `id` (matched contact, `null` until matched — as in the full format, a finished profile with `id` `null` has no matched contact); `first_name` / `last_name` / `linkedin_url` — the contact's identity at the time the verdict was computed, `null` until then; `search_status`; `match_status` (the current value — it can change after completion).                                                                                                                                                                                                                                                                                                   |
| Position                  | `ds_position_state` (`single_current`, `multiple_current`, `no_current`, `undetermined` — current positions exist but none has a start date); `ds_current_position_count` (`0` under `no_current` / `undetermined`); `ds_primary_company` / `ds_primary_title` / `ds_primary_start_date` / `ds_position_primacy_confidence` (`high` / `medium` / `low`) — the contact's primary position after the refresh, the one the verdict is about; **omitted** under `no_current` / `undetermined`.                                                                                                                                                                                |
| Baseline                  | `ds_baseline_primary_company` / `ds_baseline_primary_title` — the primary position at your account's **previous** refresh of this contact. Never omitted while the `ds_*` fields are present; `null` on the first refresh, and when the previous refresh found no current position. `ds_first_seen` — `true` when Dealsignal had no record of this contact before this request.                                                                                                                                                                                                                                                                                           |
| Verdict                   | `ds_company_change`, `ds_title_change` (= `changed_company` / `changed_title`), `ds_job_changes` (the **set** of current employers changed); `ds_company_change_type` (`job_change`, `left_employer`, `returned_to_work`, `newly_added`, `dropped_role`, `consolidated`, `added_role`, `moved_within_corporate_family`, `no_change`, `undetermined`); `ds_title_change_type` (`title_change_promotion`, `title_change_lateral`, `title_change_demotion`, `title_gained`, `title_lost`, `newly_added`, `no_change`, `undetermined`); `ds_change_events` (`{ name, confidence, …details }`); `ds_change_description`; `ds_change_status` (`{ company, title, reasons[] }`). |

**When the `ds_*` fields are absent.** They are **omitted** (not `null`) while no verdict is available for the profile: the refresh hasn't concluded for the profile yet, no contact was matched, or the profile was processed before change detection became available.

**`undetermined` means "no verdict", not "no change".** When a verdict cannot be reached, `ds_change_status.company` / `.title` read `undetermined` (with `reasons`) and the booleans read `false`. Treat the employer as "assessed, nothing changed" only when the `ds_*` keys are present, `ds_change_status.company` is `determined`, `ds_company_change` is `false` and `ds_baseline_primary_company` is not `null` — likewise `ds_change_status.title`, `ds_title_change` and `ds_baseline_primary_title` for the title. A `null` baseline means there was no previous primary position to compare against; on a first refresh the verdict only establishes the baseline. Never infer "no change" from absent keys or from an `undetermined` verdict. The full format's `changed_company` / `changed_title` read `false` in both cases; use `response_format=changes` when the difference matters.

**What the verdict compares.** `ds_company_change` / `ds_title_change` — and the full format's `changed_company` / `changed_title` — say whether the contact's **primary position changed relative to your account's previous refresh of this contact**. The verdict has no look-back window: `contact_refresh_time` doesn't limit how far back the comparison reaches; it decides which contacts get refreshed. The **first refresh of a contact for your account** has no baseline and reports `false` / `false` with `ds_baseline_*` `null`. If the contact already had positions, `ds_change_status` is `undetermined` with reason `no prior snapshot`; if this refresh delivered the contact's first positions, both change types are `newly_added` (`no_change` when those positions have all already ended). Either way it establishes the baseline for the next refresh. Moves inside a corporate family, a renamed employer, or a role that ends and resumes at the same employer never set `ds_company_change`.

**The verdict does not change.** It is computed once, when the refresh concludes for that profile — the profile can still be in progress while requested contact details are fetched. Later polls, the full format and the webhook all return the same values.

---

## Errors

| HTTP  | Body                                                                                                            | When                                        |
| ----- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `401` | `{ "message": "Invalid api key" }`                                                                              | `api_key` present but invalid.              |
| `401` | `{ "message": "You are not authorized to access this page.", "authorization_failure": true, "inactive": true }` | Account inactive.                           |
| `401` | `{ "message": "Your subscription has expired" }`                                                                | Subscription expired.                       |
| `422` | `{ "message": "No profile provided!" }`                                                                         | Create: empty `profiles`.                   |
| `422` | `{ "message": "Insufficient credits, you have N credits remaining and your request requires M credits." }`      | Create: balance does not cover the request. |
| `422` | `{ "message": "Invalid Profile Refresh Request Id or identifier!" }`                                            | `request_status`: no request matches.       |

---

## Defaults and behaviour notes

| Setting                 | Default                                          |
| ----------------------- | ------------------------------------------------ |
| Freshness window        | 30 days (when `contact_refresh_time` is omitted) |
| Page size / page number | 50 / 1                                           |

* **Stop polling once `status` is `completed`.** Polling has no fee of its own, but if you set a `webhook`, further `request_status` calls can trigger additional deliveries to it. Make the receiver idempotent.
* **Older requests stay available.** A valid `unique_identifier` keeps working after the request has completed.

---

## Endpoint reference

| Endpoint                                                                                                                                         | Method  | Purpose                                                   |
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | --------------------------------------------------------- |
| [`/api/v1/contact_profile_refresh_requests`](/api-reference/contact-profile-refresh-requests/create-contact-profile-refresh-request)             | `POST`  | Create a Contact Profile Refresh Request.                 |
| [`/api/v1/contact_profile_refresh_requests/request_status`](/api-reference/contact-profile-refresh-requests/get-contact-profile-refresh-request) | `GET`   | Poll status and results (`full`, `summary` or `changes`). |
| [Contact Profile Refresh Request completed](/api-reference/contact-profile-refresh-requests/contact-profile-refresh-request-completed)           | webhook | Payload POSTed to your `webhook` on completion.           |