# How to Integrate Replit with Zoho CRM

- Tool: Replit
- Difficulty: Intermediate
- Time required: 25 minutes
- Last updated: March 2026

## TL;DR

To integrate Replit with Zoho CRM, register an OAuth 2.0 client in the Zoho API Console, store credentials in Replit Secrets (lock icon 🔒), complete the authorization flow to obtain an access token, and use the Zoho CRM REST API from an Express or Flask server to create leads, contacts, and deals, and run custom module queries.

## Why Integrate Zoho CRM with Replit?

Zoho CRM is one of the most popular CRM platforms for small and medium businesses thanks to its generous free tier and broad feature set. For businesses running their sales pipeline in Zoho, connecting Replit to the Zoho CRM API enables automations like capturing leads from web forms, syncing customer data from external tools, building custom dashboards, and triggering follow-up workflows when deal stages change.

Zoho CRM's API is organized around modules — Leads, Contacts, Accounts, Deals, and any custom modules you have created. Each module has its own set of fields with API names that may differ from the display names in the Zoho UI. You retrieve field API names from the module metadata endpoint, which is your essential reference when mapping your data to CRM fields.

OAuth 2.0 is required for Zoho CRM API access. The key to maintaining uninterrupted access is storing the refresh token in Replit Secrets and using it to generate fresh access tokens when the 60-minute access token expires. The correct Zoho server region (US, EU, IN, AU) matters for both the accounts server (OAuth) and the CRM API base URL — mismatching regions is a common setup mistake.

## Before you start

- A Zoho CRM account (free tier is sufficient for API access)
- A registered OAuth client in the Zoho API Console (api-console.zoho.com)
- A Replit account with a Node.js or Python Repl created
- Basic understanding of OAuth 2.0 authorization code flow
- Knowledge of which Zoho data center region your account is on (US, EU, IN, AU)

## Step-by-step guide

### 1. Register an OAuth client in the Zoho API Console

Go to api-console.zoho.com and sign in with your Zoho account. Click 'GET STARTED' or 'Add Client'. Choose 'Server-based Applications' as the client type (this is the correct type for Replit server-side code). Enter your application name, homepage URL (can be your Replit URL or any URL), and the Authorized Redirect URI — set this to your Replit preview URL plus /oauth/callback (e.g., https://your-repl.your-username.repl.co/oauth/callback). Click 'Create'. Zoho provides a Client ID and Client Secret. Copy both values. Also note the Zoho Accounts Server URL for your region — this is where OAuth token requests are sent. The default is https://accounts.zoho.com for US accounts, but EU accounts use https://accounts.zoho.eu, IN accounts use https://accounts.zoho.in, and AU accounts use https://accounts.zoho.com.au. Using the wrong region URL for OAuth will result in authentication failures even with valid credentials. Similarly, the CRM API base URL is region-specific: https://www.zohoapis.com for US, https://www.zohoapis.eu for EU, etc. Store ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_ACCOUNTS_URL (e.g., https://accounts.zoho.com), and ZOHO_API_URL (e.g., https://www.zohoapis.com) in Replit Secrets.

```
// Node.js — install required packages
// Run in Replit Shell:
// npm install express axios

// Verify Zoho config secrets are present
const keys = ['ZOHO_CLIENT_ID', 'ZOHO_CLIENT_SECRET', 'ZOHO_ACCOUNTS_URL', 'ZOHO_API_URL'];
for (const key of keys) {
  const val = process.env[key];
  if (!val) console.error(`MISSING: ${key} — add via Replit Secrets (lock icon 🔒)`);
  else console.log(`OK: ${key}`);
}
```

**Expected result:** A Zoho API Console client is created with Client ID and Client Secret. ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_ACCOUNTS_URL, and ZOHO_API_URL are stored in Replit Secrets.

### 2. Complete the OAuth 2.0 authorization flow

Zoho's OAuth 2.0 flow follows the standard authorization code pattern. Build two routes: /oauth/start (redirects the user to Zoho's authorization page) and /oauth/callback (exchanges the code for tokens). The authorization URL must include your client_id, redirect_uri, response_type (code), scope (a comma-separated list of required API permissions), and access_type (set to 'offline' to receive a refresh token). The Zoho CRM scopes you need are ZohoCRM.modules.ALL (to read/write all CRM modules), or more specific scopes like ZohoCRM.modules.leads.CREATE, ZohoCRM.modules.contacts.READ. After the user authorizes, Zoho redirects to your callback URL with a code parameter. Exchange this code for tokens by POSTing to {ZOHO_ACCOUNTS_URL}/oauth/v2/token with the code, client_id, client_secret, redirect_uri, and grant_type=authorization_code. The response includes access_token (expires in 3600 seconds) and refresh_token (does not expire unless revoked). Print both to the console, copy them to Replit Secrets as ZOHO_ACCESS_TOKEN and ZOHO_REFRESH_TOKEN, and then remove the OAuth routes from your production application.

