Zoho

Add the people who submit your forms to a Zoho Campaigns mailing list, filling their contact details from the answers you mapped.

Zoho is an add-on for Forms. Once your Zoho account is connected, every form you edit gains a Zoho panel in its sidebar where you describe one or more actions. An action can add the person to a Zoho Campaigns mailing list or remove them from it. Add Contact fills their details from mapped answers; Remove Contact uses the mapped email address to identify them.

Nothing else about the form changes: validation, messages, redirects, notification emails and the Submissions list all behave as they did before — and they carry on behaving that way even when Zoho refuses the contact. That last part is the good news and the bad news at once, and it is the one thing on this page worth reading before you switch the integration on.

Mailing lists are all this plugin touches. It does not write to Zoho CRM, Desk, Books or anything else in the Zoho suite.

Where to find it

Two places, because there are two kinds of setting. The account connection is site-wide, at XPAC → Integrations on the Zoho tab, whose screen is headed Zoho Settings; the Settings link beside the plugin on the Plugins screen goes straight there. What each form sends is per-form, in the Zoho panel of the document sidebar while you edit that form.

Forms has to be active

The plugin does nothing on its own. Without XPAC Forms it registers no settings tab, no panel and no routes, and shows an admin notice asking for Forms instead.

Connecting your Zoho account

Connecting takes longer than most integrations because Zoho does not issue a simple key. You register an application in Zoho's developer console, tell it which address on your own site it is allowed to send people back to, and then approve the connection while signed in to Zoho. Do it once; every form on the site then shares that one connection.

Before you start, note which address you use to sign in to Zoho — zoho.com, zoho.eu, zoho.in and so on. You will need it twice, and getting it wrong is the single most common way this fails.

Open XPAC → Integrations → Zoho and leave the tab open. Under the Zoho Integration Setup Instructions heading you will find the exact address this site expects Zoho to return people to, with a copy button beside it. It looks like https://your-site.example/wp-admin/admin.php?page=xpac-settings&active=zoho.

In a second tab go to the Zoho API Console, signed in with the Zoho account that owns your mailing lists.

Choose Add Client, then Server-based Applications. Fill in a Client Name of your choosing and your site's address as the Homepage URL.

Paste the copied address into Authorized Redirect URIs, exactly as it was copied — no trailing slash added, no http swapped for https, nothing removed. Zoho compares it character for character, twice, and refuses the connection if it differs at all.

Choose Create. Zoho then shows you a Client ID and a Client Secret.

Back on the Zoho tab, paste them into App Client ID and App Client Secret. Both are on the same Zoho screen, the Secret directly below the ID, and the hint under each field says so. App Client Secret is a masked field, so you will not be able to read back what you pasted — paste it rather than typing it.

Set App Data Center to the region matching the address you sign in to Zoho with. Read the warning below before you move on — this is the step people get wrong.

Choose Authorize with Zoho →. You are taken to Zoho to approve the connection, and returned to this screen. When it works the screen says You are successfully connected. and the panel at the top changes from Not connected to Connected to Zoho Campaigns.

Pick the right region, or nothing will connect

Zoho runs separate, unconnected regions, and an account on one is invisible to the others. App Data Center must match the address you use to sign in to Zoho: pick Europe (.eu) if you sign in at zoho.eu, India (.in) for zoho.in, and so on. The choices are United States (.com), Europe (.eu), India (.in), Australia (.com.au), Japan (.jp), Canada (.ca), Saudi Arabia (.sa) and United Kingdom (.uk).

The field starts on United States (.com) and stays there unless you change it, so everyone outside the United States has to change it. Pick the wrong one and authorising fails with The authorization code is invalid or expired. Please re-authorize. This is often caused by a mismatched data center. — which is the same message you get for several other problems, so it is easy to spend a long time looking in the wrong place.

Register your application in the console of the same region too. The console you are sent to when you sign in to Zoho is normally already the right one.

