Skip to main content

Bulk Operations

Every CRM entity (contacts, companies, deals, tasks, notes, activities) supports batch writes:

EndpointBodyDoes
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/delete returns { "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:

Statuserror.typeMeaning
404not_foundbatch/update: an id doesn't exist. The offending id is in the error; the whole batch rolled back.
403scope_violationbatch/delete: one or more ids don't exist or aren't in scope (the message names a sample id). Nothing was deleted.
422batch_too_largeMore than 200 items — split the request.
403insufficient_scopeKey lacks {entity}:write.
429rate_limitedToo 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.

Prefer sequential over parallel

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.