HubSpot
Paid add-on. Every submission creates or updates a HubSpot contact. Subscribing to a static list, on the other hand, only happens with consent given by the person.
This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Sending answers to HubSpot”.
A CRM that is also a marketing platform
The whole module rests on that particularity.
At Zoho or Salesforce, filing a record is an act of sales administration: you note that somebody got in touch. At HubSpot, the same portal sends the campaigns. The two gestures look alike — two API calls one after the other — and rest on entirely different legal grounds.
The module separates them, and does not separate them a little:
The contact goes out as soon as the form is connected. Somebody wrote to the site; the CRM is where that gets noted, and legitimate interest carries it.
The list only goes out with consent. A static list is precisely what HubSpot sends campaigns to. Without explicit agreement, nobody enters it.
Conflating the two would turn a contact form into a subscription form, which nobody asked for.
Consent is a form field, and a specific one
Not a box ticked in the admin: a box ticked by the operator would say “these submissions consent”, which is not a consent but an assertion made on people’s behalf.
The setting therefore designates which field of the form carries the box, and only the core’s Consent field is acceptable. That is not an interface preference. That field records, with the submission, the exact text that was accepted and the timestamp. An ordinary checkbox records only a “yes” — and a “yes” without the wording it approved demonstrates nothing, since the wording displayed evolves and it is the one in force at the moment of ticking that counts.
With no field designated, there is no state in which the module assumes agreement. The subscription never goes out. That is “unticked by default” carried all the way through.
Consent is frozen at submission time, and that is the decision that matters
The tracking row carries a consent column, written once, when the submission
arrives. Nothing re-reads it afterwards.
It is the only column of its kind in the whole plugin, and the reason is precise. The other modules rebuild everything at send time: the setting may have changed, and the current state is authoritative. A consent is not a setting — it is a dated fact, performed by a person. Re-reading it at send time would make it depend on what an administrator had ticked in the meantime in the submission’s screen: in other words, a consent could be manufactured after the fact, by somebody else, with nothing to distinguish it from the real one.
Direct consequence: ticking the box in the admin screen after the submission subscribes nobody. And a manual resend replays the submission with the consent it carried, never with another.
Only mapped properties go out
The mapping runs from the HubSpot property to the form field. What is not in it stays local: that is not a restriction, it is the definition — the send is a mapping table.
File, signature, payment and password fields never go out, even if forcibly mapped: an upload address is permanent access to the file, and pasting it into a CRM record copies it to a third party outside any expiry. Fields marked sensitive are already set aside upstream, by the value formatting common to every integration.
An empty value is not sent. HubSpot distinguishes “property absent” from “property empty”, and a property sent empty erases the record’s. A second submission without a phone number would delete the one the first had put there.
The properties offered on screen come from HubSpot, never from a list written in advance: a portal carries custom properties whose internal names cannot be guessed. Calculated and read-only properties are removed from them — offering them would lead to a mapping every send of which would be refused.
The address is not a property like the others
It has its own setting, rather than a row in the mapping table.
It is what HubSpot uses to match an existing contact with a new one. Without it there is no upsert, and therefore no defence against duplicates. Treating it as one property among a hundred would have let it be unmapped in one click with nothing to signal it.
An absent or malformed address is a definitive refusal, written before any call: HubSpot would refuse the record while talking about a property, not about the form field, and its message would not be readable by the person who has to correct it.
One submission, one contact — guaranteed at three levels
UNIQUE (submission) on the tracking table, and a row claim —
UPDATE … WHERE state <> 'sent' with a time lease — which decides which of two
crossing tasks actually sends. Two tasks cross as soon as a HubSpot response takes
longer than the retry interval, which happens precisely when the CRM is slow.
The third level is at HubSpot’s:
POST /crm/v3/objects/contacts/batch/upsert with idProperty: email creates the
contact if it does not exist and updates it otherwise. That is the only defence
against the case no local guard sees — the request succeeds, the response is lost
on the way, the task is replayed. With a creation, that scenario produces two
records; with an upsert, it updates one.
HubSpot says “succeeded” with a body that says “refused”
The batch upsert returns 207 Multi-Status when only part of the batch succeeded
— including for a batch of one. The contact’s fate is in results and errors,
never in the HTTP code alone.
Reading the second would have counted as sent contacts that HubSpot refused, and the client would have seen nothing until they compared their figures.
The scopes, one of which is optional
Three required scopes: writing and reading contacts, reading the property schema.
A fourth, crm.lists.write, is asked for as an optional scope.
The distinction is not cosmetic. At HubSpot, a required scope absent from the portal makes the entire installation fail: a free portal, which does not have lists, could not connect at all — when sending contacts would suit it perfectly well.
A portal that has it grants it, one that does not connects anyway, and the screen
says which was granted. Saying so before the first submission avoids a 403
discovered three weeks later in a delivery log.
The scopes displayed are those HubSpot actually granted, read from the token — not those that were requested. That is the only source that says anything true.
Tags do not exist at HubSpot
That has to be said rather than quietly worked around: HubSpot has no tags. What comes close is a multiple-choice property, whose value is a semicolon-separated list.
The setting therefore designates a property of that type and fixed values, which describe the record’s origin — which form, which campaign. They follow the contact and are not governed by consent: a value on a CRM record sends nothing to anybody, where a list is precisely what HubSpot sends to.
A tag containing a semicolon is set aside, not split in two: it would become two values neither of which would match an existing option, and HubSpot would then refuse the whole contact.
What is not attempted, and what is retried
Nothing is scheduled if the connection is missing, if no address field is designated, or if the form’s condition is not met. A submission marked as spam does not go out, even if the task was already queued.
Definitive refusals are not replayed: VALIDATION_ERROR for an unknown property
or a refused value, MISSING_SCOPES for a missing scope, OBJECT_NOT_FOUND for a
deleted list. Every retry would consume the client’s API quota to obtain the same
refusal, and the submission’s screen carries the reason.
429 and 5xx are, at 1, 5 then 30 minutes.
A 401 earns an immediate session renewal, then a second attempt — once, not in
a loop.
A failed list subscription does not replay a contact already filed. HubSpot cannot file a contact and subscribe it in the same request: if the second fails, the first stands. The send is successful, with a missing subscription that the screen names. Replaying the whole send for a list would amount to rewriting a record that needs nothing of the sort.
A HubSpot outage never blocks the submission. It is recorded, confirmed to the visitor and notified by email before this module is called on.
What the log does not contain
Neither the properties sent, nor the address, nor the tokens.
That is a difference from the other modules, and it is deliberate. Elsewhere, the body returned by the service is recorded because it helps diagnosis. Here, that body is the echo of the properties written: HubSpot returns the record as it saved it, address included. Recording it would make the delivery log a second copy of the submission, which would go off into backups and exports — where it would read as a technical trace when it describes a person.
What is kept comes down to three things: the HTTP code, the contact’s id, and the reason for a refusal where applicable. That is enough to find the record again and understand a failure, and it describes nobody.
Legal basis, in two lines
The module does not decide it for you, and cannot: it is the data controller who answers for theirs. What it guarantees is that the two processings stay distinct and traceable — the contact on one side, the marketing subscription on the other, the latter with the text accepted and the date, kept with the submission and readable on its screen.
That is the line you come looking for the day somebody asks why they are receiving — or not receiving — a newsletter.
Schema
slf_hubspot_contacts: one row per submission, with the contact’s id, the frozen
consent, the subscription actually made, the state, the reason for the last
problem and the number of attempts. It disappears with the submission; the record
at HubSpot stays — it belongs to the CRM, and erasing it from here would be
deciding in its owner’s place.
The connection lives in an option, tokens encrypted with a key of the integration’s own. That counts double here: a HubSpot refresh token does not expire, and stays valid as long as nobody revokes it. That is why disconnecting revokes it at HubSpot before erasing it here.
What V1 does not do
No deal, no ticket, no company. No reading of contacts, no reverse synchronisation: the flow goes from the site to the CRM, and the reverse would turn a public WordPress site into a doorway onto the customer file.
No writing of the “marketing contact” status either. That would be one more property in the same call, and a portal without that option would refuse it — failing the whole contact write for an incidental setting. That status belongs to the portal’s settings.
No subscription preferences either: HubSpot’s API asks for a subscription type id and a declared legal basis. Writing those from a form means inscribing into the client’s register a legal qualification the plugin is in no position to make — and a wrong legal basis is worse than an absent one.
