Prereq: read bulk-operations/SKILL.md first — JSONL piping, dry-run/digest, history, and rate-limit hygiene live there. This skill is the upsert-by-natural-key workflow on top.
The core move: upsert, not search-then-create
hubspot objects upsert --type X --id-property <natural-key> reads JSONL on stdin and creates-or-updates each row in one CLI call (the CLI batches 100 rows per API request), keyed by a property (email for contacts, domain for companies). No race window, no branching. Do not loop search → empty? → create.
Per line in: {"id":"[email protected]","properties":{"firstname":"Jane","jobtitle":"VP"}}
Per line out: {"id":"123","ok":true,"data":{...}} or {"ok":false,"error":{...}}. Order matches input. The CLI adds no fields of its own — data is the raw batch-upsert API result row.
CSV/JSONL → upsert stream
Reshape with jq, preview with --dry-run, then execute. upsert is irreversible, so the execute step re-pipes the SAME inputs plus the --digest/--confirm lifted from the preview line (upsert confirm = the row count, always). Always lowercase the natural key — CRM match is exact. Confirm available property names with hubspot properties list --type contacts; never hard-code a list. See bulk-operations/resources/json-patterns.md for reshape idioms.
Companies: swap --type companies --id-property domain and reshape with .domain|ascii_downcase as id.
Handle per-record OK / error output
Split with jq, inspect failure modes, retry just the failures after fixing the inputs:
The CLI does not tag rows as created-vs-updated. If the batch-upsert API result row carries a new boolean, jq -r '.data.new' /tmp/upsert.ok.jsonl | sort | uniq -c splits them; otherwise compare .data.createdAt against .data.updatedAt.
429s: split the input and rerun smaller chunks (see bulk-operations rate-limit notes). 400s usually mean a bad property name or invalid enum value — fix the reshape, rerun the failed inputs.
Destructive-op safety
upsert itself is non-destructive, but write-back can clobber populated fields. Always --dry-run first and spot-check. For bulk delete or overwrite of existing data, follow the dry-run → digest → confirm flow in bulk-operations/SKILL.md. Recovery: hubspot history --since 1h.
Match without upsert: OR-search → update
When you only want to read matches (no write-back), or the natural key isn't a CRM property, use repeated --filter flags — each flag is one OR group.
Verified cap: 5 OR groups per call. 6+ returns 400 too many filterGroups (count: N, max allowed: 5). Chunk 5 at a time:
For larger keyed enrichments, prefer upsert — one pipeline, no chunking math.

