Zoho CRM integration
Paid add-on. Every submission becomes a lead in Zoho CRM: OAuth connection to your account’s data centre, field-by-field mapping, send condition, deduplication and log.
This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Posting answers into Zoho CRM”.
The connection belongs to the site, the mapping to the form
That is the split governing the whole module.
The connection — a Zoho account, a region, a token pair — is set once, in SolisForms → Zoho CRM. It holds for the whole site.
The mapping — which field feeds which CRM field, on what condition, from what source — is set per form, in the builder.
Neither is arbitrary. Putting the mapping at site level would have forced every form to carry the same fields: a contact form and a quote form do not have the same ones. Putting the connection at form level would have multiplied the places a refresh token can leak from, by the number of forms.
Why leads, and nothing else
A public form produces leads: somebody puts their hand up. Zoho’s Lead module exists exactly for that, and its conversion into Contact, Account and Deal is a sales act belonging to the CRM.
Writing directly into Contact would have required knowing whether the person already exists, deciding what to overwrite, and giving a public WordPress site the right to modify the customer directory. That is a company decision, not a form’s.
The flow therefore goes one way only: from the site to the CRM. The module reads nothing in order to prefill, and receives nothing from Zoho.
The data centre is not a detail
Zoho is not a service: it is eight services carrying the same name. An account
created in Europe lives on zoho.eu and does not exist on zoho.com — the
tokens are worth nothing there, the records are not there, and the API answers
“invalid token” without ever saying the problem is geographical.
That is the first thing that goes wrong when you connect Zoho, and the last thing you think of. The choice is therefore asked for on screen, before anything else.
The eight host pairs are written out in full in the code. The temptation was
to compose the address — 'accounts.zoho.' . $region — which would have given a
settings field the power to choose the host the server sends the client secret to
and asks tokens from. An unknown region therefore does not give an approximate
value: it gives nothing.
Zoho itself returns the API domain at every token exchange, and can move an account from one centre to another. That move is followed — but only to a host on the list.
The tokens are encrypted, and here is what that protects
A Zoho refresh token does not expire. Whoever obtains it can, for as long as it is not revoked, issue access tokens and read or write in the CRM — from anywhere, without going through the site. It is not a session id: it is a key to the house.
It is therefore encrypted at rest, in AES-256-GCM, with a key derived from the site’s salts.
What that protects: disclosure of the database alone. An SQL injection, a mislaid backup, an export handed to a contractor, shared hosting where the databases touch. That is the most frequent case, by a long way.
What it does not protect: reading the file system. The key derives from
wp-config.php; whoever reads that file also reads the database. Saying so is
more useful than letting an absolute protection be believed in — and the
encryption keeps its full point for all that, the two leaks not being the same
event.
Three practical consequences:
- Rotating the salts disconnects. The tokens become unreadable, and the connection has to be made again. That is deliberate: you rotate your salts after a compromise, and that is precisely when you must stop using tokens that may have leaked.
- Without
openssl, nothing is stored. The connection is refused, with the reason. A silent degradation where a secret is concerned is worse than the absence of the feature. - GCM, not CBC. A value altered in the database does not decrypt into another value: the decryption fails.
Application passwords, minimal scopes
The module asks for four scopes, and not one more:
| Scope | For |
|---|---|
ZohoCRM.modules.leads.CREATE | filing a record |
ZohoCRM.modules.leads.UPDATE | updating that of an already known contact |
ZohoCRM.modules.leads.READ | letting Zoho deduplicate |
ZohoCRM.settings.fields.READ | offering the mapping table |
It would have been simpler to ask for ZohoCRM.modules.ALL. That token would have
allowed reading the whole CRM — contacts, deals, accounts — from a public
WordPress site. An excess scope is never noticed: it changes nothing as long as
nothing goes wrong, and changes everything the day something does.
One submission, one record
Three mechanisms sit on top of one another, and each covers what the others let through.
The unique key on the submission. One row per submission in slf_zoho_leads:
the database refuses the second.
The row claim. UPDATE … WHERE state <> 'sent': whoever’s request modifies
the row sends, the others withdraw. That is not an optimisation — two tasks
scheduled for the same submission overlap as soon as a send takes longer than the
retry interval, which is to say precisely when the CRM is slow. The check cannot
be done in PHP: reading the state then writing it leaves between the two the
interval in which the other process reads the same value.
The upsert. There remains a case no local guard covers: the request succeeds
at Zoho, and the response is lost on the way. We then do not know whether the
record exists. POST /Leads/upsert with deduplication on the email address
creates the record if it does not exist and updates it otherwise: the replay
becomes an update, not a second record.
That is also why deduplication is on by default, and why the screen says what you lose by switching it off.
A lease, so that nothing stays stuck
A row moved to “sending” whose process dies — fatal error, memory exhausted, server restarted — would stay in that state forever, and the submission would never go out. Beyond ten minutes, the row is taken back: losing ten minutes is better than losing the submission.
What does not go out
- Unmapped fields. The send is a mapping table: what is not in it has nowhere to go.
- Passwords, payments, files and signatures, even forcibly mapped in an imported setting. The formatting sets them aside upstream, for all the plugin’s outputs at once. An upload address is, moreover, permanent access to the file: pasting it into a CRM record would copy it to a third party outside any expiry.
- Empty values. Zoho distinguishes “field absent” from “field empty”, and an upsert sending an empty field erases the existing value. Without that filtering, a second submission with no phone number would delete the one the first had put there.
The failures, and which ones are replayed
Zoho answers 202 Multi-Status when part of a batch has failed — including for a
batch of one. The record’s fate is therefore in the body, never in the HTTP code.
Relying on it would have counted as sent records that Zoho refused.
| Reason | Replayed | Why |
|---|---|---|
MANDATORY_NOT_FOUND, INVALID_DATA, DUPLICATE_DATA | no | the same call will get the same refusal |
OAUTH_SCOPE_MISMATCH, NOT_ALLOWED | no | a right is missing, not an opportunity |
429, 5xx, network cut | yes | 1, 5 then 30 minutes |
401 | once, straight away | the token is renewed, then the call replayed |
invalid_client, invalid_grant | no | the authorisation is lost, the screen says so |
Last_Name is checked before the call: Zoho refuses any record without it,
with a message that does not name the missing field. The reason written in the log
is the only one readable by whoever has to correct it.
Resending
Three failed attempts leave a submission on the floor. The cause is often external and correctable: a forgotten scope, a required field not mapped, a CRM under maintenance. The submission’s detail therefore carries a resend button.
It does not duplicate: the send is done by upsert, and a second pass updates the same record — as long as the email address is mapped.
Disconnecting
The Disconnect button revokes the token at Zoho before erasing it here. Erasing without revoking takes nothing away from the authorisation: the token would stay valid, and a backup of the database would restore a working access.
If Zoho is unreachable, the local disconnection happens anyway — a site that can no longer reach Zoho must be able to get rid of its tokens — and the screen says the authorisation remains to be withdrawn by hand from the Zoho account.
The erasure is total: tokens, secret, id, region. Disconnecting does not keep “the settings, just in case”; the client secret is a secret, and whoever disconnects wants it gone.
Diagnostics
The SolisForms → Zoho CRM screen carries the count of sends by state and the latest failures with their reason. A sending module with no diagnostic screen is judged by the submissions missing from the CRM — which is to say too late, and by somebody else.
Each submission’s detail carries the same state, and a link to the record in Zoho’s interface.
Schema
Table slf_zoho_leads: one row per submission to be sent, with its state, its
record id, the number of attempts and the reason for the last failure. No
submission value is copied into it.
The connection lives in an option — there is only one per site, and a one-row table would have cost a migration while bringing no query.
What V1 does not do
No Contact, no Deal, no Account, no Blueprint, no reverse synchronisation, no reading of the CRM. The plan bounded it that way, and each of those extensions requires a decision that is not technical: who has the right to overwrite what in the customer file.
