CRM Data Enrichment: Decide Writes First
By Kooperativa Engineering
Almost every guide to CRM data enrichment is about picking a provider. That is the easy half. The half that actually determines whether the project survives contact with a sales team is the one nobody writes about: what your code does when enriched data disagrees with what a human already typed into the field.
Get that wrong and the failure mode is not a bad match rate. It is a rep opening an account they have worked for six months, finding the title they personally confirmed on a call replaced by something a vendor guessed, and never trusting the CRM again. At that point the data can be 99 percent accurate and the project is still dead.
The four write policies, and when each is correct
There are only a few real options per field, and the mistake is applying one policy globally instead of choosing per field.
- Fill-if-empty: write only when the CRM field is null. The safe default, and the right one for anything a human might have verified directly.
- Always-overwrite: enriched value wins unconditionally. Correct only for fields where the vendor is genuinely more authoritative than your reps, which in practice means firmographics like headcount or industry, not job titles.
- Overwrite-unless-manually-touched: needs a per-field provenance flag in your schema, recording whether the current value came from a human or a sync. More work up front, and the only policy that scales past a handful of fields without argument.
- Write to a shadow field: enriched value lands in a parallel field (`title_enriched` alongside `title`), and nothing is overwritten. The least disruptive way to start, and the easiest to reverse.
A useful sequencing rule: start every new enrichment integration on shadow fields, run it for a few weeks, then compare the two columns before promoting any field to a real overwrite. The comparison itself tells you which fields the vendor is actually reliable on for your specific records, which is not knowable in advance from a marketing page.
Enrichment on ingest is half a system
The second structural problem is timing. Most integrations enrich at the moment a record is created and never again, which means accuracy is a decaying function from that point on. Job changes are the dominant source of decay in B2B records, and they happen continuously rather than on your import schedule.
That leaves two mechanisms worth having, and they solve different problems. A periodic bulk refresh catches drift across the whole database. Change subscriptions catch individual moves close to when they happen, which is the part that has commercial value, since a contact who just changed employer is a materially different sales situation from one who did not.
// Records already resolved to an internal id from a first pass.
// 500 per request is the documented ceiling for /people/bulk.
async function refreshBatch(ids) {
const res = await fetch("https://kooperativa.io/api/v1/people/bulk", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KOOPERATIVA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ids: ids.slice(0, 500) }),
});
const { profiles, matched, not_found, failed } = await res.json();
// Retry `failed` (transient). Do not retry `not_found` (checked, absent).
return { profiles, matched, not_found, failed };
}Why not_found and failed have to stay separate
This distinction matters more in a CRM sync than in a one-off enrichment run, because a sync runs on a schedule forever. If a transient timeout is recorded the same way as a genuine miss, every scheduled run re-attempts records that will never resolve while quietly dropping records that would have resolved on a second try. Over months that produces a database whose coverage is worse than it should be, with no signal explaining why.
Keep the two in your own schema, not just in the API response. A `last_enrichment_status` column with a real value per record is what lets a backfill job be resumable and what lets you answer "why is this record empty" six months later without guessing.
What to measure, once it is running
Match rate is the metric everyone reports and the least useful one in isolation, because it says nothing about whether the matched values are right. Three numbers are more diagnostic.
- Agreement rate on shadow fields: of records where both a human value and an enriched value exist, how often do they agree. This is your real accuracy figure, measured on your data.
- Staleness distribution: how old is the newest available data per record, not the dataset average. A provider average tells you nothing about your specific accounts.
- Overwrite complaint volume: how many times a rep flags a field as wrong after a sync. The only metric that predicts whether the integration keeps its permissions.
The pricing shape this argues for
A design with both a periodic full refresh and per-record change subscriptions has one uncomfortable property under credit-based pricing: the refresh is the expensive part, and it is expensive precisely in proportion to how current you want the database to be. Teams on metered plans usually end up refreshing less often than they should, not because they decided that was correct, but because the credit math made the correct interval unaffordable.
That is the honest argument for a flat licence in this specific use case, and it is narrower than a general claim about pricing. Kooperativa is $499/mo, or $449/mo annually, per workspace with unlimited requests inside a shared 500 requests per minute limit, which makes refresh frequency an engineering decision rather than a budget one. The tradeoff stated plainly: we cover profile and firmographic data, not email addresses or phone numbers, so a CRM whose main gap is contact details needs a different or additional source.
Get started
Try Kooperativa
One API key. Person and company enrichment, structured search, and monitors under one flat license.
