Skip to content

Enriching HubSpot contacts with a workflow webhook

· 7 min read · Tutorials

To enrich HubSpot contacts with a webhook, build a contact-based workflow that triggers on new contacts, add a Send a webhook action that posts the contact's email to an endpoint, and map the JSON it returns onto contact properties in a later action. With a domain intelligence API behind that endpoint, each contact gets a category (company, free webmail, disposable, ISP, education, government), a spam verdict and, for company domains, a basic company profile.

The guide uses pingutil as the lookup API and a ~60 line Cloudflare Worker to sit between HubSpot and the API. You can call the API directly from HubSpot for the simple fields; the section below explains why a small proxy is worth it for the rest.

What you need

  • A HubSpot portal with access to the Send a webhook workflow action. HubSpot's webhook action article lists it under Data Hub Professional and Enterprise as of this writing; parts of HubSpot's docs still use the older Operations Hub name. You also need edit permission for workflows.
  • A pingutil API key (free, from the dashboard). Per-key limit is 60 requests per minute.
  • Somewhere to run a small HTTPS endpoint. The example is a Cloudflare Worker, but it's plain fetch code and ports to any serverless runtime.

Why put a proxy in the middle

HubSpot can call pingutil directly. Set the webhook action to POST to https://pingutil.com/v1/lookup, choose API key authentication with name Authorization, location Request header, and a HubSpot secret whose value is Bearer pk_live_.... Then use Customize request body with a single key input mapped to the contact's Email property. That works, and category and verdict.is_spam come back on every response.

The problem is the company fields. HubSpot's docs warn to "select only outputs available in every response", because a later action that references a missing output fails. pingutil returns company: null for Gmail, disposable and ISP addresses, so company.name simply isn't there for a large chunk of contacts. Nested nulls also make property mapping awkward.

