0800 310 1010
← All articles
CRM & integrations · 9 min read

Connect HubSpot CRM to NumberGroup: log calls, create contacts, attribute calls to deals

Working setup for piping NumberGroup call data into HubSpot — small webhook receiver, HubSpot Contacts API, Calls activity timeline, deal-attribution patterns. Honest, no managed-integration upsell.

A working HubSpot integration with NumberGroup boils down to: a small webhook receiver, a HubSpot private app access token, and two API calls per inbound call (find the contact, create a Call activity). This post shows the whole thing — no managed middleware, no monthly add-on, no third-party Zapier seat.

If you haven’t read it yet, start with NumberGroup call webhooks explained — it covers the mechanism this post relies on.

What you’ll have at the end

  • Every inbound call to a NumberGroup DDI shows up as a Call activity on the matching HubSpot contact’s timeline.
  • If the caller’s number isn’t in HubSpot, a new contact is created with that number.
  • If the contact is associated with one or more open deals, the call is also attributed to those deals — so “deals that received a call” becomes a real HubSpot filter.
  • Duration, direction, recording URL (if you record calls), and the DDI dialled are all captured.

Step 1 — Create a HubSpot private app

In HubSpot: Settings → Integrations → Private apps → Create private app. Name it “NumberGroup call logger”. Under Scopes tick:

  • crm.objects.contacts.read
    +
    crm.objects.contacts.write
  • crm.objects.deals.read
    +
    crm.objects.deals.write
  • crm.objects.calls.read
    +
    crm.objects.calls.write
    (under Engagements)

Create the app and copy the access token. Treat it like a password — it can read and write your whole CRM.

Step 2 — Deploy a tiny webhook receiver

Cloudflare Worker example below. Set

HUBSPOT_TOKEN
as a Worker secret. Adapt to your stack of choice if you’re not on Workers — the logic is the same.

// numbergroup-hubspot-worker.js
export default {
  async fetch(req, env) {
    if (req.method !== "POST") return new Response("ok");
    const call = await req.json();

    // 1. Normalise the caller number to E.164
    const phone = normaliseUK(call.caller);

    // 2. Find or create the contact
    let contactId = await findContact(env, phone);
    if (!contactId) contactId = await createContact(env, phone);

    // 3. Find any open deals associated with the contact
    const dealIds = await getOpenDeals(env, contactId);

    // 4. Create the Call engagement, associated to contact + deals
    await createCallEngagement(env, {
      contactId,
      dealIds,
      from: phone,
      to: call.called,
      durationMs: (call.duration || 0) * 1000,
      timestamp: Date.parse(call.ended_at || new Date().toISOString()),
      recordingUrl: call.recording_url || null,
      direction: "INBOUND",
    });

    return new Response("ok");
  },
};

function normaliseUK(n) {
  if (!n) return null;
  n = n.replace(/[^0-9+]/g, "");
  if (n.startsWith("+")) return n;
  if (n.startsWith("0")) return "+44" + n.slice(1);
  return "+" + n;
}

async function hs(env, path, init = {}) {
  return fetch("https://api.hubapi.com" + path, {
    ...init,
    headers: {
      authorization: "Bearer " + env.HUBSPOT_TOKEN,
      "content-type": "application/json",
      ...(init.headers || {}),
    },
  });
}

async function findContact(env, phone) {
  const r = await hs(env, "/crm/v3/objects/contacts/search", {
    method: "POST",
    body: JSON.stringify({
      filterGroups: [{ filters: [{ propertyName: "phone", operator: "EQ", value: phone }] }],
      properties: ["phone"],
      limit: 1,
    }),
  });
  const j = await r.json();
  return j.results?.[0]?.id || null;
}

async function createContact(env, phone) {
  const r = await hs(env, "/crm/v3/objects/contacts", {
    method: "POST",
    body: JSON.stringify({ properties: { phone, lifecyclestage: "lead" } }),
  });
  const j = await r.json();
  return j.id;
}

async function getOpenDeals(env, contactId) {
  const r = await hs(env, `/crm/v4/objects/contacts/${contactId}/associations/deals`);
  const j = await r.json();
  return (j.results || []).map(x => x.toObjectId);
}

async function createCallEngagement(env, c) {
  await hs(env, "/crm/v3/objects/calls", {
    method: "POST",
    body: JSON.stringify({
      properties: {
        hs_timestamp: c.timestamp,
        hs_call_from_number: c.from,
        hs_call_to_number: c.to,
        hs_call_duration: c.durationMs,
        hs_call_direction: c.direction,
        hs_call_recording_url: c.recordingUrl,
        hs_call_status: "COMPLETED",
        hs_call_title: `Inbound call to ${c.to}`,
      },
      associations: [
        { to: { id: c.contactId }, types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 194 }] },
        ...c.dealIds.map(id => ({
          to: { id },
          types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 206 }],
        })),
      ],
    }),
  });
}

Association type IDs 194 and 206 are the standard contact-to-call and deal-to-call associations. HubSpot’s docs list the full set if you need others.

Step 3 — Wire it into NumberGroup

  • Portal → Web Hook Manager → new webhook.
  • Name: HubSpot — log inbound call.
  • URL: your worker URL with a shared-secret path segment.
  • Leave Conditions empty (or restrict to specific DDIs if you only want sales calls logged).
  • Save, then on each DDI you want logged, open the number, scroll to Integrations, and set End of call webhook to the HubSpot one.

Place a test call. Within seconds it should appear on the HubSpot contact timeline.

Step 4 (optional) — VIP routing on start-of-call

Add a second webhook for start-of-call. The receiver queries HubSpot for the contact’s lifecycle stage and returns:

{"vip":"yes"}  // for "customer" or "evangelist"
{"vip":"no"}   // otherwise

In the webhook’s Results panel set variable

is_vip
from regex
"vip":"(yes|no)"
. Then in your routing translation you can send
is_vip == yes
calls straight to the account manager’s mobile and everyone else through the normal hunt group.

Gotchas worth knowing

  • Phone-number formats. HubSpot stores phone numbers however users typed them — “020 1234 5678”, “+44 20 1234 5678”, “02012345678”. Search by E.164 misses messy data. Run a one-off backfill that normalises every existing contact’s phone to E.164, or do fuzzy matching in your worker.
  • Workflow recursion. If a HubSpot workflow triggers on “new call activity” and somehow places a call, you can loop. Filter your workflows to specific call sources.
  • API rate limits. Private apps get 100 requests / 10 seconds. Plenty for normal call volume; if you’re a contact centre, queue the writes.
  • Recording URL access. If you store recordings on NumberGroup, the URL we send will require auth. Either proxy it through your worker with signed URLs, or download-and-rehost on your own storage.

Why we’re not selling you a managed integration

Honest answer: a managed HubSpot integration would be a £25/mo add-on for every Member, most of which we’d burn on support tickets when HubSpot changes their API. The webhook receiver above is ~150 lines, runs free on Workers, and you control it. That’s the Membership philosophy: give you the working pattern, don’t rent it back to you.

If you don’t have anyone in-house who can deploy a Worker, ring us on 0800 310 1010. We’ll point you at a freelancer who’s done it for other Members for a fixed fee. You own the code afterwards.

Move your phones to us. We'll save you money.

Memorable numbers, unlimited UK numbers, every smart feature and a UK team — all included with Membership at £149/mo. No contract.

See what Membership includes →Browse phone numbers →
Related reading