Developer pages
Zinvee CRMGuide

Read your CRM from your own tools

Give n8n, a dashboard, a warehouse or your own app permission to read leads, enquiries and follow-ups, and to follow every change through an append-only feed. The API is read-only: no key can change or delete anything in the CRM.

Base URL https://api.crm.diginetacademy.in/api/v1. Every endpoint is in the API reference.

Quick start

  1. An owner or admin opens Integrations → Send data out in Zinvee CRM and creates a key, ticking only what the tool needs. The key is shown once: copy it straight into the tool.
  2. Check it works. whoami needs no scope, returns no lead data and costs nothing, so make it the first step of every workflow:
    Who is this key?
    export CRM_KEY="tcrm_…"
    
    curl https://api.crm.diginetacademy.in/api/v1/public/whoami \
      -H "X-API-Key: $CRM_KEY"
  3. Read what you need, for example the enquiries still open in one campaign:
    Open enquiries
    curl "https://api.crm.diginetacademy.in/api/v1/public/inquiries?lifecycle=open&campaignCode=<code>&limit=200" \
      -H "X-API-Key: $CRM_KEY"

The whole API is published as an OpenAPI 3.1 file that Postman and an n8n HTTP node can import: /public/openapi.json (no key needed).

Keys and scopes

Send the key as X-API-Key: tcrm_…, or as Authorization: ApiKey tcrm_… for clients that can’t set their own header. The scheme word is ApiKey, not Bearer.

  • A key reads only its own organization’s data, and only the parts that were ticked.
  • It is shown once and stored only as a SHA-256 hash, so nobody can read it back. Lost it? Rotate gives the same key a new secret, and the old one stops at once.
  • Revoke stops a leaked key on its very next request. Keys expire after 365 days unless another expiry is set when the key is made.
  • Each key has its own limits, set when it is made and changeable later (see Rate limits).
ScopeIn the appWhat it reads
LEADS_READRead leadsName, phone, status, owner and tags of every lead.
INQUIRIES_READRead enquiriesEach enquiry’s campaign, stage, fee and balance.
FOLLOWUPS_READRead follow-upsWhich calls are due, overdue or scheduled ahead.
REFERENCE_READRead reference listsYour stages, campaigns, sources, courses and staff.
EVENTS_READRead the change feedWhat changed, so a tool keeps in step without re-reading everything.

Paging and sync

Every list returns { "items": [...], "nextCursor": "…", "hasMore": true }. Paging is keyset, never offset: pass nextCursor back as cursor and stop when it is null. It always becomes null, and a page is never silently cut short. limit is 1 to 200 (default 50).

The loop that ends
cursor = null
repeat:
    GET /public/leads?limit=200&updatedSince=<start of last run>[&cursor=<cursor>]
    process items
    cursor = response.nextCursor
until cursor == null

For an incremental sync, store the time you started the last run and pass it as updatedSince (or createdSince) with a minute of overlap. Rows are not deduplicated for you, and seeing one twice is cheap. Unknown query parameters are ignored, and an empty one counts as absent, so blank optional fields in n8n are safe.

The change feed

GET /public/events (scope EVENTS_READ) is the endpoint to build on. Polling leads tells you a row changed; the feed tells you what happened, and it is the only way to learn about things that leave no row behind: merges, deletes and import undos.

Poll the feed
curl "https://api.crm.diginetacademy.in/api/v1/public/events?since=19542&limit=100" \
  -H "X-API-Key: $CRM_KEY"
A page of events
{
  "items": [
    {
      "seq": 19543,
      "apiVersion": "2026-09-12",
      "eventType": "inquiry.won",
      "leadId": "clx1lead0001",
      "inquiryId": "clx1inq0001",
      "data": { "fromStageCode": "negotiation", "toStageCode": "admitted" },
      "suppressed": false,
      "occurredAt": "2026-11-12T06:31:44.123Z"
    }
  ],
  "nextCursor": 19543,
  "hasMore": false
}
  1. First run: call it without since; you get the last hour (sinceMinutes, default 60).
  2. Process items in order, then store nextCursor. Next time, send it as since; it is exclusive, so you never see a row twice.
  3. An empty page means you are caught up: keep the cursor you have, wait a minute, and poll again. hasMore: true means more is waiting, so poll again straight away.
  4. Move the cursor only after the page is handled. Delivery is at least once, so handlers must be safe to run twice.

Events appear about two seconds after they happen, so a poller can never step over one that was still being saved. They are kept for 30 days. A cursor older than that gets 410 CURSOR_TOO_OLD with oldestSeq: set your cursor to oldestSeq - 1, then re-read leads and enquiries changed since the outage, because deletes in the lost window can’t be recovered. Bulk rows from imports and sweeps are hidden unless you ask for includeSuppressed=true.

Events carry ids, codes and before and after values, never a person’s name, phone or email. Read the person from GET /public/leads/{id} when you need them; a 404 there means they are gone.

