0800 310 1010
← All articles
CRM & integrations · 7 min read

Connect Zoho CRM to NumberGroup: log calls, match leads by phone number, fire workflow rules

Push NumberGroup call events into Zoho CRM — Calls module records, lead/contact matching, workflow-rule triggers, and the Zoho One vs Zoho CRM gotcha to know.

Zoho CRM is the dark horse of UK SME CRMs — popular partly because Zoho One is genuinely good value, partly because it’s the CRM your accountant likely uses. Wiring it to NumberGroup follows the same pattern as the others: webhook receiver, API token, two calls per inbound call.

Foundation: NumberGroup call webhooks explained.

What you’ll have at the end

  • Inbound calls land in the Calls module as a Completed Inbound Call.
  • Linked to the matching Lead or Contact (by phone).
  • Workflow rules on the Calls module fire — so “notify rep on inbound call from VIP” or “create follow-up task after call” just work.
  • Duration, direction, DDI dialled all captured.

First — know your data centre

Zoho runs separate data centres for different regions and the API domain differs by region:

  • EU customers (most NumberGroup Members):
    https://www.zohoapis.eu
  • UK-specific:
    https://www.zohoapis.com
    if you signed up before EU migration
  • India / US: different domains

Check your Zoho URL — if you log in at

crm.zoho.eu
you use the
.eu
API. Using the wrong domain returns
INVALID_TOKEN
with no clear hint.

Step 1 — Self-client OAuth credentials

Easiest path for server-to-server:

  • Go to api-console.zoho.eu (or
    .com
    for non-EU).
  • Add Client → Self Client.
  • Copy Client ID and Client Secret.
  • Generate code → scopes:
    ZohoCRM.modules.calls.ALL
    ,
    ZohoCRM.modules.contacts.READ
    ,
    ZohoCRM.modules.leads.ALL
    . Time duration: 10 minutes.
  • Exchange the code for a refresh token immediately:
curl -X POST "https://accounts.zoho.eu/oauth/v2/token" \
  -d "code=THE_CODE" \
  -d "grant_type=authorization_code" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"

Save the

refresh_token
as a worker secret. Access tokens are short-lived (1 hour); the refresh token mints new ones.

Step 2 — Worker code

// numbergroup-zoho-worker.js
const ACCOUNTS = "https://accounts.zoho.eu"; // or .com
const API = "https://www.zohoapis.eu";       // or .com

export default {
  async fetch(req, env) {
    if (req.method !== "POST") return new Response("ok");
    const call = await req.json();
    const phone = normaliseUK(call.caller);
    const token = await getAccessToken(env);

    let who = await findContact(token, phone);
    if (!who) who = await findLead(token, phone);
    if (!who) who = await createLead(token, phone);

    await createCallRecord(token, who, {
      caller: phone,
      dialled: call.called,
      duration: call.duration || 0,
      ts: call.ended_at || new Date().toISOString(),
      recordingUrl: call.recording_url || null,
    });

    return new Response("ok");
  },
};

function normaliseUK(n) {
  if (!n) return null;
  n = n.replace(/[^0-9+]/g, "");
  if (n.startsWith("+")) return n;
  if (n.startsWith("0")) return "+44" + n.slice(1);
  return "+" + n;
}

async function getAccessToken(env) {
  const cached = await env.KV.get("zoho_token");
  if (cached) return cached;
  const body = new URLSearchParams({
    refresh_token: env.ZOHO_REFRESH_TOKEN,
    client_id: env.ZOHO_CLIENT_ID,
    client_secret: env.ZOHO_CLIENT_SECRET,
    grant_type: "refresh_token",
  });
  const r = await fetch(`${ACCOUNTS}/oauth/v2/token`, { method: "POST", body });
  const j = await r.json();
  await env.KV.put("zoho_token", j.access_token, { expirationTtl: 3300 });
  return j.access_token;
}

async function zoho(token, path, init = {}) {
  return fetch(`${API}/crm/v6${path}`, {
    ...init,
    headers: {
      authorization: `Zoho-oauthtoken ${token}`,
      "content-type": "application/json",
      ...(init.headers || {}),
    },
  });
}

async function findContact(token, phone) {
  const r = await zoho(token, `/Contacts/search?phone=${encodeURIComponent(phone)}`);
  if (r.status === 204) return null;
  const j = await r.json();
  return j.data?.[0] ? { module: "Contacts", id: j.data[0].id } : null;
}

async function findLead(token, phone) {
  const r = await zoho(token, `/Leads/search?phone=${encodeURIComponent(phone)}`);
  if (r.status === 204) return null;
  const j = await r.json();
  return j.data?.[0] ? { module: "Leads", id: j.data[0].id } : null;
}

async function createLead(token, phone) {
  const r = await zoho(token, "/Leads", {
    method: "POST",
    body: JSON.stringify({
      data: [{
        Last_Name: `Unknown caller ${phone}`,
        Phone: phone,
        Lead_Source: "Inbound call",
        Company: "Unknown",
      }],
    }),
  });
  const j = await r.json();
  return { module: "Leads", id: j.data[0].details.id };
}

async function createCallRecord(token, who, c) {
  await zoho(token, "/Calls", {
    method: "POST",
    body: JSON.stringify({
      data: [{
        Subject: `Inbound call to ${c.dialled}`,
        Call_Type: "Inbound",
        Call_Start_Time: c.ts,
        Call_Duration: Math.round(c.duration / 60), // minutes
        Call_Duration_in_seconds: c.duration,
        Call_Result: "Completed",
        Caller_ID: c.caller,
        Description: c.recordingUrl ? `Recording: ${c.recordingUrl}` : "",
        Who_Id: who.module === "Contacts" ? who.id : undefined,
        $se_module: who.module,
        What_Id: who.module === "Leads" ? who.id : undefined,
      }],
    }),
  });
}

Step 3 — Wire it up

  • Portal → Web Hook Manager → new webhook → URL points to your worker.
  • Attach as End of call webhook on each DDI.

Step 4 — Light up Zoho workflow rules

The win with Zoho is that once Calls land in the Calls module, your existing workflow rules just fire. Examples we’ve seen Members set up:

  • If
    Caller_ID
    matches a VIP account → email account manager immediately.
  • If call duration > 5 min → auto-create a follow-up Task for tomorrow.
  • If 3+ inbound calls from same number in 7 days → bump Lead Rating to “Hot”.

Zoho-specific gotchas

  • Wrong data centre = silent fail. If the API returns
    INVALID_TOKEN
    on a fresh token, you’re hitting the wrong domain. Switch
    .eu
    ↔
    .com
    .
  • Phone search needs an exact match in standard format. If your contacts have
    020 1234 5678
    and you search for
    +442012345678
    , no hit. Either normalise existing data, or try multiple formats in your worker.
  • Call_Duration is in minutes (integer). Use
    Call_Duration_in_seconds
    for accurate reporting.
  • Free plan has no API access. You need at least Standard. (Zoho One always includes it.)
  • Rate limits: per-day per-org, scales by edition. Plenty for normal call volume.

Help getting it live

Ring 0800 310 1010 if you get stuck on the OAuth dance — that’s where most Zoho integrations fall over the first time.

Move your phones to us. We'll save you money.

Memorable numbers, unlimited UK numbers, every smart feature and a UK team — all included with Membership at £149/mo. No contract.

See what Membership includes →Browse phone numbers →
Related reading