Skip to content

How to verify emails in HubSpot with a workflow custom code action

· 4 min read · Tutorials

To verify emails in HubSpot, build a contact-based workflow with a custom code action that sends the contact's email to a verification API, returns the result as output fields, and copies those outputs into contact properties with an Edit record action. Then filter lists and sequences on the new "verification status" property. This tutorial does it with pingutil's /v1/verify, but the pattern works with any verifier that takes an email and returns JSON.

Everything below comes from HubSpot's custom code action documentation. Custom code actions need Data Hub Professional or Enterprise.

1. Create the contact properties

In Settings, Properties, create these contact properties:

Property Type Values
Email verification status Dropdown select valid, invalid, risky, unknown
Email verification reason Single-line text e.g. mailbox_not_found, accept_all
Email verification score Number 0-100
Email suggestion Single-line text A corrected address, if any

Keeping the raw reason matters. "Risky because the domain is catch-all" and "risky because it's info@" lead to different decisions.

2. Store the API key as a secret

In the custom code action, add a secret named PINGUTIL_API_KEY. HubSpot exposes secrets to your code as environment variables, so the key never appears in the code itself.

3. Build the workflow

  1. Create a contact-based workflow. Enroll contacts when Email is known and Email verification status is unknown, so each contact is checked once. Add re-enrollment later if you want periodic re-checks.
  2. Add a Custom code action, language Node.js.
  3. Under "Property to include in code", add email mapped to the contact's Email property.
  4. Paste the code below.
  5. Under Data outputs, declare verify_status (string), verify_reason (string), verify_score (number) and did_you_mean (string).
  6. Add an Edit record action after it for each property, using the code action's outputs as the values.

HubSpot's docs say outputs can feed the Edit record action directly, which saves a separate API call to update the contact.

4. The code

The custom code runtime includes axios, so we use it rather than relying on a global fetch.

const axios = require('axios');

exports.main = async (event, callback) => {
  const email = event.inputFields['email'];

  if (!email) {
    callback({
      outputFields: { verify_status: 'unknown', verify_reason: 'no_email', verify_score: 0, did_you_mean: '' },
    });
    return;
  }

  try {
    const res = await axios.post(
      'https://pingutil.com/v1/verify',
      { email },
      {
        headers: { Authorization: `Bearer ${process.env.PINGUTIL_API_KEY}` },
        timeout: 15000,
      },
    );
    const r = res.data;
    callback({
      outputFields: {
        verify_status: r.status,          // valid | invalid | risky | unknown
        verify_reason: r.reason,          // e.g. mailbox_not_found, accept_all, greylisted
        verify_score: r.score,            // 0-100
        did_you_mean: r.did_you_mean || '',
      },
    });
  } catch (err) {
    const code = err.response ? err.response.status : 0;
    if (code === 400) {
      // The API rejected the request body (e.g. an over-long value). Record it, don't retry.
      // A malformed address is not a 400: it comes back as status "invalid", reason "invalid_syntax".
      callback({
        outputFields: { verify_status: 'unknown', verify_reason: 'bad_request', verify_score: 0, did_you_mean: '' },
      });
      return;
    }
    // 429 (rate limit or daily quota), 5xx and network errors: throw so HubSpot retries.
    throw err;
  }
};

Two details from HubSpot's docs shape this code. Custom code actions must finish within 20 seconds, so the request timeout is 15 seconds. And if the action throws after a 429 or 5xx response from axios, HubSpot retries for up to three days, starting a minute after the failure. That fits pingutil's limits: 60 requests a minute per account returns 429 rate_limited, and the daily budget of 300 verifications returns 429 quota_exceeded until it resets at 00:00 UTC. A large enrollment simply drains over the following days instead of failing.

5. Use the result

Now the property does the work:

  • Marketing sends and sequences: exclude Email verification status is invalid.
  • Catch-all and unknown: risky with reason accept_all means the server accepts every address, so the mailbox can't be confirmed. Leave these in nurture but out of high-volume sends. unknown (greylisting, blocks, timeouts) deserves a re-check later; a second workflow with a delay and re-enrollment handles that. More on why in catch-all email domains.
  • Typos: when Email suggestion is set, create a task for the owner to confirm with the contact. Don't overwrite the email automatically.

A no-code alternative and its limits

HubSpot's Send a webhook action supports POST and GET, authentication by request signature or API key, and response data that "can be used for certain actions". It's fine for posting contact data to your own endpoint. For a third-party API that expects a Bearer header and a specific JSON body, the custom code action is easier to get right. If you'd rather run your own service between HubSpot and the API, the webhook enrichment tutorial shows that pattern for domain lookups.

Before you turn it on

Questions people ask

Which HubSpot plan do I need for custom code actions?
HubSpot's developer docs say custom code actions are available on Data Hub Professional or Enterprise. The Send a webhook workflow action is listed for the same tiers.
What happens if the verification API is rate limited?
If your custom code throws after a 429 or 5xx response from axios, HubSpot retries the action for up to three days, starting one minute after the failure, with gaps of up to eight hours between tries. That covers short per-minute limits and daily quotas that reset.
Should a HubSpot workflow delete invalid contacts?
No. Write the verification status to a property and use it to exclude contacts from marketing sends and sequences. Deleting loses the history of where the bad address came from.