One more thing worth knowing now: the region can only be changed while the account is not connected. Changing it later means disconnecting first, and disconnecting clears your Client ID and Secret with it.

A failed attempt does not cost you the credentials

Once the two credentials have been saved once, the screen fills App Client ID and App Data Center back in for you whenever it loads, and remembers the Secret without ever showing it. The Secret field stays blank on purpose: leave it blank and the saved one is kept, or paste a new one to replace it. The hint under the field says which of the two is happening. Authorize with Zoho → is available as soon as the Client ID is filled, and a second button, Re-authorize with Zoho, appears beside it and goes straight to Zoho with what is already saved.

One thing to watch: because a blank Secret means keep the saved one, pasting a new Client ID and leaving the Secret blank pairs the new ID with the old Secret, and Zoho rejects that as invalid_client. A new Client ID always needs its own new Secret pasted alongside it.

If pressing Authorize with Zoho → leaves the screen dimmed with a spinner, the spinner now clears and an error notice appears; the notice is about a request to your own site failing rather than anything to do with Zoho. Reload and try again.

Missing credentials do not hide your actions

The Zoho panel and its saved actions remain visible when the OAuth credentials are missing, disconnected or revoked. Account-backed pickers show the connection error, and a submission still creates its Forms entry before the queued delivery is marked failed. Re-authorize Zoho, then retry it from XPAC → Forms → Deliveries.

Adding someone to a list

Edit the form, open the Zoho panel and press the round + button (Add action). The Add Contact modal opens.

Choose the mailing list under List. It is marked required, and Save stays disabled until you pick one.

Map the fields. Each row pairs a Local field from your form with a Remote field — one of the contact fields on your Zoho account — and you add as many rows as you need. One of them has to carry the person's email address; read the warning below before you save.

Save the action, then update the form. Updating the form is what stores it.

Choose Add Contact or Remove Contact in the action. A form can hold several actions, and Conditional Logic can decide which apply to a submission.

Mapping fields

ControlWhat it offers
ListYour Zoho Campaigns mailing lists, under --- Select ---. Required.
Local fieldEvery field in the form that holds a value, under its label, or under its field name where the label is blank.
Remote fieldThe contact fields on your Zoho account, by their display names. The same set is offered whichever list you pick, because in Zoho those fields belong to the account rather than to a list.
  • A row with either half left empty is ignored, so a half-finished mapping costs nothing. An action where no row has both halves filled sends nothing at all.
  • Two rows pointing at the same Remote field do not both send — only the last one counts.
  • The lists and the contact fields are read from Zoho the first time the panel renders and then held for the rest of the editing session. A list or a field you create in Zoho while the editor is open will not appear until you reload the page.
  • Only your first hundred lists are offered. There is no search box and no way to page through, so a list beyond that cannot be chosen here today.

Map the email address to Contact Email

Zoho identifies a contact by email. The editor will not save either action until a form field maps to Zoho's literal Contact Email field. Make that local field required too. If the saved field later disappears or submits an empty value, the queued delivery records a visible configuration failure.

Map single-answer fields only

A field that can hold several answers at once — a checkbox group with more than one box ticked — is handed to Zoho as it stands and Zoho will not accept it as a contact detail, which loses the contact in the silent way described below. A file upload field is no more useful: what reaches Zoho is an internal reference to the file, not the file or a link to it. Text, email, number, single-choice and dropdown fields are the safe ones.

How people join the list is decided in Zoho, not here

This plugin has no confirmation step and no setting for one. It hands the contact straight to the list you chose and whatever that list is configured to do in Zoho is what happens next — so if your list is set up to add people without confirming, the moment the form is submitted they are on it and can be mailed. Check the list's own sign-up settings in Zoho before you point a form at it, and check them again if you change lists.

