message.comDevelopers

Sync HubSpot contacts to message.com

Mirror HubSpot contact records into message.com visitor records. Lifecycle stage, lead status, owner, and any custom property you choose. The inbox shows where each visitor sits in the funnel.

1. Create a HubSpot private app

  1. In HubSpot, go to Settings → Integrations → Private Apps.
  2. Click Create a private app.
  3. Grant scopes: crm.objects.contacts.read, crm.schemas.contacts.read.
  4. Copy the access token into HUBSPOT_TOKEN.
  5. From the same app page, copy the client secret into HUBSPOT_APP_SECRET for signature verification.

2. Subscribe to webhooks

  1. Open the private app's Webhooks tab.
  2. Set the target URL to your HTTPS endpoint.
  3. Subscribe to contact.creation and contact.propertyChange. For property changes, pick email, firstname, lastname, lifecyclestage, hs_lead_status, and any custom property you want pushed.
  4. 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());

    await fetch("https://app.message.com/api/v1/visitors/upsert", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.MESSAGE_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": String(evt.eventId),
      },
      body: JSON.stringify({
        email: contact.properties.email,
        name: `${contact.properties.firstname ?? ""} ${contact.properties.lastname ?? ""}`.trim(),
        externalId: `hubspot:${evt.objectId}`,
        attributes: {
          lifecycleStage: contact.properties.lifecyclestage,
          leadStatus: contact.properties.hs_lead_status,
        },
      }),
    });
  }

  res.status(200).end();
});

HubSpot batches events, so each delivery is an array. Loop, fetch the contact, upsert. Use the event ID as the idempotency key.

4. Verify

  1. Change a HubSpot contact's lifecycle stage to customer.
  2. Watch your server logs.
  3. Open the matching visitor in app.message.com. Confirm lifecycleStage: customer in attributes.

Common pitfalls

  • Bulk imports. Importing 10k contacts into HubSpot fires 10k webhooks. Add a small queue (Redis, BullMQ, SQS) between your endpoint and the message.com upsert to smooth the burst.
  • Email-less contacts. Some HubSpot contacts have no email (form fills, anonymous tracking). Upsert by external ID anyway; let the email backfill on the next event.
  • HubSpot merges. When two contacts merge, you get a contact.merge event. The losing record's ID becomes stale. Listen and delete or merge in message.com.
  • 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