assert (upsert) endpoints, so an import is idempotent: re-running the same job will update the existing rows instead of creating duplicates.
The CRM API requires a workspace on a plan that includes CRM. Custom objects require a plan that supports them — see the CRM overview for the full schema.
Prerequisites
Before you start, make sure you have:- An API token with the
crm:writescope.crm:readis sufficient for discovery, but every write call requirescrm:write. - A source export — typically a CSV, JSON dump, or a query against the source system’s API.
- A clear dedup key for each entity in your source data:
- Companies → a domain (e.g.
acme.com) - People → an email address
- Custom records → any attribute you mark
isUnique
- Companies → a domain (e.g.
Migration flow at a glance
1. Discover the target schema
Before importing, list all CRM objects and their attribute definitions. This tells you exactly which keys your payloads need to use.2. Add custom attributes
If your source has fields that don’t fit the standard schema (a HubSpotLifecycle Stage, a Salesforce Lead Score, an Airtable status), create a custom attribute before you import.
Add a custom attribute with POST /crm/objects/{slug}/attributes. The attribute key is auto-generated from the label.
If you plan to use a custom attribute as the matching key for
assert (e.g. an external_id carried over from the source system), set isUnique: true when you create it.
3. Create custom objects
If the source system has entities that aren’t people or companies — deals, properties, tickets, vehicles — create a custom object before importing its records.id, created_at, updated_at). Add the rest with the attributes endpoint described above — at minimum, add a unique external_id (or equivalent) text attribute so you can upsert records by it.
4. Upsert companies
ThePUT /crm/companies endpoint is the recommended path for imports. Send the full payload and let Zeeg decide whether to create or update:
200 OK— a company with the samedomainalready existed and was updated.201 Created— no match was found and a new record was created.
domain — no other attribute is supported on the companies upsert.
domain → company.id map in memory as you iterate — you will need the company UUID when upserting people in the next step.
5. Upsert people
People work the same way, but the matching attribute is configurable. Useemail for typical contact imports — it matches against both the primary and any secondary emails on a person.
matchingAttribute query parameter accepts any standard or custom attribute slug on people. If you tracked a stable external_id from the source system (and created it as a unique custom attribute in step 2), pass matchingAttribute=external_id instead — that’s the most robust dedup key for ongoing syncs because it survives email changes.
6. Upsert custom records
Custom records use the same upsert pattern viaPUT /crm/{objectSlug}, with matchingAttribute set to any unique attribute on the object (typically external_id or a domain-specific key like sku).
The request body is a flat object of attributeSlug → value pairs.
7. Link records via relations
ThecompanyId field on a person is enough for the standard person-to-company link. For any other relation — many-to-many, custom-object-to-custom-object, person-to-deal — use the relation endpoint.
PATCH /crm/{objectSlug}/{recordId}/relation adds or removes IDs from a relation or user attribute. Supply add, remove, or both. The same ID cannot appear in both arrays.
user attributes (e.g. setting an Account Owner on a company) — pass the workspace member’s ID in add.
Batching, rate limits, and error handling
The CRM API rate-limits per endpoint and per workspace — see Rate Limits for the general policy. For a bulk import:- Send sequentially or in small parallel batches. Five to ten parallel workers is usually a safe starting point; back off if you start seeing 429s.
- Implement exponential backoff on 429. Sleep
2^attempt + jitterseconds, up to a 60-second cap. - Log failed rows, don’t abort. A failed row should not stop the whole import. Capture the row, the request, and the error response so you can re-run only the failures — the upsert semantics make this safe.
- Validate locally first. Required fields, type constraints, and length limits are checked server-side; pre-validating saves round trips on a large run.
Worked example: importing a CSV
A complete script that imports companies and people from two CSV files. The dedup keys aredomain for companies and email for people; re-running the script updates existing rows without creating duplicates. It uses the upsert helper from the previous section so 429s are retried automatically.
Python
Next steps
- Subscribe to webhooks to receive updates when CRM-linked events are scheduled or cancelled.
- Browse the full CRM API reference for every endpoint, including delete, individual
GET, and the non-upsertPOST/PATCHvariants.