Zoho CRM
Olyron CRM connects to Zoho CRM over OAuth 2.0 so you can push leads, deals, tasks, and notes from your Olyron workspace into Zoho and pull leads and deals back. Zoho is a fully implemented CRM connector, wired through the `crm-connectors` SDK and the `/api/crm-connectors/[provider]/callback` OAuth handler.
#What the Zoho connector does
The Zoho connector is built on Olyron's shared connector SDK (src/lib/crm-connectors). It authenticates with Zoho's OAuth 2.0 endpoints, stores the resulting connection in the crm_connections table, and exposes read/write operations against Zoho's v2 REST API at https://www.zohoapis.com/crm/v2. Every request is authenticated with a Zoho-oauthtoken header.
On the integrations screen (/dashboard/settings/integrations), Zoho appears as an Available provider. HubSpot and Salesforce connector classes also ship in the codebase, and getSupportedProviders() in factory.ts now marks all three providers as implemented; this guide documents the Zoho connector.
| Capability | Direction | Status |
|---|---|---|
| Leads | Push, update, and fetch (pull) | Implemented |
| Deals | Push, update, and fetch (pull) | Implemented |
| Tasks | Create (push) | Implemented |
| Notes | Push (attached to a lead) | Implemented |
| Vendor enrollment sync | Push | Implemented (Note or Task) |
| Webhooks / bidirectional | Inbound | Capability flag set, no inbound endpoint yet |
src/lib/crm-connectors/providers/zoho.ts. Shared SDK and base class (including token encryption): src/lib/crm-connectors/sdk.ts. OAuth URL builder and provider registry: src/lib/crm-connectors/factory.ts.#Before you connect
You need a Zoho API client (created in the Zoho Developer Console / API Console) and three environment variables set on the Olyron deployment. The connector reads these directly when exchanging and refreshing tokens.
ZOHO_CLIENT_IDstring- Client ID of your Zoho self-client or server-based app. Used to build the authorization URL and to exchange the code for tokens.
ZOHO_CLIENT_SECRETstring- Client secret for the same Zoho app. Used on the server during token exchange and refresh only.
ZOHO_REDIRECT_URIstring- The redirect URI registered in Zoho. Must match the callback Olyron uses (see the warning below), otherwise the token exchange fails.
CRM_ENCRYPTION_KEYstring- Symmetric key used by the SDK's token-encryption helpers in
sdk.tsto encrypt OAuth tokens at rest with AES-256-GCM. Any string works — it is SHA-256 hashed into a 32-byte key. Required in production: the SDK throws if it is missing. In non-production it falls back to an insecure built-in dev key with a warning. Set a strong, stable value per environment.
When you register the Zoho app, request the scopes the connector uses: ZohoCRM.modules.ALL and ZohoCRM.settings.ALL. These are hard-coded into getAuthUrl() and grant access to the Leads, Deals, Tasks, and Notes modules.
<origin>/api/crm-connectors/zoho/callback, but the token exchange in connect() sends ZOHO_REDIRECT_URI. Zoho requires both to be identical and both registered on the Zoho app. Set ZOHO_REDIRECT_URI to exactly https://<your-domain>/api/crm-connectors/zoho/callback. The .env.example sample value points at an older /api/crm/callback/zoho path — do not copy it verbatim.#Connecting your Zoho account
- 1Open IntegrationsGo to
/dashboard/settings/integrations. Under Available Integrations, find the Zoho CRM card and choose Connect Zoho CRM. - 2Review scope and vendor optionsThe connect screen (
/dashboard/settings/integrations/zoho/connect) lists what will sync and lets you toggle Vendor enrollment sync and pick which vendor fields (plan name, status, effective date, termination date, premium) to include. Selecting Authorize Zoho CRM starts the flow. - 3Authorize in ZohoOlyron generates a CSRF state token of the form
<tenantId>:<timestamp>, stashes your vendor-sync preferences in session storage, and redirects you tohttps://accounts.zoho.com/oauth/v2/authwithaccess_type=offlineso a refresh token is issued. Sign in to Zoho and grant access. - 4Callback and token exchangeZoho redirects back to
/api/crm-connectors/zoho/callbackwith acodeandstate. The route validates that both are present, reads the tenant ID from the state, and exchanges the code for tokens athttps://accounts.zoho.com/oauth/v2/token. - 5Connection savedA row is written to
crm_connectionswithstatus: active, storing the access token and refresh token (encrypted at rest with AES-256-GCM), the expiry, andinstance_url(from Zoho'sapi_domain). You are redirected to the field-mapping step for the provider.
crm_connections filtered by your tenant. A provider card shows as connected once a row exists for that tenant and provider, and the Connect button is replaced with a Configure link.#How Olyron fields map to Zoho modules
The connector translates Olyron's internal lead/deal/task/note shapes to Zoho module fields. You can override the defaults with a fieldMappings record (Olyron field name to Zoho API name) passed into the connector; mapFields() in the SDK applies it, and anything under a record's custom_fields object is merged directly onto the Zoho payload.
Leads module
| Olyron field | Zoho field | Notes |
|---|---|---|
| first_name | First_Name | Direct |
| last_name | Last_Name | Also used as search criteria on fetch |
| Also used as search criteria on fetch | ||
| phone | Phone | Direct |
| status | Lead_Status | Defaults to "Not Contacted" on push |
| source | Lead_Source | Direct |
Deals, Tasks, and Notes
| Olyron object | Olyron field | Zoho field |
|---|---|---|
| Deal | title / amount / stage | Deal_Name / Amount / Stage |
| Deal | expected_close_date / probability | Closing_Date / Probability |
| Task | title / description / status | Subject / Description / Status |
| Task | priority (high|low|other) | Priority (High|Low|Normal) |
| Task | due_date | Due_Date (YYYY-MM-DD) |
| Note | title / body | Note_Title / Note_Content |
Parent_Id with se_module: "Leads".Vendor enrollment sync
syncVendorEnrollment(payload) records a vendor/carrier enrollment change in Zoho. When payload.member_external_id is set, it writes a Note on that member's Zoho Lead (via pushNote → POST /crm/v2/Notes with se_module: "Leads") titled Vendor enrollment: <vendor_name>. When the member has no linked Zoho lead id, it falls back to a standalone Zoho Task (POST /crm/v2/Tasks) with the same title, status: "Completed", medium priority, and Due_Date taken from the enrollment's effective date. The connector does not create or match a Zoho Lead on its own, so a Task (not a Note) is written whenever the member is not already linked to a Zoho lead.
The Note or Task body is a newline-separated summary assembled from whichever payload values are present: member name, plan, enrollment status, change type, effective date, termination date, and a free-text change description.
#API and endpoint reference
The only Olyron HTTP endpoint in the Zoho flow is the OAuth callback. Everything else happens server-side through the connector calling Zoho's API.
| Endpoint | Method | Purpose |
|---|---|---|
| /api/crm-connectors/zoho/callback | GET | Handles the Zoho OAuth redirect, exchanges the code, and persists the connection |
Token exchange (server-side)
POST https://accounts.zoho.com/oauth/v2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=$ZOHO_CLIENT_ID
&client_secret=$ZOHO_CLIENT_SECRET
&redirect_uri=$ZOHO_REDIRECT_URI
&code=<code from callback>
// Response fields consumed by Olyron:
// access_token, refresh_token, expires_in, api_domainExample: push a lead to Zoho
POST https://www.zohoapis.com/crm/v2/Leads
Authorization: Zoho-oauthtoken <access_token>
Content-Type: application/json
{
"data": [
{
"First_Name": "Dana",
"Last_Name": "Ruiz",
"Email": "dana@example.com",
"Phone": "+1-555-0100",
"Lead_Status": "Not Contacted",
"Lead_Source": "Referral"
}
]
}
// Olyron reads data[0].code === "SUCCESS" and returns
// data[0].details.id as the external_idrefreshToken() calls the same token endpoint with grant_type=refresh_token using the stored refresh token. disconnect() is a no-op by design — Zoho tokens simply expire, so removing the crm_connections row is what ends the integration.#How OAuth tokens are stored
Access and refresh tokens are encrypted at rest before they are written to crm_connections.access_token_enc and refresh_token_enc. The SDK's encryptToken() / decryptToken() helpers use AES-256-GCM (authenticated encryption) with a key derived from CRM_ENCRYPTION_KEY. BaseConnector decrypts on demand through its accessToken / refreshTokenValue getters, so plaintext tokens are never persisted.
- Ciphertext format is
v1:gcm:<iv>:<tag>:<ciphertext>, with each component base64-encoded. CRM_ENCRYPTION_KEYis SHA-256 hashed into a 32-byte key, so any-length secret is accepted; keep it stable so existing tokens stay decryptable.- In production the key is required — the SDK throws rather than store tokens unencrypted. Non-production falls back to an insecure built-in dev key and logs a warning.
- Legacy base64-only tokens written before this change are still readable and are transparently upgraded to AES-256-GCM the next time the token is persisted (
isLegacyToken()detects them).
src/lib/secrets/envelope.ts.#Limits, honesty notes, and troubleshooting
- Vendor enrollment sync is implemented:
syncVendorEnrollment()writes a Note on the member's Zoho Lead whenmember_external_idis known, and otherwise creates a standalone Zoho Task summarizing the enrollment. It does not create or match Zoho Leads itself, so unlinked members get a Task rather than a Note. - The connector advertises
webhooksandbidirectionalcapabilities, but there is no inbound Zoho webhook route in the app yet. Pulling data is on-demand viafetchLeads/fetchDeals, not push-from-Zoho. - Token storage is real encryption (AES-256-GCM keyed off
CRM_ENCRYPTION_KEY), not the old base64 placeholder. It is still a symmetric app-key scheme, so protectCRM_ENCRYPTION_KEYand database access, and set a strong key in production (the SDK refuses to store tokens without one). - The post-connect redirect points at a per-provider field-mapping page; if that page is not present in your build, you will land on a route that does not yet render a mapping UI. Connections still save correctly.
Callback error codes
If the OAuth callback fails it redirects back to /dashboard/settings/integrations?error=<code>. Use the code to diagnose:
| error code | Meaning | Fix |
|---|---|---|
| no_code | Zoho returned no authorization code | Retry the connect flow; confirm the app is authorized in Zoho |
| invalid_state | No state parameter came back | Restart from the Connect screen so a fresh state token is generated |
| connection_failed | Token exchange with Zoho failed | Check ZOHO_CLIENT_ID / ZOHO_CLIENT_SECRET and that ZOHO_REDIRECT_URI matches the registered URI |
| db_error | The connection row could not be written | Check Supabase logs and the crm_connections table permissions |
| callback_error | Unexpected exception in the handler | Inspect server logs for the thrown error |
ZOHO_REDIRECT_URI, the value registered in the Zoho API Console, and <origin>/api/crm-connectors/zoho/callback all identical.