Either way, getting consent is down to you and to the form you built. Say plainly on the form what submitting it signs the visitor up to, ask for it explicitly rather than by implication, and keep the record: the form's own Submissions list holds their answers, including whatever consent box you added. Where marketing consent has to be provable, do not point a Zoho action at a form that does not ask for it.

When the contact is sent, and what a failure costs

After the submission is safely stored, never before it. A successful submission validates the answers, processes uploads and writes the entry to Submissions; only then are the contacts handed to a background queue, and your notification emails go out after that. The queue sends the contacts moments later on a request of its own — so the visitor is never kept waiting for Zoho, and nothing Zoho does can delay or block the form.

One consequence is worth knowing. If the entry could not be written, nothing is queued and no contact is sent at all. So every answer that reached Zoho is also an answer you have on your own site; there is no case where Zoho has someone that Submissions does not.

A refusal from Zoho does not cost you the submission

The integration never abandons a submission because Zoho said no. Whatever happens with Zoho, the visitor sees the normal success message, the entry is written to Submissions and notification emails go out. The answers are always kept on your side, so a contact that failed to reach Zoho can be added by hand afterwards from the entry.

Configuration, authentication and validation failures reported as 400/401/403/404/410/422 are left failed for an administrator to fix. Rate limits, 5xx replies and transport failures are retried after about 1, 5, 30 and 120 minutes, for at most five attempts. Re-authorize the account or repair the action, then retry a permanent failure manually.

Where a refusal shows up, and where it does not

A refusal is recorded rather than swallowed, in two places. It becomes a failed delivery on the Forms Deliveries screen, carrying Zoho's own explanation and retried on the schedule described below. And once a failure has been recorded, every admin screen shows administrators a notice reading Zoho Campaigns is not connected. Contacts are not reaching Zoho. Reauthorise on the Zoho settings screen. with Zoho's message in brackets after it.

Three things still do not happen, so it is worth knowing what not to wait for. Nothing is added to the entry itself, so Submissions looks the same either way. No email is sent to you. And the visitor is told nothing, because by this point they have already had the normal success message.

The notice is driven by a recorded failure, which means something has to try before anything appears. A connection that lapsed while no form was being submitted stays quiet until the next attempt.

A lapsed connection now says so, with one gap worth knowing

The site's permission to reach your Zoho account is not permanent. Revoking the application in Zoho, deleting it, changing the account, or Zoho withdrawing the permission for its own reasons all end it. Forms carry on succeeding when it happens — the answers are never at risk — but the contacts stop arriving.

The Zoho Settings screen no longer claims otherwise. It reports a connection only while a refresh permission is stored and no failure has been recorded against it, so the first refusal flips the panel from Connected to Zoho Campaigns to Not connected — contacts are not reaching Zoho. Reauthorise below. with Zoho's message after it. The Token status block reports Last refresh — a date and time, or Not refreshed yet — beside Access token cache.

The gap is that this is driven by a recorded failure, so it tells you what happened last, not what would happen now. Two ways to ask the question directly:

  • Press Force refresh access token. A lapsed connection answers with an error message saying so.
  • Open any form that has a Zoho action. If the List dropdown is replaced by a message such as The access token is invalid or expired. Please re-authorize., the connection has lapsed.

One wrinkle if you use the first of those: renewing the token by hand does not clear a failure that was already recorded. A successful Force refresh access token can therefore leave the warning in place until the automatic renewal succeeds, which happens in the last few minutes of the token's hour. The warning clearing is good news; the warning persisting straight after a manual refresh is not by itself bad news.

The cure for a genuinely lapsed connection is Re-authorize with Zoho, which reuses the credentials already saved and only asks you to approve again at Zoho. It does not ask for the Client ID and Secret a second time.

When an action does not run

A Conditional Logic condition that does not match, or an action switched off in the panel, is an intentional skip. Missing credentials, a missing list or mapping, and a missing Contact Email value are different: the saved action remains visible and the Deliveries screen records why it could not run.

