Tutorial

Address Validation in Salesforce and HubSpot via API

Both CRMs can call an outside address API when a record is created or changed. Here are the options each vendor documents, the plan or edition each needs, a HubSpot example we ran against the live API, a Salesforce Flow recipe, response times we measured, and the field mapping for writing results back.

James Whitaker
October 16, 2026 · 10 min read · Last reviewed

To validate CRM addresses through an API, send each record's street, city, state and ZIP Code to an address verification API when the record is created or changed, then write the standardized address and a deliverability status back to the record. In HubSpot that runs as a custom code workflow action or a webhook action. In Salesforce it runs as a record-triggered flow with an HTTP Callout on an asynchronous path.

The options in each CRM

Each row below comes from the CRM vendor's own documentation and pricing pages, read on October 16, 2026.

CRMOptionWhat you needLimits to plan for
HubSpotCustom code action in a workflow (JavaScript, or Python in beta)Data Hub Professional (from $720 a month) or Enterprise (from $2,000 a month)Must finish within 20 seconds; up to 128 MB of memory; up to 50 properties per action
HubSpotWebhook action in a workflow (a GET webhook retrieves data)Data Hub Professional or EnterpriseResponse data can be used in other workflow actions
SalesforceFlow HTTP Callout (no code)Customize Applications permission; a named credential set up first; Enterprise, Performance, Unlimited or Developer Edition (HTTP Callout runs on External Services)Request headers are not supported on the action, so the API key goes in the credential; a status of 300 or more is an error, handled with a fault path
SalesforceApex calloutEnterprise, Performance, Unlimited or Developer Edition (or Database.com); a Remote Site Setting, or a named credential as the endpointDefault timeout 10 seconds, up to 120,000 milliseconds; 100 callouts and 120 seconds of callout time per Apex transaction

The HubSpot prices are the starting prices shown on HubSpot's Data Hub pricing page, each with one core seat. Both plans offered a 14-day trial there.

Field mapping: CRM field to sthan.io field

Send the four address fields in, and write these fields back. HubSpot names below are the labels of its default contact properties; Salesforce names are the Contact object's API field names. Use custom fields for county and status.

HubSpot contact propertySalesforce Contact fieldsthan.io response fieldExample (live, October 16, 2026)
Street addressMailingStreetaddressLine11355 Market St STE 900
CityMailingCitycitySan Francisco
State/Region (or State/Region Code)MailingState (or MailingStateCode)stateCodeCA
Postal codeMailingPostalCodezipCode + "-" + zip494103-1337
Custom: countyCustom: countycountySan Francisco
Custom: address statusCustom: address statusdeliverableStatusConfirmed

HubSpot describes its Street address property as "the contact's street address, including apartment or unit #", so the unit stays in the street line, which matches addressLine1. In Salesforce, if your org uses State and Country/Territory Picklists, write the two-letter code to MailingStateCode, a picklist of ISO codes.

How long a lookup took on October 16, 2026

CRM actions have hard time limits (20 seconds in HubSpot, 10 seconds by default for an Apex callout), so we timed real lookups. We sent 9 requests to the production sthan.io verification API on October 16, 2026, one at a time, and measured each from request to full answer:

Address sentAnswerSeconds
1355 Market St, San Francisco, CA 94103 (no suite)1355 Market St, San Francisco, CA 94103-1326 (Unknown, with an empty dpvConfirmation field, as returned today)0.41
1600 Amphitheatre Parkway, Mountain View, CA1600 Amphitheatre Pkwy, Mountain View, CA 94043-1351 (Confirmed)0.86
200 North Spring Street, Los Angeles, CA 90012200 N Spring St, Los Angeles, CA 90012-4801 (ConfirmedPrimaryOnly)1.21
525 South Winchester Boulevard, San Jose CA525 S Winchester Blvd, San Jose, CA 95128-2537 (Confirmed)1.26
1355 Market Street Suite 900, San Francisco CA1355 Market St STE 900, San Francisco, CA 94103-1337 (Confirmed)1.68
350 Fifth Avenue Suite 3304, New York NY (first request)350 5th Ave STE 3304, New York, NY 10118-3304 (Confirmed)55.48
350 Fifth Avenue Suite 3304, New York NY (second request)Same answer0.99
98765 Imaginary Blvd, Sacramento CA (made up)Not found (HTTP 404)92.49
98765 Imaginary Blvd, Sacramento CA (second request)Not found (HTTP 404)95.09