eventTypeWhen
lead.createdA new person enters the CRM, by any route.
lead.updatedA person’s own fields change. data.changed names the fields, never the values.
lead.mergedTwo duplicates were merged; the source lead is gone.
lead.deletedAn admin deleted the lead, or an import was undone.
inquiry.createdA person shows interest in a campaign or course, including a returning lead’s second enquiry.
inquiry.stage_changedAn enquiry moved along the pipeline: fromStageCode, toStageCode.
inquiry.wonIt entered a stage that converts.
inquiry.lostIt entered a stage that closes without converting.
inquiry.deletedAn admin deleted the enquiry, on its own or with its lead.
payment.recordedA payment was recorded against an enquiry.
followup.dueA scheduled follow-up came due.
import.completedA CSV import finished (counts only; hidden unless includeSuppressed=true).
leads.bulk_updatedA sweep changed many leads at once (hidden unless includeSuppressed=true).
org.leads_wipedEvery lead in the organization was deleted: drop your copy and sync again.

Filter with eventType=lead.created,inquiry.won (up to 20). A type the CRM doesn’t know matches nothing rather than failing. This is polling, not push: the CRM sends no webhooks.

Codes, phones and status

  • Codes belong to each organization. Stages, call outcomes, campaigns, sources and courses are rows each business can rename, not fixed values. Read them from GET /public/reference before branching on one. For open, converted or lost, use an enquiry’s lifecycle, which keeps working when stages are renamed.
  • Phones. phone is stored as it came in (imports and ad forms write raw text), so it isn’t always dialable. Send to phoneE164, and skip the row when it is null.
  • A lead’s status follows its newest enquiry. A lead can read NEW while holding an enquiry that converted. Check enquiries before messaging someone, so an admitted student isn’t sent a sales message.
  • Times are ISO 8601 in UTC. Show them in the organization’s timezone (organization.timezone in whoami).

Errors

Every error has the same body. error is a stable code: branch on it, not on message.

An error
{
  "statusCode": 403,
  "error": "SCOPE_DENIED",
  "message": "This key does not have the LEADS_READ scope.",
  "requiredScope": "LEADS_READ",
  "requestId": "req_V1StGXR8Z5jdHi6B",
  "path": "/api/v1/public/leads",
  "timestamp": "2026-11-12T06:31:44.123Z"
}
StatusCodesRetry?
400VALIDATION_FAILED: issues lists every problem, not just the first.No
401MISSING_API_KEY, INVALID_API_KEY, KEY_EXPIRED, KEY_REVOKEDNo
403SCOPE_DENIED (requiredScope names what is missing), ORG_SUSPENDED, ORG_ARCHIVEDNo
404NOT_FOUND, including a record that belongs to another organizationNo
410CURSOR_TOO_OLD: the change feed only. You have missed events.After resetting the cursor
429RATE_LIMITED, ROW_RATE_EXCEEDED, ROW_BUDGET_EXCEEDEDYes, after Retry-After
500INTERNAL_ERRORYes

Retry nothing else: a SCOPE_DENIED retried in a loop only burns your own limit.

Rate limits

Three separate limits, all per key, set when the key is made and changeable later by an admin. whoami and /public/reference cost no rows; every other endpoint counts one row for each row it returns.

LimitDefaultOver it
Requests a minute120 (up to 600)RATE_LIMITED
Rows a minute2,000ROW_RATE_EXCEEDED
Rows a day (India time)100,000ROW_BUDGET_EXCEEDED

Every response says where you stand, so a client can slow down before it ever sees a 429:

HeaderMeaning
X-Request-IdThis request’s id: quote it when you write to us.
X-RateLimit-LimitYour requests-a-minute limit.
X-RateLimit-RemainingRequests left in this rolling minute.
X-RateLimit-ResetUnix seconds when the request window rolls over.
X-Row-Rate-RemainingRows left in this rolling minute.
X-Row-Budget-RemainingRows left in today’s budget (a lower bound).
Retry-AfterOn a 429 only: seconds to wait.

The daily budget resets at midnight India time. The CRM tells the organization’s admins the first time a key passes 80% of its daily budget, which is how a leaked key gets noticed. Planning a one-off bulk export? Tell your admin first.

Connect Claude

Claude, Anthropic’s AI assistant, can read the CRM and answer questions in plain language: which hot leads nobody has called in three days, how each caller did this week, how much fee is still pending. It reads with the permissions of the person who connected it and can never change, delete, call or message anything.

  • It is off for every organization until an owner turns it on in Integrations → Claude. Owners and admins can then connect; a manager can connect once an owner ticks them, and sees only their campaigns and team. Telecallers can’t.
  • To connect, copy the connector URL from Integrations → Claude, then in Claude open Settings → Connectors → Add custom connector, paste it, sign in to the CRM and press Allow.
  • Every question Claude asks is listed under Recent activity for 90 days. Disconnect ends a connection on its next question.