```
# Python — Zoho OAuth 2.0 flow (zoho_oauth.py)
from flask import Flask, request, redirect
import requests
import os

app = Flask(__name__)

CLIENT_ID = os.environ['ZOHO_CLIENT_ID']
CLIENT_SECRET = os.environ['ZOHO_CLIENT_SECRET']
ACCOUNTS_URL = os.environ.get('ZOHO_ACCOUNTS_URL', 'https://accounts.zoho.com')
REDIRECT_URI = os.environ.get('REPLIT_URL', 'http://localhost:3000') + '/oauth/callback'
SCOPES = 'ZohoCRM.modules.ALL,ZohoCRM.settings.ALL'

@app.route('/oauth/start')
def oauth_start():
    auth_url = (
        f'{ACCOUNTS_URL}/oauth/v2/auth'
        f'?scope={SCOPES}'
        f'&client_id={CLIENT_ID}'
        f'&response_type=code'
        f'&access_type=offline'
        f'&redirect_uri={REDIRECT_URI}'
    )
    return redirect(auth_url)

@app.route('/oauth/callback')
def oauth_callback():
    code = request.args.get('code')
    resp = requests.post(
        f'{ACCOUNTS_URL}/oauth/v2/token',
        data={
            'grant_type': 'authorization_code',
            'client_id': CLIENT_ID,
            'client_secret': CLIENT_SECRET,
            'redirect_uri': REDIRECT_URI,
            'code': code
        }
    )
    tokens = resp.json()
    print(f"ACCESS TOKEN: {tokens.get('access_token')}")
    print(f"REFRESH TOKEN: {tokens.get('refresh_token')}")
    return (f"Authorization complete!<br>"
            f"Copy ZOHO_ACCESS_TOKEN and ZOHO_REFRESH_TOKEN to Replit Secrets.<br>"
            f"Expires in: {tokens.get('expires_in')} seconds")

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=3000)
```

**Expected result:** After visiting /oauth/start and authorizing the app in Zoho, the callback page displays access and refresh tokens. Both are stored in Replit Secrets as ZOHO_ACCESS_TOKEN and ZOHO_REFRESH_TOKEN.

### 3. Implement token refresh and verify connection

Zoho CRM access tokens expire after 60 minutes. Your application must automatically refresh them using the stored refresh token. Build a token refresh function that calls POST {ZOHO_ACCOUNTS_URL}/oauth/v2/token with grant_type=refresh_token and your refresh token credentials. The response includes a new access_token; update it in your application's in-memory state and optionally write it to a local file for persistence across server restarts. Call this refresh function whenever you receive a 401 response from the CRM API. Implement a wrapper around your API calls that retries once after refreshing the token on 401. Verify the connection by calling GET {ZOHO_API_URL}/crm/v2/org to retrieve your organization details — this is a lightweight call that confirms your credentials are valid and tells you your organization name, CRM edition, and time zone. Add ZOHO_ORG_ID to Replit Secrets once you have it from the org endpoint.

```
// Node.js — token refresh and connection test (zoho-client.js)
const axios = require('axios');
const qs = require('querystring');

const ACCOUNTS_URL = process.env.ZOHO_ACCOUNTS_URL || 'https://accounts.zoho.com';
const API_URL = process.env.ZOHO_API_URL || 'https://www.zohoapis.com';
let accessToken = process.env.ZOHO_ACCESS_TOKEN;

async function refreshToken() {
  const resp = await axios.post(
    `${ACCOUNTS_URL}/oauth/v2/token`,
    qs.stringify({
      grant_type: 'refresh_token',
      client_id: process.env.ZOHO_CLIENT_ID,
      client_secret: process.env.ZOHO_CLIENT_SECRET,
      refresh_token: process.env.ZOHO_REFRESH_TOKEN
    }),
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
  );
  accessToken = resp.data.access_token;
  console.log('Access token refreshed');
  return accessToken;
}

async function zohoRequest(method, path, data = null) {
  const makeCall = async () => axios({
    method,
    url: `${API_URL}/crm/v2${path}`,
    headers: { Authorization: `Zoho-oauthtoken ${accessToken}` },
    data
  });
  try {
    return (await makeCall()).data;
  } catch (err) {
    if (err.response?.status === 401) {
      await refreshToken();
      return (await makeCall()).data;
    }
    throw err;
  }
}

// Test connection
(async () => {
  const org = await zohoRequest('GET', '/org');
  console.log('Connected to Zoho CRM:');
  console.log(`  Org: ${org.org?.[0]?.company_name}`);
  console.log(`  Edition: ${org.org?.[0]?.edition}`);
})();

module.exports = { zohoRequest, refreshToken };
```

