# HubSpot Service Hub setup for the Spun helpdesk bridge

Workflow: `spun-hubspot-bridge.n8n.json` (import into n8n 1.x). Reference facts:
`API-NOTES.md` in this folder (HubSpot's public developer documentation, read and
re-checked 10/2026, not yet live-tested). General template notes, node map and the
live-test checklist: `../README.md`.

Every HubSpot shape below is marked **verified** (stated in HubSpot's documentation) or
**assumed** (the documentation is ambiguous or silent; the conservative choice is
taken). Treat the assumed items as the first things to check in the live run.

## 0. What you end up with

| Side | Item |
|---|---|
| HubSpot | Two custom ticket properties (`spun_chat_id`, `spun_reply`), one key for API calls, one app whose webhook subscriptions call n8n, a pipeline with known open and closed stages. |
| n8n | The imported workflow, two credentials, the Settings node filled in, the workflow active. |
| Spun | An API key with `send_message` (plus `manage_conversations` if you want HubSpot-side close, step 8) and a webhook subscription with a signing secret. |

How HubSpot differs from the generic template (resolved here, details below):

- **Tickets have no customer-facing reply box in the API.** Notes and communications are
  internal timeline records (verified). An agent answers the WhatsApp customer by
  typing into the ticket property **Spun reply**; the workflow sends that text through
  Spun and clears the property (step 7).
- **Messages are logged as WhatsApp communications** on the ticket (channel type
  `WHATS_APP`), linked to the contact as well, so the contact timeline shows them too.
  HubSpot has no direction field, so each entry starts with `[WhatsApp]`,
  `[Sent from Spun]` or `[Sent from HubSpot]`.
- **The Spun CRM contact sync owns contacts; this workflow owns tickets** (step 5).
- **There is no closed flag.** A ticket is closed when its pipeline stage is a CLOSED
  stage. Closing and reopening are stage changes in both directions (step 8).
- **Phone search ignores the country code.** HubSpot indexes the area code and local
  number only (verified), so the workflow searches the national number.

## 1. Keys and apps

HubSpot splits the two jobs (verified):

| Job | What works |
|---|---|
| API calls from n8n (create tickets, log messages, change stages) | A **service key** (Development, Keys, Service keys; super admin or Developer tools permission), or the access token of a **legacy private app**. Both are sent as `Authorization: Bearer ...`. |
| Webhooks to n8n (agent replies, stage changes) | An **app**: a legacy private app you already have, or a project-based app. **A service key cannot receive webhooks.** |

**Legacy private apps:** HubSpot disables creating new ones on **Oct 26, 2026** for
existing accounts (Sept 28, 2026 for accounts created on or after that date). Apps that
exist keep working (verified). So:

- You already have a legacy private app, or can create one before the cut-off: use it
  for both jobs. Its access token goes into the n8n credential and its webhook settings
  hold the subscriptions. This is the shortest setup.
- Otherwise: a service key for the API calls, plus a **project-based app** for the
  webhooks. A project app is built and uploaded with the HubSpot CLI and a developer
  project (verified); that is real developer work. Its subscriptions live in the
  project's `webhooks.json` (step 6).

In n8n, create a credential of type **Header Auth**: name `Authorization`, value
`Bearer <service key or private app token>`. Name it "HubSpot key" and select it on
every `HUBSPOT:` node (HTTP Request, Authentication = Generic Credential Type, Header
Auth). The workflow never uses Spun's own HubSpot connection (step 5).

HubSpot recommends rotating keys and tokens every six months; a rotated token stays
valid for 7 days unless you expire it at once (verified). Update the n8n credential when
you rotate.

## 2. Scopes

| Purpose | Scope |
|---|---|
| Create, read and update tickets | `crm.objects.tickets.read`, `crm.objects.tickets.write` |
| Read pipelines and stages (setup helper) | `crm.schemas.tickets.read` |
| Create the two ticket properties through the API (optional) | `crm.schemas.tickets.write` |
| Search and create contacts; notes and communications | `crm.objects.contacts.read`, `crm.objects.contacts.write` |

Verified from HubSpot's scope list. **Assumed:** the notes and communications pages list
only the contacts scopes, even for records linked to a ticket; the set above holds both
the contacts and the tickets scopes, which covers either reading. The workflow uploads
no files, so `files` scopes are not needed.

## 3. Ticket properties

Create two ticket properties (Settings, Properties, Ticket properties, Create property):

| Label | Internal name | Field type | Purpose |
|---|---|---|---|
| Spun chat id | `spun_chat_id` | Single-line text | The Spun chat this ticket belongs to. Set by the workflow; agents leave it alone. |
| Spun reply | `spun_reply` | Multi-line text | The agent types the WhatsApp reply here (step 7). |

The internal names must be exactly these. Add **Spun reply** to the ticket sidebar
(the record customization for tickets) so agents see it next to the conversation.

Through the API instead (needs `crm.schemas.tickets.write`):
`POST /crm/properties/2026-09/tickets` with
`{"name": "spun_chat_id", "label": "Spun chat id", "type": "string", "fieldType": "text", "groupName": "ticketinformation"}`
and the same for `spun_reply` with `"fieldType": "textarea"`. The endpoint and the
string/text pairs are verified; the group name `ticketinformation` is **assumed** (any
existing ticket property group works).

**Optional third property: Spun LID.** WhatsApp hides some customers' numbers behind a
privacy ID (an "LID", chat id `<digits>@lid`); Spun reports such a chat with
`peer_phone_e164: null`, `peer_lid` and `identity_state: lid_unlinked`
(`../README.md`, "WhatsApp privacy IDs (LID)"). To keep that id on the ticket, add a
single-line text ticket property labelled "Spun LID" (internal name `spun_lid`, same
API call with `"name": "spun_lid"`) and set `hubspot_ticket_lid_property` to `spun_lid`.
The contact property of the same name is in step 5. Both are optional: while a setting
still starts with `PASTE` the workflow leaves that property out.

## 4. Pipeline and stages

Stage ids are account-specific and a pipeline can have more than one CLOSED stage
(verified). Never guess them:

1. Select the HubSpot credential on **HUBSPOT setup: list ticket pipelines** (bottom
   left of the canvas).
2. Click **Test workflow** on **Setup helper: run by hand**. The last node lists every
   pipeline and stage: `pipeline_id`, `stage_id`, the stage name and its
   `ticket_state` (`OPEN` or `CLOSED`).
3. Copy into Settings:
   - `hubspot_pipeline_id`: the pipeline WhatsApp tickets go to. The workflow always
     sends it, so a second pipeline added later cannot reroute tickets.
   - `hubspot_open_stage_id`: an OPEN stage for new and reopened tickets (usually the
     first one, "New").
   - `hubspot_closed_stage_id`: the CLOSED stage to use when Spun closes a chat.
   - `hubspot_closed_stage_ids`: **every** CLOSED stage id of that pipeline. A ticket in
     any of them counts as closed: the next WhatsApp message starts a new ticket, and an
     agent moving a ticket there is a close (step 8).
   - Optional `hubspot_ticket_priority`: `LOW`, `MEDIUM` or `HIGH`; left out while it
     is a placeholder.

The helper calls `GET /crm/pipelines/2026-09/tickets` (verified; stages carry
`metadata.ticketState`). It never runs with the webhooks.

## 5. Contacts and the Spun CRM contact sync

Spun has a separate CRM contact sync (Settings in Spun, connected with Spun's own
HubSpot app). The two work side by side under one rule: **the sync owns contacts; this
workflow owns tickets** and the communications and notes on them.

| | Spun CRM contact sync | This workflow |
|---|---|---|
| Connection | Spun's HubSpot app, contacts and deals scopes, no tickets | Your own key or private app (step 1) |
| Contacts | Creates and updates them (name, phone, email) | Looks them up by phone, links them to tickets, never edits them |
| Tickets, communications | None | Creates and updates them |
| Webhooks | Its own address in Spun | Its own n8n address only |

Set `crm_sync_connected` in Settings:

- `true` (the default, keep it when unsure): the workflow **never creates a contact**.
  It searches by phone; when the sync has not pushed the contact yet (it runs about
  every 30 seconds and HubSpot's search index lags a few moments), the ticket is created
  without a contact and the workflow links it on a later message of the same chat.
  This avoids the sync and the workflow creating the same person at the same moment.
- `false`: when the search finds nobody, the workflow creates the contact with the
  E.164 phone, the WhatsApp name as first name, and the email when Spun has one. If
  HubSpot answers that a contact with that email exists, that contact is used.

The phone search (`POST /crm/objects/2026-09/contacts/search`) looks for the national
number in `phone` and `mobilephone`, plus a token match on its last 10 digits, and
accepts a hit only when the stored number is exactly this one (with or without the
country code), oldest contact first. Verified: the endpoint, the national-number rule,
the operators. **Assumed:** that a number stored with spaces or dashes is found by the
token match; the exact-digit check keeps a wrong hit out either way.

Two things to keep in mind:

- **Never point a HubSpot webhook of this workflow at Spun's CRM sync address**
  (`.../api/crm/hook/...`). That address treats every object id as a contact id.
- When Spun has no email for a contact, the sync can send an empty email to HubSpot and
  clear one an agent typed there. Until that is fixed in Spun, type the email in Spun
  (or in both places).

**Privacy-ID chats** (no phone, see step 3). The phone search cannot run, so:

- With a contact property "Spun LID" (single-line text, internal name `spun_lid`,
  `POST /crm/properties/2026-09/contacts`, needs `crm.schemas.contacts.write`) and
  `hubspot_contact_lid_property` set to `spun_lid`, the workflow searches contacts with
  `spun_lid` `EQ` the privacy ID and accepts only an exact match, oldest first. When
  `crm_sync_connected` is `false` and nobody is found, it creates the contact with the
  WhatsApp name (or `privacy ID <digits>`) as first name, the email when Spun has one,
  and `spun_lid`; no phone (the digits are not a phone).
- Without that property the search is skipped: with `crm_sync_connected` `false` the
  contact is created as above without `spun_lid`, and the workflow's own map
  (`lid:<digits>`) is the only thing that finds it again; with `true` the ticket is
  created without a contact.
- When Spun later learns the phone (`identity_state: lid_linked`) the workflow keeps
  using the same contact. It never edits a contact, so it does not add the phone; the
  Spun CRM contact sync or an agent can.

## 6. The webhook (HubSpot to n8n)

1. Replace `PASTE-A-LONG-RANDOM-SUFFIX` in the **Helpdesk reply** node's path with a
   long random string (letters, digits and dashes), keeping the `hubspot-reply-`
   prefix. The router matches the prefix, so no other node changes.
2. Activate the workflow and copy the production URL of **Helpdesk reply**
   (`.../webhook/hubspot-reply-<your suffix>`). Paste it, exactly as HubSpot will call
   it, into `hubspot_webhook_url`.
3. Paste the **client secret** of the app that sends the webhooks into
   `hubspot_app_client_secret`.
4. Add the subscriptions in the app, target URL = the URL from step 2:

| Object | Event | Property | Needed for |
|---|---|---|---|
| Ticket | Property changed | `spun_reply` | Agent replies (step 7). Required. |
| Ticket | Property changed | `hs_pipeline_stage` | HubSpot-side close and reopen (step 8). Optional. |

   - **Legacy private app:** the app's Webhooks tab: set the target URL, then create the
     two subscriptions. They can be set in the UI only (verified).
   - **Project app:** in `webhooks.json`, `settings.targetUrl` = the URL and two
     `ticket.propertyChange` entries under `legacyCrmObjects` (one per property). The
     section names and `targetUrl` are verified; the exact entry format is in HubSpot's
     webhooks component reference.

**Authenticity.** The workflow accepts either signature HubSpot documents (verified):

- v3: `X-HubSpot-Signature-v3` = base64(HMAC-SHA256(client secret, `POST` + exact
  URL + raw body + `X-HubSpot-Request-Timestamp`)), refused when older than 5 minutes.
  This is why `hubspot_webhook_url` must be the exact URL HubSpot calls: behind a proxy
  the URL n8n sees can differ.
- v1: `X-HubSpot-Signature` = hex(SHA-256(client secret + raw body)).

The two Crypto nodes compute both; the adapter compares them in constant time. While
`hubspot_app_client_secret` is still a placeholder, everything is dropped. **Assumed:**
which of the two headers app subscriptions carry (the documentation describes both),
and where the client secret is shown in a legacy private app (its Auth tab).

**Delivery (verified).** HubSpot sends a JSON array of up to 100 events, in no fixed
order, waits 5 seconds and retries up to 10 times over 24 hours. The Helpdesk reply
node answers 200 on receipt; the workflow dedupes on portal, ticket and event time.
Events older than `dedupe_hours` are ignored.

## 7. How agents reply

1. Open the ticket, type the reply into **Spun reply**, save.
2. The change reaches n8n; the workflow sends the text to the customer through Spun
   (`POST /send-message`, idempotency key `hs:<portal>:<ticket>:<event time>`).
3. It clears **Spun reply**, so the agent can type the next reply (HubSpot only
   notifies on a change). The clearing change comes back with an empty value and is
   ignored.
4. It logs the sent text on the ticket as a communication: `[Sent from HubSpot] text`
   (with `(queued)` when Spun queued the message). If Spun refused it, a note
   `Not sent to WhatsApp (<reason>): text` is added instead, and the property is still
   cleared.

The reply goes to the chat stored in the ticket's `spun_chat_id`. Typing into **Spun
reply** on a ticket the workflow did not create, or on an older ticket of a chat that
has a newer open one, sends nothing (the execution ends at "No Spun chat for this
ticket"). A reply that starts with `[WhatsApp]` is never sent (echo guard). Text only:
attachments in HubSpot are not sent to WhatsApp.

Any user or automation that writes `spun_reply` sends a WhatsApp message, including
HubSpot workflows and imports. Keep the property out of bulk edits and imports.

## 8. HubSpot-side close and reopen (optional)

With the `hs_pipeline_stage` subscription (step 6) and `close_in_spun_when_desk_closes`
set to true, moving a ticket to any stage in `hubspot_closed_stage_ids` archives the
conversation in Spun through `POST /api/integrations/conversations/close` (reason
`closed_in_hubspot`); moving it back to an open stage calls `/reopen` (reason
`reopened_in_hubspot`).

- The Spun API key needs `manage_conversations` ("Close & Reopen Conversations" under
  Manage API Keys). A 403 from Spun means the key lacks it; a 404 means no conversation
  matched.
- The workflow finds the conversation through its own ticket map. A ticket closed after
  the workflow's data was reset is not closed in Spun until a new message for it has
  passed through.
- Spun to HubSpot always works: `conversation.closed` moves the ticket to
  `hubspot_closed_stage_id`, `conversation.reopened` to `hubspot_open_stage_id`. The
  stage changes the workflow makes itself come back as webhooks and are ignored,
  because the map already has the new state.
- Without the subscription, a ticket closed in HubSpot is noticed on the next WhatsApp
  message of that chat: the workflow re-reads the stage and starts a new ticket.

## 9. Spun side

1. Webhooks page, Manage API Keys: create a key with `send_message` (and
   `manage_conversations` for step 8). Copy it once; create the n8n Header Auth
   credential `Authorization` = `Bearer wap_...` and select it on the three `SPUN:`
   nodes.
2. Webhooks page: create a subscription to the production URL of the **Spun events**
   node with the events `message.inbound`, `message.outbound`, `conversation.closed`,
   `conversation.reopened`, payload template **raw**, and a signing secret. Paste the
   secret into `spun_webhook_secret`.
3. Send a WhatsApp message to the line from another phone and follow the Delivery Log
   (Webhooks page) and the n8n executions list. The live-test checklist in
   `../README.md` lists the expected result of every step.

## 10. Settings node, all values

| Key | Value |
|---|---|
| `spun_api_base` | `https://api.spun.com/api/integrations` |
| `spun_webhook_secret` | the Spun subscription's signing secret (step 9) |
| `hubspot_base`, `hubspot_api_version` | `https://api.hubapi.com`, `2026-09` (keep) |
| `hubspot_app_client_secret` | step 6 |
| `hubspot_webhook_url` | step 6, the exact production URL of Helpdesk reply |
| `hubspot_pipeline_id`, `hubspot_open_stage_id`, `hubspot_closed_stage_id`, `hubspot_closed_stage_ids` | step 4 |
| `hubspot_ticket_priority` | step 4, optional |
| `crm_sync_connected` | `true` unless you are sure the Spun CRM contact sync is not connected (step 5) |
| `hubspot_contact_lid_property`, `hubspot_ticket_lid_property` | optional, `spun_lid` once the contact property (step 5) and the ticket property (step 3) exist |
| `desk_integration_user_id` | empty; set it to a HubSpot user id (as it appears in the event's `sourceId`) to ignore that user's `spun_reply` edits |
| `skip_groups` | `true`. Groups and newsletters always end at "Skipped (group or newsletter)" (no single person to make a contact for); privacy-ID chats are handled (steps 3 and 5) |
| `inbound_prefix` | `[WhatsApp]` (also the echo filter) |
| `outbound_prefix` | `[Sent from Spun]` |
| `reply_log_prefix` | `[Sent from HubSpot]` |
| `desk_close_reason`, `desk_reopen_reason` | `closed_in_hubspot`, `reopened_in_hubspot` (loop guard for step 8) |
| `close_in_spun_when_desk_closes` | `false` until step 8 is set up and its first close is checked |
| `dedupe_hours` | `24` |

## 11. Message mapping

| Spun | HubSpot |
|---|---|
| First `message.inbound` of a conversation | `POST /crm/objects/2026-09/tickets`: subject "WhatsApp: " plus the first line (80 characters), `content` = the message, pipeline and open stage, `spun_chat_id`, optional `spun_lid`, linked to the contact when one is known (type 16). The message is also logged as a communication. |
| Every `message.inbound` | `POST /crm/objects/2026-09/communications`: `WHATS_APP`, `[WhatsApp] Name (+number): text` (`(privacy ID <digits>)` for a privacy-ID chat with no known phone), the message time, linked to the ticket (83) and the contact (81). |
| Message after the ticket moved to a closed stage | A new ticket. |
| Media | The same communication with the file type, size and a link valid for 24 hours. Not uploaded to HubSpot files. |
| `message.outbound` sent from the Spun inbox or the phone | Communication `[Sent from Spun] Agent name: text`. |
| `conversation.closed` / `reopened` | Stage = closed stage / open stage. |
| `spun_reply` set on a ticket | WhatsApp text message to the customer, then the property is cleared and the text logged. |
| Agent notes, emails and other ticket activity in HubSpot | Not sent. |

Communication and note bodies are sent as escaped HTML with line breaks as `<br>` and
links clickable. **Assumed:** HubSpot renders `hs_communication_body` as HTML, the way it
renders note bodies.

## 12. HubSpot API usage and limits (verified figures)

- A new conversation costs 4 to 5 requests (contact search, ticket search, ticket
  create, communication, plus a contact create when the sync is off); a follow-up
  message 2 (stage read, communication), plus 1 to link the contact the first time it
  is found; an agent reply 2 (clear, communication), plus a ticket read when the
  workflow's map lost the ticket. While the sync has not pushed a contact yet, each
  message of that chat repeats the contact search.
- **100 requests per 10 seconds** per app on Free and Starter, **190** on Professional
  and Enterprise; **250,000 / 625,000 / 1,000,000 per day** per account, shared with your
  other integrations.
- **Search: 5 requests per second per account**, separate from the above. A burst of
  brand-new chats can get 429 on a search; that execution fails and can be re-run.
- A refused communication fails its n8n execution (visible in the execution list) and
  the message stays in Spun only. The workflow does not combine or delay messages.
- Note bodies are limited to 65,536 characters (verified); the workflow cuts every
  body at 65,000.

## 13. What was verified and what was assumed

| Item | Status |
|---|---|
| Date-based version `2026-09`, paths `/crm/objects/2026-09/...`, at least 18 months of support | Verified |
| Bearer auth with a service key or legacy private app token; service keys cannot receive webhooks | Verified (re-checked 10/07) |
| Legacy private app creation ends Oct 26, 2026 (Sept 28 for new accounts); existing apps keep working | Verified (re-checked 10/07) |
| Scopes per object; notes and communications list contacts scopes only | Verified; which scope applies to ticket-linked records is assumed (both requested) |
| Contact search on the national number, `EQ`, `CONTAINS_TOKEN`, at most 5 filter groups, 5 searches per second, index lag | Verified |
| Contact create; 409 names the existing id on a duplicate email | Create verified; the 409 message format assumed |
| Privacy-ID chats: contact search `EQ` on a custom `spun_lid` property, contact create without phone | Operators and create verified; search on a custom contact property assumed to work like a default one; not live-tested |
| Ticket create with `subject`, `content`, `hs_pipeline`, `hs_pipeline_stage`, `hs_ticket_priority`, inline association 16 | Verified |
| Custom ticket properties through `POST /crm/properties/2026-09/tickets` | Verified; group name assumed |
| Ticket search on `spun_chat_id` with `NOT_IN` closed stages | Verified operators; search on a custom property assumed to work like a default one |
| Close and reopen by patching `hs_pipeline_stage`; `metadata.ticketState` | Verified |
| PATCH and the default association `PUT` answer with the record or a status object | Assumed; the workflow treats a missing `id` as failure |
| Clearing a property with an empty string | Assumed |
| Communications: `WHATS_APP`, `logged_from = CRM`, `hs_timestamp` required, association 83 and 81, no direction or attachment field | Verified |
| Communication body rendered as HTML | Assumed |
| Notes: `hs_note_body` up to 65,536 characters, association 228 | Verified |
| Ticket `propertyChange` subscriptions, batch of up to 100, unordered, 5-second timeout, 10 retries over 24 hours, `eventId` not unique | Verified (re-checked 10/07) |
| Signature v3 (method + URL + body + timestamp, 5 minutes) and v1 (secret + body) | Verified; which one app subscriptions send assumed (both accepted) |
| Client secret location in a legacy private app | Assumed |
| `changeSource` values for API writes | Not documented; the workflow does not rely on them |
| Agents see and edit **Spun reply** in the ticket sidebar | Assumed (standard custom property behaviour) |
