Sync HubSpot contacts to message.com
Mirror HubSpot contact records into message.com Contact records. Lifecycle stage, lead status, and any custom property you choose. The customer directory shows where each contact sits in the funnel.
1. Create a HubSpot private app
- In HubSpot, go to Settings → Integrations → Private Apps.
- Click Create a private app.
- Grant scopes:
crm.objects.contacts.read,crm.schemas.contacts.read. - Copy the access token into
HUBSPOT_TOKEN. - From the same app page, copy the client secret into
HUBSPOT_APP_SECRETfor signature verification.
2. Subscribe to webhooks
- Open the private app's Webhooks tab.
- Set the target URL to your HTTPS endpoint.
- Subscribe to
contact.creationandcontact.propertyChange. For property changes, pickemail,firstname,lastname,lifecyclestage,hs_lead_status, and any custom property you want pushed. - Save and activate.
3. Build the handler
server.ts
// Node / Express handler for HubSpot webhooks
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
app.post("/webhooks/hubspot", async (req, res) => {
const sig = req.get("X-HubSpot-Signature-v3")!;
const timestamp = req.get("X-HubSpot-Request-Timestamp")!;
const base = "POST" + req.protocol + "://" + req.get("host") + req.originalUrl + req.body + timestamp;
const computed = crypto
.createHmac("sha256", process.env.HUBSPOT_APP_SECRET!)
.update(base)
.digest("base64");
if (computed !== sig) return res.status(401).end();
const events: Array<{ objectId: number; eventId: number; propertyName?: string; propertyValue?: string }>
= JSON.parse(req.body.toString());
for (const evt of events) {
const contact = await fetch(
`https://api.hubapi.com/crm/v3/objects/contacts/${evt.objectId}?properties=email,firstname,lastname,lifecyclestage,hs_lead_status`,
{ headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}` } }
).then(r => r.json());
const email = contact.properties.email;
const name = `${contact.properties.firstname ?? ""} ${contact.properties.lastname ?? ""}`.trim();
const customFields = {
hubspotContactId: evt.objectId,
lifecycleStage: contact.properties.lifecyclestage,
leadStatus: contact.properties.hs_lead_status,
};
const authHeader = { Authorization: `Bearer ${process.env.MESSAGE_WORKSPACE_JWT}` };
// There is no upsert endpoint: find-or-create yourself. Contacts are
// only searchable by name/email/phone/company, not by a custom field,
// so email is the real join key here.
const found = email
? await fetch(`https://app.message.com/api/v1/contacts?search=${encodeURIComponent(email)}`, { headers: authHeader })
.then(r => r.json())
.then(d => d.contacts.find((c: { email: string | null }) => c.email === email))
: null;
if (found) {
await fetch(`https://app.message.com/api/v1/contacts/${found.id}`, {
method: "PATCH",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ name, customFields }),
});
} else {
await fetch("https://app.message.com/api/v1/contacts", {
method: "POST",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ email, name }),
}).then(r => r.json())
.then(d => fetch(`https://app.message.com/api/v1/contacts/${d.contact.id}`, {
method: "PATCH",
headers: { ...authHeader, "Content-Type": "application/json" },
body: JSON.stringify({ customFields }),
}));
}
}
res.status(200).end();
});HubSpot batches events, so each delivery is an array. Loop, fetch the contact, then find-or-create in message.com. There is no idempotency-key support on the message.com side, so the find-by-email step also protects against duplicate processing of the same event.
4. Verify
- Change a HubSpot contact's lifecycle stage to
customer. - Watch your server logs.
- Open the matching contact in
app.message.com. ConfirmlifecycleStage: customerin its custom fields.
Common pitfalls
- Bulk imports. Importing 10k contacts into HubSpot fires 10k webhooks. Add a small queue (Redis, BullMQ, SQS) between your endpoint and message.com to smooth the burst.
- Email-less contacts. Some HubSpot contacts have no email (form fills, anonymous tracking). Since email is the only real lookup key, you cannot find-or-create reliably without one; queue those events until an email shows up on a later property change.
- HubSpot merges. When two contacts merge, you get a
contact.mergeevent. The losing record's ID becomes stale. There is no delete or merge endpoint on message.com contacts; fold the surviving record's data into the existing contact via PATCH and leave the stale one in place. - Two-way sync. This tutorial pushes HubSpot to message.com. Pushing the other direction (chat events to HubSpot timeline) requires the HubSpot Engagements API. See OAuth apps when that ships.
Next steps
- Identify logged-in users with HMAC.
- Sync Salesforce leads.
- Send proactive messages based on lifecycle stage.