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.idisnulluntil a contact is matched. A matched contact that is already fresh is usuallycompletedright away; whenrequested_attributesincludes contact details such asemail, it can stay in progress until they are fetched. A stale one readsrefreshing. - In the background refreshes the stale contacts and searches for the ones it could not match. You poll for the results; a
webhookcan 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; addingmetadatais fine).linkedin_urlemailfirst_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). A POST can repeat and doesn’t carry every profile, so treat it as a signal and read the results with request_status.
Quickstart
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:
Then poll until status is completed. response_format=summary is the lightest check; drop it to fetch the profiles:
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
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
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:
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
422when 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.idnull) ornot_found, profiles whose contact has no current position, and every profile of atestrequest.
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:
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_positionandcurrent_company_and_title: objects ofcompany,title,company_linkedin_url,company_domain.previous_companies_and_titles: an array of the same object.contact_education: an array ofuniversity,major,degree,start_date,end_date,logo_url(keys without a value are omitted).current_position_details/old_position_details: arrays oftitle,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.
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.
- Refreshed profiles — profiles that completed with a matched contact. Skipped when there is nothing to report.
- Not-found profiles — profiles that ended
not_foundwithout a matched contact (contact_datacarries 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.
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 incustom_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 compactprofilescollection carrying theds_*change-detection fields instead of the fullcontact_data.
response_format=changes and the ds_* fields
profiles follows the same synchronous / asynchronous split as the full format (flat array vs. paginated wrapper).
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
Defaults and behaviour notes
- Stop polling once
statusiscompleted. Polling has no fee of its own, but if you set awebhook, furtherrequest_statuscalls can trigger additional deliveries to it. Make the receiver idempotent. - Older requests stay available. A valid
unique_identifierkeeps working after the request has completed.