DEV Community

Cover image for Mergetober: two places a deleted thing can hide, building a Zoho Desk connector for cognee
Oscar Kandir
Oscar Kandir

Posted on

Mergetober: two places a deleted thing can hide, building a Zoho Desk connector for cognee

Teams on Zoho's suite keep their support history in Zoho Desk: customer problems, agent replies, internal comments and knowledge-base articles. Zoho CRM had a cognee issue of its own, but Desk is a separate product with its own API, and nobody had proposed a connector for it. So I wrote the issue, tested the API on a trial account, and built it for Mergetober.

The demo: a deleted article the API still calls "Published"

I set up a small support case in Zoho Desk: a ticket from a customer whose password reset email never arrived, with an internal comment from the agent ("our mail provider was flagging reset emails as spam, fixed the SPF record"), and a knowledge-base article saying reset links stay valid for 15 minutes. Then I synced it into cognee (OpenAI as the LLM) and asked one question that needs both:

How long does a password reset link stay valid, and why were customers not receiving password reset emails?

Before: cognee answers with the 15-minute link validity and the spam fix

Validity: Password reset links stay valid for 15 minutes. After that, request a new link from the sign-in page.

Why emails weren't received: The mail provider/server was flagging reset emails as spam due to an SPF record issue. The SPF record was fixed.

The 15 minutes came from the article, and the spam fix from the ticket's internal comment. Here's the article in Zoho Desk:

The knowledge-base article in Zoho Desk

Then I deleted the article. This is what the Zoho Desk API said about it right afterwards:

After the delete: not in the Recycle Bin, gone from the listing, but still Published with isTrashed true

It isn't in the Recycle Bin, where deleted tickets go. It's gone from the article listing. But looked up by id it still answers 200, with status: Published. Only isTrashed: true says it's been deleted. A connector that checks the status, or one that waits for the article to show up in the Recycle Bin, would keep it in memory for good.

The next sync forgot it (deleted 1), and the same question now gets:

After the sync: cognee no longer knows how long a reset link stays valid

Validity: The context does not specify how long a password reset link remains valid.

Why emails weren't received: The mail provider/server flagged the reset emails as spam due to an SPF record issue. The SPF record was fixed.

The article is gone from memory, and the ticket, which nobody touched, still answers its half of the question.

Setting it up

The one-time setup, in the API console for your data center (api-console.zoho.com, .eu, .in, …):

  1. Add Client → Self Client, and note the Client ID and Client Secret.
  2. Generate Code with these read-only scopes: Desk.tickets.READ,Desk.basic.READ,Desk.search.READ,Desk.articles.READ,Desk.contacts.READ,Desk.settings.READ That's three more than I planned on the issue: search for the incremental sync, contacts for customer names, and settings for the Recycle Bin.
  3. Exchange the code for a refresh token once (the README has the exact request). The refresh token doesn't expire until you revoke it, so keep it like a password.

Zoho's Self Client gives you a refresh token straight from the API console, with no redirect server. The scopes are read-only, and the data center (com, eu, in, …) picks the domains:

import cognee
from cognee_community_connector_zoho_desk import zoho_desk_source

await cognee.remember(
    zoho_desk_source(
        data_center="com", client_id=client_id, client_secret=client_secret,
        refresh_token=refresh_token,
    ),
    dataset_name="zoho_desk",
    primary_key="id",
    write_disposition="merge",
    max_rows_per_table=0,
)
Enter fullscreen mode Exit fullscreen mode

Each ticket becomes one document: number, subject, department, status, priority, channel, assignee and customer name, then every thread and comment in time order, labelled Customer, Agent, Internal thread, Internal comment or Comment. Each published article becomes one document too. The department name is the node set, and departments=[...] limits the sync. Documents carry names only, never email addresses or phone numbers.

How it finds changes

The first run reads every ticket. Later runs ask the Search API for tickets modified since the cursor (modifiedTimeRange). A new reply or comment moves a ticket's modifiedTime, so a changed ticket is read again with its whole conversation. The search index can lag a little behind a change, so each window starts 10 minutes before the cursor, and tickets whose modifiedTime was already synced are skipped. The cursor comes from Zoho's own Date header, not from the machine running the sync.

Where deleted things hide

My issue assumed deletes were simple: Zoho Desk's Recycle Bin keeps deleted tickets and articles for 60 days, and the API lists them. Testing on a live account showed two surprises.

1. A just-deleted ticket is still in search. Right after I moved a ticket to the trash, the Search API still returned it, and reading its conversation answered 403. A connector that reads search results first would fail the run on a ticket that no longer exists. So each run reads the Recycle Bin first and doesn't read tickets that are in it.

2. Deleted articles don't go to the Recycle Bin at all. The first time I tested this, I published an article, deleted it, and the sync said nothing had changed. A small probe showed why (the demo above shows the same thing): a deleted article goes to the Knowledge Base's own trash. It drops out of /articles, and GET /articles/{id} still answers 200, with status: "Published" and isTrashed: true. So the article listing is read in full every run, and a stored article that's missing from it is looked up by id and forgotten when it's trashed, unpublished or gone.

Live run

On a Zoho Desk account (.in data center), through a real dlt merge:

Step Result
First sync (full read) the test tickets stored with their description, no email addresses
Internal comment added on A found through the Search API: scanned: 1, the comment labelled
B moved to the trash deleted: 1, A kept
Nothing changed scanned: 0
Article published in the Knowledge Base scanned: 1, the article stored
Article deleted (Knowledge Base trash) deleted: 1, the article forgotten

Checking my own work

After opening the pull request, I reviewed it once more and found a gap the live run hadn't hit: a ticket deleted between the Recycle Bin read and the ticket's own read answers 403, and that failed the whole run. Now it's skipped, and the next sync forgets it through the Recycle Bin. A full read also no longer looks up tickets that are already in the bin. Both fixes have tests that fail on the old code.

Two things from making the demo:

  • My demo script couldn't find the new article at first. Zoho Desk's listings count from 1, and asking for from=0 answers 422. The connector already handles that (it retries from 1), which is why the sync found the article when my script didn't. My script now does the same.
  • In one earlier demo run, a sync a few seconds after the delete still kept the article. That run didn't measure the listing, so I can't say why. In the run above, the listing dropped the article immediately and the sync forgot it. If you see a delete take one extra sync to land, that's the case.

Credits, not just rate limits

Zoho Desk meters API calls in credits per day, and every answer reports how many are left (X-Rate-Limit-Remaining-v3). When fewer than 50 remain, the run stops cleanly, keeps what it read, and leaves the cursor for the next run, so a sync never uses up the credits a team's other integrations need.

A sync more than 50 days after the last full read lists every ticket again. The Recycle Bin keeps deletes for 60 days, so a long gap can't hide one.

What I learned

Don't trust the field that sounds right. A deleted article says status: Published, and the place called "Recycle Bin" never sees it. The only honest signal was a flag I only found by deleting something and reading the whole response. For deletes, I now look at what the API returns after a delete, not at what its fields are called.

The setup steps are in the README in the pull request, and examples/example.py is the script to try it.

Top comments (0)