The visitor never waits for Zoho

Each action is one request to Zoho, and renewing the permission — which happens roughly once an hour — can add one more. None of them runs while the visitor is waiting: the form has already answered and the entry is already saved before any of it starts. Each request is allowed up to ten seconds, which is patience with a slow API rather than something the visitor pays for.

A request that fails transiently follows the widening schedule above. Every accepted action records its own completion key, so a later attempt retries only failed actions and skips successful siblings.

Add Contact and Remove Contact describe list state rather than one-off messages. Zoho's "already on this list" (2003) and "not on this list" (2103) replies are treated as the requested terminal state, so a retry after an ambiguous timeout converges instead of failing indefinitely.

Looking after the connection

The Zoho Settings screen offers three buttons once you are connected.

ButtonWhat it does
Force refresh access tokenRenews the site's access to Zoho immediately and reports the outcome. The most direct way to find out whether the connection still works.
Re-authorize with ZohoSends you to Zoho to approve again, reusing the Client ID, Secret and region already saved. The fix for a lapsed connection.
DisconnectForgets the connection.

Disconnect clears more than the connection

It also clears your App Client ID, App Client Secret and App Data Center, so reconnecting afterwards means going back for the credentials rather than pressing one button. And it does not withdraw anything at Zoho's end — the application you registered stays authorised there. If your reason for disconnecting is that the credentials leaked, remove the application in the Zoho API Console as well; disconnecting here is not enough.

Treat the Client Secret as a password

It is typed into a masked field, so it is not readable over your shoulder or in a screenshot of that screen, and it is never shown again afterwards — the screen only ever reports whether one is stored. It is kept on the site, and only people who can already reach the site's settings can make use of it. If it does leak, replace it in the Zoho API Console rather than reusing it.

Editing and removing an action

Each row in the panel starts with an Enable Add Contact toggle, then the action's name — Add Contact — then a pencil (Edit Zoho action) that reopens the modal and a red X (Remove Zoho action) that asks Remove this item? with a Remove button before acting.

Use Enable Add Contact to pause or resume a row without losing its mapping. The X removes the selected row after confirmation; other actions keep their order and settings.

For developers

Everything about the connection lives in one non-register_setting option, xpac_zoho_keys (packages/Zoho/Addon.php:39), read with get_option() at :1070 and written whole with update_option() at :1115. It holds client_id, client_secret, data_center, access_token, refresh_token, token_type, api_domain, expires_in and current_time, plus last_error, last_error_time and last_refresh once a refresh has been attempted. Because nothing calls register_setting() on it, it is absent from /wp/v2/settings, and because update_option() is called without an $autoload argument it is autoloaded on every request, front end included. The client secret and refresh token are never sent to the browser: the settings field is declared with 'default' => '' (:113) and the only route that reads credentials back returns clientId, dataCenter, redirectUrl and a hasSecret boolean (:817-833) — the boolean is what lets the screen offer a re-authorisation without ever rendering the secret.

The settings screen is a custom control of 'type' => 'authorize_zoho' on the shared xpac Integrations module at priority 400 (:79-175). Its React implementation attaches to wp_react_settings_authorize_zoho_control_content (assets/packages/zoho/custom-fields/authorize.js:30), which is the dynamic wp_react_settings_${type}_control_content filter applied at assets/packages/admin-ui/settings/controls/Control.js:199 — a hook-surface report that flags this registration as never fired is reading the static name against a dynamic node, not finding a dead registration.

Routes

All eight are on manage_options and are registered even before credentials exist. That is what lets the editor and settings screen report a missing connection without hiding saved actions.