These are single observations from one day, not a full measure of response times. Six of the 9 answered in under 1.7 seconds. The first request for the New York suite took 55 seconds, and the same address answered in under a second the second time. The made-up address took more than 90 seconds both times before it came back as not found. So plan for two cases: most lookups answer well inside both CRMs' limits, and a few, including addresses with no match, can run past HubSpot's 20 seconds. The examples below handle both.

HubSpot: a custom code action in Node.js

First create three custom contact properties: address status, address as typed and county. Add a Custom code action to a contact workflow. Add a secret named STHAN_API_KEY (HubSpot makes secrets available as environment variables). Add the contact's address, city, state and ZIP properties as inputs named address, city, state and zip. Then paste:

const axios = require('axios');
const UNIT_WORDS = /(#|\b(apt|apartment|ste|suite|unit|fl|floor|rm|room|bldg)\b)/i;

exports.main = async (event, callback) => {
  const f = event.inputFields;
  const oneLine = [f.address, f.city, f.state, f.zip].filter(Boolean).join(', ');
  const keepTyped = status => callback({
    outputFields: { verified_status: status, original_input: oneLine, write_address: 'no' }
  });

  try {
    const res = await axios.get(
      'https://api.sthan.io/v2/address-verification/usa/speculative/' + encodeURIComponent(oneLine),
      { headers: { Authorization: 'Bearer ' + process.env.STHAN_API_KEY }, timeout: 15000 }
    );
    const r = res.data.Result;
    const m = r.matchCode || {};
    const typedUnit = UNIT_WORDS.test(f.address || '');
    const safeToWrite = r.deliverableStatus === 'Confirmed'
      && m.houseNumber === 'Matched' && m.street === 'Matched'
      && (m.unit === 'Matched' || (m.unit === 'NotApplicable' && !typedUnit));
    if (!safeToWrite) return keepTyped(r.deliverableStatus);

    callback({
      outputFields: {
        verified_status: r.deliverableStatus,
        original_input: oneLine,
        write_address: 'yes',
        verified_street: r.addressLine1,
        verified_city: r.city,
        verified_state: r.stateCode,
        verified_zip: r.zip4 ? r.zipCode + '-' + r.zip4 : r.zipCode,
        verified_county: r.county || ''
      }
    });
  } catch (err) {
    const status = err.response ? err.response.status : 0;
    // HubSpot retries the action on a 429 or 5XX only if the error is thrown.
    if (status === 429 || status >= 500) throw err;
    keepTyped(status === 404 ? 'NotFound' : 'CheckFailed');
  }
};

Define the eight output names as data outputs (type string). HubSpot says "You can then use the output from your code action as in input to the Edit records action", so no second API call is needed. Wire the workflow in this order:

  1. An Edit records action that always copies verified_status into address status and original_input into address as typed.
  2. A branch of the type One property or action output, on write_address.
  3. On the yes branch only, an Edit records action that copies verified_street, verified_city, verified_state, verified_zip and verified_county into the contact properties from the mapping table. The no branch leaves the typed address alone.

The function sets write_address to yes only when the status is Confirmed and the response's matchCode shows the house number and street as Matched, and the unit as Matched (or not applicable when no unit was typed). So a Confirmed answer that changed the street, or one that would drop a typed unit, does not overwrite the record, even when it adds a ZIP+4.

We ran this exact function locally on October 16, 2026 against the production API with the input 1355 market street #900, san francisco, ca and an empty ZIP. It answered in 0.92 seconds and returned:

{
 "outputFields": {
  "verified_status": "Confirmed",
  "original_input": "1355 market street #900, san francisco, ca",
  "write_address": "yes",
  "verified_street": "1355 Market St STE 900",
  "verified_city": "San Francisco",
  "verified_state": "CA",
  "verified_zip": "94103-1337",
  "verified_county": "San Francisco"
 }
}

With the made-up address from the timing table, the same function stopped at its 15-second timeout and returned CheckFailed with write_address set to no, well before HubSpot's 20-second limit, so the action did not fail. We also ran the function offline with stubbed responses: ConfirmedPrimaryOnly, NotDeliverable, Unknown, a 404 (NotFound), a timeout (CheckFailed), a Confirmed answer with a changed street and one that would drop a typed unit all return write_address no with no address fields, and a 429 or 503 is thrown so HubSpot retries the action.

Because an address with no match can take longer than HubSpot allows, a record that ends as CheckFailed twice is better sent to a person for review than re-enrolled again. You can also turn on the action's rate limit, which HubSpot documents as a way "to determine how often the custom code action should execute", to stay inside your API plan's per-minute and per-day limits. HubSpot notes the rate limit also applies to all the actions after the custom code action in that workflow.

No Data Hub Professional? The webhook action needs the same plans. On other plans you can export contacts, clean the file in bulk and re-import it.

Salesforce: named credential first, then a record-triggered flow

Store the API key in a named credential. Salesforce's steps for an API key that goes in a header:

  1. Create an external credential with the authentication protocol set to Custom, and store your sthan.io key as an authentication parameter in a principal (here the external credential is Sthan_Key and the parameter is ApiKey).
  2. Add a custom header to the external credential named Authorization, with a formula value such as {!'Bearer ' & $Credential.Sthan_Key.ApiKey}.
  3. Create a named credential (here Sthan) for https://api.sthan.io that references the external credential. Turn off Generate Authorization Header and make sure Allow Formulas in HTTP Header is on.
  4. Enable user external credentials for the users who will run the flow.

Fields to add on Contact. Text fields Address_Status__c, Address_Input__c (the address as typed), Address_Written__c (the address the flow last wrote) and County__c, and a checkbox Recheck_Address__c that anyone can tick to ask for a new check.

The flow, no code. Salesforce describes HTTP Callout as a way to "connect flows to external APIs without coding". Salesforce says request headers "aren't supported" on the action itself, which is why the key sits in the credential above. Build a record-triggered flow on Contact that runs when a record is created or updated, and set it up like this:

  1. Entry conditions. In the Start element, choose Formula Evaluates to True and enter a formula like the one below. Under When to Run the Flow for Updated Records, choose "Only when a record is updated to meet the condition requirements". The flow then runs for a new or edited address and when someone ticks Recheck Address, and the flow's own saves do not start it again, because afterwards the address equals what it stored.
    OR(
      Recheck_Address__c,
      AND(
        NOT(ISBLANK(MailingStreet)),
        MailingStreet & ", " & MailingCity & ", " & MailingState & ", " & MailingPostalCode <> Address_Input__c,
        MailingStreet & ", " & MailingCity & ", " & MailingState & ", " & MailingPostalCode <> Address_Written__c
      )
    )
  2. Asynchronous path. Salesforce "prevents callouts to external services from running while a record is saving", so on the Start element turn on Add Asynchronous Path (under Actions and Related Records) and put every step below on that path. It runs after the record save is committed.
  3. First step, before any branch: an Update Records element on the triggering record ($Record) that sets Address_Status__c to Pending and Address_Input__c to the joined street, city, state and ZIP (keep the earlier value when the current address is the one in Address_Written__c, so a recheck does not replace what was typed). Every record that enters the flow gets both fields, whatever happens next.
  4. An Action element: click Create HTTP Callout, pick the Sthan named credential and GET, and enter the path with a path variable in curly brackets: /v2/address-verification/usa/speculative/{address}. Pass the joined address. Paste a sample JSON response when asked (Salesforce asks you to "Prepare a sample API response in JSON format"); a live response from the API works.
  5. A Decision element with the same rule as the HubSpot code: write only when deliverableStatus is Confirmed, matchCode shows houseNumber and street as Matched, and unit is Matched (or NotApplicable only when the typed street has none of the unit markers the HubSpot code checks: "#", or the words Apt, Apartment, Ste, Suite, Unit, Fl, Floor, Rm, Room or Bldg, in any case and as whole words). If any marker is present and unit is not Matched, keep the typed address.
  6. Write outcome: an Update Records element that sets the mailing fields from the mapping table (MailingStateCode instead of MailingState if you use State and Country/Territory Picklists), County__c, Address_Written__c (the new street, city, state and ZIP joined the same way as in the formula), Address_Status__c to the returned status, and Recheck_Address__c to false.
  7. Keep outcome: an Update Records element that sets only Address_Status__c to the returned status and Recheck_Address__c to false. The typed address stays.
  8. Fault path on the callout: a status of 300 or more comes back as an error, and that includes the 404 when no match is found. Add an Update Records element there that sets Address_Status__c to CheckFailed and Recheck_Address__c to false.

To retry a record, tick Recheck Address. One detail from Salesforce's considerations page: the enum data type is not supported, so deliverableStatus arrives as a plain string. The Salesforce pages we read do not state a timeout for the HTTP Callout action; some answers in the timing table took 55 to 95 seconds, so watch for CheckFailed in testing.

We have not built or run this flow in a Salesforce org. It follows the Salesforce documentation linked in the sources. Test it in a sandbox before going live, with a new contact, an edited address, a ticked Recheck Address, and addresses that include a unit written each way the rule checks ("#", Apt, Apartment, Ste, Suite, Unit, Fl, Floor, Rm, Room, Bldg).

Step 3 saves Pending and the typed address before step 4 makes the callout. For that order to work, set the HTTP Callout action's transaction control to "Let the flow decide (recommended)", the default for actions configured in Winter '26 or later. Salesforce's transaction documentation says that with this setting, "If this action involves a callout, and the current transaction has pending operations, the current transaction is committed before running the action in a new transaction", while "Always continue in current transaction" makes the flow fail in that case. Check the setting when you reuse an older action.

Imports and other bulk saves. Salesforce says that "When a flow runs for multiple records at the same time, Salesforce creates one flow interview per record", and that "If, despite the bulkification, any interview hits a governor limit, all the interviews in the transaction fail." The pages we read do not say how many HTTP callouts an asynchronous path can make for one bulk save. So keep this flow for records people add or edit one at a time, and clean big lists before you import them. sthan.io's Address Cleaner takes an Excel, .csv or .tsv file of up to 100,000 rows and 50 MB, returns your original columns plus the cleaned address and a status for every row, and costs $1.20 per 1,000 rows found in postal records (the first 100 rows each day are free). Deactivate the flow while you import the cleaned file, then activate it again.

Apex. An Apex callout is the other Salesforce option in the table above. We do not publish an Apex example on this page: a safe one needs retry, bulk and queue handling that we could not test in an org.

What to write back for each status

Write back by status, so a doubtful result never overwrites an address a rep typed. In every row, store the status and the address as typed in their own fields:

StatusWhat it meansCRM action
Confirmedsthan.io: "safe to ship"Overwrite the address fields with the standardized ones if the house number, street and unit came back as typed; otherwise keep the original and review
ConfirmedPrimaryOnlysthan.io: "primary OK but secondary unit missing or invalid"Keep the original, so a typed unit is not lost; create a task to get the right unit number
NotDeliverablesthan.io: "postal authority rejected"Keep the original; flag the record for review
Unknownsthan.io: "no postal confirmation on file"Keep the original; store the status only
NotFound (HubSpot example)The API answered 404: no matching address (the Salesforce flow records it as CheckFailed)Keep the original; flag the record for review
CheckFailed (both examples)Timeout or error before an answerRetry once later (in Salesforce, tick Recheck Address); after a second CheckFailed, send it for review
Pending (Salesforce flow)The flow started but saved no answerTick Recheck Address

Where sthan.io fits. sthan.io is the address API on the other end of these calls: one GET request returns the standardized street line, separate unit fields, city, state, ZIP+4, county and a deliverability status for a US address. Verification is free for 3,000 lookups a month (up to 100 a day, 10 a minute) with no credit card; paid plans start at $12 a month for 10,000. sthan.io is not CASS-certified.

Frequently Asked Questions

HubSpot's documentation lists Data Hub Professional or Enterprise for both the custom code workflow action and webhooks in workflows. On HubSpot's pricing page on October 16, 2026, Data Hub Professional started at $720 a month and Enterprise at $2,000 a month.
It must finish within 20 seconds and use no more than 128 MB of memory. If a Node.js action throws on a 429 or 5XX error from axios, HubSpot retries it for up to three days, starting one minute after the failure.
Yes. Salesforce's HTTP Callout action connects flows to external APIs without code. You set up authentication in a named credential first, and the API key header goes in the credential because the action does not support request headers.
Not during the save itself. Salesforce prevents callouts to external services while a record is saving, so put the HTTP Callout on an asynchronous path, which runs after the record save is committed.
Always store the deliverability status and the address as typed, each in its own field. Overwrite the street line, city, state, ZIP+4 and county only when the status is Confirmed and the house number, street and unit came back as typed; for ConfirmedPrimaryOnly, NotDeliverable, Unknown, a 404 or an error, keep the typed address.

Sources

All read October 16, 2026:

Plan names, prices and limits change; check the vendor's page before you build.

Test it on your CRM records free

3,000 verifications a month, no credit card. Standardized address, ZIP+4, county and deliverability status with each successful match.

James Whitaker
Written by James Whitaker

James Whitaker writes about PHP and Ruby integrations and address checks for ecommerce checkout flows.

More from James Whitaker