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.
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.
| CRM | Option | What you need | Limits to plan for |
|---|---|---|---|
| HubSpot | Custom 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 |
| HubSpot | Webhook action in a workflow (a GET webhook retrieves data) | Data Hub Professional or Enterprise | Response data can be used in other workflow actions |
| Salesforce | Flow 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 |
| Salesforce | Apex callout | Enterprise, Performance, Unlimited or Developer Edition (or Database.com); a Remote Site Setting, or a named credential as the endpoint | Default 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 property | Salesforce Contact field | sthan.io response field | Example (live, October 16, 2026) |
|---|---|---|---|
| Street address | MailingStreet | addressLine1 | 1355 Market St STE 900 |
| City | MailingCity | city | San Francisco |
| State/Region (or State/Region Code) | MailingState (or MailingStateCode) | stateCode | CA |
| Postal code | MailingPostalCode | zipCode + "-" + zip4 | 94103-1337 |
| Custom: county | Custom: county | county | San Francisco |
| Custom: address status | Custom: address status | deliverableStatus | Confirmed |
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 sent | Answer | Seconds |
|---|---|---|
| 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, CA | 1600 Amphitheatre Pkwy, Mountain View, CA 94043-1351 (Confirmed) | 0.86 |
| 200 North Spring Street, Los Angeles, CA 90012 | 200 N Spring St, Los Angeles, CA 90012-4801 (ConfirmedPrimaryOnly) | 1.21 |
| 525 South Winchester Boulevard, San Jose CA | 525 S Winchester Blvd, San Jose, CA 95128-2537 (Confirmed) | 1.26 |
| 1355 Market Street Suite 900, San Francisco CA | 1355 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 answer | 0.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:
- An Edit records action that always copies
verified_statusinto address status andoriginal_inputinto address as typed. - A branch of the type One property or action output, on
write_address. - On the
yesbranch only, an Edit records action that copiesverified_street,verified_city,verified_state,verified_zipandverified_countyinto the contact properties from the mapping table. Thenobranch 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:
- 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_Keyand the parameter isApiKey). - Add a custom header to the external credential named
Authorization, with a formula value such as{!'Bearer ' & $Credential.Sthan_Key.ApiKey}. - Create a named credential (here
Sthan) forhttps://api.sthan.iothat references the external credential. Turn off Generate Authorization Header and make sure Allow Formulas in HTTP Header is on. - 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:
- 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 ) ) - 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.
- First step, before any branch: an Update Records element on the triggering record (
$Record) that setsAddress_Status__ctoPendingandAddress_Input__cto the joined street, city, state and ZIP (keep the earlier value when the current address is the one inAddress_Written__c, so a recheck does not replace what was typed). Every record that enters the flow gets both fields, whatever happens next. - An Action element: click Create HTTP Callout, pick the
Sthannamed 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. - A Decision element with the same rule as the HubSpot code: write only when
deliverableStatusis Confirmed,matchCodeshowshouseNumberandstreetasMatched, andunitisMatched(orNotApplicableonly 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 andunitis notMatched, keep the typed address. - 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__cto the returned status, andRecheck_Address__cto false. - Keep outcome: an Update Records element that sets only
Address_Status__cto the returned status andRecheck_Address__cto false. The typed address stays. - 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__ctoCheckFailedandRecheck_Address__cto 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:
| Status | What it means | CRM action |
|---|---|---|
| Confirmed | sthan.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 |
| ConfirmedPrimaryOnly | sthan.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 |
| NotDeliverable | sthan.io: "postal authority rejected" | Keep the original; flag the record for review |
| Unknown | sthan.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 answer | Retry once later (in Salesforce, tick Recheck Address); after a second CheckFailed, send it for review |
| Pending (Salesforce flow) | The flow started but saved no answer | Tick 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
Sources
All read October 16, 2026:
- HubSpot developer docs: Workflows: Custom Code Actions
- HubSpot Knowledge Base: How to Trigger Webhooks in HubSpot Contact-Based Workflows (last updated September 16, 2026), HubSpot's default contact properties (last updated September 21, 2026), Use branches in workflows (last updated September 23, 2026)
- HubSpot: Data Hub pricing
- Salesforce Apex Developer Guide: What is Apex?, Invoking Callouts Using Apex, Callout Limits and Limitations
- Salesforce Help: Record-Triggered Flow Considerations, How Entry Conditions Work in Record-Triggered Flows, Start an Automation When a Record Is Created or Changed, Flow Bulkification in Transactions, Connecting to an API Without a Connector Using HTTP Callout, HTTP Callout Considerations and Limits, Configure an HTTP Callout Action, Supported Editions for Flow Builder and Licenses for Integrated Features, Before-Save Record-Triggered Flows, External Services, Create Named Credentials and External Credentials, Use API Keys in Custom Headers with Named Credentials, Let Users Select States, Countries, and Territories from Picklists
- Salesforce Object Reference: Contact
- sthan.io: API docs (endpoint and deliverableStatus definitions), US Address Verification, US pricing, Address Cleaner
- 11 production calls to the sthan.io verification API on October 16, 2026: 9 timed requests and 2 runs of the Node example
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.