Strapi Hubspot Plugin

Strapi Hubspot Plugin

By Paul Lefizelier

Stop typing HubSpot property names from memory. This plugin turns any CRM property field in your content types into a searchable picker fed by your actual portal, and refuses a bad mapping at save time instead of letting it fail silently three weeks later.

Latest version

v0.16.0

released September 22, 2026

npm install strapi-plugin-hubspot

Strapi HubSpot

The form builder for HubSpot — and the safety net under it.

Build multi-step lead forms in a dedicated admin page: fields mapped to your portal's real CRM properties through a searchable picker, conditional fields and steps (AND/OR rules re-evaluated server-side), a public API your frontend renders and posts to, and a submission pipeline that posts a native HubSpot form conversion (Forms API + visitor hutk), finds-or-creates the Company by corporate domain, and stores every submission in Strapi whatever the CRM's mood.

Under it, the safety net this plugin has always been: property pickers as custom fields for your own content types, save-time mapping validation, a portal-wide audit, and a sending service with retries and a replay queue.

The form builder

HubSpot Forms, in the admin menu (RBAC-gated), is a builder page — not a Content Manager view. Steps and fields are cards you reorder and configure; the right-hand panel holds the selected field's settings, including its CRM mapping picked from the portal (object select, property search, one-click import of an enumeration's options, deep link to the property).

Conditional fields and steps

Any field or step can declare visibleIf: rules like [field] [is] [value] combined with AND/OR. The editor only offers fields placed earlier in the form, so evaluation is a single deterministic pass. Operators: eq, neq, contains, empty, notEmpty, gt, lt.

Conditions are enforced server-side at submission, not just in the UI: a hidden field loses its required, and its value is discarded even if the browser sent it — the payload that reaches HubSpot is the payload the visitor actually saw.

The public API

Two content-api routes (grant them to the Public role in Settings → Users & Permissions):

MethodPathPurpose
GET/api/hubspot/forms/:slug?locale=The published form — meta, steps, fields, conditions. The CRM mapping is stripped: the browser never learns your property names
POST/api/hubspot/forms/:slug/submitValidates (bounds, conditions, required), maps server-side, syncs HubSpot, stores the submission

The submit pipeline is a policy, not a single path. Settings → HubSpot (or forms.* in config/plugins.ts) pick how this install talks to HubSpot — so a portal that wants native conversions and a portal that only wants CRM upserts can share the same plugin:

SettingDefaultWhat it does
forms.submissionModeautoauto: Forms API when a marketing GUID is linked, else CRM upsert. forms: always try the Forms API. crm: never count as a conversion
forms.writeExtraPropertiestrueAfter a conversion, CRM-write mapped contact fields the HubSpot form dropped (they 400 the Forms API)
forms.syncFieldsOnPublishfalseOn publish, PATCH missing mapped contact fields onto the HubSpot form. Off by default so HubSpot-first installs aren't mutated

The visitor's hutk is forwarded so attribution sticks. Then the contact is looked up by email and, when the address is on a corporate domain, the company is found-or-created and associated. Timeline notes are opt-in and only used on the CRM-upsert path.

CRM properties are never created. Map to properties that already exist in the portal; opting into field sync only adds form fields that point at those properties.

Portal id, region and form GUIDs are configuration. A test portal today, production tomorrow: swap portalId / region / the GUID, no code change.

hubspot: {
  config: {
    apiKey: env("HUBSPOT_API_KEY"),
    portalId: env("HUBSPOT_PORTAL_ID"),       // test now, production later
    region: env("HUBSPOT_REGION", "eu1"),     // eu1 → api-eu1.hsforms.com
    forms: {
      companyFromDomain: true, // Company by corporate domain + association
      timelineNote: false,     // recap note on the CRM-upsert fallback only
      defaultFormId: env("HUBSPOT_DEFAULT_FORM_ID", ""),
      submissionMode: "auto",  // auto | forms | crm
      writeExtraProperties: true,
      syncFieldsOnPublish: false,
    },
  },
}

Every submission is stored in HubSpot form submissions (Content Manager), synced or not, with the CRM ids when the sync succeeded — the source of truth lives in your database, not in HubSpot's availability.

