# HubSpot

Connect a HubSpot portal so inbox contacts become HubSpot contacts, messages land on their timelines and closed-won deals reach your ad platforms.

Link a HubSpot portal to a profile, and the profile's [inbox](https://pozy.ai/docs/inbox) flows into your CRM by itself. Anyone who writes to you is created as a HubSpot contact, each message lands on that contact's timeline, and won deals can go back to your ad networks as purchases. You make no calls after connecting: each message syncs a few seconds after it arrives.

## What you can do

| Job | How |
| --- | --- |
| Create or match HubSpot contacts for inbox contacts | Automatic (`syncContacts`, on by default) |
| Log every inbox message on the contact's timeline | Automatic (`logMessages`, on by default) |
| Write a contact at a chosen step of a flow | `hubspot_upsert_contact` workflow node |
| Send closed-won deals to ad platforms as purchases | `reportDeals`, off by default |

| Property | Value |
| --- | --- |
| Platform id | `hubspot` |
| How it connects | OAuth, through HubSpot's own consent screen, which installs the app in the portal |
| Scopes | Always `oauth`, `crm.objects.contacts.read` and `crm.objects.contacts.write`; `crm.objects.deals.read` when you want deals |
| Portals per profile | A single one |
| Posting, analytics, inbox on the account itself | No |

## Connect a portal

Whoever connects needs a HubSpot login with permission to install apps in that portal.

```bash cURL
curl "https://api.pozy.ai/v1/connect/hubspot?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/hubspot?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
// Redirect the user to data.authUrl
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/connect/hubspot",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={"profileId": "6710c3a9e4b0d2f1a8c95e21", "redirect_url": "https://app.example.com/connected"},
)
auth_url = resp.json()["authUrl"]
```

The user picks a portal and approves the app. They return to your `redirect_url` with `connected=hubspot`, `profileId`, `accountId` and `username` (the portal domain). Contact sync and message logging start right away. Reconnecting the same portal refreshes the account and keeps its settings.

## Choose what syncs

```bash cURL
curl -X PATCH https://api.pozy.ai/v1/accounts/6710c4e7e4b0d2f1a8c95e60/hubspot-settings \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "logMessages": false, "reportDeals": true }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/accounts/6710c4e7e4b0d2f1a8c95e60/hubspot-settings", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ logMessages: false, reportDeals: true }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.account);
```
```python Python
resp = requests.patch(
    "https://api.pozy.ai/v1/accounts/6710c4e7e4b0d2f1a8c95e60/hubspot-settings",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={"logMessages": False, "reportDeals": True},
)
```

| Setting | Default | What it does |
| --- | --- | --- |
| `syncContacts` | `true` | Creates a HubSpot contact when an inbox contact matches none |
| `logMessages` | `true` | Writes each inbox message on the contact's timeline |
| `reportDeals` | `false` | Sends deals that become closed won as `Purchase` events |

Read the current values, plus the portal id and granted scopes, with `GET /v1/accounts/{accountId}/hubspot-settings`.

## How contacts are matched

For each message, Pozy looks for the HubSpot contact in this order:

1. A contact with the same email address.
2. A contact with the same phone number, taken from the person's WhatsApp, SMS, RCS or iMessage channel.
3. With `syncContacts` on, a new contact with the person's name, email and phone. With it off, the message is skipped.

Once found, the link is kept. Pozy attaches to a contact that already exists without changing its name or any property. Should that contact later be deleted or merged, Pozy looks for a match again.

## How messages are logged

| Channel | Logged as |
| --- | --- |
| WhatsApp | Communication (WhatsApp) |
| SMS | Communication (SMS) |
| Any other inbox channel | Note |

Each entry names the channel and direction, then the text. Attachments are listed as links. A message is logged once, even when its sync is retried. Only conversations linked to an inbox contact sync, and only messages sent or received after you connected. Nothing is backfilled.

## Save a contact from a workflow