**Expected result:** The zohoRequest function connects to Zoho CRM and prints the organization name and edition. The token refresh logic handles 401 responses automatically.

### 4. Create and search CRM records

With authenticated API access established, you can create and manage CRM records. To create a Lead, POST to /crm/v2/Leads with a JSON body containing a 'data' array of lead objects. Each field must use the Zoho API field name, not the display name. Common Lead fields include Last_Name (required), First_Name, Email, Phone, Company, Lead_Source, and Lead_Status. To get all available field names for a module, call GET /crm/v2/settings/fields?module=Leads — the response lists every field with its api_name, data_type, and whether it is required. To search for existing records (for deduplication before creating a new lead), use GET /crm/v2/Leads/search?email={email} or the COQL (CRM Object Query Language) endpoint at /crm/v2/coql for more complex queries. Build a lead capture endpoint that first searches by email (to avoid creating duplicates), creates the lead if not found, or updates the existing record if found. This upsert pattern is essential for any form-based lead capture integration.

```
# Python — create leads and search records (crm_operations.py)
import requests
import os

ACCESS_TOKEN = os.environ['ZOHO_ACCESS_TOKEN']
API_URL = os.environ.get('ZOHO_API_URL', 'https://www.zohoapis.com')

headers = {
    'Authorization': f'Zoho-oauthtoken {ACCESS_TOKEN}',
    'Content-Type': 'application/json'
}

def search_lead_by_email(email):
    """Search for an existing lead by email address."""
    resp = requests.get(
        f'{API_URL}/crm/v2/Leads/search',
        params={'email': email},
        headers=headers
    )
    if resp.status_code == 200:
        data = resp.json().get('data', [])
        return data[0] if data else None
    return None

def create_lead(lead_data):
    """Create a new lead in Zoho CRM."""
    payload = {'data': [lead_data]}
    resp = requests.post(f'{API_URL}/crm/v2/Leads', json=payload, headers=headers)
    if resp.status_code == 201:
        result = resp.json().get('data', [{}])[0]
        return result.get('details', {}).get('id')
    else:
        print(f'Create error {resp.status_code}: {resp.text}')
        return None

def upsert_lead(first_name, last_name, email, company, lead_source='Website'):
    """Create or update a lead based on email."""
    existing = search_lead_by_email(email)
    if existing:
        print(f'Lead already exists: {existing.get("id")} — updating')
        # Update existing lead (PUT /Leads/{id})
        resp = requests.put(
            f'{API_URL}/crm/v2/Leads/{existing["id"]}',
            json={'data': [{'Lead_Source': lead_source}]},
            headers=headers
        )
        return existing.get('id')
    else:
        lead_data = {
            'First_Name': first_name,
            'Last_Name': last_name,
            'Email': email,
            'Company': company,
            'Lead_Source': lead_source
        }
        lead_id = create_lead(lead_data)
        print(f'Lead created: {lead_id}')
        return lead_id

# Test: create or update a lead
lead_id = upsert_lead('Jane', 'Smith', 'jane@example.com', 'Acme Corp', 'Website')
print(f'Lead ID: {lead_id}')
```

**Expected result:** The upsert_lead function either finds and updates an existing lead or creates a new one. The lead appears in Zoho CRM under the Leads module with all specified fields populated.

### 5. Build a lead capture server and deploy

Combine the token refresh logic, upsert pattern, and an Express or Flask server to build a complete lead capture endpoint that external services can call. The endpoint should accept form-field POST requests, validate required fields, call the upsert function, and return a structured JSON response with success status and the lead ID. Add CORS middleware (using the cors package for Node.js or flask-cors for Python) if the endpoint will be called directly from browser forms. Deploy as Autoscale for form submission handlers — Autoscale ensures the endpoint is always available when a visitor submits a form, even during traffic spikes. For periodic CRM sync jobs that run on a schedule rather than on demand, use Replit's Scheduled deployment type instead. After deployment, update the form's action URL or your webhook configuration to point to the production deployment URL (not the development preview URL), and test the full flow from form submission to CRM lead creation.

