# Log every text on the HubSpot contact’s timeline

> Put every text you send and every reply you get on the HubSpot contact’s timeline as a logged SMS, from a webhook handler of about sixty lines, so your team sees the whole conversation without opening Messages.

Jordan is a financial planner at Bright Path Planning. His clients text him at his own number, and the conversations live on his iPhone, where nobody else at the firm can see them. When Jordan is out, his assistant opens a contact in HubSpot and finds nothing since the last email. With this guide, every text Jordan sends through BlueBot and every reply he gets shows up on the contact’s timeline in HubSpot, logged as an SMS as soon as it happens. Texts he types by hand on his iPhone to those people are logged too.

## What you’ll have

- Each text in and out with a HubSpot contact logged on their timeline as an SMS, with who sent it and when.
- Texts you send from HubSpot (through Zapier or your own code) tied to the right contact by their HubSpot record ID, so replies find the right person even when the phone number is written differently in HubSpot.
- A **Stopped texting** box ticked on the contact when they text STOP, so nobody at the firm texts them by hand.

## What you need

- A BlueBot account with your Mac connected.
- A HubSpot account where you can make a private app and a contact property. Logging communications works on every HubSpot tier.
- Somewhere to run a small web server with an `https` address: a cloud function, a small server, or your laptop with a tunnel while you build ([Testing webhooks](https://usebluebot.com/docs/webhooks/testing)).
- Node.js 18 or later, or Python 3.10 or later.

## How it fits together

BlueBot sends your server a webhook for each text: `message.sent` when one goes out (from the API, Zapier, the inbox or your own iPhone) and `message.received` when one comes in. Your server finds the HubSpot contact for that number and adds a **communication** to their timeline through HubSpot’s CRM API, the same record HubSpot makes when someone logs an SMS by hand. When a person texts STOP, BlueBot sends `contact.stopped` and your server ticks a box on the contact.

The only hard part is finding the contact. HubSpot keeps phone numbers as people typed them, so `(555) 555-0123` in HubSpot doesn’t equal `+15555550123` from BlueBot. The handler tries two ways, in order:

1. **The record ID you sent with the text.** When you text from HubSpot, put the contact’s record ID in the text’s [metadata](https://usebluebot.com/docs/concepts/metadata) as `hubspot_contact_id`. BlueBot echoes metadata on every webhook about that text, and the handler remembers which number belongs to which contact. A reply from that number then goes straight to the right record.
2. **A search by phone number.** For anyone you haven’t texted from HubSpot, the handler searches the contact’s **Phone number** and **Mobile phone number** for the number as BlueBot writes it and as ten digits.

### Make a HubSpot private app

In HubSpot, make a private app named “BlueBot texts” with these two scopes, then copy its access token. HubSpot’s own guide to private apps shows where they live in your account’s settings.

- `crm.objects.contacts.read`
- `crm.objects.contacts.write`

Communications are logged with the contact scopes, so you need nothing else.

### Add a Stopped texting property

In HubSpot’s settings, open **Properties**, choose **Contact properties** and press **Create property**. Name it **Stopped texting**, make it a **Single checkbox**, and check that its internal name is `stopped_texting`. The handler ticks it when someone texts STOP, and your team can see it on the contact and filter lists by it.

### Make a BlueBot webhook

In the BlueBot dashboard, open **Developers**, then **Webhooks**, press **New webhook**, give it your server’s address and tick `message.received`, `message.sent` and `contact.stopped`. Or make it with the API:

```bash
curl -X POST "https://usebluebot.com/api/v1/webhooks" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://brightpath.example/bluebot/webhooks",
    "events": [
      "message.received",
      "message.sent",
      "contact.stopped"
    ],
    "description": "Log texts on HubSpot contacts"
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const webhook = await client.webhooks.create({
  url: 'https://brightpath.example/bluebot/webhooks',
  events: ['message.received', 'message.sent', 'contact.stopped'],
  description: 'Log texts on HubSpot contacts',
});
console.log(webhook);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

webhook = client.webhooks.create(
    url="https://brightpath.example/bluebot/webhooks",
    events=["message.received", "message.sent", "contact.stopped"],
    description="Log texts on HubSpot contacts",
)
print(webhook)
```

The answer carries the webhook’s signing secret, `whsec_…`, shown this once. Put it and the HubSpot token in your server’s environment:

```bash
export BLUEBOT_API_KEY="bb_live_…"        # a key with messages:send, for the texts you send in the next step
export BLUEBOT_WEBHOOK_SECRET="whsec_…"
export HUBSPOT_TOKEN="pat-na1-…"
```

### Write the handler

The handler checks BlueBot’s signature on the raw body before it trusts anything, logs the text in HubSpot and then answers. HubSpot answers within a second or two, well inside the 10 seconds BlueBot waits. It skips group chats, since a group text has no one contact to log it on.

BlueBot sends each event with an `id`, the same on every retry. The handler keeps the ids it has logged so a retry never logs a text twice. The examples keep them in memory; in production, keep them in a table with a unique key on the event id, or on the message id, so a restart doesn’t forget them.

```ts title="TypeScript"
// server.ts: npm install express @usebluebot/sdk
import express from 'express';
import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk';

const bluebot = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });
const HUBSPOT = 'https://api.hubapi.com';
const COMMUNICATION_TO_CONTACT = 81; // HubSpot's association type for a communication on a contact

const logged = new Set<string>(); // event ids; use a table in production
const contactByNumber = new Map<string, string>(); // +15555550123 -> HubSpot record ID; use a table in production

async function hubspot(path: string, init: RequestInit = {}) {
  const response = await fetch(`${HUBSPOT}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`, 'Content-Type': 'application/json' },
  });
  if (!response.ok) throw new Error(`HubSpot answered ${response.status}: ${await response.text()}`);
  return response.json();
}

async function findContact(number: string): Promise<string | null> {
  const known = contactByNumber.get(number);
  if (known) return known;
  const tenDigits = number.replace(/\D/g, '').slice(-10);
  const values = [number, tenDigits];
  const result = await hubspot('/crm/v3/objects/contacts/search', {
    method: 'POST',
    body: JSON.stringify({
      filterGroups: ['phone', 'mobilephone'].flatMap((propertyName) =>
        values.map((value) => ({ filters: [{ propertyName, operator: 'EQ', value }] })),
      ),
      properties: ['firstname', 'lastname'],
      limit: 2,
    }),
  });
  if (result.total !== 1) return null; // nobody, or two people share the number: don't guess
  contactByNumber.set(number, result.results[0].id);
  return result.results[0].id;
}

function describe(message: { text: string | null; files: { kind: string }[] }) {
  if (message.text) return message.text;
  const kinds = message.files.map((file) => file.kind.replace('_', ' ')).join(', ');
  return kinds ? `(sent ${kinds})` : '(no words)';
}

const app = express();

app.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = bluebot.webhooks.verify(req.body.toString('utf8'), req.headers, process.env.BLUEBOT_WEBHOOK_SECRET!);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send('Bad signature');
    throw error;
  }
  if (logged.has(event.id)) return res.status(204).end();

  try {
    if (event.type === 'message.sent' || event.type === 'message.received') {
      const message = event.data;
      if (message.chatId) return res.status(204).end(); // a group chat has no one contact
      const incoming = message.direction === 'in';
      const number = (incoming ? message.from : message.to)!;
      if (message.metadata.hubspot_contact_id) contactByNumber.set(number, message.metadata.hubspot_contact_id);

      const contactId = await findContact(number);
      if (contactId) {
        await hubspot('/crm/v3/objects/communications', {
          method: 'POST',
          body: JSON.stringify({
            properties: {
              hs_communication_channel_type: 'SMS',
              hs_communication_logged_from: 'CRM',
              hs_communication_body: `${incoming ? `Text from ${number}` : `Text to ${number}`}: ${describe(message)}`,
              hs_timestamp: incoming ? message.receivedAt : message.sentAt,
            },
            associations: [
              { to: { id: contactId }, types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: COMMUNICATION_TO_CONTACT }] },
            ],
          }),
        });
      }
    }

    if (event.type === 'contact.stopped') {
      const contactId = await findContact(event.data.handle);
      if (contactId) {
        await hubspot(`/crm/v3/objects/contacts/${contactId}`, {
          method: 'PATCH',
          body: JSON.stringify({ properties: { stopped_texting: 'true' } }),
        });
      }
    }

    logged.add(event.id);
    res.status(204).end();
  } catch (error) {
    console.error('Could not log the text in HubSpot', event.id, error instanceof Error ? error.message : error);
    res.status(500).end(); // BlueBot tries again in a minute, then on a schedule for 3 days
  }
});

app.listen(3000);
```

```python title="Python"
# server.py: pip install flask requests bluebot
import os
import re

import requests
from flask import Flask, abort, request
from bluebot import BlueBot, WebhookVerificationError

app = Flask(__name__)
bluebot = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])
HUBSPOT = "https://api.hubapi.com"
COMMUNICATION_TO_CONTACT = 81  # HubSpot's association type for a communication on a contact

logged: set[str] = set()  # event ids; use a table in production
contact_by_number: dict[str, str] = {}  # +15555550123 -> HubSpot record ID; use a table in production

def hubspot(method: str, path: str, body: dict) -> dict:
    response = requests.request(
        method,
        f"{HUBSPOT}{path}",
        json=body,
        headers={"Authorization": f"Bearer {os.environ['HUBSPOT_TOKEN']}"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

def find_contact(number: str) -> str | None:
    if number in contact_by_number:
        return contact_by_number[number]
    ten_digits = re.sub(r"\D", "", number)[-10:]
    result = hubspot(
        "POST",
        "/crm/v3/objects/contacts/search",
        {
            "filterGroups": [
                {"filters": [{"propertyName": name, "operator": "EQ", "value": value}]}
                for name in ("phone", "mobilephone")
                for value in (number, ten_digits)
            ],
            "properties": ["firstname", "lastname"],
            "limit": 2,
        },
    )
    if result["total"] != 1:  # nobody, or two people share the number: don't guess
        return None
    contact_by_number[number] = result["results"][0]["id"]
    return contact_by_number[number]

def describe(message) -> str:
    if message.text:
        return message.text
    kinds = ", ".join(f.kind.replace("_", " ") for f in message.files)
    return f"(sent {kinds})" if kinds else "(no words)"

@app.post("/bluebot/webhooks")
def bluebot_webhook():
    try:
        event = bluebot.webhooks.verify(
            request.get_data(), request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"]
        )
    except WebhookVerificationError:
        abort(400)
    if event.id in logged:
        return "", 204

    try:
        if event.type in ("message.sent", "message.received"):
            message = event.data
            if message.chat_id:  # a group chat has no one contact
                return "", 204
            incoming = message.direction == "in"
            number = message.from_ if incoming else message.to
            if message.metadata.get("hubspot_contact_id"):
                contact_by_number[number] = message.metadata["hubspot_contact_id"]

            contact_id = find_contact(number)
            if contact_id:
                hubspot(
                    "POST",
                    "/crm/v3/objects/communications",
                    {
                        "properties": {
                            "hs_communication_channel_type": "SMS",
                            "hs_communication_logged_from": "CRM",
                            "hs_communication_body": f"{'Text from' if incoming else 'Text to'} {number}: {describe(message)}",
                            "hs_timestamp": (message.received_at if incoming else message.sent_at).isoformat(),
                        },
                        "associations": [
                            {
                                "to": {"id": contact_id},
                                "types": [
                                    {"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": COMMUNICATION_TO_CONTACT}
                                ],
                            }
                        ],
                    },
                )

        if event.type == "contact.stopped":
            contact_id = find_contact(event.data.handle)
            if contact_id:
                hubspot("PATCH", f"/crm/v3/objects/contacts/{contact_id}", {"properties": {"stopped_texting": "true"}})

        logged.add(event.id)
        return "", 204
    except requests.RequestException as error:
        app.logger.error("Could not log the text in HubSpot: %s %s", event.id, error)
        return "", 500  # BlueBot tries again in a minute, then on a schedule for 3 days
```

The handler logs the error with the event id and nothing from the text. Keep it that way: logs outlive the 30 days BlueBot keeps a text’s words.

Answering `500` when HubSpot fails is on purpose. BlueBot tries a failed delivery again after 1 minute, 5 minutes, 30 minutes and then on a widening schedule for about 3 days ([Retries and deliveries](https://usebluebot.com/docs/webhooks/retries)), so a HubSpot outage delays the log rather than losing it.

### Send texts from HubSpot with the record ID

The logging works for any text, but texts you start from HubSpot should carry the contact’s record ID, so the reply never depends on how the number was typed.

In **Zapier**, start a Zap with HubSpot’s trigger for a new contact (or the one your team uses, such as a contact entering a list), add BlueBot’s **Send Text** action, map the contact’s mobile number to **To**, write the text, and under **Metadata** add a key `hubspot_contact_id` mapped to the contact’s record ID. Zapier labels it **Record ID**, **Object ID** or **Contact ID** depending on the trigger.

From **your own code**, put it in `metadata` on the send:

```bash
curl -X POST "https://usebluebot.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: hubspot-48213-welcome" \
  -d '{
    "to": "+15555550123",
    "text": "Hi Ana, this is Jordan at Bright Path. Thanks for your note. What’s a good time to talk this week?",
    "metadata": {
      "hubspot_contact_id": "48213"
    }
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const message = await client.messages.send({
  to: '+15555550123',
  text: 'Hi Ana, this is Jordan at Bright Path. Thanks for your note. What’s a good time to talk this week?',
  metadata: {
    hubspot_contact_id: '48213',
  },
}, { idempotencyKey: 'hubspot-48213-welcome' });
console.log(message);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

message = client.messages.send(
    to="+15555550123",
    text="Hi Ana, this is Jordan at Bright Path. Thanks for your note. What’s a good time to talk this week?",
    metadata={
        "hubspot_contact_id": "48213",
    },
    idempotency_key="hubspot-48213-welcome",
)
print(message)
```

Build the `Idempotency-Key` from the record and the reason for the text (`hubspot-<record ID>-welcome`), so a retried job or a Zap that runs twice never sends the welcome twice. [Idempotency](https://usebluebot.com/docs/concepts/idempotency)

### Test it with your own number

1. In HubSpot, make a contact for yourself with your own mobile number in **Mobile phone number**. Note its record ID from the address bar.
2. Send yourself the text above with your number in `to` and your record ID in `metadata.hubspot_contact_id`.
3. Reply to it from your phone with a word or two.
4. Open your contact in HubSpot and look at the activity timeline.

| You got                                                    | It means                                                                                                                  | Do next                                                                                                                                                            |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Two SMS entries on your contact: “Text to” and “Text from” | It works both ways.                                                                                                       | Point your Zaps and code at real contacts and put the record ID in metadata.                                                                                       |
| Only “Text to”                                             | BlueBot never saw your reply. Your phone may be signed in to the same Apple ID as the Mac, so the reply is your own text. | Reply from a phone with a different number, or check the reply in **Messages** in the BlueBot dashboard.                                                           |
| Nothing on the timeline                                    | The webhook didn’t reach your server, or HubSpot refused the log.                                                         | Open the webhook in **Developers → Webhooks** and read its deliveries: each shows your server’s status code and answer.                                            |
| Deliveries answered `400`                                  | The signature didn’t match.                                                                                               | Check that the handler reads the raw body before parsing it, and that the secret is the one this webhook was made with. [Verify signatures](https://usebluebot.com/docs/webhooks/verify) |
| Deliveries answered `500` and a HubSpot `403` in your log  | The private app lacks a scope.                                                                                            | Add `crm.objects.contacts.write` to the private app and copy the token again.                                                                                      |

## When it goes wrong

- **A reply is logged on nobody.** The number isn’t in HubSpot in a form the search can match, or two contacts share it. The handler doesn’t guess. Send future texts from HubSpot with the record ID in metadata, or save the number on the contact in the `+15555550123` form.
- **The same text shows twice.** Your server answered after BlueBot gave up waiting (it waits 10 seconds), so BlueBot tried again. Keep the logged event ids in a table rather than in memory, and answer quickly.
- **Old texts aren’t on the timeline.** The webhook only logs what happens after you make it. To fill in the past, page through `GET /v1/messages?person=+15555550123` and log each text the same way; texts older than 30 days come back without their words ([Data retention](https://usebluebot.com/docs/concepts/data-retention)).
- **Your server was down for a day.** Nothing is lost: deliveries wait and retry for about 3 days. After a longer outage, read what you missed from `GET /v1/events` and replay it ([Replay events](https://usebluebot.com/docs/webhooks/replay)).
- **A conversation is missing from HubSpot.** BlueBot copies chats only with people you’ve texted through it, unless you set **Which chats to copy** to **Everything**. Someone who texted you first, and whatever you typed to them on your iPhone, stays on the phone. [Receiving texts](https://usebluebot.com/docs/sending/receiving)

> **Words in two places.** BlueBot deletes a text’s words after 30 days and keeps the facts. HubSpot keeps the copy you log for as long as you keep the contact. Tell your clients you keep texts on their record, the same as emails.

## Next

- [HubSpot recipes](https://usebluebot.com/docs/recipes/hubspot): Text new contacts from a form, text when a deal moves stage, and log replies with Zapier.
- [Text people who fill in your form](https://usebluebot.com/docs/guides/form-replies): Reply to every form entry within a minute.
- [Follow Up Boss](https://usebluebot.com/docs/guides/follow-up-boss): The same idea for a real estate CRM: text new leads at once and log every text on the person.
- [Webhooks](https://usebluebot.com/docs/webhooks): Set up, sign, retry and replay the events this guide reads.
