Zoho
Zoho CRM is a configurable CRM: each org defines modules (Leads, Contacts, Deals, custom modules, etc.) and field API names per module.
Official Zoho doc hubs (for behavior beyond this adapter):
- Query (COQL) API — overview — SQL-like reads; uses module and field API names.
- COQL limitations — supported comparators, limits, and field-type caveats.
- Insert records — create rows in a module (
POST …/crm/v7/{Module_api_name}). - Update records — update by record id (
PUT …/crm/v7/{Module_api_name}/{record_id}).
In Zoho CRM, open Settings → Modules and Fields → [module] → [field]. Use the API name (not the label) in YAML when passing module fields. Mandatory fields depend on the module and org — the customer should confirm what must be set for Leads, Contacts, or any custom module you target.
CRM config (crmConfig)
These values are set once per customer environment (not in public bot YAML). They tune how lookups and defaults behave.
| Field | Required | Default (if unset) | Use |
|---|---|---|---|
orgId | No (recommended) | — | Used only to build the crmData.deepLink URL to the open record in Zoho's UI. If omitted, the adapter still works but crmData.deepLink will be undefined. Can be found in the customer's Zoho URL, with an org prefix (e.g., org1429453822). |
fieldsOverride | No | — | Array of Zoho field API names that replaces the default getCustomerDetails field list (id, Full_Name, Owner). Phone fields (phoneField / secondaryPhoneField) are always appended automatically. ⚠️ If your override omits id, Full_Name, or Owner, the corresponding crmData.recordId/contactId/name/ownerId will be missing — include them unless you specifically don't need them. |
contactModule | No | Contacts | Module used for getCustomerDetails (COQL FROM clause). |
phoneField | No | Phone | Primary phone field API name on that module. |
secondaryPhoneField | No | Mobile | Secondary phone field API name (also searched). |
defaultRecord | No | Leads | Default module for create-style ops when params.record is omitted. |
closeTicketDateField | No | — | Used by closeTicket when stamping a datetime field. If unset (and no YAML params are passed), closeTicket skips the record PUT entirely. |
disableCloseTicketNote | No | false | When true, closeTicket will not create the chat-transcript Note on the contact. Set this for customers that don't want a note written back to Zoho. |
closeTicketNoteTitle | No | שיחת ווצאפ | Prefix for the auto-created Note title. The final title is "{prefix} {dd/mm/yyyy of last message}". |
apiDomain | No | From OAuth metadata or https://www.zohoapis.com | Zoho API base; some regions/datacenters differ - for example https://www.zohoapis.eu |
OAuth for Zoho is configured in the Texter environment; bot YAML does not contain secrets.
Adapter functions
Supported operations in code: getCustomerDetails, customQuery, createRecord, newOpportunity, updateRecord, closeTicket.
newOpportunity and createRecord share the same create implementation — documented below.
getCustomerDetails
Looks up a contact (or configured module row) by matching the chat’s phone against two phone fields on that module.
When it runs: Same pattern as other CRMs: from bot YAML, and the CRM panel in the Texter UI can rely on the same kind of lookup when a chat is opened (phone must be present on the chat).
The adapter runs a COQL SELECT on crmConfig.contactModule (default Contacts). It compares the channel phone in several shapes (digits-only, locally formatted, E.164) against phoneField (default Phone) and secondaryPhoneField (default Mobile).
Basic
zoho_lookup:
type: func
func_type: crm
func_id: getCustomerDetails
on_complete: known_customer
on_failure: unknown_customer
| Param | Required | Notes |
|---|---|---|
| (none) | — | Uses the chat’s E.164 channel phone. If the chat has no phone, the operation fails (on_failure). |
fields | No | Array of extra Zoho field API names to include in the SELECT (strings only). The adapter always requests id, Full_Name, Owner, and both phone fields; fields adds to that list. |
Result: On success, crmData includes at least:
recordId— Zoho recordid.contactId— stable contact id (same asrecordIdat lookup time). UnlikerecordId, this is not overwritten by latercreateRecord/updateRecordcalls against other modules, so it's the safe handle to attach child records (like thecloseTicketNote) back to the original contact.name— fromFull_Name.phone— the value from the primary phone field if present, otherwise the secondary.ownerId— fromOwner.id(owner user id).deepLink— a URL to the record in Zoho’s UI.- Raw fields from the selected row (including any extra columns you asked for in
fields) are merged intocrmDataas returned by Zoho.
Advanced — pull extra columns for later nodes (e.g. custom fields the customer added in Zoho):
zoho_lookup_with_extras:
type: func
func_type: crm
func_id: getCustomerDetails
params:
fields:
- "Email"
- "Mailing_Street"
- "Custom_Field_API_Name"
on_complete: known_customer
on_failure: unknown_customer
Use only API names that exist on the configured contact module; invalid or unsupported COQL fields will cause the lookup to fail (same as any COQL error).
Lookup and other complex fields: Zoho often returns lookup fields (e.g. Account_Name) as an object with id, name, etc. After getCustomerDetails, you can use expressions like %chat:crmData.Account_Name.id% in later nodes or inside a customQuery WHERE clause. Include those field API names in params.fields when you need them on the contact row.
customQuery
Runs a COQL SELECT you provide. Use this when getCustomerDetails is not enough — e.g. read a field from another module (via id from a lookup on the contact).
| Param | Required | Notes |
|---|---|---|
query | Yes | Full COQL string passed to Zoho’s COQL API. You can embed %chat:crmData…% (and other supported interpolations) so the query depends on an earlier CRM step. |
Result: On success, the first row of the result set is stored under crmData.queryResult: each selected column’s API name becomes a key (e.g. crmData.queryResult.First_Name for SELECT First_Name FROM …). Existing crmData from earlier in the flow is preserved; queryResult is added/updated for this step.
Basic — follow-up read after a contact lookup:
fetch_related_flag:
type: func
func_type: crm
func_id: customQuery
params:
query: "SELECT Custom_Flag FROM Accounts WHERE id = '%chat:crmData.Account_Name.id%'"
on_complete: branch_on_flag
on_failure: handle_query_error
branch_on_flag:
type: func
func_type: system
func_id: switchNode
params:
input: "%chat:crmData.queryResult.Custom_Flag%"
cases:
"true": flow_a
"false": flow_b
on_complete: done
If the COQL response itself is missing (!res.data) the adapter fails. But if the query simply returned zero rows, the adapter still returns success: true with crmData.queryResult: {} (empty object). To branch on emptiness, check the size of queryResult keys in a switchNode after the query — e.g. %chat:crmData.queryResult|length%.
createRecord / newOpportunity
Creates a new row in a Zoho module (POST insert records). createRecord and newOpportunity use the same implementation — pick the name that matches your conventions.
When it runs: Most often when you need a Lead, Contact, Task, Case, or custom-module row that does not exist yet (e.g. after on_failure from getCustomerDetails, or to open a task linked to crmData.recordId).
| Param | Required | Notes |
|---|---|---|
record | No* | *Module API name (Contacts, Leads, Tasks, Cases, custom module, …). If omitted, uses crmConfig.defaultRecord (default Leads). |
dateField | No | If set, the adapter sets this Zoho field to the current time (YYYY-MM-DDTHH:mm:ssZ). |
| (other keys) | No | Zoho field API names → values on the new record. |
Required fields are whatever Zoho enforces for that module in that org — get names and mandatory columns from the customer.
Result: On success, crmData.recordId is set to the new record’s id from Zoho. When the created record is on the contact module (crmConfig.contactModule, default Contacts), crmData.contactId is also set to the same id so later ops have a stable handle to the contact even after subsequent creates/updates on other modules. Other crmData keys are preserved as-is.
Basic — contact with phone and source:
create_contact:
type: func
func_type: crm
func_id: newOpportunity
params:
record: Contacts
Last_Name: "%chat:title%"
Mobile: '%chat:phone|formatPhone("smart","IL")|replace("-","","g")%'
Lead_Source: "Texter"
on_complete: next_step
Advanced — task on the current contact (Who_Id = contact recordId after getCustomerDetails or after creating the contact):
open_task_for_contact:
type: func
func_type: crm
func_id: newOpportunity
params:
record: Tasks
dateField: "LastUpdate_WA"
Who_Id: "%chat:crmData.recordId%"
Subject: "Follow up from WhatsApp"
Status: "Not Started"
on_complete: done
updateRecord
Updates an existing row (PUT update records) by recordId.
When it runs: Whenever you already know the Zoho id (usually crmData.recordId after getCustomerDetails) and need to patch fields without creating a new row.
| Param | Required | Notes |
|---|---|---|
| (prerequisite) | — | crmData.recordId must be on the chat — set by getCustomerDetails, createRecord, or newOpportunity. |
recordId | Yes | Zoho record id — typically %chat:crmData.recordId%. |
record | No* | *Module API name (Contacts, Leads, Tasks, Cases, custom module, …). If omitted, uses crmConfig.defaultRecord (default Leads). |
dateField | No | If set, the adapter sets this Zoho field to the current time (YYYY-MM-DDTHH:mm:ssZ). |
| (other keys) | No | Zoho field API names → values on the new record. |
Result: On success, crmData.recordId is set from the API response (usually the same id). When the updated record is on the contact module (crmConfig.contactModule, default Contacts), crmData.contactId is refreshed to the same id. crmData is otherwise merged from the chat as before.
Basic
tag_contact:
type: func
func_type: crm
func_id: updateRecord
params:
record: Contacts
recordId: "%chat:crmData.recordId%"
Lead_Source: "Texter"
Client_Type: "Business"
on_complete: next_step
Advanced — update “last WhatsApp activity” (or any datetime field) in one call:
touch_contact_wa:
type: func
func_type: crm
func_id: updateRecord
params:
record: Contacts
recordId: "%chat:crmData.recordId%"
dateField: "LastUpdate_WA"
on_complete: next_step
closeTicket
Writes the chat transcript as a Note on the Contact (Zoho Create Notes API), and optionally PUTs the same record / dateField as updateRecord.
When it runs: When the chat is resolved (usually with no YAML params). Called from YAML if you also want to close a module row (e.g. a Case) in the same step.
Behavior:
- Record PUT (same as
updateRecord) — only runs when there is actually something to update:crmConfig.closeTicketDateFieldis set, and/or YAMLparamsare passed. - Note on Contact — created by default on
crmConfig.contactModule(defaultContacts), using the stablecrmData.contactIdso the Note lands on the original contact even ifrecordIdhas since been moved to another module. Note title is"{closeTicketNoteTitle || 'שיחת ווצאפ'} {dd/mm/yyyy of last message}". On the very firstcloseTicketfor a chat with >100 messages, only the last 100 are written. SetcrmConfig.disableCloseTicketNote: trueto skip the Note.
Basic — agent resolves the chat:
zoho_close:
type: func
func_type: crm
func_id: closeTicket
on_complete: done
on_failure: done
Advanced — close a Case and write the note in one step:
close_case_in_zoho:
type: func
func_type: crm
func_id: closeTicket
params:
record: Cases
Status: "Closed"
on_complete: done
on_failure: handoff
| Param | Required | Notes |
|---|---|---|
| (prerequisite) | — | For the PUT step: crmData.recordId (or previousBotSession.store.accountId). For the Note: a contact id on the chat (crmData.contactId → crmData.id → recordId). |
record | No | Module API name for the PUT; else crmConfig.defaultRecord. |
recordId | No | Override for the PUT; else previousBotSession.store.accountId || crmData.recordId. |
dateField | No | Override for the PUT; else crmConfig.closeTicketDateField. |
| (other) | No | Any Zoho field API name → value. Passing any key here also forces the PUT to run. |
Result: { success: true, lastMessageStoredInCRMTimestamp } when a note is written; { success: true } when disableCloseTicketNote is set and the PUT (if any) succeeded.
Bulthaup uses a different closeTicket implementation (its own WhatsApp transcript / file-attach flow). The behavior above applies to all other customers.
Zoho onboarding (for Texter Support)
Use this flow to register a Zoho OAuth app and wire client credentials + orgId for a customer. Set apiDomain in CRM config if the org is not on the default US API host (see crmConfig.apiDomain).
Access: Texter support does not have access to the customer’s Zoho org. Creating the API Console app must be done while logged into that customer’s Zoho account — either they create it themselves, or you guide them via AnyDesk (or similar) from their machine.
Step-by-step: Zoho OAuth connection
1. Create a server-based app in the Zoho API Console
Open Zoho API Console (under the customer’s Zoho login) and create a Server-based Applications client.
2. Set the redirect URI (and basic app details)
Use a consistent app name / URL if you like; what matters is Authorized redirect URI:
https://<PROJECT_ID>.texterchat.com/server/auth/oauth/v2/authorize-callback/zoho/default
Replace <PROJECT_ID> with the customer’s Texter project id.
3. Copy Client ID and Client Secret into customer config
After saving, Zoho shows Client ID and Client Secret. Only Gal has permissions to edit OAuth fields in config so ask him to set it up.
4. Authorize from the customer’s Inbox (OAuth settings)
From the customer’s computer (e.g. AnyDesk), open Texter Inbox → Settings → Developers → OAuth. Pick the Zoho OAuth entry and complete the flow: choose scopes (prefer listing all required scopes — if unsure, safer to include more than too few), Save changes, then approve access inside Zoho when prompted. You should end on a success / OK screen.
5. Customer DB — crmConfig fields
Set orgId to the value from the customer’s Zoho CRM URL (open crm.zoho.com while logged in as them — the org id appears in the URL, e.g. org886758394).