```
// Node.js — complete lead capture server (server.js)
const express = require('express');
const { zohoRequest, refreshToken } = require('./zoho-client');

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Optional: add CORS for browser form submissions
// const cors = require('cors');
// app.use(cors());

async function upsertLead(data) {
  const { firstName, lastName, email, company, source } = data;
  if (!lastName || !email) throw new Error('lastName and email are required');

  // Search for existing lead
  const search = await zohoRequest('GET', `/Leads/search?email=${encodeURIComponent(email)}`);
  const existing = search.data?.[0];

  if (existing) {
    await zohoRequest('PUT', `/Leads/${existing.id}`, {
      data: [{ Lead_Source: source || 'Website' }]
    });
    return { action: 'updated', id: existing.id };
  }

  const result = await zohoRequest('POST', '/Leads', {
    data: [{
      First_Name: firstName || '',
      Last_Name: lastName,
      Email: email,
      Company: company || '',
      Lead_Source: source || 'Website'
    }]
  });
  const id = result.data?.[0]?.details?.id;
  return { action: 'created', id };
}

app.post('/capture-lead', async (req, res) => {
  try {
    const result = await upsertLead(req.body);
    console.log(`Lead ${result.action}: ${result.id}`);
    res.json({ success: true, ...result });
  } catch (err) {
    console.error('Error:', err.message);
    res.status(500).json({ success: false, error: err.message });
  }
});

app.listen(3000, '0.0.0.0', () => console.log('Zoho CRM lead capture server running'));
```

**Expected result:** The lead capture server accepts POST requests, creates or updates Zoho CRM leads, and returns JSON responses. Deployed as Autoscale, it handles form submissions reliably without sleeping.

## Best practices

- Store ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_ACCESS_TOKEN, ZOHO_REFRESH_TOKEN, ZOHO_ACCOUNTS_URL, and ZOHO_API_URL in Replit Secrets (lock icon 🔒) — never hardcode OAuth credentials
- Use the Authorization header format 'Zoho-oauthtoken YOUR_TOKEN' (not 'Bearer') for all Zoho CRM API requests
- Implement retry-on-401 with automatic token refresh — access tokens expire after 60 minutes and the refresh cycle must be transparent to API callers
- Always use API field names (not display names) from GET /settings/fields — Zoho silently ignores unrecognized field names, causing hard-to-debug missing data issues
- Use the search endpoint to check for existing records before creating new ones — prevent CRM duplicate records by implementing an email-based upsert pattern
- Use region-specific server URLs for both OAuth (ZOHO_ACCOUNTS_URL) and API (ZOHO_API_URL) — mixing regions causes authentication failures that are hard to diagnose
- Deploy as Autoscale for webhook receivers and form capture endpoints; use Scheduled deployments for nightly CRM sync jobs
- Log the Zoho CRM record ID returned from every create call — this is the canonical reference for future updates and activity logging against that record

## Use cases

### Web Form Lead Capture to Zoho CRM

When a visitor fills out a lead generation form on your website, your Replit backend receives the form data, validates it, and immediately creates a Lead record in Zoho CRM with all captured fields: first name, last name, email, phone, company, lead source, and any campaign parameters from the URL. High-quality leads are automatically assigned to the appropriate sales rep based on geography or product interest.

Prompt example:

```
Build an Express server with a POST /capture-lead endpoint that accepts form data (first name, last name, email, company, lead source), validates required fields, creates a Zoho CRM Lead record via the API, and returns the created record ID. Store ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_ACCESS_TOKEN, and ZOHO_REFRESH_TOKEN in Replit Secrets.
```

### Deal Stage Change Notifier

Configure a Zoho CRM webhook that fires when any deal changes stage. Your Replit server receives the webhook event, extracts the deal name, new stage, deal value, and owner, and sends a Slack notification to the #sales channel. The team gets real-time visibility into pipeline movement without needing to monitor the CRM dashboard.

Prompt example:

```
Build a Flask webhook handler at /zoho-deal-webhook that receives Zoho CRM deal update events, parses the Stage field change, and posts a formatted message to a Slack webhook URL with the deal name, old stage, new stage, and deal value. Store SLACK_WEBHOOK_URL in Replit Secrets alongside Zoho credentials.
```

### Cross-Tool Customer Data Sync

When a customer's subscription status changes in your billing system (Stripe, Chargebee, etc.), a Replit job looks up the corresponding Contact in Zoho CRM by email, updates their subscription tier custom field, logs a 'Subscription Updated' activity, and creates a follow-up task for the account manager. The CRM stays in sync with billing data without manual updates.

Prompt example:

