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
httpsaddress: a cloud function, a small server, or your laptop with a tunnel while you build (). - 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:
- The record ID you sent with the text. When you text from HubSpot, put the contact’s record ID in the text’s 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. - 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.readcrm.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:
/v1/webhookscurl -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"
}'The answer carries the webhook’s signing secret, whsec_…, shown this once. Put it and the HubSpot token in your server’s environment:
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.
// 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);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 (), 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:
/v1/messagescurl -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"
}
}'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.
Test it with your own number
- In HubSpot, make a contact for yourself with your own mobile number in Mobile phone number. Note its record ID from the address bar.
- Send yourself the text above with your number in
toand your record ID inmetadata.hubspot_contact_id. - Reply to it from your phone with a word or two.
- 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. |
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
+15555550123form. - 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=+15555550123and log each text the same way; texts older than 30 days come back without their words (). - 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/eventsand replay it (). - 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.