Bulk Operations
Every CRM entity (contacts, companies, deals, tasks, notes, activities) supports batch writes:
| Endpoint | Body | Does |
|---|---|---|
POST /v1/objects/{entity}/batch/create | { "records": [ {...}, ... ], "options": {...} } | Create up to 200 records |
POST /v1/objects/{entity}/batch/update | { "updates": [ { "id": "...", ...fields }, ... ], "options": {...} } | Update up to 200 records — each item carries its own fields; custom_fields merge PATCH-style |
POST /v1/objects/{entity}/batch/delete | { "ids": [ "...", ... ], "options": {...} } | Delete up to 200 records by id |
Batch writes use the same scope as single-record writes ({entity}:write) — no extra grant needed.
The contract: each batch is atomic
A batch either fully succeeds or fully fails — there are no partial writes inside a request:
- Success →
{ "data": [ ...full records... ], "count": N }(batch/deletereturns{ "deleted": true, "count": N, "ids": [...] }). Every record in the request was written. - Failure → an error response, and nothing in that request was written. The error tells you exactly which items were bad and why.
This makes retry logic trivial: a failed batch can be corrected and resent as-is, with no risk of double-writing the records that were fine.
Error anatomy
Validation failures return 422 batch_validation_error with a per-index breakdown:
{
"error": {
"type": "batch_validation_error",
"message": "1 of 200 records failed validation; nothing was written.",
"details": [
{
"index": 36,
"message": "Request body failed validation.",
"details": [
{ "loc": ["lead_score"], "msg": "Input should be a valid integer", "input": "high" }
]
}
]
}
}
index is the item's position within the request you sent (0-based) — map it back to your source rows by adding the chunk's offset.
Other batch errors:
| Status | error.type | Meaning |
|---|---|---|
404 | not_found | batch/update: an id doesn't exist. The offending id is in the error; the whole batch rolled back. |
403 | scope_violation | batch/delete: one or more ids don't exist or aren't in scope (the message names a sample id). Nothing was deleted. |
422 | batch_too_large | More than 200 items — split the request. |
403 | insufficient_scope | Key lacks {entity}:write. |
429 | rate_limited | Too many requests — honor Retry-After. |
Beyond 200: chunk client-side
To write 1,000 records, send 5 chunks of 200. Because each chunk is atomic, you always know exactly which rows are committed: every chunk either landed completely or not at all.
The pattern below processes all chunks, collects a per-chunk outcome, and reports which source rows succeeded and which failed with the reason:
import time
import requests
API = "https://api.expedify.ai/v1/objects/contacts/batch/update"
HEADERS = {"Authorization": "Bearer agx_your_key"}
CHUNK = 200
def run_batches(updates: list[dict], suppress_automations: bool = True) -> list[dict]:
"""Send updates in chunks of 200. Returns one outcome dict per chunk."""
outcomes = []
for offset in range(0, len(updates), CHUNK):
chunk = updates[offset : offset + CHUNK]
rows = f"rows {offset + 1}-{offset + len(chunk)}"
while True: # retry loop for 429s only
resp = requests.post(API, headers=HEADERS, json={
"updates": chunk,
"options": {"suppress_automations": suppress_automations},
})
if resp.status_code != 429:
break
time.sleep(int(resp.headers.get("Retry-After", "5")))
if resp.ok:
outcomes.append({"rows": rows, "ok": True, "count": resp.json()["count"]})
else:
err = resp.json()["error"]
outcomes.append({
"rows": rows, "ok": False,
"type": err["type"], "message": err["message"],
# map per-item indexes back to positions in YOUR source list
"failed_rows": [offset + d["index"] + 1 for d in err.get("details", []) if "index" in d],
"details": err.get("details", []),
})
return outcomes
outcomes = run_batches(all_1000_updates)
for o in outcomes:
if o["ok"]:
print(f"✅ {o['rows']}: {o['count']} updated")
else:
print(f"❌ {o['rows']}: {o['type']} — {o['message']}"
+ (f" (source rows {o['failed_rows']})" if o.get("failed_rows") else ""))
Example report for a 1,000-row run where chunk 2 had a bad value:
✅ rows 1-200: 200 updated
❌ rows 201-400: batch_validation_error — 1 of 200 records failed validation; nothing was written. (source rows [237])
✅ rows 401-600: 200 updated
✅ rows 601-800: 200 updated
✅ rows 801-1000: 200 updated
Rows 201–400 are not written — fix source row 237 (the error's details say which field and why) and resend just that chunk. Everything else is already committed and must not be resent.
Send chunks sequentially. 5 calls complete in a few seconds anyway, the per-chunk report stays ordered, and you won't race the per-key rate limit. If you do parallelize, keep the 429/Retry-After handling.
Automation suppression
By default, batch writes behave exactly like single-record writes: workflow automations fire for every record. A 1,000-row update means up to 1,000 trigger evaluations — if you have a "contact updated" workflow, it runs 1,000 times (real credit spend).
For data maintenance — imports, backfills, cleanups — pass:
{ "options": { "suppress_automations": true } }
With suppression on, the records are written but no workflow triggers fire for that batch. Two things still work as normal:
- Search indexing (vector sync) — imported records stay findable.
- The write itself is identical — same validation, same scoping, same response.
Leave suppression off when the automations are the point — e.g. importing leads that should enter your welcome workflow.
Rate limits at scale
The rate limit counts requests, not records — a 200-record batch costs the same as a single-record write. 1,000 records = 5 requests; even 100,000 records = 500 requests, which fits comfortably in a plan's per-minute budget when sent sequentially with 429 handling (as in the pattern above). For very large one-shot imports (lakhs of records from a file), contact us — a dedicated async import-job API is on the roadmap.
Over MCP
The same capability is exposed as three generic tools — batch_create_records, batch_update_records, batch_delete_records — that take the entity as a parameter:
{
"name": "batch_update_records",
"arguments": {
"entity": "contacts",
"updates": [ { "id": "…", "lead_status": "qualified" } ],
"suppress_automations": true
}
}
Same 200-item cap, same atomic semantics, same errors. See the MCP recipes for the agent-side chunking pattern.