POST /wp-json/xpac/v1/form/zoho/save-credentials      clientId, clientSecret, dataCenter
POST /wp-json/xpac/v1/form/zoho/access-token          code
GET  /wp-json/xpac/v1/form/zoho/get-credentials
GET  /wp-json/xpac/v1/form/zoho/authorize-url
POST /wp-json/xpac/v1/form/zoho/disconnect
POST /wp-json/xpac/v1/form/zoho/force-refresh-token
GET  /wp-json/xpac/v1/form/zoho/lists
GET  /wp-json/xpac/v1/form/zoho/fields

disconnect and force-refresh-token are WP_REST_Server::CREATABLE, because both change stored state — the first wipes the stored keys, the second writes a new access token. They refuse GET. Neither lists nor fields turns a Zoho failure into an HTTP error: both catch the exception and answer 200 with a message key (:866-870, :897-901), which the editor renders where the dropdown would be (assets/packages/zoho/useStoreData.js:37-39, Components/FormSettings.js:32).

Token lifecycle

The authorisation URL is built client-side at assets/packages/zoho/custom-fields/utils/utils.js:1-17 as https://accounts.<dataCenter>/oauth/v2/auth with response_type=code, access_type=offline, prompt=consent and the single scope ZohoCampaigns.contact.ALL. There is no state parameter, and none is validated on return. redirect_uri is add_query_arg('active', 'zoho', <module page URL>), built identically for the authorisation request (Addon.php:91-95) and for the code exchange (:514-518), which is why the value pasted into Zoho has to match exactly.

The code lands as a query parameter on the settings page; authorize.js:67-80 strips code and accounts-server from the address bar with pushState and then authorize.js:82-110 exchanges it. Addon.php:500-555 stores the response, keeping the existing keys and only overwriting refresh_token when one came back (:537-539).

refreshAccessToken() (:627-677) is called before every Zoho call — getLists() at :843, getContactFields() at :883, and once per submission at :1010. It returns early with no refresh token (:629-631) or no stored expiry (:636-638), and otherwise refreshes only inside the last five minutes of the hour (:640-642). On failure it writes to error_log() and calls recordFailure() (:675), which stores last_error and last_error_time on the option (:697-704); a successful refresh clears both again (:662-663).

That recorded failure is what the screen reads. isConnected() (:684-688) is refresh_token present and last_error empty, and it is what the settings field publishes as isAuthorized (:98) — so a refused refresh flips the panel to Not connected rather than leaving it green on the strength of a dead access token, which a string test could never detect. The same state drives renderConnectionNotice() (:713-736), an admin_notices listener registered at :255 that prints a notice-error on every admin screen for users with manage_options, but only once last_error is non-empty. calcMinsRemaining() (:1094-1104) still derives the Access token cache row from expires_in and current_time alone, so that row on its own says nothing about whether the next refresh will work — isConnected() is the part that does.

Two gaps remain in that mechanism, neither of them addressed here. forceRefreshToken() (:586-620) writes the new token but does not clear last_error, unlike the automatic path at :662-663, so a successful manual refresh leaves the warning standing until the automatic renewal runs. And recordFailure() is also called from process() (:1022) for a rejected addContact(), so a per-contact business error — a bad listkey, say — marks the whole connection as failed until the next successful refresh clears it.

disconnect() is setAccessKeys([]) (:745) — it wipes the app credentials with the tokens and makes no revocation call to Zoho.

Client.php maps the eight regions to their accounts. and campaigns. hosts (:87-128), falling back to zoho.com for an unknown value (:130), and prefers the api_domain Zoho returned on token exchange over the dropdown when it mentions campaigns (:67-69). Failure handling is thorough for this family: send() checks is_wp_error() and the status code (:214-222), the token calls check the error key that Zoho returns with HTTP 200 (:316-317, :345-346), and assertCampaignsSuccess() (:266-278) catches the {"status":"error","code":…} bodies that Campaigns also returns with HTTP 200. Fourteen Zoho error codes are mapped to readable messages at :434-486. wp_remote_request() (:195) is given a timeout of ten seconds, filterable through xpac_zoho_request_timeout (:174). That wait does not happen inside the visitor's submit: process() is scheduled through the deliveries queue and runs from Deliveries\Runner after the entry has been written, so ten seconds is a tolerance for a slow API rather than a latency budget.

