Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Stop Guessing at Contact Data (Enrich Dynamics 365 With an API)

Pull contacts from Dataverse, enrich each by email, and write back only the fields you trust. About 20 minutes for a 25-contact test.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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_status you 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 example https://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.
  • curl and jq.
  • 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).

bash
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.jsonl

Filter 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.

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

bash
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.

bash
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.jsonl

The 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).

bash
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.jsonl

This 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.

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

MeasureValue
Mailable people (verified, catch_all_valid, catch_all)322,167
Same filters, any email status825,690
Distinct employers (estimate)99,327

The count spent no records.

Troubleshooting

ErrorCauseFix
401 from LeadOceanMissing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is exported in the shell running the loop
429Requests above the plan's rate limit (5 a second on Free, 200 on Agency)Honour Retry-After. Keep the sleep 0.25 on Free
402The 1,000 free records are spentMove to Solo ($97 a month) or Agency ($497 a month) at /pricing
404 on enrichWe hold nothing for that identifierSkip the row. It costs nothing
Empty search resultsAn enum value with the wrong case, such as director for DirectorCall leadocean_list_enum_values. Enum values are exact strings
Export refused above 50,000 rowsPer-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 →