You can enrich Dynamics 365 contacts with about 40 lines of shell: read contacts from Dataverse, enrich each by email through LeadOcean, then PATCH the result back. No connector or middleware is needed. This guide gives the requests, the field mapping and the failure modes.
Most Dynamics 365 enrichment advice lists vendors. This one skips the vendor tour. A technical buyer wants to see the request, the response and the write-back, so each step below is a command you can paste. The tradeoff is plain: you run the script yourself, and nothing refreshes the table on a schedule until you schedule it.
Guessing is what happens when a sales rep opens a contact and cannot tell whether the email is live or the phone is current. The fix is a status per contact that a filter can read. LeadOcean returns email_status on every enriched person, and that one field turns "I think this works" into a rule.
TL;DR
- Time: about 20 minutes for a 25-contact test. A full table is the same script with a longer loop.
- Cost: one LeadOcean record per contact we hold. A contact we hold nothing for is a 404 and costs nothing. 25 contacts is at most 25 of the 1,000 records on the Free plan.
- Result: each contact we hold gets the email and phone we have on file, plus an
email_statusyou can filter on. Nothing is overwritten unless you say so.
Prerequisites
- A Dynamics 365 environment with the Dataverse Web API enabled, and a bearer token for it in
$DYNAMICS_TOKEN. Your org URL goes in$ORG_URL(for examplehttps://acme.crm.dynamics.com). - A LeadOcean key in
$LEADOCEAN_API_KEY. The Free plan is enough: 1,000 records for life, no card, 5 requests a second. Get one at app.leadocean.io. curlandjq.- A test view of 25 contacts. Do not start with the whole table.
Step 1: Pull the contacts you want to enrich
Query the contacts table and select only what you need. The Dataverse contact table has emailaddress1, telephone1, mobilephone, jobtitle and contactid (Microsoft Learn, October 2026).
curl -s "$ORG_URL/api/data/v9.2/contacts?\$select=contactid,fullname,emailaddress1,mobilephone&\$filter=emailaddress1 ne null&\$top=25" \
-H "Authorization: Bearer $DYNAMICS_TOKEN" \
-H "Accept: application/json" \
-H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0" \
| jq -c '.value[] | {id: .contactid, email: .emailaddress1, mobile: .mobilephone}' > contacts.jsonl
wc -l contacts.jsonlFilter to contacts that already have an email. LeadOcean enriches by one identifier: linkedin_url, email, phone or person_id. There is no name plus company lookup, so a contact with none of those has to be found by search first.
Keep the file small on purpose. 25 rows is enough to see the 404 rate, the status mix and the write-back behaviour. If most rows come back 404, your table holds mostly personal or stale addresses, and a bigger run would only repeat that result at a higher record count.
Step 2: Size the net-new list for free
Before you spend records on enrichment, check how many people in your target segment you do not have yet. A count is free and returns nothing but totals.
{
"jobLevel": ["Director"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"]
}Send that filter set to the leadocean_count_leads MCP tool, or to people search with count=true as a query parameter and limit=1. The answer is in meta.total.
This matters for planning, not for the sync itself. Enrichment fixes the contacts you have. A count tells you how many more people match your target profile, so you can decide whether the gap is bad data or missing data. Two filters are worth knowing: jobLevel takes exact strings such as Director or VP, and emailStatus narrows to addresses worth sending to. A count with an email filter is a count of people with that email status, not a promise that every address will deliver.
Step 3: Enrich one contact by email
Enrichment takes exactly one identifier. Pass reveal_email: true to get the addresses and phones we hold. It costs no extra record.
curl -s -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "jane.doe@acme.com", "reveal_email": true}'A 200 returns the person. A 404 means we hold nothing for them. Treat a 404 as "leave this Dynamics row alone", not as an error.
The response also carries has_email and has_phone, which tell you whether we hold a contact point before you read it. Since 17 October 2026, enrich also returns career history in resume_data: past roles, education and certifications. Past employers carry no company_id yet, so do not try to join them to your account table.
Step 4: Loop over the file and keep the safe fields
Read contacts.jsonl, enrich each line, and store the email status beside each result so the write-back can gate on it. Space the calls out to stay under 5 requests a second on Free.
while read -r row; do
id=$(jq -r .id <<<"$row"); email=$(jq -r .email <<<"$row")
resp=$(curl -s -w "\n%{http_code}" -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d "{\"email\":\"$email\",\"reveal_email\":true}")
code=${resp##*$'\n'}; body=${resp%$'\n'*}
[ "$code" = "200" ] || { echo "$id skip $code"; sleep 0.25; continue; }
jq -c --arg id "$id" '{
id: $id,
status: .data.contact_data.email_status,
phone: (.data.contact_data.contact_phones[0].phone // null)
}' <<<"$body" >> enriched.jsonl
sleep 0.25
done < contacts.jsonlThe email_status field is the gate. The default mailable set is verified, catch_all_valid and catch_all. Anything else (risky, unknown, invalid, role) should not overwrite a working address. We do not refund bounces, so read the status before you send.
Step 5: Write back to Dataverse without creating rows
Update each contact with a PATCH and an If-Match: * header. Dataverse treats a PATCH as an upsert, so without the header a deleted contact would be recreated. With If-Match: * the update fails instead (Microsoft Learn, October 2026).
while read -r row; do
id=$(jq -r .id <<<"$row"); phone=$(jq -r '.phone // empty' <<<"$row")
[ -n "$phone" ] || continue
curl -s -o /dev/null -w "$id %{http_code}\n" -X PATCH \
"$ORG_URL/api/data/v9.2/contacts($id)" \
-H "Authorization: Bearer $DYNAMICS_TOKEN" \
-H "Content-Type: application/json" \
-H "OData-MaxVersion: 4.0" -H "OData-Version: 4.0" \
-H "If-Match: *" \
-d "{\"mobilephone\": \"$phone\"}"
done < enriched.jsonlThis writes only mobilephone, and only where a number exists. Run it on the 25-contact test first, then open three records in Dynamics and compare. Add emailaddress1 to the payload once you trust the status gate.
Two habits keep this safe at scale. First, log every PATCH status code, because a 412 or 404 means the row changed or vanished since Step 1. Second, write to a custom column such as a "LeadOcean phone" field at first, and promote values into mobilephone after a human review. That costs one extra column and removes the risk of replacing a number a rep typed by hand.
What you get
The shape of one enrich response, from the API reference (api.leadocean.io/openapi.json, October 2026), with placeholder values. This is the response shape from the API reference, not a real person.
{
"success": true,
"data": {
"contact_data": {
"has_email": true,
"has_phone": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [
{ "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
],
"contact_phones": [
{ "phone": "+14155550133", "type": "mobile", "priority": 1 }
],
"contact_current_experiences": []
}
}
}The free count from Step 2, run on 21 October 2026 with leadocean_count_leads: US, job level Director, function Sales & Business Development.
| Measure | Value |
|---|---|
| Mailable people (verified, catch_all_valid, catch_all) | 322,167 |
| Same filters, any email status | 825,690 |
| Distinct employers (estimate) | 99,327 |
The count spent no records.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 from LeadOcean | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported in the shell running the loop |
| 429 | Requests above the plan's rate limit (5 a second on Free, 200 on Agency) | Honour Retry-After. Keep the sleep 0.25 on Free |
| 402 | The 1,000 free records are spent | Move to Solo ($97 a month) or Agency ($497 a month) at /pricing |
| 404 on enrich | We hold nothing for that identifier | Skip the row. It costs nothing |
| Empty search results | An enum value with the wrong case, such as director for Director | Call leadocean_list_enum_values. Enum values are exact strings |
| Export refused above 50,000 rows | Per-request export ceiling (500,000 rows a day) | Split the pull into several exports by country or job function |
FAQ
Does this need a Dynamics 365 connector?
No. The script uses two REST APIs and a bearer token. A scheduled Power Automate flow or Azure function can run the same requests if you want it off your laptop.
How many records does a full table cost?
One per contact we hold. A table of 10,000 contacts is up to 10,000 records, less every 404. Free has 1,000 records once. Solo is $97 a month and Agency is $497 a month, with a fair-use pace instead of credits.
Why enrich by email and not by name?
The enrich endpoint takes one of linkedin_url, email, phone or person_id. It does not match on name plus company. Search by domain first, then enrich by person_id.
Can I enrich accounts as well as contacts?
Yes. POST /v1/companies/enrich takes a company identifier, and GET /v1/companies/{domain}/people lists people at a domain, up to 10,000 rows. The same Dataverse pattern applies to the accounts table.
How fresh is the data?
The dataset is refreshed monthly. Each record also carries its own fetched_at date, so you can skip rows you enriched recently.
Related reading: see the Dynamics 365 enrichment tool roundup, what data enrichment actually costs and the Zendesk enrichment tool ranking. All blog guides are listed in the blog hub.
Enrich your first 25 Dynamics contacts for free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →