Integrations
CRM API-quirks: stille merges en emoji die verdwijnt
We rolden een sales-enablement agent uit bij 24 BDR's in Utrecht. Drie weken later bleek in de audit logs dat elke leadSource-attributie was overschreven.

De BDR ging om 09:14 op een dinsdag in Utrecht aan zijn bureau zitten en plakte de e-mailhandtekening van een Nederlandse klant in een lead-notitie. De handtekening eindigde op een tulp — 🌷 — en een regel over de dochter van de klant die die week aan school begon. Onze sales-enablement agent pikte de notitie op, dedupliceerde tegen een Salesforce Lead die de vrijdag ervoor uit een LinkedIn-export was geïmporteerd, mergede de twee records en pushte het resultaat terug naar de CRM API. Het antwoord was 200 OK. De tulp was weg. De leadSource, die "LinkedIn — Joost referral" had gezegd, was overschreven met "Web - Default". Drie weken lang had niemand het door.
We hebben inmiddels veertien agents in productie staan, en deze — een sales-enablement agent voor een B2B SaaS-team van 24 man — heeft ons meer geleerd over CRM REST-quirks dan de vorige dertien bij elkaar. Het team werkte verdeeld over drie CRM's: Salesforce voor de enterprise-pipeline, HubSpot voor het SMB-spoor en Pipedrive met drie jaar oude deals die niemand wilde migreren. Hieronder de cheatsheet die eruit gerold is, gerangschikt naar welke quirks stilletjes data slopen versus welke alleen irriteren.
Tier 1: stille leadSource-overschrijvingen bij merges
Deze pakken we tegenwoordig in met een pre-merge snapshot. Als je na het lezen van deze post maar één ding doet, doe dan dit.
Salesforce Lead.merge kiest de leadSource van de master, ook als die leeg is
De composite Lead endpoints van Salesforce documenteren dat 'the master record wins' voor de meeste velden, maar die formulering verbergt een scherp randje: LeadSource op de master wint ook als de waarde op de master leeg is en die op het duplicaat de echte attributie bevat. We hebben dit in een sandbox bevestigd met zeven testrijen. De fix is volgorde-gevoelig:
curl -X PATCH \
"$SF_INSTANCE/services/data/v60.0/sobjects/Lead/$MASTER_ID" \
-H "Authorization: Bearer $SF_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
-d "{\"LeadSource\": \"$DUPLICATE_LEAD_SOURCE\"}"
# THEN run the merge. Order matters.
Als de master leeg is, patchen we hem eerst met de LeadSource van het duplicaat en pas daarna mergen we. De merge draait een PATCH van een milliseconde eerder niet terug.
HubSpot contact-merge gooit lifecyclestage-historie weg
De contacts API van HubSpot biedt POST /crm/v3/objects/contacts/merge, en de docs zijn duidelijk dat de properties van het primaire contact winnen. Wat ze minder duidelijk maken: de property-history endpoint verliest de lifecyclestage-timeline van het secundaire contact volledig. Ging het secundaire contact van MQL → SQL → Opportunity en bleef het primaire contact op 'lead', dan ziet het gemergde record eruit als een lead die nooit van zijn plek is gekomen. Voor attributiedashboards is dat fataal.
Pipedrive deal-merge laat custom fields van de niet-primaire deal stilletjes vallen
De deal-merge endpoint van Pipedrive geeft 200 OK terug met de body van de gemergde deal. Custom fields die op de niet-primaire deal staan maar niet op de primaire, zitten niet in het antwoord. Ze zitten daarna ook niet meer in de deal. Geen waarschuwingsheader, geen X-Discarded-veld in de body, niets. Wij hadden het door omdat een finance custom field — het contractreferentienummer voor facturatie — op één middag bij drie merges verdween.
Salesforce duplicate-rule bypass
De header Sforce-Duplicate-Rule-Header: allowSave=true is de aanbevolen manier om een record door een strikte duplicate rule heen te duwen. Hij slaat de dedupe ook compleet over. We zagen het terug in de audit logs: 41 leads op één middag aangemaakt, allemaal met allowSave=true, allemaal duplicaten van bestaande leads. De agent had instructie gekregen om de header bij een retry te zetten. Hij zette 'm op elke call.
convertedLeadSource stroomt niet door naar de Account
Wanneer je een Salesforce Lead converteert, krijgt het veld LeadSource op de resulterende Contact een waarde. De Account krijgt niets. Als je rapportage via Account joint, ziet elke geconverteerde lead er bronloos uit. We hebben dat opgelost door het AccountSource-veld op de Account te patchen als post-conversion stap in de workflow van de agent.
Tier 2: 200 OK met stille data loss (de emoji-tier)
Hier is de tulp gebleven.
Salesforce custom text fields en 4-byte UTF-8
Salesforce slaat tekstvelden op als Unicode, maar het storage-pad op oudere orgs kapt 4-byte UTF-8-sequenties af. De meeste emoji, waaronder 🌷, zijn 4 bytes in UTF-8. De API accepteert de payload, geeft 200 OK terug en slaat de tekst op tot de byte vóór de emoji. De rest van de string verdwijnt ook, dus een handtekeningregel met "Groet, Marieke 🌷 (school start vandaag)" wordt "Groet, Marieke ". De fix zit op org-niveau: stap over op Long Text Area of Rich Text Area, allebei met een storage-pad dat 4 bytes aankan.
HubSpot v3 versus form-submission: emoji-afhandeling
De CRM v3 API van HubSpot accepteert emoji in singleline_text-properties zonder gedoe. De legacy form-submission endpoint, POST /uploads/form/v2/..., doet dat niet. Hij geeft 200 OK terug en slaat de tekst op zonder de emoji, geen error, geen header. Valt je agent ooit terug op die endpoint (wij deden dat om marketing-attributiecookies intact te houden), dan verdwijnt elke emoji stilletjes.
Pipedrive search index laat 4-byte UTF-8 vallen
De endpoint /persons/search van Pipedrive draait een Unicode-normalisatiestap die 4-byte sequenties uit de doorzoekbare index strippt. Het persoonsrecord zelf houdt de emoji. Erna op de naam zoeken: niet. We hadden een BDR die volhield dat 'Daan 🚀 Visser' niet in het CRM stond. Hij stond er wel, geïndexeerd als 'Daan Visser' met twee spaties en zonder raket.
Salesforce Bulk API 2.0 verbergt failures in een aparte CSV
De endpoints successfulResults en failedResults van Bulk 2.0 splitsen successen en failures over aparte CSV's. De job-status endpoint geeft 200 OK terug met state: "JobComplete", ook als de helft van de records is gefaald. Checkt je agent alleen de job-status, dan vlieg je blind.
JOB_ID=$1
curl -sS "$SF_INSTANCE/services/data/v60.0/jobs/ingest/$JOB_ID/failedResults" \
-H "Authorization: Bearer $SF_TOKEN" \
| wc -l # > 1 means the job-status endpoint hid failures
HubSpot batch-update geeft COMPLETE_WITH_ERRORS terug
De HubSpot endpoint POST /crm/v3/objects/contacts/batch/update geeft een 200 OK terug met een status-veld dat ofwel "COMPLETE" ofwel "COMPLETE_WITH_ERRORS" bevat. Veel SDK's kijken alleen naar de HTTP-status en behandelen COMPLETE_WITH_ERRORS als succes. Lees de body. Altijd.
Elke API in deze tier geeft 200 OK bij gedeeltelijke data loss. Is het succescriterium van je agent 'HTTP-status 2xx', dan heb je een data quality-bom met een tijdslot.
Tier 3: alleen maar irritant
Deze kosten tijd, maar slopen niet stilletjes je data.
- Salesforce composite endpoint: sub-request failures komen terug in de body, maar de top-level HTTP is
200tenzij jeallOrNone: truezet. Zet 'm. - HubSpot associationTypeId-mismatch: een onbekend ID valt zonder waarschuwing terug op de default-associatie. Valideer ID's bij startup tegen
/crm/v4/associations/{from}/{to}/labels. - Pipedrive v1
add_time: accepteert ISO 8601, slaat op in de tijdzone van het account. Stuur timestamps mee met een expliciete offset en bevestig de opgeslagen waarde tijdens testen met een follow-up GET. - HubSpot UTM-properties: elke form-submission overschrijft
hs_analytics_sourceen consorten. Snapshot bij first touch de originele attributie naar een aparte custom property. - Salesforce PATCH op lege string: leegt het veld in plaats van het ongemoeid te laten. Strip lege strings server-side voordat je ze doorstuurt.
De referentiekaart
Dit is de kaart die we in het team-channel hebben gepinned. Rangorde is 'hoe erg verwoest dit je data als je blind live gaat'.
| # | Vendor | Quirk | Symptoom |
|---|---|---|---|
| 1 | Salesforce | Lead-merge wint op leadSource van master, ook als die leeg is | Attributie overschreven met "Web - Default" |
| 2 | HubSpot | Contact-merge gooit lifecyclestage-historie weg | Dashboards verliezen stage-timelines |
| 3 | Pipedrive | Deal-merge laat custom fields van niet-primaire deal vallen | Contractreferenties verdwijnen |
| 4 | Salesforce | allowSave=true omzeilt dedupe | Duplicaten stapelen zich op bij retries |
| 5 | Salesforce | convertedLeadSource bereikt Account niet | Geconverteerde leads ogen bronloos |
| 6 | Salesforce | 4-byte UTF-8 wordt afgekapt in tekstvelden | Strings gekapt bij de eerste emoji |
| 7 | HubSpot | Form-submission endpoint strippt emoji | Geplakte handtekeningen verliezen tekens |
| 8 | Pipedrive | Search index strippt 4-byte UTF-8 | Records bestaan maar zijn niet vindbaar |
| 9 | Salesforce | Bulk 2.0 verbergt failures in failedResults CSV | Halve job faalt stilletjes |
| 10 | HubSpot | Batch-update geeft COMPLETE_WITH_ERRORS terug | SDK-consumers missen failures |
| 11 | Salesforce | Composite geeft 200 zonder allOrNone | Partial writes blijven onopgemerkt |
| 12 | HubSpot | Foute associationTypeId valt stilletjes terug | Verkeerde associatie aangemaakt |
| 13 | Pipedrive | v1 add_time wordt opgeslagen in account-tijdzone | Timestamps N uur eraf |
| 14 | HubSpot | UTM-properties worden bij form-submit auto-overschreven | Originele attributie weg |
| 15 | Salesforce | PATCH "" leegt het veld in plaats van skippen | Velden geleegd bij partial updates |
Wat we in de agent hebben aangepast
Uit deze rollout zijn drie patronen gekomen. Eén: elke merge-call wordt voorafgegaan door een diff snapshot. We doen een GET op beide records, slaan de union van niet-lege veldwaarden op in een kleine Postgres-audittabel en POSTen pas dán de merge. Verliest het gemergde resultaat een waarde die vóór de merge wel bestond, dan PATCHt de agent 'm terug. Twee: elke batch- en bulk-endpoint heeft een follow-up reader die de per-object-status uitleest, niet alleen de HTTP-code. Drie: de agent normaliseert payloads op de uitgaande weg naar application/json; charset=utf-8 en valideert op een random sample van 1% de round-trip door het record opnieuw op te halen en strings byte-voor-byte te vergelijken. Die laatste check is wat de tulp boven water heeft gebracht.
Het snapshot-patroon is kort genoeg om in elke worker queue te plakken:
def safe_merge(primary_id, duplicate_ids, vendor_merge_fn):
snapshot = {}
for rec_id in [primary_id, *duplicate_ids]:
record = vendor_get(rec_id)
for field, value in record.items():
if value and field not in snapshot:
snapshot[field] = value
merged = vendor_merge_fn(primary_id, duplicate_ids)
for field, original_value in snapshot.items():
if not merged.get(field):
vendor_patch(merged["id"], {field: original_value})
return vendor_get(merged["id"]) # round-trip verify
De eerste keer dat we dit live zetten, sloeg de round-trip verify binnen een uur aan. Het was die keer geen emoji. Het was een Nederlandse achternaam met de ij-digraph, in HubSpot opgeslagen als twee combinerende characters maar in onze normalisatielaag als één grafeem. De bytes matchten niet, ook al renderden de strings in elke UI die we openden identiek. We hebben daarna op elke uitgaande payload een Unicode NFC-normalisatie toegevoegd. Het is geen data loss in de letterlijke zin, maar het brak de equality-check, en die equality-check is de hele bedoeling.
Detectie in productie
De cheatsheet verdient zijn huur op de dag dat een vendor onder je voeten zijn gedrag verandert. We draaien drie checks in een doorlopende loop. De eerste is een canary record per CRM, elke vijf minuten geschreven en gelezen door een kleine worker: faalt de round-trip string equality op een veld, dan gaat PagerDuty af. De tweede is een dagelijkse diff over de laatste 24 uur aan LeadSource-waarden, gesegmenteerd per bronintegratie. Verschuift een segment meer dan 10% week-op-week zonder bekende campagnewijziging, dan krijgt iemand een Slack-melding. De derde is een uurlijkse query tegen de audittabel op velden die de agent na een merge heeft moeten re-PATCHen: een piek in re-PATCHes betekent dat vendor-gedrag is verschoven, en dan komt er een nieuwe regel op de cheatsheet.
Drie weken nadat we deze checks erin hadden gezet, ving de canary een stille Salesforce-wijziging op: convertedLeadSource ging op sommige orgs het Account-record vullen, op andere niet. We zagen de drift binnen het uur. We hoefden 'm niet uit een kwartaalattributie-audit te halen waar in oktober iemand op finance had moeten vragen waarom de cijfers van Q2 waren weggelopen.
Toen we de sales-enablement AI-agent voor de Utrechtse klant bouwden, liepen we ertegenaan dat '200 OK' blijkbaar betekent 'ik heb je request ontvangen' in plaats van 'ik heb je data weggeschreven'. We hebben dat opgelost door de agent elk vendor-succesresponse te laten wantrouwen en de write op een sample te verifiëren door 'm weer in te lezen.
Het kleinste wat je vandaag kunt doen: trek de laatste 200 leads op die door een integratie zijn aangemaakt in je Salesforce-org, groepeer op LeadSource en tel hoeveel er "Web - Default" zijn terwijl de integratie iets anders had moeten zetten. Is dat aantal groter dan nul, dan heb je minstens één quirk uit deze lijst, en op dit moment in productie.
Kern
Elke CRM REST API in deze rollout gaf 200 OK terug bij gedeeltelijke data loss — is je succescheck de HTTP-status, dan heb je een data quality-bom met een tijdslot in productie staan.
FAQ
Overschrijft Salesforce echt de leadSource bij een duplicate merge?
Ja. De LeadSource van de master wint, ook als die leeg is. Is de master leeg, PATCH 'm dan eerst met de waarde van het duplicaat en roep daarna pas de merge-endpoint aan.
Waarom geeft HubSpot 200 OK terug als batch-updates falen?
De batch-endpoint geeft COMPLETE_WITH_ERRORS terug in de body, met per-object statuscodes. De HTTP-status zegt alleen of het request is ontvangen, niet of alle writes zijn gelukt.
Hoe houd ik emoji in custom CRM-velden?
Zet Salesforce-tekstvelden op Long Text Area of Rich Text Area voor 4-byte UTF-8-support. Gebruik in HubSpot de CRM v3 API in plaats van de legacy form-submission endpoint. Pipedrive blijft ze uit de search strippen.
Wat is de kleinste check die de meeste van deze quirks vangt?
Lees het record na de write op een random sample weer in en vergelijk strings byte-voor-byte. Byte-equality is voor een CRM-integratie een sterkere health check dan HTTP 2xx.