Contact imports
Preview contact files, track confirmed row results and download authorized error reports.
Preview before writing
Send the file to POST /v1/contacts/import/preview, then import it with POST /v1/contacts/import using a new Idempotency-Key. Both require contacts:write. HTTP accepts CSV, TXT, XLSX and XLS up to 10 MB; MCP preview_contacts_import and import_contacts accept base64 CSV up to 10 MB. This update does not add ODS to these public upload contracts.
The preview keeps the planned create, update, add_role, merge_candidate, conflict and invalid classifications. Resolve fiscal identity conflicts first. An update preserves fields omitted from the file: omitted bank_accounts preserves accounts, while an explicit [] clears them. dry_run=true always returns HTTP 200 without reserving or consuming rows; import_uuid, execution status and counters are null.
Fill in the downloaded template
Download the contact template with GET /v1/contacts/import/template (contacts:read), keep its column headers and enter one contact per data row. The downloaded CSV uses UTF-8 with BOM and commas. If you convert the template to XLSX, preserve its original headers. Upload the filled file, review its column mapping and preview the rows before applying the import.
A template containing only column headers is valid for preview: HTTP returns 200, total=0, rows=[] and source_headers in the original order. Empty columns are retained, including when a worksheet has no data rows. dry_run=true behaves the same way and does not create an import record.
Applying a file with no data rows returns HTTP 422: error.code=business_rule_violation, error.subcode=empty_import and error.param=file. Fill in the template and retry. No import record is created and no work is queued. The MCP tools preserve the same preview/apply distinction; these status codes describe HTTP.
Read the accepted import
Logical CSV records are counted, including quoted records with embedded newlines. HTTP executes up to 199 records synchronously with 200; 200 or more are accepted with 202 and queued=true. Both execution paths return import_uuid. Queued responses have status=queued and null execution counters; acceptance does not mean that every row completed. MCP returns the same JSON payload; 200/202 are HTTP transport codes.
Use GET /v1/contacts/imports/{id}, passing the returned import_uuid as id. It requires contacts:read and returns object=contact_import, entity_type=business_contacts, persisted status, counters and outcomes. The company comes from the authenticated credential. Foreign-company, unknown and other-module UUIDs return the same 404 import_not_found; missing read scope returns 403 insufficient_scope.
| Field | Meaning |
|---|---|
total_rows / total | Source records in GET and preview/synchronous responses. The immediate queued payload retains total=0; read the accepted total with GET. |
added_count | Confirmed applied create, update and add_role rows. |
skipped_count / failed_count | Confirmed skipped or rejected rows. |
unprocessed_count | Source records that have no confirmed outcome. |
status / failure_reason | Actual persisted state, including failed jobs that retain partial progress. |
A terminal import can have status=failed even when failed_count=0: infrastructure or maintenance can close a job with confirmed writes and pending rows. The synchronous response uses that same persisted state. Its total retains the source record count, but rows and action categories contain confirmed rows only; each confirmed row includes result as applied, skipped or failed. Preview rows omit result.
For example, maintenance closed this synchronous import after one confirmed row. The pending row is not presented as applied:
{
"data": {
"rows": [
{
"row": 2,
"action": "create",
"result": "applied",
"target_uuid": "0198f4d1-a492-7e05-9a35-000000000001",
"errors": [],
"warnings": []
}
],
"source_headers": ["name", "tax_id", "roles"],
"total": 2,
"create": 1,
"update": 0,
"add_role": 0,
"merge_candidate": 0,
"conflict": 0,
"invalid": 0,
"dry_run": false,
"queued": false,
"import_uuid": "0198f4d1-a492-7e05-9a35-000000000201",
"added_count": 1,
"skipped_count": 0,
"failed_count": 0,
"unprocessed_count": 1,
"status": "failed",
"failure_reason": "stale_timeout"
}
}Download original diagnostics
GET /v1/contacts/imports/{id}/errors.csv requires contacts:read and returns UTF-8 CSV bytes with a BOM and the headers row_number,field,code,message. The source coordinate is preserved: CSV uses the logical record number with header=1; spreadsheets use the physical row index. A missing report returns 404 import_not_found.
Reports are available for terminal imports during the configured retention period, 90 days by default. State stops advertising error_report_url after expiry, even before physical cleanup. If the CSV is missing or cannot be read, confirmed diagnostics rebuild it without writing another file. Imports with no row diagnostics have no error report.
In MCP, use get_contact_import to read progress and download_contact_import_errors to receive id, filename, mime_type, hash and file_base64. Both require contacts:read; the decoded bytes match HTTP and hash is SHA-256. Downloads require authentication and do not generate anonymous URLs.
Monthly row allowance
New contact admissions share the plan’s monthly import row allowance. Applied create, update and add_role rows consume it; skipped, rejected and pending rows do not. Concurrent reservations protect the allowance before writing or queueing. Imports admitted under the previous contact exemption retain that exemption.
An exhausted allowance returns HTTP 429 import_row_quota_exceeded, with error type rate_limit_error, before contact writes or queue dispatch. This allowance is separate from uploaded bytes and HTTP request rate limits.