A proxy fixes that by returning a flat object where every key is always present (empty string when there's no value). It also gives you one place to handle rate limits, keep the API key out of HubSpot, and change the mapping without editing the workflow.

Step 1: deploy the endpoint

// worker.js (Cloudflare Workers, module syntax)
// Secrets: PINGUTIL_KEY (pk_live_...), WEBHOOK_SECRET (any long random string)

const EMPTY = {
  pingutil_category: 'unknown',
  pingutil_is_spam: 'false',
  pingutil_low_trust: 'false',
  pingutil_reason: '',
  pingutil_company_name: '',
  pingutil_industry: '',
  pingutil_employee_size: '',
  pingutil_country: '',
  pingutil_linkedin_url: '',
  pingutil_confidence: '',
};

export default {
  async fetch(request, env) {
    if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
    if (request.headers.get('x-webhook-secret') !== env.WEBHOOK_SECRET) {
      return new Response('unauthorized', { status: 401 });
    }

    const { email } = await request.json().catch(() => ({}));
    if (!email || !email.includes('@')) return Response.json(EMPTY);

    const res = await fetch('https://pingutil.com/v1/lookup', {
      method: 'POST',
      headers: {
        authorization: `Bearer ${env.PINGUTIL_KEY}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({ input: email }),
    });

    if (res.status === 429) {
      const { retry_after_seconds = 60 } = await res.json().catch(() => ({}));
      // HubSpot retries 429s and reads Retry-After in milliseconds (per its docs).
      return new Response('rate limited', {
        status: 429,
        headers: { 'retry-after': String(retry_after_seconds * 1000) },
      });
    }
    if (res.status === 400) return Response.json(EMPTY); // invalid input: don't retry
    if (!res.ok) return new Response('upstream error', { status: 502 }); // HubSpot retries

    const d = await res.json();
    const c = d.company || {};
    return Response.json({
      ...EMPTY,
      pingutil_category: d.category,
      pingutil_is_spam: String(d.verdict.is_spam),
      pingutil_low_trust: String(d.verdict.low_trust),
      pingutil_reason: d.verdict.reason || '',
      pingutil_company_name: c.name || '',
      pingutil_industry: c.industry || '',
      pingutil_employee_size: c.employee_size_estimate || '',
      pingutil_country: c.headquarters?.country || '',
      pingutil_linkedin_url: c.linkedin_url || '',
      pingutil_confidence: c.confidence == null ? '' : String(c.confidence),
    });
  },
};

Deploy with wrangler deploy after setting both secrets with wrangler secret put PINGUTIL_KEY and wrangler secret put WEBHOOK_SECRET.

A few deliberate choices in there:

  • Full lookup, not skip_enrichment. You want company data, so the request leaves enrichment on. The first lookup of a never-seen company domain runs an LLM and can take a few seconds. Repeat domains come from pingutil's cache. HubSpot doesn't document a webhook timeout, only that slow webhooks make the action take longer. If you only need the spam verdict, add skip_enrichment: true and the response comes back in milliseconds.
  • 400 becomes 200. HubSpot doesn't retry 4XX responses (except 429), so a bad email would just log an error. Returning unknown lets the workflow continue and makes the bad record easy to find.
  • 429 passes through. If the key's 60/min budget is exhausted, HubSpot backs off and retries. Their docs say retries continue for up to three days.
  • Strings everywhere. Simplest to map onto single-line text or dropdown properties.

Step 2: create the contact properties

In HubSpot settings, create contact properties to hold the results. Put them in their own property group so nobody confuses them with rep-entered data:

Property Type Notes
pingutil_category Dropdown: company, free, disposable, isp, educational, government, unknown Branch on this
pingutil_is_spam Dropdown: true, false
pingutil_reason Single-line text disposable_domain, role_based, etc.
pingutil_company_name, pingutil_industry, pingutil_employee_size, pingutil_country, pingutil_linkedin_url Single-line text Empty for non-company domains
pingutil_confidence Single-line text or number Model's own 0-1 estimate

pingutil_low_trust is optional; add it if you route role addresses differently.

Step 3: build the workflow

  1. Create a contact-based workflow. Enroll contacts when Email is known, or when they're created, whichever matches your intake. Leave re-enrollment off unless you want to re-check on email changes.
  2. Add Send a webhook. Method POST, URL is your Worker URL.
  3. Authentication: API key. Key name x-webhook-secret, location Request header, and pick a HubSpot secret holding the same value as WEBHOOK_SECRET.
  4. Choose Customize request body and add one property: key email, property Email. Sending only the email keeps contact data you don't need out of a third-party request.
  5. Optional but recommended: turn on the action's rate limiting and cap it below 60 executions per minute, for example 50. HubSpot's rate limiting is off by default, and a big import can enroll thousands of contacts at once. The 429 handling covers you anyway, but pacing on HubSpot's side means fewer retries and a cleaner action log.
  6. Click Test action with a real contact and check the Response tab. Then select the outputs (all ten keys).
  7. Add an action that sets contact property values and map each pingutil_* property to the matching output under Action data for the webhook action.
  8. After the properties are written, add an if/then branch on pingutil_category and pingutil_is_spam.

Branching on the stored property rather than the raw action output is deliberate: properties persist after the workflow ends, show up in lists and reports, and can be used anywhere else in HubSpot.

Step 4: decide what each branch does

A starting point:

  • pingutil_is_spam is true: set lifecycle stage or lead status to something your team ignores, and suppress from marketing email. Don't delete; you'll want the record if it was a mistake.
  • company: copy pingutil_company_name and pingutil_industry into the standard Company name and Industry fields only if they're empty, then hand off to routing. The lead routing guide covers using employee size and industry for assignment.
  • free or isp: a real person, not necessarily a business. Route to nurture rather than sales. See the ISP domains explainer for why comcast.net isn't a company.
  • educational / government: usually a separate motion; tag and route accordingly.
  • role_based reason: info@ and sales@ are often real companies; keep the lead but don't assign it to a rep as a named contact.

Low pingutil_confidence values deserve care. That number is the model's own estimate, not a measured accuracy. If you auto-populate standard fields, consider requiring something like 0.7 and leaving the rest for manual review. The trade-offs are covered in how accurate LLM company enrichment is.

Backfilling existing contacts

The same workflow can enroll existing contacts, but go slowly. With the action's rate limiting at 50 per minute, 10,000 contacts take a bit over three hours, which is fine for a weekend run. Contacts at the same company share one cached pingutil result, so repeat domains are cheap on pingutil's side, but each contact still costs one request against your 60/min key budget. If you're cleaning a large historical database rather than enriching new leads, export to CSV and use a deduped script instead; the CRM data hygiene guide shows that route.

Limits to be honest about

  • The company profile is LLM-generated from public information and can be wrong, especially for small or recently renamed companies. Treat it as a draft.
  • Disposable detection depends on a community blocklist. Brand-new throwaway domains get through until they're listed.
  • There's no person-level data: no job title, no phone number. If you need those, this isn't the tool.

To see the exact response shape before building the workflow, run a few of your own contacts through the API reference or check what we have on file for a known company like HubSpot itself.

Questions people ask

Which HubSpot plan do I need for the Send a webhook action?
HubSpot's knowledge base lists the Send a webhook workflow action as available with Data Hub Professional or Enterprise at the time of writing. Check the Available with note on that article against your own portal, since packaging changes.
What happens if the enrichment API is down or rate limited?
HubSpot retries failed webhooks for up to three days with increasing gaps. It retries 429 responses but not other 4XX codes, so the endpoint in this guide returns 429 when it is rate limited and 200 with a category of unknown for input it cannot classify.
Will this overwrite data my sales team entered by hand?
Only if you map the webhook outputs onto properties they edit. Write to dedicated properties such as pingutil_category and pingutil_company_name, and copy into standard fields like Industry with a branch that only fills empty values.