Salesforce integration
Paid add-on. Every submission becomes a Salesforce Lead or Contact: OAuth connection to the organisation, mapping validated against the real schema, optional campaign, 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 Salesforce”.
Why this module is stricter than the others
The organisations that really use Salesforce have validation rules, permission sets, duplicate rules and custom fields. A permissive connector produces serial refusals there that nobody reads, or worse, half-filled records that have to be cleaned up by hand.
Three positions follow from that:
- The fields offered come from the organisation, through
describe. No list written in advance would describe two organisations at once. - Required fields are checked before the call, and named in the log.
- The external reference is recommended, because it is the only thing that makes a retry safe.
The connection belongs to the site, the mapping to the form
The connection — a connected app, an environment, a token pair — is set once, in SolisForms → Salesforce.
The mapping — the object targeted, which field feeds which field, on what condition — is set per form.
A contact form feeds a Lead, a client-area form updates a Contact: those are
not the same required fields, and therefore not the same mapping. An object chosen
at site level would have imposed the same one on everybody.
Three hosts, and none is trusted by default
The login host is chosen: production (login.salesforce.com), sandbox
(test.salesforce.com), or the organisation’s own domain.
The own domain is typed in, and therefore checked. An organisation may live on
mycompany.my.salesforce.com or
mycompany--rec.sandbox.my.salesforce.com: enumerating those hosts is impossible,
they contain the client’s name. We therefore hold a whitelist of suffixes —
and that is all that separates “the client’s instance” from “any machine at all”.
The classic trap of this kind of list is the suffix without a dot:
attacker-salesforce.com ends with salesforce.com. It does not show on reading
the code; it shows in the tests, which try it explicitly.
The instance_url is returned by Salesforce at token exchange, and it is the
one we will call afterwards, with an access token in the header. It is checked on
the same footing, and refused over plain HTTP.
The tokens are encrypted, and here is what that protects
The encryption is the same as for Zoho — AES-256-GCM, key derived from the site’s salts — but the key is this integration’s own: breaking one does not open the other’s tokens.
Protected: disclosure of the database alone. SQL injection, mislaid backup, export handed over, shared hosting.
Not protected: reading the file system. The key derives from wp-config.php;
whoever reads that file also reads the database.
Without openssl, nothing is stored: the connection is refused, with the reason.
There is no expiry date, and that is the big difference
Zoho announces expires_in: you know when to renew. Salesforce says nothing — a
session’s duration depends on an organisation setting the site does not read, and
an administrator can revoke it at any moment.
The access token is therefore used until it is refused, and it is the
401 INVALID_SESSION_ID that triggers the renewal — once, then we stop. Guessing
a duration would have been worse than knowing nothing: too short, you exhaust the
quota; too long, every send starts with a refusal. The refusal remains, in any
case, the only reliable signal.
Two scopes, not full
| Scope | For |
|---|---|
api | access to the REST API |
refresh_token | the token without which the connection dies at the end of the session |
What is not asked for counts just as much: neither full, nor web, nor
chatter_api. An excess scope is never noticed: it changes nothing as long as
nothing goes wrong.
One submission, one record
Three mechanisms sit on top of one another.
The unique key on the submission. One row per submission in
slf_salesforce_records: the database refuses the second.
The row claim. UPDATE … WHERE state <> 'sent': whoever’s request modifies
the row sends, the others withdraw. 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 external reference. There remains the case no local guard covers: the request succeeds at Salesforce, and the response is lost on the way. We then do not know whether the record exists.
The three write paths, from safest to least safe
| Path | Requests | Survives a lost response |
|---|---|---|
PATCH by external reference | 1 | yes |
Search by address, then POST or PATCH | 2 | no |
Plain POST | 1 | no |
The module takes the best path the setting allows, and the screen says what the last two do not guarantee. The reference is derived from the site and the submission — two sites feeding the same organisation both have a submission number 42 — and it is kept in the database rather than recomputed: a site’s address changes, and the record already filed would become untraceable.
An “External ID” field is created in Salesforce Setup, by ticking “External ID” on a text field. It is the only prerequisite on the organisation’s side, and the connection test says whether one exists.
The search by address is parameterised, not concatenated
SOQL has no prepared statements, and the value searched for comes from a public form. A single apostrophe would be enough to break out of the string. They are escaped, and the value bounded. A SOQL injection does not give write access, but it does give read access to the customer file.
The failures, and which ones are replayed
| Reason | Replayed |
|---|---|
REQUIRED_FIELD_MISSING, INVALID_FIELD, STRING_TOO_LONG | no |
FIELD_CUSTOM_VALIDATION_EXCEPTION (the organisation’s rule) | no |
DUPLICATES_DETECTED (duplicate rule) | no |
INSUFFICIENT_ACCESS_OR_READONLY | no |
UNABLE_TO_LOCK_ROW, 5xx, 429, network cut | yes — 1, 5 then 30 minutes |
401 INVALID_SESSION_ID | once, straight away |
invalid_grant at the exchange | no: the authorisation is lost |
Required fields are checked before the call, twice: at mapping time —
Company not mapped on a Lead — and on the values, because an optional form
field mapped to Company leaves the requirement unsatisfied one time in two.
The campaign commands nothing
Adding the record to a campaign is a second request, after the save. It can fail on its own: campaign deleted, rights missing.
Its failure does not fail the delivery, and does not trigger a retry: the record arrived, and replaying for a deleted campaign would not bring it back. The submission’s detail carries a separate line for the campaign, so that “the record did not go out” is not confused with “it did not join the campaign”.
What does not go out
- Unmapped fields. The send is a mapping table.
- Passwords, payments, files and signatures, even forcibly mapped in an imported setting.
- Empty values. An update sending an empty field erases the existing value: a second submission with no phone number would delete the one the first had put there.
Resending
Three failed attempts leave a submission on the floor. The cause is often external and correctable: a validation rule, an unmapped field, a permission set. The submission’s detail carries a resend button.
With an “External ID” field, resending updates the same record. Without one, it may create a second — and the screen says so rather than letting you find out.
Disconnecting
The button revokes the token at Salesforce 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 Salesforce is unreachable, the local disconnection happens anyway, and the screen says the application remains to be removed by hand.
Diagnostics
The SolisForms → Salesforce screen carries the count of sends by state and the
latest failures with their reason, written as Salesforce returned it:
REQUIRED_FIELD_MISSING can be looked up in its documentation, “required field
missing” can be looked up nowhere.
Each submission’s detail carries the same state, the external reference, the campaign’s state, and a link to the record in Lightning.
Schema
Table slf_salesforce_records: one row per submission to be sent, with its
object, its state, its record id, its external reference, the campaign’s state,
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.
What V1 does not do
No Account, no Opportunity, no Case, no custom object, no reverse synchronisation, no updating of unmapped data. The plan bounded it that way, and each of those extensions requires deciding who has the right to overwrite what in the company’s system.
