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.
Integration method
Replit connects to Zoho CRM via the Zoho CRM REST API using OAuth 2.0 credentials stored in Replit Secrets. Your Express or Flask server completes the OAuth flow to obtain access and refresh tokens, then uses those tokens to create and update CRM records across leads, contacts, accounts, and deals modules. Zoho's OAuth 2.0 implementation requires a refresh token strategy since access tokens expire every hour.
Prerequisites
- 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
Register an OAuth client in the Zoho API Console
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.
1// Node.js β install required packages2// Run in Replit Shell:3// npm install express axios45// Verify Zoho config secrets are present6const keys = ['ZOHO_CLIENT_ID', 'ZOHO_CLIENT_SECRET', 'ZOHO_ACCOUNTS_URL', 'ZOHO_API_URL'];7for (const key of keys) {8 const val = process.env[key];9 if (!val) console.error(`MISSING: ${key} β add via Replit Secrets (lock icon π)`);10 else console.log(`OK: ${key}`);11}Pro tip: Always use the region-specific accounts server and API URL for your Zoho account. Using a mismatched region URL is the most common setup error and produces confusing authentication failures. Find your region in Zoho CRM under Settings > General > Timezone β the timezone will indicate your region.
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.
Complete the OAuth 2.0 authorization flow
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.
1# Python β Zoho OAuth 2.0 flow (zoho_oauth.py)2from flask import Flask, request, redirect3import requests4import os56app = Flask(__name__)78CLIENT_ID = os.environ['ZOHO_CLIENT_ID']9CLIENT_SECRET = os.environ['ZOHO_CLIENT_SECRET']10ACCOUNTS_URL = os.environ.get('ZOHO_ACCOUNTS_URL', 'https://accounts.zoho.com')11REDIRECT_URI = os.environ.get('REPLIT_URL', 'http://localhost:3000') + '/oauth/callback'12SCOPES = 'ZohoCRM.modules.ALL,ZohoCRM.settings.ALL'1314@app.route('/oauth/start')15def oauth_start():16 auth_url = (17 f'{ACCOUNTS_URL}/oauth/v2/auth'18 f'?scope={SCOPES}'19 f'&client_id={CLIENT_ID}'20 f'&response_type=code'21 f'&access_type=offline'22 f'&redirect_uri={REDIRECT_URI}'23 )24 return redirect(auth_url)2526@app.route('/oauth/callback')27def oauth_callback():28 code = request.args.get('code')29 resp = requests.post(30 f'{ACCOUNTS_URL}/oauth/v2/token',31 data={32 'grant_type': 'authorization_code',33 'client_id': CLIENT_ID,34 'client_secret': CLIENT_SECRET,35 'redirect_uri': REDIRECT_URI,36 'code': code37 }38 )39 tokens = resp.json()40 print(f"ACCESS TOKEN: {tokens.get('access_token')}")41 print(f"REFRESH TOKEN: {tokens.get('refresh_token')}")42 return (f"Authorization complete!<br>"43 f"Copy ZOHO_ACCESS_TOKEN and ZOHO_REFRESH_TOKEN to Replit Secrets.<br>"44 f"Expires in: {tokens.get('expires_in')} seconds")4546if __name__ == '__main__':47 app.run(host='0.0.0.0', port=3000)Pro tip: Include access_type=offline in the authorization URL to ensure Zoho returns a refresh token. Without this parameter, you receive only a short-lived access token and cannot maintain long-term API access without requiring user re-authorization.
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.
Implement token refresh and verify connection
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.
1// Node.js β token refresh and connection test (zoho-client.js)2const axios = require('axios');3const qs = require('querystring');45const ACCOUNTS_URL = process.env.ZOHO_ACCOUNTS_URL || 'https://accounts.zoho.com';6const API_URL = process.env.ZOHO_API_URL || 'https://www.zohoapis.com';7let accessToken = process.env.ZOHO_ACCESS_TOKEN;89async function refreshToken() {10 const resp = await axios.post(11 `${ACCOUNTS_URL}/oauth/v2/token`,12 qs.stringify({13 grant_type: 'refresh_token',14 client_id: process.env.ZOHO_CLIENT_ID,15 client_secret: process.env.ZOHO_CLIENT_SECRET,16 refresh_token: process.env.ZOHO_REFRESH_TOKEN17 }),18 { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }19 );20 accessToken = resp.data.access_token;21 console.log('Access token refreshed');22 return accessToken;23}2425async function zohoRequest(method, path, data = null) {26 const makeCall = async () => axios({27 method,28 url: `${API_URL}/crm/v2${path}`,29 headers: { Authorization: `Zoho-oauthtoken ${accessToken}` },30 data31 });32 try {33 return (await makeCall()).data;34 } catch (err) {35 if (err.response?.status === 401) {36 await refreshToken();37 return (await makeCall()).data;38 }39 throw err;40 }41}4243// Test connection44(async () => {45 const org = await zohoRequest('GET', '/org');46 console.log('Connected to Zoho CRM:');47 console.log(` Org: ${org.org?.[0]?.company_name}`);48 console.log(` Edition: ${org.org?.[0]?.edition}`);49})();5051module.exports = { zohoRequest, refreshToken };Pro tip: Note that the Zoho authorization header format is 'Zoho-oauthtoken YOUR_TOKEN' (not 'Bearer YOUR_TOKEN') β using the wrong format will result in authentication failures even with a valid token.
Expected result: The zohoRequest function connects to Zoho CRM and prints the organization name and edition. The token refresh logic handles 401 responses automatically.
Create and search CRM records
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.
1# Python β create leads and search records (crm_operations.py)2import requests3import os45ACCESS_TOKEN = os.environ['ZOHO_ACCESS_TOKEN']6API_URL = os.environ.get('ZOHO_API_URL', 'https://www.zohoapis.com')78headers = {9 'Authorization': f'Zoho-oauthtoken {ACCESS_TOKEN}',10 'Content-Type': 'application/json'11}1213def search_lead_by_email(email):14 """Search for an existing lead by email address."""15 resp = requests.get(16 f'{API_URL}/crm/v2/Leads/search',17 params={'email': email},18 headers=headers19 )20 if resp.status_code == 200:21 data = resp.json().get('data', [])22 return data[0] if data else None23 return None2425def create_lead(lead_data):26 """Create a new lead in Zoho CRM."""27 payload = {'data': [lead_data]}28 resp = requests.post(f'{API_URL}/crm/v2/Leads', json=payload, headers=headers)29 if resp.status_code == 201:30 result = resp.json().get('data', [{}])[0]31 return result.get('details', {}).get('id')32 else:33 print(f'Create error {resp.status_code}: {resp.text}')34 return None3536def upsert_lead(first_name, last_name, email, company, lead_source='Website'):37 """Create or update a lead based on email."""38 existing = search_lead_by_email(email)39 if existing:40 print(f'Lead already exists: {existing.get("id")} β updating')41 # Update existing lead (PUT /Leads/{id})42 resp = requests.put(43 f'{API_URL}/crm/v2/Leads/{existing["id"]}',44 json={'data': [{'Lead_Source': lead_source}]},45 headers=headers46 )47 return existing.get('id')48 else:49 lead_data = {50 'First_Name': first_name,51 'Last_Name': last_name,52 'Email': email,53 'Company': company,54 'Lead_Source': lead_source55 }56 lead_id = create_lead(lead_data)57 print(f'Lead created: {lead_id}')58 return lead_id5960# Test: create or update a lead61lead_id = upsert_lead('Jane', 'Smith', 'jane@example.com', 'Acme Corp', 'Website')62print(f'Lead ID: {lead_id}')Pro tip: Always call GET /crm/v2/settings/fields?module=Leads to get the exact API field names for your Zoho account. Custom fields have names like 'Custom_Field_1' or a descriptive API name you assigned when creating them. Using display names instead of API names will result in field not found errors.
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.
Build a lead capture server and deploy
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.
1// Node.js β complete lead capture server (server.js)2const express = require('express');3const { zohoRequest, refreshToken } = require('./zoho-client');45const app = express();6app.use(express.json());7app.use(express.urlencoded({ extended: true }));89// Optional: add CORS for browser form submissions10// const cors = require('cors');11// app.use(cors());1213async function upsertLead(data) {14 const { firstName, lastName, email, company, source } = data;15 if (!lastName || !email) throw new Error('lastName and email are required');1617 // Search for existing lead18 const search = await zohoRequest('GET', `/Leads/search?email=${encodeURIComponent(email)}`);19 const existing = search.data?.[0];2021 if (existing) {22 await zohoRequest('PUT', `/Leads/${existing.id}`, {23 data: [{ Lead_Source: source || 'Website' }]24 });25 return { action: 'updated', id: existing.id };26 }2728 const result = await zohoRequest('POST', '/Leads', {29 data: [{30 First_Name: firstName || '',31 Last_Name: lastName,32 Email: email,33 Company: company || '',34 Lead_Source: source || 'Website'35 }]36 });37 const id = result.data?.[0]?.details?.id;38 return { action: 'created', id };39}4041app.post('/capture-lead', async (req, res) => {42 try {43 const result = await upsertLead(req.body);44 console.log(`Lead ${result.action}: ${result.id}`);45 res.json({ success: true, ...result });46 } catch (err) {47 console.error('Error:', err.message);48 res.status(500).json({ success: false, error: err.message });49 }50});5152app.listen(3000, '0.0.0.0', () => console.log('Zoho CRM lead capture server running'));Pro tip: Store the ZOHO_REFRESH_TOKEN in Replit Secrets and implement persistent token storage by writing the refreshed access token to a local tokens.json file β this ensures your server survives restarts without requiring re-authorization.
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.
Common 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.
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.
Copy this prompt to try it in Replit
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.
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.
Copy this prompt to try it in Replit
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.
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.
Copy this prompt to try it in Replit
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.
1// Retry once after 401 with token refresh2try {3 return await makeCall();4} catch (err) {5 if (err.response?.status === 401) {6 await refreshToken();7 return await makeCall();8 }9 throw err;10}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.
1# Get field API names2import requests, os3headers = {'Authorization': f'Zoho-oauthtoken {os.environ["ZOHO_ACCESS_TOKEN"]}'}4resp = requests.get(5 f'{os.environ["ZOHO_API_URL"]}/crm/v2/settings/fields?module=Leads',6 headers=headers7)8for field in resp.json().get('fields', []):9 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.
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
Alternatives
Salesforce is the enterprise CRM standard with a more powerful API and larger ecosystem, but is significantly more expensive and complex to integrate than Zoho CRM.
HubSpot offers a free CRM tier with a well-documented modern API and stronger inbound marketing integration, making it a good alternative if you need marketing automation alongside CRM.
Pipedrive provides a visual sales pipeline with a clean REST API and simpler OAuth setup, better suited for sales-focused teams that prioritize deal tracking over a full CRM suite.
Freshsales offers simpler API key authentication (no OAuth required) as part of the Freshworks suite, making initial setup faster than Zoho's OAuth flow.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation