Short answer: create a Self Client in the Zoho API Console for your data center, trade its grant code for a refresh token, set ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_REGION and LLM_API_KEY, then pass zoho_crm_source() from cognee-community-connector-zoho-crm to cognee.remember() with write_disposition="merge". Your Zoho CRM leads, contacts, accounts and deals, plus their notes, become a knowledge graph in Cognee, and every later sync only fetches what changed.
One of the notes in my Zoho CRM trial reads "Spoke to him today. wants a call after 3 months". Notes like that sit on one record at a time, so finding every customer who asked for a call back means opening records one by one. I wanted to ask that question in plain English instead, so I built a Zoho CRM connector for Mergetober, the open source month run by WeMakeDevs and Cognee.
Cognee (docs) is an open source memory layer for AI agents. You give it data with add, cognify turns that data into a knowledge graph (this step needs an LLM API key), and search asks the graph questions. A connector is a way to feed it a tool's data instead of a folder of files.
What does the Zoho CRM connector for Cognee do?
- Turns every record in Leads, Contacts, Accounts and Deals into one document of
Label: valuelines, with lookups shown by name instead of id. - Adds the notes on those records (title, parent record, author, created time and text), and text attachments if you opt in.
- Syncs incrementally with
If-Modified-Since, one cursor per module. - Forgets what you delete in Zoho on the next sync, along with the notes and attachments of a deleted record.
- Works in every Zoho data center:
com(the default),eu,in,com.au,jp,com.cn,caandsa.
How do you connect Zoho CRM to Cognee?
You need cognee 1.4.0 or newer and a Zoho CRM account. Setup takes five steps:
- Install the package.
- Open the Zoho API Console for your data center (
api-console.zoho.infor India,api-console.zoho.eufor the EU), click Add Client, choose Self Client and copy the Client ID and Client Secret. - On the Generate Code tab, enter the scopes
ZohoCRM.modules.READ,ZohoCRM.settings.READ, pick how long the code should live (I used 10 minutes), add a short description and create the code. - Before the code expires, trade it for a refresh token with one POST to your data center's accounts server. Keep the
refresh_tokenfrom the response. It doesn't expire unless you revoke it. - Export the variables, then sync and search.
Both scopes are read-only, so the connector can't change anything in your CRM.
uv pip install cognee-community-connector-zoho-crm # Step 4: swap .in for your data center curl -X POST "https://accounts.zoho.in/oauth/v2/token" \ -d "grant_type=authorization_code" \ -d "client_id=$ZOHO_CLIENT_ID" \ -d "client_secret=$ZOHO_CLIENT_SECRET" \ -d "code=PASTE_THE_CODE_HERE" # Step 5 export ZOHO_CLIENT_ID="1000...." export ZOHO_CLIENT_SECRET="..." export ZOHO_REFRESH_TOKEN="1000...." export ZOHO_REGION="in" # com, eu, in, com.au, jp, com.cn, ca or sa export LLM_API_KEY="sk-..." import cognee from cognee_community_connector_zoho_crm import zoho_crm_source await cognee.remember( zoho_crm_source(), # or zoho_crm_source(modules=["Leads", "Deals"]) dataset_name="zoho_crm", write_disposition="merge", # REQUIRED ) answer = await cognee.search( query_text="Which deals are open, and what do the notes say about them?", query_type=cognee.SearchType.GRAPH_COMPLETION, datasets=["zoho_crm"], ) One gotcha: write_disposition="merge" is required. Cognee defaults to replace, which reloads the table on every run. An incremental run only sees what changed, so replace would forget everything else.
What happened when I ran it on a real Zoho CRM account?
I used the sample data in a Zoho CRM trial account in the India data center: 10 records in each of Leads, Contacts, Accounts and Deals, plus 5 notes. I synced, synced again without touching anything, then made two edits in Zoho and synced a third time.
First edit: I changed the Lead Status of Felix Hirpara (Sample). The field is on the right side of the Edit Lead form. After I saved, his page showed Not Qualified.
Second edit: I deleted Yvonne Tjepkema (Sample), a lead in the Pre-Qualified stage. Her page lists an e-mail address, a phone number and a mobile number. The connector leaves all three out of the documents by default.
Here is what the connector did each time:
| Sync | What changed in Zoho | What the connector did |
|---|---|---|
| 1 | Nothing, first run | Ingested 40 records and 5 notes |
| 2 | Nothing | Fetched 0 records (every module answered 304) and left all 40 records and 5 notes unchanged |
| 3 | The two edits above | Re-synced 1 record and forgot 1 lead (40 → 39 records) |
What can you ask once your CRM is in memory?
After a sync, cognee.search answers questions against the graph. The query in the connector's example is "Which deals are open, and what do the notes say about them?" For the note above I'd also ask "Which leads asked for a call back, and when?"
[TODO: replace this line with your screenshot of Cognee's answers (upload it in the editor), plus a sentence or two on what it got right. Delete this line if you don't, because it will publish as written.]
How does the sync know what changed?
Each module has its own cursor, and so do Notes and Attachments. The cursor is the UTC time the previous run started, and it only moves forward after a run finishes without errors, so a run that dies halfway can't skip records. Every request sends the cursor as If-Modified-Since. When nothing changed, Zoho answers 304 with no body, which is why a quiet account costs very little. Paging follows next_page_token, so there is no 2,000-record ceiling.
A first sync costs roughly one request per 200 records per module, plus a few calls for field metadata. Zoho meters API usage in daily credits that depend on your edition, so check yours before pointing this at a big org.
What happens when you delete a record in Zoho CRM?
Each incremental run calls Zoho's deleted-records endpoint (/{module}/deleted?type=all). Deleted records, notes and attachments become tombstones, and Cognee's orphan_cleanup removes them from the graph. The notes and attachments of a deleted record are removed through their parent's id. In my live run I deleted a lead. The cascade to its notes and attachments is covered by unit tests, not by my live test.
Any error that isn't a retryable one (rate limits, server errors and network failures are retried with backoff) stops the run before the cursor moves or anything is forgotten, so a partial read can never delete data by mistake. One limit: a record you restore from Zoho's Recycle Bin only comes back if Zoho updates its modified time.
What surprised me about the Zoho CRM API?
The Generate Code dialog refused my scopes
At one point, generating a grant code ended in this dialog. It says "Youre not part of any org for some of the requested scopes. Remove the scopes below to generate the token." and lists ZohoCRM.modules.READ and ZohoCRM.settings.READ, the two scopes the connector needs.
Removing them would leave nothing to read, so that isn't the fix. The message doesn't say which org or data center it checked. Zoho keeps each org in one data center and runs a separate API Console for each, so if you hit this, check first that the console you opened matches the data center of your CRM. My account and my CRM are both in the India data center.
Every data center has its own login server
Exchange your code at the wrong accounts server and you get a bare invalid_client, which says nothing about the region. The connector exchanges the token at the account's own data center (region or ZOHO_REGION), takes the API host from the api_domain field of the token response (www.zohoapis.in for me), and raises a clear error when the region is wrong.
fields is mandatory and capped at 50
Zoho's records API needs a fields parameter and caps it at 50. The Leads module has 55 fields. Ask for all of them and Zoho answers LIMIT_EXCEEDED. The connector asks for at most 50.
A quiet account sends back nothing, on purpose
When nothing changed, Zoho answers 304 with an empty body. An empty list of deleted records is a 204, also empty. Code that parses every response as JSON fails on both, so the connector treats them as "nothing to do". If-Modified-Since is also inclusive: a record modified exactly at the cursor time comes back once more, which merge makes harmless.
Frequently asked questions
Which Zoho CRM data does it sync?
Leads, Contacts, Accounts and Deals by default, each with their notes. Pass modules=[...] to choose other modules or fewer, include_notes=False to skip notes, or include_attachments=True for text attachments (.txt, .md, .csv, .json, .html and similar, up to 1 MB each by default, set with attachment_max_bytes).
Does the connector send e-mails or phone numbers to Cognee?
Not by default. They're left out unless you pass redact_contact_details=False. Volatile fields such as Modified_Time and Last_Activity_Time are left out too, so a record is only reprocessed when its real content changes.
Which Zoho data centers does it support?
All eight: com, eu, in, com.au, jp, com.cn, ca and sa. Set ZOHO_REGION or pass region.
Can I remove the data from Cognee later?
Yes, if you sync into a dedicated dataset (the examples use dataset_name="zoho_crm"). One cognee.prune removes it.
Try it
The code is in cognee-community pull request #283, open for review, and it closes cognee issue #4749. It has 17 passing tests against a fake Zoho CRM API. To check that they test something, I removed each safeguard one at a time (the 304 and 204 handling, the 50-field cap, the deleted-records feed, the parent cascade for notes and attachments, the cursor rule and the contact redaction), and every removal made tests fail. A Zoho CRM trial account is enough to run it yourself.
I also built connectors for Readwise, Canny, Help Scout and Freshsales, and each gets its own post.
If you try it on your own Zoho account, tell me what you ask it and where it breaks.