HubSpot's native legal consent block is not a CRM field. Importing a HubSpot form skips it (you'll see "rebuild it as a field"). The Forms API submit sends legalConsentOptions when the HubSpot marketing form has a GDPR block and the visitor ticked consent.

To connect consent so the CRM and Strapi both keep a proof:

  1. In HubSpot (Settings → Properties → Contact), create a checkbox property, e.g. rgpd_consent. Optionally a datetime rgpd_consented_at.
  2. In the form builder (Admin → HubSpot Forms), last step: add a Checkbox, name it consent (that name is what the site recognizes — it will not inject a second box), mark it required.
  3. Map it: Object = Contact, property = rgpd_consent. The visitor's tick then lands on the contact (if that property is on the HubSpot form) and in legalConsentOptions. Leave consentedAt unmapped; the frontend sends it in meta.consentedAt, stored on the submission.
  4. Point the builder form at a HubSpot marketing form (right-hand panel) so the tick is a native conversion, not a CRM note.
  5. Publish the form. The public submit pipeline already refuses a payload without consent when the site is in front (Nuxt BFF). Direct calls to /api/hubspot/forms/:slug/submit still accept a form that has no consent field — add the checkbox so required-field validation covers them too.

The frontend should also send meta.hutk (the hubspotutk cookie set by HubSpot's tracking script) and meta.pageUrl so Original Source is Organic Search / Direct / etc. rather than Offline sources.

Browsing submissions

The Submissions button on the forms list (also reachable from a form's count in the table) opens the submissions browser: every stored answer, newest first, filterable by form, with the sync state at a glance and the full answer set one click away. Export CSV downloads one form's whole history — columns follow the form's field order, and keys found only in older submissions (a renamed or removed field) are appended rather than dropped. Deleting stays in the Content Manager, where the type remains visible.

Typed payloads for your frontend:

import type { PublicForm, SubmitRequest } from "strapi-plugin-hubspot/types";

Importing existing forms

If your forms currently live in a content type (the steps/fields + hsObject/hsProperty shape this README documents below), point the builder at it:

hubspot: {
  config: {
    forms: {
      import: {
        uid: "api::form.form",
        // Optional remapping when your attribute names differ:
        // steps: "etapes", fields: "champs",
        // field: { label: "libelle", object: "objet", property: "propriete" },
      },
    },
  },
}

The list page then offers Import an existing form: one click converts the entry — every locale — into a builder draft with the same slug. The source is never modified, re-importing overwrites the draft only, and unpublishing rolls a migrated form back. Migrate form by form; the validation middleware keeps protecting the ones that stay.

Importing forms from HubSpot

Forms built in HubSpot itself can be imported too — give the private app token the forms read scope and the list page offers every regular form of the portal. The translation carries over what the builder can express:

  • fields, labels, placeholders, help texts, required flags and options — and since a HubSpot form field is a CRM property, the imported form arrives with a mapping that is already valid for the portal;
  • two-fields-per-row layouts become two half-width fields;
  • dependent fields become visibleIf conditions (value lists are expanded into OR'd/AND'd rules).

Everything else — file uploads, hidden fields, content blocks, GDPR consent blocks, an operator with no equivalent — is skipped and reported after the import, so nothing is silently half-migrated. The HubSpot original is never modified; re-importing overwrites the draft, never the published version.

i18n and publishing

Forms are draft & publish, localized like your content: name and slug are shared, everything else — including the structure — is per-locale. Opening a locale that doesn't exist yet starts it from the default locale's structure. Publishing is blocked while a mapping problem remains; drafts save anyway, with the problem flagged on the field carrying it.


The safety net

The problem it solves

If you build lead forms in Strapi and push them to HubSpot, somewhere in your schema there is a field where an editor types a property name — hs_role, numberofemployees, jobtitle. It is a free-text field, and nothing checks it.

That matters more than it looks, because of how the HubSpot API behaves:

A single unknown property makes HubSpot reject the entire upsert.

So one typo doesn't cost you one answer. It costs you the whole lead. The submission is accepted by your site, stored in Strapi, and never reaches the CRM — with no error anyone will notice until someone asks why the pipeline is empty.

The three ways this happens are all invisible in a text input:

What you typedWhat's wrong
hs_rôleDoesn't exist — a typo, an autocorrect, a copy-paste
role Exists, but with a trailing space
nameExists, but on Company — you mapped it to Contact

This plugin makes all three impossible to save.

What you get

A property picker instead of a text field

Replace your property field's type with the hubspot.property custom field. Editors then search the real, writable properties of your portal:

Contact · Rôle (hs_role)
Contact · Prénom (firstname)
Société · Nombre d'employés (numberofemployees)

The list narrows to the object you picked. Point the field at the sibling that holds the object — from the Content-Type Builder, under HubSpot → Object field — and choosing Contact leaves only contact properties. The prefix disappears, since it is no longer telling you anything. In schema.json that setting reads:

{
  "hsProperty": {
    "type": "customField",
    "customField": "plugin::hubspot.property",
    "options": { "objectField": "hsObject" }
  }
}

Once filtered, the list is ordered and prefixed by the property's HubSpot group — the portal's own way of organising hundreds of properties:

Informations de contact · Prénom (firstname)
Informations de contact · Rôle (hs_role)
Historique · Source (hs_analytics_source)

Read-only properties — the ones HubSpot computes and always refuses to accept — are filtered out, so the list only ever offers things that will actually work. When the portal id is readable, each selected property gets a Voir dans HubSpot link straight to its settings page.

The schema is fetched once per page whatever the number of fields, and cached ten minutes server-side. A property created in HubSpot a minute ago is one click away: the ↻ button under any picker re-fetches the portal and updates every picker on the page at once. And switching a field's object clears a property that doesn't exist on the new one — it could only fail at save time.

An object picker to feed it

The sibling object field doesn't have to be a hand-typed enum: the hubspot.object custom field is a select over the portal's configured objects. An object the token can't read stays selectable, flagged with its missing scope.

{
  "hsObject": {
    "type": "customField",
    "customField": "plugin::hubspot.object"
  }
}

One-click option import

For an enumeration property, the picker shows an Import the N options from HubSpot button that fills the sibling repeatable (default options, entries { value, label } — the same shape the validation reads) with the enumeration's real choices. The bad-option check detects a select that drifted from the CRM; this is what fixes it. Point optionsField (in the field's HubSpot options, next to objectField) at the repeatable if it isn't called options.

Validation on save

Point the plugin at the content types that carry mappings and it walks each entry before it is written, at any depth — steps, repeatable components, dynamic zones. An invalid mapping is refused with a message that says which property and why:

Invalid HubSpot mapping — "name" exists on company, not on contact

It catches four things:

CodeCase
unknownThe property doesn't exist in the portal
wrong-objectIt exists, but on another object
whitespaceIt exists, but the stored value has surrounding spaces
bad-optionThe field offers a choice the enumeration doesn't accept

That last one closes a real hole: HubSpot refuses a value outside an enumeration exactly as hard as it refuses an unknown property, so a select whose choices drifted from the CRM fails at send time with nothing to show for it. Point optionsField at the repeatable holding the choices (default options, each { value?, label? }) and they are checked too.

Each problem is also carried as a structured code in the error's details, so a host app can localize the message instead of parsing the sentence.

unknown is the one code a legitimate workflow can produce — a mapping written before its property was deleted in HubSpot, or content created through the API against a property staged but not created yet. Set strict: false on a validate target and those pass with a warning in the logs instead of blocking the save. The other three codes always block, whatever strict says: no workflow produces a wrong object, a trailing space, or a drifted option on purpose.

An audit of existing content

Save-time validation only protects entries as they are written. A property deleted in HubSpot afterwards leaves invalid mappings dormant in content nobody re-saves — until a submission silently fails. Settings → HubSpot → Mapping audit walks every entry of the validated content types (drafts, every locale, at any depth) against a freshly fetched schema, and lists each entry the portal would reject today, linking straight to it in the Content Manager.

The audit reports unknown properties even on strict: false targets: strict only decides whether a save is blocked, not whether the mapping would reach the CRM.

A sending service

The host app doesn't have to talk to HubSpot itself — the plugin exposes the upsert, with everything above applied on the way out:

const result = await strapi.plugin("hubspot").service("submit").upsert({
  object: "contact",
  idProperty: "email", // find-or-create key
  properties: {
    email: "jane@acme.com",
    firstname: "Jane",
    hs_role: "dev;designer", // multi-select values use HubSpot's `;` separator
  },
});
// { ok: true, id: "…" }
// { ok: false, problems: [{ code: "unknown", … }] }  — refused before sending
// { ok: false, queued: true, error: "…" }            — parked for replay

The payload is validated against the portal schema before it is sent — the same checks as on save, values coerced to strings, empty ones dropped. Then:

  • a permanent refusal (4xx) comes back as { ok: false, error } with HubSpot's message — retrying can't fix a wrong payload, so nothing is queued;
  • a transient failure (429, 5xx, network) is retried twice with backoff, then parked in Content Manager → HubSpot failed submissions and reported as { ok: false, queued: true }.

service("submit").retryFailures() replays the queue, oldest first — from a cron in the host app, or the Replay now button in Settings → HubSpot. A replay that succeeds removes the row; one that keeps failing stays, with its error and attempt count, until it works or an admin deletes it.

Sending needs write scopes on the token: crm.objects.contacts.write (and its equivalent per object you send to).

A settings screen

Settings → HubSpot holds the private app token, the HubSpot account that receives conversions (portal id, region, default marketing form), and the submission policy: conversion vs CRM upsert, leftover contact writes, form field sync on publish. Values saved here override config/plugins.ts and env — switching test → production, or switching HubSpot-first ↔ Strapi-first, is an admin change, not a deploy.

The token is stored server-side and never returned to the browser: the UI only receives whether a key exists, where it comes from, and its last four characters. Portal id / region / form GUID are not secrets and round-trip so they can be edited. A "Test connection" button round-trips to HubSpot and reports how many properties it can read.

Access is gated by a dedicated RBAC permission — Settings → Roles → Plugins → Hubspot. A role without it neither sees the settings link nor can call the settings routes; reading properties stays open to every authenticated admin, since the picker needs it in the Content Manager.

On storage: the key is kept in Strapi's core store, which is not encrypted at rest — it is readable by anyone with database access. It never reaches the browser, but treat it like any other secret in your database. Use HUBSPOT_API_KEY if your deployment already manages secrets properly.

Stability & versioning

From 1.0 onward this plugin follows semver against a frozen public contract, enforced by a dedicated test suite (contract.test.ts):

  • the definition document (version: 1): steps → fields → visibleIf conditions, the 12 operators, stable stp_/fld_ ids;
  • the public API payloads: GET /forms/:slug (rendering props served, CRM mappings never), POST /forms/:slug/submit and its structured 422, GET /company-search;
  • the submission conventions: values keyed by field name, company companion keys <name>__siret / <name>__company, honeypot __hp;
  • the exported types (strapi-plugin-hubspot/types).

Anything else — admin UI, internals, HubSpot plumbing — may improve in minor versions. Breaking any of the above means a major version, and a version: 2 definition would ship with an automatic migration from version: 1.

Install

npm install strapi-plugin-hubspot

Enable it in config/plugins.ts:

export default ({ env }) => ({
  hubspot: {
    enabled: true,
    config: {
      // Optional — the key can also be set from Settings → HubSpot.
      apiKey: env("HUBSPOT_API_KEY", ""),
      portalId: env("HUBSPOT_PORTAL_ID", ""),
      region: env("HUBSPOT_REGION", "eu1"),

      // Optional — objects whose properties are offered.
      // Defaults to ["contact", "company"]. Standard names: contact, company,
      // deal, ticket, product, line_item, quote. A custom object is either its
      // type id, or { name, path } when the two differ.
      objects: ["contact", "company", "deal"],

      // Optional — entries whose mappings are validated on save.
      validate: [
        {
          uid: "api::form.form",
          objectField: "hsObject",     // holds "contact" | "company"
          propertyField: "hsProperty", // holds the property name
          optionsField: "options",     // optional — the field's choices
          strict: true,                // optional — false lets `unknown`
                                       // properties through with a warning
        },
      ],
    },
  },
});

Then point your property field at the custom field, in the component or content type that holds it. options.objectField names the sibling holding the object — omit it and the picker simply lists every object's properties:

{
  "hsProperty": {
    "type": "customField",
    "customField": "plugin::hubspot.property",
    "options": { "objectField": "hsObject" }
  }
}

Restart Strapi and hard-refresh the admin.

The token

Create a private app in HubSpot with a read scope per object you list:

  • crm.schemas.contacts.read
  • crm.schemas.companies.read
  • crm.schemas.deals.read, crm.schemas.custom.read… as needed
  • crm.objects.contacts.read / write, crm.objects.companies.read / write
  • forms — list portal forms (import + builder picker) and submit them via the Forms API (native conversions). Without this scope the pipeline falls back to a CRM upsert, which HubSpot treats as an offline source.

An object the token can't read is skipped, not fatal: the picker keeps working for the others and explains which one is missing a scope. oauth is worth adding too — it exposes the portal id and the portal's UI host, which turn on the view in HubSpot links.

Regions

The REST API is global: api.hubapi.com routes by token, whatever the portal's hosting region. The web app and the Forms API are not — an EU-hosted portal lives on app-eu1.hubspot.com and submits to api-eu1.hsforms.com. Set config.region (eu1, na1, …) to match. Deep links are built from the uiDomain HubSpot reports for your portal rather than a hardcoded host.

The key, portal id, region and default form GUID are resolved in this order, first match wins:

  1. saved from Settings → HubSpot
  2. config/plugins.ts (apiKey, portalId, region, forms.defaultFormId)
  3. environment variables (HUBSPOT_API_KEY, HUBSPOT_PORTAL_ID, HUBSPOT_REGION, HUBSPOT_DEFAULT_FORM_ID)

Migrating an existing field

The custom field is backed by a plain string. Switching an existing text field to it needs no migration: every value already saved stays valid and selectable, and uninstalling the plugin leaves readable data behind.

A stored value your portal doesn't recognise — typed before you installed this, or since deleted in HubSpot — stays selected and is flagged inconnue du portail rather than being silently dropped on the next save.

How it degrades

A plugin that sits between your editors and their content has to fail quietly. This one never blocks work:

SituationBehaviour
No API key configuredThe field falls back to a plain text input, with a note explaining why
HubSpot unreachableSaving proceeds; validation is skipped and a warning is logged
Property staged in HubSpot but not created yetNot offered — properties are managed in HubSpot, the picker never invents one. A value already stored (e.g. written via the API) passes the save if the target sets strict: false
An object's scope is missingThat object is skipped; the others still work
Portal id unreadableDeep links are omitted; everything else is unaffected
Plugin uninstalledValues remain as strings — nothing to undo

The property schema is cached for 10 minutes and de-duplicated across concurrent requests, so a busy Content Manager doesn't hammer the HubSpot API. Saving a new key drops the cache immediately.

Admin API

All routes require an authenticated admin; the settings routes additionally require the plugin::hubspot.settings RBAC permission.

MethodPathPurpose
GET/hubspot/propertiesWritable properties, readable objects, unreachable ones and the portal id. ?refresh=1 bypasses the cache
GET/hubspot/auditScans every entry of the validated content types and returns the invalid mappings, per entry
GET/hubspot/failuresNumber of parked submissions
POST/hubspot/failures/retryReplays the parked submissions and reports the outcome
GET/hubspot/settingsWhether a key exists, its source and hint — never the key
PUT/hubspot/settingsSave a key ({ apiKey })
DELETE/hubspot/settingsRemove the stored key

i18n

The admin UI ships in English and French, keyed on the Strapi admin locale. Server-side error messages stay in English; the structured codes in the error's details are the localization hook for host apps.

Tests

npm test

Vitest over the server logic: mapping checks, deep collection through dynamic zones and repeatables, schema loading (cache, concurrent de-duplication, missing scopes) and the strict/non-strict save middleware.

Compatibility

Strapi v5. Node >= 18.

License

MIT

Submit your content

Share your work with the community and get it listed in the Strapi ecosystem for everyone to discover and use.

Submit
Submit your content