npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

strapi-plugin-hubspot

v0.16.9

Published

The form builder for HubSpot in Strapi — multi-step forms with conditional fields mapped to real CRM properties, a public submit pipeline with retries, and save-time mapping validation.

Readme

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):

| Method | Path | Purpose | |---|---|---| | 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/submit | Validates (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:

| Setting | Default | What it does | |---|---|---| | forms.submissionMode | auto | auto: Forms API when a marketing GUID is linked, else CRM upsert. forms: always try the Forms API. crm: never count as a conversion | | forms.writeExtraProperties | true | After a conversion, CRM-write mapped contact fields the HubSpot form dropped (they 400 the Forms API) | | forms.syncFieldsOnPublish | false | On publish, PATCH missing mapped contact fields onto the HubSpot form. Off by default so HubSpot-first installs aren't mutated | | forms.timelineNote | true | Recap note on the contact after a successful sync (page URL, subject, answers). Sits next to a native conversion; turn off if you only want the form submission |

The visitor's hutk is forwarded so attribution sticks. The conversion carries context.pageUri / pageName (the page the visitor submitted from). 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 (forms.timelineNote, default true) add a recap of that page plus the originating subject on the contact timeline — they don't replace the conversion.

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: true,      // recap note (page + subject) next to the conversion
      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.

GDPR consent

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).

The HubSpot original is never modified. Re-importing (or Resync from HubSpot in the editor) updates mapped fields in place and appends new ones — Strapi steps, conditions and extra fields stay. Matching is by HubSpot form GUID, then slug. The published version is never touched.

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 typed | What's wrong | |---|---| | hs_rôle | Doesn't exist — a typo, an autocorrect, a copy-paste | | role | Exists, but with a trailing space | | name | Exists, 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:

| Code | Case | |---|---| | unknown | The property doesn't exist in the portal | | wrong-object | It exists, but on another object | | whitespace | It exists, but the stored value has surrounding spaces | | bad-option | The 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: "[email protected]",
    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:

| Situation | Behaviour | |---|---| | No API key configured | The field falls back to a plain text input, with a note explaining why | | HubSpot unreachable | Saving proceeds; validation is skipped and a warning is logged | | Property staged in HubSpot but not created yet | Not 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 missing | That object is skipped; the others still work | | Portal id unreadable | Deep links are omitted; everything else is unaffected | | Plugin uninstalled | Values 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.

| Method | Path | Purpose | |---|---|---| | GET | /hubspot/properties | Writable properties, readable objects, unreachable ones and the portal id. ?refresh=1 bypasses the cache | | GET | /hubspot/audit | Scans every entry of the validated content types and returns the invalid mappings, per entry | | GET | /hubspot/failures | Number of parked submissions | | POST | /hubspot/failures/retry | Replays the parked submissions and reports the outcome | | GET | /hubspot/settings | Whether a key exists, its source and hint — never the key | | PUT | /hubspot/settings | Save a key ({ apiKey }) | | DELETE | /hubspot/settings | Remove 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