To push a lead to HubSpot at a chosen step, say right after a bot collects an email address, put a `hubspot_upsert_contact` node in the [workflow](https://pozy.ai/docs/automations/workflows):

```json
{
  "id": "save-lead",
  "type": "hubspot_upsert_contact",
  "config": { "email": "{{answer}}", "properties": { "lifecyclestage": "lead" } }
}
```

The node finds the contact with the same matching as the inbox sync, then writes just the fields you filled in; empty values are ignored. Keys in `properties` are HubSpot's internal property names. Afterwards the flow continues on `success`, or on `error` if the workflow's profile has no HubSpot portal or HubSpot rejects the update.

## Report closed-won deals

With `reportDeals` on, every deal that becomes closed won is sent as a `Purchase` to the profile's event destinations, so Meta, Google and your other ad platforms see real revenue. See [Conversions](https://pozy.ai/docs/ads/conversions).

| Purchase field | Taken from |
| --- | --- |
| `value`, `currency` | The deal's amount and currency, falling back to the portal's default currency |
| `eventTime` | When the deal closed |
| `eventId` | `hubspot_deal_{portalId}_{dealId}`, which stops a network from counting one deal twice |
| `user` | Email, phone, name and address of the first contact on the deal |

Deals are checked about once an hour. Deals closed before you turned the setting on are not sent, and each deal is sent once. The portal must have granted deal access: if the deals option shows as locked on the consent screen, a HubSpot admin enables it under **Settings > Integrations > Connected Apps**, then you reconnect.

## Disconnect

`DELETE /v1/accounts/{accountId}` stops syncing and uninstalls the app from the portal, unless another profile still uses that portal. Uninstalling from HubSpot shows the account as disconnected the next time a message syncs. Contacts and timeline entries already written stay in HubSpot.

## Limits

- One portal per profile.
- Contacts and their timelines only. Companies, deals and tickets are not synced (deals are read only for purchase reporting).
- Some channels log only messages sent from the inbox, not every broadcast, sequence or workflow send.
- A busy portal that HubSpot rate limits can lag by a few minutes; Pozy waits and retries.

## Errors

| Error | Why | What to do |
| --- | --- | --- |
| `hubspot_profile_has_portal` on the redirect | A different portal is already linked to this profile | Disconnect that one first, or pick another profile |
| `hubspot_access_denied` on the redirect | The user did not approve the app | Start again and approve it |
| `hubspot_invalid_state` on the redirect | The connect link was too old or had been used already | Get a new `authUrl` |
| `hubspot_connection_failed` on the redirect | HubSpot did not complete the token exchange, or Pozy could not read the portal | Try again; if it keeps failing, write to support@pozy.ai |
| `400` on settings | No boolean field in the body, deal access missing for `reportDeals`, or not a HubSpot account | Send at least one setting to a HubSpot account id |
| `404` on settings | The account is gone or your key cannot reach it | List accounts for a current id |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/connect/hubspot`](https://pozy.ai/docs/api/getHubSpotConnectUrl) | Get HubSpot OAuth connect URL |
| [`GET /v1/accounts/{accountId}/hubspot-settings`](https://pozy.ai/docs/api/getHubSpotSettings) | Get HubSpot account settings |
| [`PATCH /v1/accounts/{accountId}/hubspot-settings`](https://pozy.ai/docs/api/updateHubSpotSettings) | Update HubSpot account settings |
| [`GET /v1/contacts`](https://pozy.ai/docs/api/listContacts) | List contacts |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`contact.updated`](https://pozy.ai/docs/webhooks/events#contact-updated) | Contact updated. |
| [`contact.deleted`](https://pozy.ai/docs/webhooks/events#contact-deleted) | Contact deleted. |
| [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. |
| [`contact.tag_added`](https://pozy.ai/docs/webhooks/events#contact-tag-added) | Contact tag added. |
| [`contact.tag_removed`](https://pozy.ai/docs/webhooks/events#contact-tag-removed) | Contact tag removed. |
| [`contact.field_changed`](https://pozy.ai/docs/webhooks/events#contact-field-changed) | Contact field changed. |