Per-form settings

Per-form actions live in the form's form_settings post meta on the xpac-form post type under a zoho key, registered on the Forms meta schema (Addon.php:306-350) and therefore readable and writable over the REST API at /wp-json/wp/v2/xpac-form/<id>. The default is an empty items array (:361-368):

{
	"zoho": {
		"items": [
			{
				"status": true,
				"action": "addContact",
				"list": "3z1a4b5c6d7e8f9",
				"map": [{ "local": "email", "remote": "Contact Email" }]
			}
		]
	}
}

action is addContact or removeContact, and list is a Zoho listkey. Items carry additionalProperties: true, which is what lets Conditional Logic store its own condition object on an item with no schema entry of its own. Writing status is optional. Forms' shared filter reads $item['status'] ?? true (Forms/core/base/Utils.php:72-74), so an item carrying no status at all counts as on rather than being dropped in silence — write it only when you mean to switch an action off. Zoho does declare status in its own schema, so a value you do write is validated as a boolean.

Send order. process() (Addon.php:982) is registered on xpac_forms_submit_callbacks (:963, hooked at :292), which is fired by Deliveries\Dispatcher::dispatch() (Forms/core/deliveries/Dispatcher.php:54). Dispatch schedules nothing unless the entry row already exists (:49-51), so the Zoho push always happens after the entry is written, never before, and it runs from Deliveries\Runner rather than inside the visitor's request. The add-on therefore cannot abort a submission: what it returns is a delivery outcome. Every configured action is attempted, accepted actions call recordDelivered(), and a later attempt skips them with alreadyDelivered() while retaining the first failure's own status.

prepareContact() reports a switched-on action with no list, no usable mapping or no mapped Contact Email as a permanent configuration failure. Remove Contact sends only that email value; Add Contact sends the mapped contact fields.

Hooks and blocks

The package fires one hook of its own and registers no blocks. The hook is the filter xpac_zoho_request_timeout (Client.php:174, documented at :163-173), which sets how many seconds to wait for Zoho to answer and defaults to 10; it is listed with its signature and call site in the Zoho hook reference. There is no do_action anywhere in the package. Almost everything else it does is a callback on somebody else's contract.

It attaches to five Forms hooks — xpac_forms_init to bootstrap (Addon.php:212, fired at Forms/Bootstrap.php:93, with a doing_action() guard at :224), xpac_forms_post_meta_schema and xpac_forms_post_meta_default_values for the shape of its settings (fired at Forms/core/base/PostType.php:126 and :118), xpac_forms_post_script_dependencies to load its editor panel (PostType.php:329), and xpac_forms_submit_callbacks for the send — plus rest_api_init twice for its routes, plugin_action_links_… for the Plugins-screen link (:187), and plugins_loaded/admin_notices in Bootstrap.php.

On the editor side the panel is contributed from JavaScript through the xpac-forms-form-panels filter (assets/packages/zoho/index.js:8, applied at assets/packages/forms/hooks/useFormPanels.js:62). It declares no priority, which is why it sorts below every core Forms panel and why its position relative to other add-ons' panels is not stable (useSortedAndFilteredPanels.js:18-21). Its action modal fires xpac-forms-after-settings-modal-content (assets/packages/zoho/Components/Modal.js:40), passing the action's current options with an onChange callback and the module id xpac-zoho; Conditional Logic is the only listener (assets/packages/conditional-logic/forms/admin.js:76).

Two scripts are registered: xpac-zoho-custom-fields on the settings page and xpac-zoho for the form editor. Both remain available without current credentials so their error state can be shown.

The package's only licensing touchpoint is Updater::register() in Bootstrap.php:53. Nothing in Addon.php checks a licence.

On this page