Skip to content

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

ScopeFor
apiaccess to the REST API
refresh_tokenthe 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

PathRequestsSurvives a lost response
PATCH by external reference1yes
Search by address, then POST or PATCH2no
Plain POST1no

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

ReasonReplayed
REQUIRED_FIELD_MISSING, INVALID_FIELD, STRING_TOO_LONGno
FIELD_CUSTOM_VALIDATION_EXCEPTION (the organisation’s rule)no
DUPLICATES_DETECTED (duplicate rule)no
INSUFFICIENT_ACCESS_OR_READONLYno
UNABLE_TO_LOCK_ROW, 5xx, 429, network cutyes — 1, 5 then 30 minutes
401 INVALID_SESSION_IDonce, straight away
invalid_grant at the exchangeno: 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.