message.comDevelopers

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

  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());

    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

  1. Change a HubSpot contact's lifecycle stage to customer.
  2. Watch your server logs.
  3. Open the matching contact in app.message.com. Confirm lifecycleStage: customer in 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.merge event. 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