The Zoho CRM Actions API is where Zoho CRM keeps the verbs: merge, mass delete, change owner, clone, send mail, enrol in cadences. It has 48 operations, and 34 are POST. Several start background jobs, and they do not agree on how to tell you so. The word “idempotency” does not appear anywhere in the contract.
What ships
The record lists an OpenAPI 3.2.0 document with 130 component schemas, an AsyncAPI document for notifications, webhooks, and pointers to source files in Zoho’s own crm-oas repository. Zoho CRM scores 82.7 on the Kin Score (exemplar, up 1.6) and 42.6 on Agent Readiness (agent-ready).
Paths read as actions on a module: POST /{module}/actions/mass_delete, POST /{module}/{masterRecordId}/actions/merge, POST /{module}/{record}/actions/change_owner. This is RPC on purpose, and it reads well. Because {module} is a variable, one operation covers Leads, Deals and Contacts. Our phrasing overlay classifies 31 operations as writes, 11 as reads and 6 as destructive. Authentication is OAuth2 only, with scopes declared per module, such as ZohoCRM.modules.Leads.CREATE. That gives an agent a real way to be limited.
The async inconsistency
At least four operations pair a POST with a status GET on the same path, keyed by a job ID:
| Job | POST responses | Status check |
|---|---|---|
mass_change_owner |
202, 400 | GET same path, with job ID |
merge |
200, 201, 202, 204, 400 | GET same path, with job ID |
mass_delete |
200, 207, 400 | GET same path, job_id |
mass_update |
200, 400 | GET same path, job ID |
All four behave as jobs in the descriptions, and the status operation for mass_update calls it “an asynchronous mass update job.” Only two of them declare 202. A client generated from this contract has to read prose to learn that a 200 from mass_update does not mean the update happened. merge declares five different responses, and its description says it supports more than one mode.
9 operations declare a 207 for partial success, including four notification channel operations on /actions/watch, so the bulk surface does take partial failure seriously.
The risk shows up when you combine these. mass_delete is destructive, runs in the background, and has no idempotency key in the contract. If a network failure hits between the POST and the job ID, nothing in the contract says a retry will not start a second job. For an agent, the safe path is to record the job ID, poll, and treat any unknown outcome as needing a human. Nothing in the spec gives it a safer one. That is what the contract states, and Zoho’s runtime may do more.
Capacity matters here too. Our Zoho CRM plans record shows api_credits_per_day of 5,000 on Free and concurrency running from 5 on Free to 25 on Ultimate, so a retry loop has a real cost.
Three fixes on our side
The record’s description says “36 operation(s)” while operation_count is 48 and the file holds 48. Trust the count. The humanURL points to v2 notifications documentation, while the baseURL is v8. And the first source pointer, v8.0/apis.json, returned a 404 when we checked it. All three are ours to correct.
Takeaway
This is a capable, well-scoped action surface. Declare 202 on every operation that returns a job and add an idempotency key to the mass operations, and it becomes a contract an agent can run unattended. Read it at the Zoho CRM Actions API page.