Skip to navigation

Contact Profile Refresh Requests Overview

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

1

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.

2

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.

3

Optional: get notified

Set a webhook to be told when the request completes (see 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

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:

{
"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:

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.

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


Request fields

FieldRequiredDefaultDescription
api_keyyes—In the query string or the JSON body.
profilesyes—Array of profile objects (below).
contact_refresh_timeno30 daysFreshness 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).
webhookno—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.
synchronousnotruetrue: 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_refreshnotrueWhen 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_attributesno[{ "name": "contact_profile", "priority_order": 0 }]Data to request for each profile. Sets the maximum credits a profile can cost — see Pricing.
testnofalsetrue 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.

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

FieldDescription
ds_contact_idExisting Dealsignal contact ID. Profiles that carry it count as requested_with_ds_contact_id in credit metrics.
first_name, last_name, company_nameName + company triple, used to match when no stronger identifier is given.
emailContact email.
linkedin_urlLinkedIn profile URL.
metadataArbitrary 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.

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)TierMaximum credits per profile
contact_profile only, or omittedContact Profile0.25
… plus emailLead Profile1.00
… plus corporate_phoneFull Prospect Profile1.25
… plus any of direct_phone, mobile_number, cma, personal_emailFull Prospect Profile with Direct Dial1.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.

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:

FieldDescription
idRequest ID.
unique_identifierUUID to poll with.
webhookThe webhook URL you sent; null when omitted.
statusrunning while background processing continues; completed once every profile is resolved — possibly already in the create response.
contact_refresh_timeThe threshold you sent, echoed back; null when omitted.
userThe authenticated account (id, email, name).
contact_profile_refreshesThe 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 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 fieldValues
search_statusProcessing 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_statusHuman-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_statusfound_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_titleAlways 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.

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.

[
{
"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", "...": "…" }
}
]

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

{
"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).

GroupFields
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).
Positionds_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.
Baselineds_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.
Verdictds_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

HTTPBodyWhen
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

SettingDefault
Freshness window30 days (when contact_refresh_time is omitted)
Page size / page number50 / 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

EndpointMethodPurpose
/api/v1/contact_profile_refresh_requestsPOSTCreate a Contact Profile Refresh Request.
/api/v1/contact_profile_refresh_requests/request_statusGETPoll status and results (full, summary or changes).
Contact Profile Refresh Request completedwebhookPayload POSTed to your webhook on completion.