Yes, Cargo writes enriched accounts back to HubSpot natively: upsertRecords updates the companies whose property matches your value and creates one when none does, updateRecords only updates, and insertRecord only creates. To match by record ID, then domain, then name and skip ambiguous matches, count the matches with countRecords first and send only the rows with exactly one match to an update.
This page exists because coding agents keep missing that. In a study of 480 coding-agent runs across 31 go-to-market jobs, measured on 2026-10-09, the job “sync enriched accounts to HubSpot” came up 15 times. All 15 runs wrote their own sync script, and 3 of them walked away concluding that Cargo “has no create/upsert path”. It has three.
The three write actions #
All three take an objectType (companies here) and a list of mappings, each a HubSpot property name and the value to write. Update and upsert also take one matchingPropertyName and one matchingValue, and find records with an exact-equality search on that property.
| Action | No record matches | One record matches | Several records match |
|---|---|---|---|
insertRecord | Creates one | Creates one more | Creates one more |
updateRecords | Writes nothing, returns an empty array | Updates it | Updates every match |
upsertRecords | Creates one, including the matched value | Updates it | Updates every match |
Three details decide how you use them:
- Several matches are all updated. Neither update nor upsert treats a duplicate as an error. If two companies share a domain, both get the write. That is why the ambiguity check below runs before the write, not inside it.
- A record ID that no longer exists fails the run. Matching on
hs_object_idreads the record by ID instead of searching. A stale ID (the record was merged or deleted) fails that row; it does not create a duplicate. skipIfExistprotects what a rep typed. Set it on a mapping and, on update, the property is written only when it is empty in HubSpot. On create, every mapping is written.
The connector also has countRecords, which returns count for a filter, and matchRecords, which returns the first matching record only. Use countRecords for the ambiguity check: a first match cannot tell you there was a second.
Match by record ID, then domain, then name #
Use the strongest key each row has, and stop at the first one that gives an answer:
- Record ID (
hs_object_id). If an earlier sync stored the HubSpot ID on your account, update by it. It is the only key that cannot be ambiguous. - Domain (
domain). The key most enriched accounts carry. Count first: exactly one match is an update, more than one is a skip. - Name (
name). The weakest key, used only when there is no domain. In Cargo’s own HubSpot, on 2026-10-16, an exact-match count on the name “Cargo” returned 2 companies; the count on the domain getcargo.io returned 1.
Skip ambiguous matches. A row whose domain or name matches more than one company is not written. Send it to a person instead: picking one of two duplicates by rule is how a sync writes enrichment onto the wrong account, and merging them is a separate decision. When no key matches anything, create the company with upsertRecords on domain, so the new record carries its domain and the next run finds it.
Run it from the CLI #
Install the CLI, sign in, and find the actions. Each result carries the action object the commands below take:
npm install -g @cargo-ai/cli
cargo-ai login --email you@company.com
cargo-ai orchestration action list upsert --kind connector --integration-slug hubspot
Every record you pass to execute-batch is the action’s input. Extra keys, like a rowId of your own, are carried through to the results file, which is how you join a count back to the account it belongs to.
1. Accounts with a HubSpot ID: update by ID.
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"hubspot","actionSlug":"updateRecords"}' \
--records '[{"objectType":"companies","matchingPropertyName":"hs_object_id","matchingValue":"18342937415","mappings":[{"propertyName":"numberofemployees","value":120},{"propertyName":"description","value":"Payroll API for marketplaces","skipIfExist":true}]}]' \
--wait-until-finished
2. Accounts without an ID: count the matches by domain.
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"hubspot","actionSlug":"countRecords"}' \
--records '[{"rowId":"acc_1","objectType":"companies","filter":{"conjonction":"and","groups":[{"conjonction":"and","conditions":[{"propertyName":"domain","operator":"is","values":["acme.com"]}]}]}}]' \
--wait-until-finished
cargo-ai orchestration batch download --uuid <batch-uuid>
batch download returns a URL to a CSV: your input columns, including rowId, plus cargo_count. Note the spelling conjonction: it is the field name, and the filter is rejected without it.
3. Write each row by what the count said.
cargo_countis 1: run step 1’s command with"matchingPropertyName":"domain"and the domain asmatchingValue.cargo_countis more than 1: skip the row and list it for review.cargo_countis 0: repeat the count with"propertyName":"name". One match is an update byname, more than one is a skip, and none is a create:
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"hubspot","actionSlug":"upsertRecords"}' \
--records '[{"objectType":"companies","matchingPropertyName":"domain","matchingValue":"acme.com","mappings":[{"propertyName":"name","value":"Acme"},{"propertyName":"numberofemployees","value":120}]}]' \
--wait-until-finished
Pass --limit 5 to any of these to run a pilot on the first five records and check the result in HubSpot before the full set.
What each action returns #
insertRecord,updateRecordsandupsertRecordsreturndataas an array of HubSpot records, each shapedid,properties,createdAt,updatedAt,archived. A create returns one record; an update returns one per record it changed; an update that matched nothing returns an empty array.countRecordsreturnscount.matchRecordsreturnsmatch(true or false) andrecord, the first match or null.
None of the HubSpot actions carries a credit price, and the count runs behind the figures on this page each reported 0 credits used.
Contacts work the same way #
The same actions take "objectType":"contacts". Match by hs_object_id first, then email, and skip what matches twice. To keep the sync running as accounts are enriched, put the same steps in a play that fires when a record changes, rather than a script on a laptop. The broader rules for keeping the CRM clean once data flows into it are in CRM hygiene as code, and the CRM enrichment cookbook fills the identifiers this matching depends on.
Frequently asked questions #
Yes. The HubSpot connector’s upsertRecords action searches for records
whose property equals your value, updates every one it finds, and creates a
record when it finds none. updateRecords only updates and insertRecord
only creates.
updateRecords and upsertRecords update both. To skip ambiguous matches,
run countRecords on the domain first and only write the rows where the
count is exactly 1.
Yes, by exact equality on the name property, through the same
matchingPropertyName field. Use it last, after record ID and domain,
because company names are the key most likely to match more than one record.
No. Matching on hs_object_id reads the record by ID, and an ID that no
longer exists fails that row. Neither update nor upsert creates a
replacement.
Only if you let it. Set skipIfExist on a mapping and an update writes that
property only when it is empty in HubSpot.