```
Build a Python script that accepts an email and new subscription tier, searches Zoho CRM Contacts by email address, updates the found contact's subscription field with the new tier value, and creates a follow-up call task due in 7 days assigned to the contact owner. Store Zoho OAuth credentials in Replit Secrets.
```

## Troubleshooting

### OAuth token request returns 'invalid_client_id' or 'invalid_client' error

Cause: The Client ID or Client Secret in Replit Secrets does not match the values in the Zoho API Console, or the request is being sent to the wrong region's accounts server URL.

Solution: Verify ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET exactly match the values from api-console.zoho.com. Confirm ZOHO_ACCOUNTS_URL matches your account's region (https://accounts.zoho.com for US, https://accounts.zoho.eu for EU, https://accounts.zoho.in for IN, https://accounts.zoho.com.au for AU). Sending requests to the wrong region returns client errors even with valid credentials.

### API returns 401 with 'AUTHENTICATION_FAILURE' after the integration was working

Cause: The access token has expired (60-minute lifetime). The application did not refresh the token before making the API call.

Solution: Implement a token refresh on every 401 response (retry-on-401 pattern). Check that ZOHO_REFRESH_TOKEN is in Replit Secrets and the refresh function uses the correct token endpoint URL with the correct region.

```
// Retry once after 401 with token refresh
try {
  return await makeCall();
} catch (err) {
  if (err.response?.status === 401) {
    await refreshToken();
    return await makeCall();
  }
  throw err;
}
```

### Create Lead returns success but a field value is missing or shows wrong value

Cause: The field was sent with the display name instead of the API field name. Zoho silently ignores fields with unrecognized names rather than returning an error.

Solution: Call GET /crm/v2/settings/fields?module=Leads to get the exact api_name for every field. Custom fields have unique API names visible in the Zoho CRM field settings. Always use api_name values in your API payloads.

```
# Get field API names
import requests, os
headers = {'Authorization': f'Zoho-oauthtoken {os.environ["ZOHO_ACCESS_TOKEN"]}'}
resp = requests.get(
    f'{os.environ["ZOHO_API_URL"]}/crm/v2/settings/fields?module=Leads',
    headers=headers
)
for field in resp.json().get('fields', []):
    print(f'{field["display_label"]} -> api_name: {field["api_name"]}')
```

### Authorization URL redirects to 'This site can't be reached' during OAuth flow

Cause: The redirect URI registered in the Zoho API Console does not match the current Replit preview URL, so the browser tries to load the callback on a URL that is not running.

Solution: The Replit preview URL changes when you create a new Repl or change the Repl name. Update the Authorized Redirect URI in the Zoho API Console to match your current Replit URL, and update the REDIRECT_URI in your oauth setup code to match.

## Frequently asked questions

### How do I connect Replit to Zoho CRM?

Register an OAuth client in the Zoho API Console at api-console.zoho.com, complete the OAuth 2.0 authorization code flow to obtain access and refresh tokens, store credentials in Replit Secrets (lock icon 🔒), and make authenticated requests to the Zoho CRM REST API using the 'Zoho-oauthtoken YOUR_TOKEN' Authorization header from your Express or Flask server.

### Does Replit work with Zoho CRM for free?

Zoho CRM's free tier (up to 3 users) includes API access. Replit's free tier is sufficient for development and testing. For production lead capture endpoints that must be always available, you need a paid Replit Autoscale deployment.

### How do I store the Zoho CRM API credentials in Replit?

Zoho CRM uses OAuth 2.0, so you need to store multiple values in Replit Secrets: ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET (from the API Console), ZOHO_ACCESS_TOKEN and ZOHO_REFRESH_TOKEN (from the OAuth flow), and region-specific ZOHO_ACCOUNTS_URL and ZOHO_API_URL. Access them with process.env.KEY in Node.js or os.environ['KEY'] in Python.

### Why does my Zoho CRM integration stop working after an hour?

Zoho access tokens expire after 60 minutes. Your application must use the stored ZOHO_REFRESH_TOKEN to obtain a new access token when the current one expires. Implement a retry-on-401 pattern that automatically refreshes the token and retries the failed request when the API returns a 401 Unauthorized response.

### How do I find the API field names for Zoho CRM modules?

Call GET {ZOHO_API_URL}/crm/v2/settings/fields?module=Leads (or replace Leads with Contacts, Deals, etc.) with your authorization header. The response lists every field with its display_label (what you see in the UI) and api_name (what you must use in API calls). Custom fields also appear in this response with their unique API names.

---

Source: https://www.rapidevelopers.com/replit-integration/zoho-crm
© RapidDev — https://www.rapidevelopers.com/replit-integration/zoho-crm
